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.
|
||||
- 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.
|
||||
- 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.
|
||||
- System log tab for Winston request/application logs.
|
||||
- Users & Groups administration with modal create flows.
|
||||
@@ -508,10 +509,10 @@ Payload:
|
||||
| `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/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. |
|
||||
| `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` | 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. |
|
||||
@@ -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.
|
||||
`targetType: "host"` records are standalone host connections that use the
|
||||
vSphere Web Services SOAP endpoint at `/sdk`; they validate direct ESXi
|
||||
connectivity but cannot perform vCenter VM inventory imports. Existing
|
||||
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
|
||||
the `Configured default` import connection for single-vCenter deployments or
|
||||
Docker-provided configuration.
|
||||
|
||||
VMware vCenter import can use a saved vCenter system selected by `connectionId` or the legacy configured default from Config / Integrations (`vcenter_*` settings or `VCENTER_*` environment variables). `apiMode` / `vcenter_api_mode` can be `auto`, `api`, or `rest`. `api` uses the current vSphere Automation API flow (`POST /api/session`, `GET /api/vcenter/vm`). `rest` uses the legacy/community vCenter REST flow (`POST /rest/com/vmware/cis/session`, `GET /rest/vcenter/vm`). `auto` tries the current `/api` profile first and falls back to `/rest` when the endpoint is not supported.
|
||||
|
||||
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 `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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1154,6 +1164,9 @@ with per-host log rows.
|
||||
| `POST` | `/api/runplans/:id/execute` | User | Queue/execute a RunPlan. |
|
||||
| `GET` | `/api/jobs` | User | List jobs. |
|
||||
| `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:
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
| Method | Route | Auth | Description |
|
||||
|
||||
@@ -597,6 +597,16 @@
|
||||
</article>
|
||||
</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">
|
||||
<IntuneGraphConfig
|
||||
:graph-connections="graphConnections"
|
||||
@@ -984,6 +994,7 @@ import ErrorView from './views/ErrorView.vue';
|
||||
import LoginView from './views/LoginView.vue';
|
||||
import PsadtView from './views/PsadtView.vue';
|
||||
import AssetsView from './views/AssetsView.vue';
|
||||
import SystemJobsView from './views/SystemJobsView.vue';
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker() {
|
||||
@@ -1205,6 +1216,9 @@ const assetFolders = ref([]);
|
||||
const assets = ref([]);
|
||||
const runplans = ref([]);
|
||||
const jobs = ref([]);
|
||||
const systemJobs = ref([]);
|
||||
const systemJobActions = ref([]);
|
||||
const systemJobsLoading = ref(false);
|
||||
const variableCatalog = ref({ builtIns: [], psadt: [], custom: [] });
|
||||
const psadtCatalog = ref({ module: {}, sources: [], supportMatrix: [], structure: [], invokeParameters: [], functions: [], admxPolicies: [], launchCommands: [], snippets: [] });
|
||||
const psadtProfiles = ref([]);
|
||||
@@ -1299,6 +1313,7 @@ const nav = [
|
||||
{ id: 'credentials', label: 'Credential Vault', icon: KeyRound },
|
||||
{ id: 'runplans', label: 'RunPlans', icon: Rocket },
|
||||
{ id: 'logs', label: 'Logs', icon: Activity },
|
||||
{ id: 'system-jobs', label: 'System Jobs', icon: Database },
|
||||
{ id: 'users', label: 'Users & Groups', icon: UsersRound },
|
||||
{ id: 'admin', label: 'Config', icon: Settings }
|
||||
];
|
||||
@@ -1745,6 +1760,11 @@ async function refreshAll() {
|
||||
vcenterConnections.value = vcenterRows;
|
||||
runplans.value = runplanRows;
|
||||
jobs.value = jobRows;
|
||||
if (user.value?.role === 'admin') await refreshSystemJobs(true);
|
||||
else {
|
||||
systemJobs.value = [];
|
||||
systemJobActions.value = [];
|
||||
}
|
||||
if (selectedScript.value) {
|
||||
const refreshedSelection = scriptRows.find((script) => script.id === selectedScript.value.id);
|
||||
if (refreshedSelection) selectScript(refreshedSelection);
|
||||
@@ -2532,7 +2552,7 @@ async function previewVCenterImport(payload) {
|
||||
vcenterImportError.value = '';
|
||||
vcenterTraceEntries.value = [{
|
||||
info: true,
|
||||
message: 'vCenter preview request is running.',
|
||||
message: 'VMware host preview request is running.',
|
||||
details: {
|
||||
filter: payload.filter,
|
||||
limit: payload.limit,
|
||||
@@ -2550,7 +2570,7 @@ async function previewVCenterImport(payload) {
|
||||
details: {
|
||||
route: '/api/hosts/import/vcenter/preview',
|
||||
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: {
|
||||
route: '/api/hosts/import/vcenter/preview',
|
||||
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 || [];
|
||||
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) {
|
||||
vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{
|
||||
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 }
|
||||
}];
|
||||
vcenterTraceOpen.value = true;
|
||||
@@ -2602,7 +2625,7 @@ async function importVCenterHosts(payload) {
|
||||
vcenterImportError.value = '';
|
||||
vcenterTraceEntries.value = [{
|
||||
info: true,
|
||||
message: 'vCenter import request is running.',
|
||||
message: 'VMware host import request is running.',
|
||||
details: { vmIds: payload.vmIds, startedAt: new Date().toISOString() }
|
||||
}];
|
||||
vcenterTraceOpen.value = true;
|
||||
@@ -2619,7 +2642,7 @@ async function importVCenterHosts(payload) {
|
||||
} catch (err) {
|
||||
vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{
|
||||
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 }
|
||||
}];
|
||||
vcenterTraceOpen.value = true;
|
||||
@@ -2717,6 +2740,49 @@ async function loadSystemLogs() {
|
||||
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() {
|
||||
const payload = {};
|
||||
Object.entries(settingsDraft).forEach(([key, row]) => { payload[key] = row.value; });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
title="Import hosts from vCenter"
|
||||
eyebrow="VMWARE VCENTER"
|
||||
description="Search vCenter virtual machines, preview discovered identity data, and create managed hosts with one shared vault credential."
|
||||
title="Import VMware hosts"
|
||||
eyebrow="VMWARE"
|
||||
description="Import virtual machines from vCenter or standalone ESXi inventory into the Host Library."
|
||||
wide
|
||||
@close="$emit('close')"
|
||||
>
|
||||
@@ -12,17 +12,17 @@
|
||||
<div class="section-heading">
|
||||
<span>1</span>
|
||||
<div>
|
||||
<h4>Search filter</h4>
|
||||
<p>Use vCenter VM names, guest hostnames, FQDNs, or IPs to narrow the import set before writing hosts.</p>
|
||||
<h4>{{ isStandaloneHost ? 'ESXi VM inventory' : 'Search filter' }}</h4>
|
||||
<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 class="vcenter-filter-grid">
|
||||
<label class="filter-value">
|
||||
<span>vCenter system</span>
|
||||
<span>VMware connection</span>
|
||||
<select v-model="draft.connectionId">
|
||||
<option :value="null">Configured default</option>
|
||||
<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>
|
||||
</select>
|
||||
</label>
|
||||
@@ -52,14 +52,19 @@
|
||||
<span>Preview limit</span>
|
||||
<input v-model.number="draft.limit" type="number" min="1" max="500" />
|
||||
</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 class="wizard-actions">
|
||||
<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>
|
||||
<span v-if="status" class="vcenter-status">
|
||||
<span :class="['status-dot', activeStatus.configured ? 'success' : 'warning']"></span>
|
||||
{{ activeStatus.configured ? `Using ${activeStatus.name || 'vCenter'} at ${activeStatus.baseUrl}` : 'vCenter settings incomplete' }}
|
||||
{{ activeStatus.configured ? `Using ${activeStatus.name || activeStatus.typeLabel || 'VMware'} at ${activeStatus.baseUrl}` : 'VMware settings incomplete' }}
|
||||
</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>View API trace
|
||||
@@ -123,11 +128,11 @@
|
||||
<span>3</span>
|
||||
<div>
|
||||
<h4>Preview and select</h4>
|
||||
<p>Guest FQDN/IP data depends on VMware Tools. Missing values are imported with the VM name as the address fallback.</p>
|
||||
<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 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>
|
||||
<span>{{ diagnostics.totalFromVCenter }} VM(s) returned by vCenter</span>
|
||||
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
|
||||
@@ -137,6 +142,15 @@
|
||||
</button>
|
||||
<small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small>
|
||||
</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">
|
||||
<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' }}
|
||||
@@ -219,13 +233,16 @@ const draft = reactive({
|
||||
});
|
||||
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 activeStatus = computed(() => {
|
||||
if (draft.connectionId) {
|
||||
const connection = importConnections.value.find((item) => item.id === draft.connectionId);
|
||||
const connection = selectedConnection.value;
|
||||
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),
|
||||
baseUrl: connection?.baseUrl || ''
|
||||
};
|
||||
|
||||
@@ -4645,6 +4645,95 @@ body {
|
||||
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 {
|
||||
padding: 15px;
|
||||
border-radius: 20px;
|
||||
@@ -5128,6 +5217,23 @@ body {
|
||||
}
|
||||
|
||||
@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 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -5250,9 +5356,18 @@ body {
|
||||
.status-pill.stdout,
|
||||
.status-pill.info,
|
||||
.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.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.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;
|
||||
}
|
||||
|
||||
.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,
|
||||
.preview-toolbar {
|
||||
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', [
|
||||
'Host Groups are target collections. The table supports search, sort, pagination, a read-only summary view, and CSV/XLS exports of assigned hostnames, IP addresses, and VMware source system.',
|
||||
'Config / VMware supports two connection types: vCenter inventory systems and standalone ESXi hosts. vCenter systems can import VM inventory and run pre-execution power-state checks.',
|
||||
'Standalone ESXi host connections use the vSphere Web Services API for direct host validation. They are not vCenter inventory sources, so they cannot be selected in the VM import wizard.'
|
||||
'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 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', [
|
||||
'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.'
|
||||
]),
|
||||
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', [
|
||||
'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('Get', '/api/jobs', 'List job history.'),
|
||||
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.')
|
||||
,
|
||||
section('VMware connection payloads', [
|
||||
'Saved VMware connection records use targetType = vcenter for inventory import/power-state checks or targetType = host for a standalone ESXi host connection.',
|
||||
'Standalone host connections force apiMode = web-services and test with the vSphere Web Services SOAP endpoint at /sdk. Inventory preview/import requires a vCenter connection.'
|
||||
'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}')
|
||||
], ['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.', [
|
||||
|
||||
14
server/db.js
14
server/db.js
@@ -471,6 +471,19 @@ export function migrate() {
|
||||
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 (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
@@ -515,6 +528,7 @@ export function migrate() {
|
||||
// VMware connection type separates vCenter inventory/power-state APIs from
|
||||
// standalone ESXi host checks that use the vSphere Web Services SOAP API.
|
||||
ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'");
|
||||
ensureColumn('system_job_actions', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'");
|
||||
// Tenant write-back is opt-in per Graph connection. Default 0 so existing
|
||||
// read-only connections can never be used to change a tenant by accident.
|
||||
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
@@ -399,3 +399,19 @@ export function camelJobLog(row) {
|
||||
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 { runPlanRoutes } from './runPlanRoutes.js';
|
||||
import { settingsRoutes } from './settingsRoutes.js';
|
||||
import { systemJobRoutes } from './systemJobRoutes.js';
|
||||
import { userRoutes } from './userRoutes.js';
|
||||
import { variableRoutes } from './variableRoutes.js';
|
||||
|
||||
@@ -41,4 +42,5 @@ apiRoutes.use('/intune/applications', applicationRoutes);
|
||||
apiRoutes.use('/intune', governanceRoutes);
|
||||
apiRoutes.use('/runplans', runPlanRoutes);
|
||||
apiRoutes.use('/jobs', jobRoutes);
|
||||
apiRoutes.use('/system-jobs', systemJobRoutes);
|
||||
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 { normalizeOsFamily } from '../utils/osFamily.js';
|
||||
|
||||
const activeJobs = new Map();
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
// RunPlans can fan out in parallel or execute serially while preserving identical log semantics.
|
||||
logCompatibilityPreflight(jobId, script, hosts);
|
||||
const runner = async (host) => runHost(jobId, script, host, executionContext);
|
||||
if (parallel) {
|
||||
await Promise.all(hosts.map(runner));
|
||||
} else {
|
||||
for (const host of hosts) await runner(host);
|
||||
}
|
||||
const state = { canceled: false, children: new Map() };
|
||||
activeJobs.set(jobId, state);
|
||||
try {
|
||||
logCompatibilityPreflight(jobId, script, hosts);
|
||||
const runner = async (host) => runHost(jobId, script, host, executionContext);
|
||||
if (parallel) {
|
||||
await Promise.all(hosts.map(runner));
|
||||
} else {
|
||||
for (const host of hosts) {
|
||||
if (isJobCanceled(jobId)) {
|
||||
cancelQueuedHosts(jobId);
|
||||
break;
|
||||
}
|
||||
await runner(host);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count;
|
||||
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
|
||||
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;
|
||||
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) {
|
||||
@@ -204,6 +223,10 @@ function logCompatibilityPreflight(jobId, script, hosts) {
|
||||
|
||||
async function runHost(jobId, script, host, executionContext) {
|
||||
// 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);
|
||||
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')}`);
|
||||
@@ -233,6 +256,7 @@ async function runHost(jobId, script, host, executionContext) {
|
||||
|
||||
async function vCenterPowerPreflight(jobId, host) {
|
||||
if ((host.source_type || 'manual') !== 'vmware') return true;
|
||||
if (isJobCanceled(jobId)) return false;
|
||||
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.');
|
||||
return true;
|
||||
@@ -328,11 +352,22 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
|
||||
},
|
||||
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;
|
||||
|
||||
function settle(status, exitCode) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
activeJobs.get(jobId)?.children.delete(host.id);
|
||||
finishHost(jobId, host.id, status, exitCode);
|
||||
resolve();
|
||||
}
|
||||
@@ -346,6 +381,10 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) return;
|
||||
if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
|
||||
if (isJobCanceled(jobId)) {
|
||||
settle('canceled', 130);
|
||||
return;
|
||||
}
|
||||
const exitCode = code ?? 1;
|
||||
settle(exitCode === 0 ? 'completed' : 'failed', exitCode);
|
||||
});
|
||||
@@ -366,6 +405,10 @@ function appendLog(jobId, hostId, stream, line) {
|
||||
`).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) {
|
||||
db.prepare(`
|
||||
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}`);
|
||||
}
|
||||
|
||||
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) {
|
||||
const assetManifest = JSON.stringify(executionContext.assets || []);
|
||||
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() : '';
|
||||
}
|
||||
|
||||
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) {
|
||||
return extractSoapTag(text, 'faultstring') || extractSoapTag(text, 'reason') || '';
|
||||
}
|
||||
@@ -399,6 +424,16 @@ function localizedText(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) {
|
||||
const interfaces = unwrapList(interfacesPayload);
|
||||
const interfaceIp = firstIpFromInterfaces(interfaces);
|
||||
@@ -415,7 +450,7 @@ export function normalizeVCenterVm(vm, identity = null, interfacesPayload = null
|
||||
address,
|
||||
ipAddress: ipAddresses[0] || '',
|
||||
ipAddresses,
|
||||
powerState: vm.power_state || vm.powerState || '',
|
||||
powerState: normalizePowerState(vm.power_state || vm.powerState || ''),
|
||||
osFamily: inferOsFamily(identity || {}),
|
||||
guestFullName: localizedText(identity?.full_name) || identity?.name || '',
|
||||
toolsAvailable: Boolean(identity || interfaces.length),
|
||||
@@ -431,7 +466,7 @@ function normalizeVCenterSummaryVm(vm) {
|
||||
address: vm.name || vm.vm || vm.id || '',
|
||||
ipAddress: '',
|
||||
ipAddresses: [],
|
||||
powerState: vm.power_state || vm.powerState || '',
|
||||
powerState: normalizePowerState(vm.power_state || vm.powerState || ''),
|
||||
osFamily: 'other',
|
||||
guestFullName: '',
|
||||
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 = {}) {
|
||||
const operator = filter.operator || 'contains';
|
||||
const needle = String(filter.value || '').trim().toLowerCase();
|
||||
@@ -494,6 +554,8 @@ export function buildVmListPath(profile, filter = {}) {
|
||||
}
|
||||
|
||||
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))];
|
||||
return {
|
||||
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 = {}) {
|
||||
const trace = [];
|
||||
const settings = settingsFromVCenterConnection(options.connection);
|
||||
@@ -527,7 +792,37 @@ export async function previewVCenterHosts(options = {}) {
|
||||
throw new VCenterTraceError(err.message, trace, 400);
|
||||
}
|
||||
if (settings.targetType === 'host') {
|
||||
throw new VCenterTraceError('Standalone VMware host connections use the vSphere Web Services API and cannot perform vCenter VM inventory imports. Choose a vCenter connection for VM discovery.', trace, 400);
|
||||
const 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 allVms = unwrapList(vmPayload);
|
||||
@@ -586,29 +881,17 @@ export async function testVCenterConnection(connection) {
|
||||
}
|
||||
|
||||
export async function testStandaloneHostConnection(connection, settings = settingsFromVCenterConnection(connection), trace = []) {
|
||||
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
|
||||
mode: 'web-services',
|
||||
endpoint: soapEndpoint(settings)
|
||||
const vms = await retrieveStandaloneVmInventory(settings, {
|
||||
connection,
|
||||
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 {
|
||||
status: vcenterConnectionStatus(connection),
|
||||
apiProfile: WEB_SERVICES_PROFILE_LABEL,
|
||||
count: 0,
|
||||
count: vms.length,
|
||||
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.' };
|
||||
const settings = settingsFromVCenterConnection(connection);
|
||||
if (settings.targetType === 'host') {
|
||||
return { checked: false, reason: 'Standalone VMware host connections do not provide vCenter VM power-state inventory lookups.' };
|
||||
assertConfigured(settings);
|
||||
const 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);
|
||||
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
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 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';
|
||||
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at)
|
||||
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', () => {
|
||||
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(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 {
|
||||
buildVmListPath,
|
||||
buildHostPayloadFromVm,
|
||||
buildStandaloneHostCandidate,
|
||||
filterSummaryVms,
|
||||
getVCenterVmPowerState,
|
||||
normalizeBaseUrl,
|
||||
normalizeVCenterVm,
|
||||
previewVCenterHosts,
|
||||
@@ -14,29 +16,124 @@ const {
|
||||
vmMatchesFilter
|
||||
} = 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', () => {
|
||||
assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local');
|
||||
});
|
||||
|
||||
test('standalone VMware host connections use the Web Services profile', async () => {
|
||||
const settings = settingsFromVCenterConnection({
|
||||
const restoreFetch = installStandaloneEsxiSoapStub();
|
||||
const connection = {
|
||||
id: 'vc_host',
|
||||
name: 'ESXi 01',
|
||||
target_type: 'host',
|
||||
base_url: 'https://esxi01.lab.local/sdk',
|
||||
username: 'root',
|
||||
password: 'secret',
|
||||
enabled: 1,
|
||||
api_mode: 'api'
|
||||
});
|
||||
};
|
||||
const settings = settingsFromVCenterConnection(connection);
|
||||
|
||||
assert.equal(settings.targetType, 'host');
|
||||
assert.equal(settings.apiMode, 'web-services');
|
||||
assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk');
|
||||
|
||||
await assert.rejects(
|
||||
() => previewVCenterHosts({ connection: { ...settings, name: 'ESXi host' } }),
|
||||
/Standalone VMware host connections/
|
||||
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' } });
|
||||
restoreFetch();
|
||||
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user