This commit is contained in:
2026-06-25 14:43:13 -05:00
parent 6f77fac58e
commit aaa6a9ee26
25 changed files with 1542 additions and 67 deletions

View File

@@ -13,6 +13,8 @@ DEFAULT_ADMIN_PASSWORD=change-me-now
DEFAULT_ADMIN_NAME=POSH Admin
POWERSHELL_BIN=pwsh
ALLOW_SCRIPT_EXECUTION=true
# Minutes between dynamic Host Group rule syncs. 0 disables the worker.
HOST_GROUP_SYNC_INTERVAL_MINUTES=60
# Minutes between automatic catalog upstream-version checks. 0 disables the worker.
CATALOG_CHECK_INTERVAL_MINUTES=0
# Minutes between auto-promote soak checks. 0 disables the worker.

118
README.md
View File

@@ -29,7 +29,7 @@ The current UI is a Vue/Vite view layer over the Express API. All create/update/
- PSADT Best Practice Verifier for saved scripts, rendered profiles, and pasted script content with severity scoring, line-aware findings, remediation text, and rule metadata.
- Intune Publishing Wizard for PSADT Win32 deployment plans, including package source, `.intunewin` tracking, command style, UI compatibility, requirements, detection rules, return-code policy, assignment rings, status, Graph app ID, and visibility.
- Microsoft Graph Intune tracking for PSADT deployments, with Graph environment configuration managed under Config / Intune plus mobile app linking, status sync, failure review, device/user status rows, and sync run history.
- Host Library with searchable/sortable/paginated table, modal add/edit flow, and VMware vCenter VM import wizard with preview filters and credential attachment.
- Host Library with searchable/sortable/paginated table, modal add/edit flow, VMware vCenter VM import wizard, and manual/dynamic Host Groups for RunPlan and script-test targeting.
- Credential Vault as its own menu item with encrypted-at-rest secrets and modal add/edit flow.
- RunPlan Library with searchable/sortable/paginated table, modal composer, and execute action.
- Aggregate job log viewer with search/filter/pagination.
@@ -165,6 +165,7 @@ Important environment variables:
| `VCENTER_API_MODE` | `auto`, `api`, or `rest`. Defaults to `auto`, which tries modern `/api` then legacy `/rest` on incompatible endpoints. |
| `VCENTER_REQUEST_TIMEOUT_MS` | Per-request vCenter HTTP timeout. Defaults to `20000`. |
| `VCENTER_ALLOW_UNTRUSTED_TLS` | Set `true` to allow self-signed/private CA vCenter certificates. |
| `HOST_GROUP_SYNC_INTERVAL_MINUTES` | Minutes between dynamic Host Group rule syncs. Defaults to `60`; set `0` to disable the worker. |
| `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. |
| `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. |
| `TOOLS_DIR` | Directory for bundled tools. Defaults to `./tools`. |
@@ -505,9 +506,20 @@ Payload:
| --- | --- | --- | --- |
| `GET` | `/api/hosts` | User | List host library. |
| `POST` | `/api/hosts` | User | Create host. |
| `GET` | `/api/hosts/import/vcenter/status` | User | Return effective vCenter import configuration status without the password. |
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved vCenter systems. |
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved vCenter systems. |
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create an encrypted vCenter system record. |
| `PUT` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Update a visible vCenter system; omit password to keep the existing secret. |
| `DELETE` | `/api/hosts/import/vcenter/connections/:connectionId` | User | Delete a visible vCenter system. |
| `POST` | `/api/hosts/import/vcenter/connections/:connectionId/test` | User | Authenticate and list VM summaries to verify a vCenter system. |
| `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. |
| `POST` | `/api/hosts/groups` | User | Create a manual or dynamic Host Group. |
| `PUT` | `/api/hosts/groups/:groupId` | User | Update a visible Host Group. Manual groups accept explicit host members; dynamic groups accept rules. |
| `DELETE` | `/api/hosts/groups/:groupId` | User | Delete a visible Host Group and its membership rows. |
| `POST` | `/api/hosts/groups/:groupId/sync` | User | Recalculate one dynamic Host Group immediately. |
| `POST` | `/api/hosts/groups/sync` | User | Recalculate all dynamic Host Groups immediately. |
| `PUT` | `/api/hosts/:id` | User | Update host. |
| `DELETE` | `/api/hosts/:id` | User | Delete host. |
@@ -525,15 +537,88 @@ Payload:
"tags": ["production", "database"],
"notes": "Primary SQL host",
"visibility": "shared",
"groupId": null
"groupId": null,
"sourceType": "manual",
"sourceConnectionId": null,
"sourceRef": ""
}
```
`transport` can be `winrm`, `ssh`, `local`, or `api`. `visibility` can be
`personal`, `shared`, or `group`; hosts default to `shared`. RunPlans can only
target hosts visible to the operator who creates or edits them.
target hosts visible to the operator who creates or edits them. `sourceType`,
`sourceConnectionId`, and `sourceRef` are hidden provenance fields. Manual
hosts default to `sourceType: "manual"`. vCenter imports set
`sourceType: "vmware"`, `sourceConnectionId` to the selected vCenter system,
and `sourceRef` to the vCenter VM id.
VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). `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.
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
without duplicating RunPlans. Manual groups store an explicit list of visible
host IDs that an operator can add/remove in the Hosts screen. Dynamic groups
store rules and are recalculated by the API worker every
`host_group_sync_interval_minutes` minutes, by the
`HOST_GROUP_SYNC_INTERVAL_MINUTES` environment variable, or on demand with the
Sync Now actions. Dynamic group membership is rule-owned, so the UI does not
allow hand-picking members for those groups.
Create a manual Host Group:
```json
{
"name": "Engineering Manual",
"description": "Pinned engineering test machines.",
"mode": "manual",
"hostIds": ["hst_101", "hst_205"],
"visibility": "shared",
"groupId": null
}
```
Create a dynamic Host Group:
```json
{
"name": "Engineering",
"description": "Any visible host whose name contains ENG.",
"mode": "dynamic",
"matchMode": "all",
"rules": [
{ "field": "name", "operator": "contains", "value": "ENG" }
],
"visibility": "shared",
"groupId": null
}
```
Dynamic group rule fields are `name`, `fqdn`, `address`, `tags`, `transport`,
`sourceType`, and `osFamily`. Operators can combine rules with `matchMode:
"all"` or `matchMode: "any"`. Operators can use `contains`, `equals`,
`startsWith`, and `endsWith` operators. Rule matching is evaluated only against
hosts the group owner/creator can see through personal/shared/group visibility.
Create/update a saved vCenter system:
```json
{
"name": "Production vCenter",
"baseUrl": "https://vcenter.contoso.local",
"username": "administrator@vsphere.local",
"password": "stored-encrypted-on-save",
"apiMode": "auto",
"requestTimeoutMs": 20000,
"allowUntrustedTls": false,
"enabled": true,
"visibility": "shared",
"groupId": null
}
```
Saved vCenter passwords are encrypted with the same credential-store key used
by the Credential Vault. Existing `vcenter_*` settings and `VCENTER_*`
environment variables remain supported as the `Configured default` import
connection for single-vCenter deployments or Docker-provided configuration.
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.
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.
@@ -547,6 +632,7 @@ Preview payload:
```json
{
"connectionId": "vc_...",
"filter": {
"field": "hostname",
"operator": "contains",
@@ -560,6 +646,7 @@ Import payload:
```json
{
"connectionId": "vc_...",
"filter": {
"field": "hostname",
"operator": "contains",
@@ -582,6 +669,14 @@ previewed candidates that match the filter are considered. Existing visible
hosts with the same name, address, or FQDN are skipped and returned in the
`skipped` array so imports can be rerun safely.
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`
are logged and marked `skipped` for that host, so powered-off machines are not
treated like script failures. If the vCenter connection is missing or the
management API is temporarily unavailable, the runner logs the reason and
continues execution rather than blocking the whole job on a control-plane issue.
### Script Library
| Method | Route | Auth | Description |
@@ -1008,7 +1103,9 @@ The catalog returned by `/api/psadt/catalog` also includes `intune` coverage for
### RunPlans And Jobs
RunPlans pair one script with one or more host IDs. Execution creates a job with per-host log rows.
RunPlans pair one script with one or more direct host IDs, Host Group IDs, or
both. Execution expands Host Groups into their current members and creates a job
with per-host log rows.
| Method | Route | Auth | Description |
| --- | --- | --- | --- |
@@ -1031,10 +1128,17 @@ RunPlan payload:
"visibility": "shared",
"groupId": null,
"parallel": true,
"hostIds": ["hst_..."]
"hostIds": ["hst_..."],
"hostGroupIds": ["hg_..."]
}
```
`hostIds` target individual hosts. `hostGroupIds` target visible Host Groups.
Manual groups run against their saved members. Dynamic groups run against the
membership from the latest scheduler/manual sync, so use the Hosts screen Sync
Now action before execution when a just-imported host should be picked up
immediately.
Execute:
```bash

View File

@@ -213,6 +213,7 @@
:open="scriptTestModalOpen"
:script="scriptTestTarget"
:hosts="hosts"
:host-groups="hostGroups"
:credentials="credentials"
:job="scriptTestJob"
:running="scriptTestRunning"
@@ -331,10 +332,21 @@
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-address="{ row }"><code>{{ row.address }}</code></template>
<template #cell-transport="{ row }"><span :class="['status-pill', row.transport]">{{ row.transport }}</span></template>
<template #cell-sourceType="{ row }"><span :class="['status-pill', row.sourceType]">{{ row.sourceType === 'vmware' ? 'VMware' : 'Manual' }}</span></template>
<template #cell-credentialName="{ row }">{{ row.credentialName || 'No credential' }}</template>
<template #cell-tags="{ row }"><span class="tag-row">{{ row.tags?.join(', ') || '-' }}</span></template>
<template #cell-actions="{ row }"><button class="ghost-button compact" type="button" @click="openHostModal(row)">Edit</button></template>
</ResourceTable>
<HostGroupsPanel
:host-groups="hostGroups"
:hosts="hosts"
:groups="groups"
@save="saveHostGroup"
@delete="deleteHostGroup"
@sync="syncHostGroup"
@sync-all="syncHostGroups"
/>
</section>
<section v-if="view === 'credentials'" class="resource-page">
@@ -406,7 +418,7 @@
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-scriptName="{ row }">{{ row.scriptName || 'No script selected' }}</template>
<template #cell-visibility="{ row }"><span :class="['status-pill', row.visibility]">{{ row.visibility }}</span></template>
<template #cell-hostCount="{ row }">{{ row.hostCount || row.hostIds?.length || 0 }} host(s)</template>
<template #cell-hostCount="{ row }">{{ row.hostCount || row.hostIds?.length || 0 }} host(s)<span v-if="row.hostGroupCount"> · {{ row.hostGroupCount }} group(s)</span></template>
<template #cell-mode="{ row }">{{ row.parallel ? 'Parallel' : 'Serial' }}</template>
<template #cell-actions="{ row }">
<div class="table-actions">
@@ -422,6 +434,7 @@
:preview-rows="vcenterPreviewRows"
:credentials="credentials"
:groups="groups"
:connections="vcenterConnections"
:loading="vcenterImportLoading"
:error="vcenterImportError"
:status="vcenterImportStatus"
@@ -498,6 +511,15 @@
<label v-if="runPlanForm.visibility === 'group'"><span>Group</span><select v-model="runPlanForm.groupId"><option :value="null">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<label class="toggle modal-toggle"><input v-model="runPlanForm.parallel" type="checkbox" /><span><strong>Parallel execution</strong><small>Run against selected hosts concurrently.</small></span></label>
<label class="full"><span>Description</span><textarea v-model="runPlanForm.description" rows="3" placeholder="Purpose, operator notes, approval context"></textarea></label>
<div class="full modal-host-picker">
<div class="list-header"><span>Target host groups</span><strong>{{ runPlanForm.hostGroupIds.length }} selected</strong></div>
<div class="host-picker">
<label v-for="hostGroup in hostGroups" :key="hostGroup.id">
<input v-model="runPlanForm.hostGroupIds" type="checkbox" :value="hostGroup.id" />
<span><strong>{{ hostGroup.name }}</strong><small>{{ hostGroup.mode }} · {{ hostGroup.memberCount || 0 }} host(s)</small></span>
</label>
</div>
</div>
<div class="full modal-host-picker">
<div class="list-header"><span>Target hosts</span><strong>{{ runPlanForm.hostIds.length }} selected</strong></div>
<div class="host-picker">
@@ -585,6 +607,14 @@
@test="testGraphConnection"
/>
<VCenterConnectionsConfig
:connections="vcenterConnections"
:groups="groups"
@save="saveVCenterConnection"
@delete="deleteVCenterConnection"
@test="testVCenterConnection"
/>
<article :class="['glass-panel form-panel settings-layout', { 'panel-collapsed': panelCollapsed.applicationConfig }]">
<div class="section-title"><h3>Application Config</h3><div class="section-actions"><SlidersHorizontal :size="18" /><button class="panel-toggle" type="button" @click="togglePanel('applicationConfig')"><component :is="panelCollapsed.applicationConfig ? ChevronDown : ChevronUp" :size="14" /></button></div></div>
<p class="panel-intro">Database settings are editable here; environment-sourced values are shown so Docker configuration stays visible.</p>
@@ -936,6 +966,7 @@ import AppSidebar from './components/AppSidebar.vue';
import AppTopbar from './components/AppTopbar.vue';
import ConfigSection from './components/settings/ConfigSection.vue';
import IntuneGraphConfig from './components/settings/IntuneGraphConfig.vue';
import VCenterConnectionsConfig from './components/settings/VCenterConnectionsConfig.vue';
import HelpCenter from './components/help/HelpCenter.vue';
import ParticleBackdrop from './components/ParticleBackdrop.vue';
import BaseModal from './components/ui/BaseModal.vue';
@@ -943,6 +974,7 @@ import ResourceTable from './components/ui/ResourceTable.vue';
import ScriptExplorer from './components/scripts/ScriptExplorer.vue';
import ScriptTestRunModal from './components/scripts/ScriptTestRunModal.vue';
import VariableEditorModal from './components/scripts/VariableEditorModal.vue';
import HostGroupsPanel from './components/hosts/HostGroupsPanel.vue';
import VCenterImportModal from './components/hosts/VCenterImportModal.vue';
import VCenterTraceModal from './components/hosts/VCenterTraceModal.vue';
import DashboardView from './views/DashboardView.vue';
@@ -1112,6 +1144,12 @@ const settingMetadata = {
description: 'Master safety switch that controls whether RunPlans can execute scripts on target hosts.',
section: 'Execution'
},
host_group_sync_interval_minutes: {
label: 'Host Group Sync Interval',
description: 'Minutes between background sync runs for dynamic host groups. Set 0 to disable the scheduler.',
placeholder: '60',
section: 'Execution'
},
vcenter_enabled: {
label: 'VMware vCenter',
description: 'Enable VM discovery and import through the VMware vCenter REST API.',
@@ -1158,6 +1196,7 @@ const users = ref([]);
const groups = ref([]);
const credentials = ref([]);
const hosts = ref([]);
const hostGroups = ref([]);
const folders = ref([]);
const scripts = ref([]);
const assetFolders = ref([]);
@@ -1172,6 +1211,7 @@ const catalogApplications = ref([]);
const changeRequests = ref([]);
const reportingOverview = ref(null);
const graphConnections = ref([]);
const vcenterConnections = ref([]);
const graphStatusResult = ref(null);
const graphAuditResult = ref([]);
const driftResult = ref(null);
@@ -1198,9 +1238,9 @@ const loginForm = reactive({ email: 'admin@posh.local', password: 'change-me-now
const scriptDraft = reactive({ id: null, folderId: null, name: '', description: '', content: '', visibility: 'personal', groupId: null });
const scriptRenameForm = reactive({ name: '' });
const scriptCloneForm = reactive({ name: '' });
const hostForm = reactive({ id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null });
const hostForm = reactive({ id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null, sourceType: 'manual', sourceConnectionId: null, sourceRef: '' });
const credentialForm = reactive({ id: null, name: '', kind: 'username_password', username: '', secret: '', visibility: 'personal', groupId: null });
const runPlanForm = reactive({ id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [] });
const runPlanForm = reactive({ id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [], hostGroupIds: [] });
const groupForm = reactive({ name: '', description: '' });
const userForm = reactive({ email: '', displayName: '', password: '', role: 'user', groupIds: [] });
const profileForm = reactive({ displayName: '', email: '', jobTitle: '', phone: '', timezone: '', avatarUrl: '' });
@@ -1286,6 +1326,7 @@ const hostColumns = [
{ key: 'name', label: 'Name' },
{ key: 'address', label: 'Address' },
{ key: 'transport', label: 'Transport' },
{ key: 'sourceType', label: 'Source' },
{ key: 'credentialName', label: 'Credential' },
{ key: 'tags', label: 'Tags', sortable: false },
{ key: 'actions', label: 'Actions', sortable: false }
@@ -1428,6 +1469,8 @@ const filteredHosts = computed(() => filterRows(hosts.value, hostFilters, (host)
host.address,
host.fqdn,
host.transport,
host.sourceType,
host.sourceConnectionName,
host.credentialName,
host.tags?.join(' ')
]));
@@ -1618,21 +1661,51 @@ async function safeRefresh(label, promise, fallback, failures, quiet = false) {
return await promise;
} catch (err) {
console.warn(`POSHManager refresh failed for ${label}`, err);
if (!quiet) failures.push(`${label}: ${err.message || 'request failed'}`);
if (!quiet) failures.push(normalizeRefreshFailure(label, err));
return typeof fallback === 'function' ? fallback() : fallback;
}
}
function normalizeRefreshFailure(label, err) {
const message = err?.message || 'request failed';
return {
label,
message,
path: err?.path || '',
status: err?.status || null,
network: !err?.status
};
}
function formatRefreshFailures(failures) {
const visibleFailures = failures.filter(Boolean);
if (!visibleFailures.length) return '';
const networkFailures = visibleFailures.filter((failure) => failure.network);
if (networkFailures.length === visibleFailures.length) {
const first = networkFailures[0];
return `API is unreachable (${first.message}). Using last known data where available.`;
}
return `Some data did not refresh: ${visibleFailures.slice(0, 2).map((failure) => `${failure.label}: ${failure.message}`).join('; ')}`;
}
async function refreshAll() {
// Bootstrap all API-backed view state together so navigation does not trigger partial reload races.
try {
await api.get('/api/health');
} catch (err) {
console.warn('POSHManager refresh skipped because the API health probe failed', err);
notify(`API is unreachable (${err.message || 'health probe failed'}). Using last known data where available.`);
return;
}
const refreshFailures = [];
const [meResult, boot, userRows, groupRows, credentialRows, hostRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, runplanRows, jobRows] = await Promise.all([
api.get('/api/auth/me'),
const [meResult, boot, userRows, groupRows, credentialRows, hostRows, hostGroupRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, vcenterRows, runplanRows, jobRows] = await Promise.all([
safeRefresh('session', api.get('/api/auth/me'), () => ({ user: user.value }), refreshFailures),
safeRefresh('bootstrap', api.get('/api/bootstrap'), () => ({ summary: summary.value, settings: settings.value }), refreshFailures),
safeRefresh('users', api.get('/api/users'), () => users.value, refreshFailures, true),
safeRefresh('groups', api.get('/api/groups'), () => groups.value, refreshFailures),
safeRefresh('credentials', api.get('/api/credentials'), () => credentials.value, refreshFailures),
safeRefresh('hosts', api.get('/api/hosts'), () => hosts.value, refreshFailures),
safeRefresh('host groups', api.get('/api/hosts/groups'), () => hostGroups.value, refreshFailures),
safeRefresh('folders', api.get('/api/folders'), () => folders.value, refreshFailures),
safeRefresh('scripts', api.get('/api/scripts'), () => scripts.value, refreshFailures),
safeRefresh('asset folders', api.get('/api/assets/folders'), () => assetFolders.value, refreshFailures),
@@ -1642,6 +1715,7 @@ async function refreshAll() {
safeRefresh('PSADT profiles', api.get('/api/psadt/profiles'), () => psadtProfiles.value, refreshFailures),
safeRefresh('Intune deployments', api.get('/api/psadt/intune/deployments'), () => psadtIntuneDeployments.value, refreshFailures),
safeRefresh('Graph connections', api.get('/api/graph/connections'), () => graphConnections.value, refreshFailures),
safeRefresh('vCenter connections', api.get('/api/hosts/import/vcenter/connections'), () => vcenterConnections.value, refreshFailures),
safeRefresh('RunPlans', api.get('/api/runplans'), () => runplans.value, refreshFailures),
safeRefresh('jobs', api.get('/api/jobs'), () => jobs.value, refreshFailures)
]);
@@ -1654,6 +1728,7 @@ async function refreshAll() {
groups.value = groupRows;
credentials.value = credentialRows;
hosts.value = hostRows;
hostGroups.value = hostGroupRows;
folders.value = folderRows;
scripts.value = scriptRows;
assetFolders.value = assetFolderRows;
@@ -1663,6 +1738,7 @@ async function refreshAll() {
psadtProfiles.value = psadtProfileRows;
psadtIntuneDeployments.value = psadtIntuneRows;
graphConnections.value = graphRows;
vcenterConnections.value = vcenterRows;
runplans.value = runplanRows;
jobs.value = jobRows;
if (selectedScript.value) {
@@ -1671,7 +1747,8 @@ async function refreshAll() {
} else if (scriptRows.length) {
selectScript(scriptRows[0]);
}
if (refreshFailures.length) notify(`Some data did not refresh: ${refreshFailures.slice(0, 2).join('; ')}`);
const refreshMessage = formatRefreshFailures(refreshFailures);
if (refreshMessage) notify(refreshMessage);
}
async function openHelpCenter() {
@@ -2237,6 +2314,28 @@ async function testGraphConnection(id) {
notify(result.message || 'Graph connection test completed');
}
async function saveVCenterConnection(payload) {
const body = { ...payload };
const connectionId = body.id;
delete body.id;
if (connectionId) await api.put(`/api/hosts/import/vcenter/connections/${connectionId}`, body);
else await api.post('/api/hosts/import/vcenter/connections', body);
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
notify('vCenter system saved');
}
async function deleteVCenterConnection(id) {
await api.delete(`/api/hosts/import/vcenter/connections/${id}`);
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
notify('vCenter system deleted');
}
async function testVCenterConnection(id) {
const result = await api.post(`/api/hosts/import/vcenter/connections/${id}/test`, {});
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
notify(result.message || 'vCenter connection test completed');
}
async function linkGraphApp(payload) {
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/link`, {
connectionId: payload.connectionId,
@@ -2371,6 +2470,35 @@ async function saveHost() {
notify('Host saved');
}
async function saveHostGroup(payload) {
const body = { ...payload };
const groupId = body.id;
delete body.id;
if (groupId) await api.put(`/api/hosts/groups/${groupId}`, body);
else await api.post('/api/hosts/groups', body);
hostGroups.value = await api.get('/api/hosts/groups');
notify('Host group saved');
}
async function deleteHostGroup(groupId) {
await api.delete(`/api/hosts/groups/${groupId}`);
hostGroups.value = await api.get('/api/hosts/groups');
await refreshAll();
notify('Host group deleted');
}
async function syncHostGroup(groupId) {
await api.post(`/api/hosts/groups/${groupId}/sync`, {});
hostGroups.value = await api.get('/api/hosts/groups');
notify('Host group synced');
}
async function syncHostGroups() {
const result = await api.post('/api/hosts/groups/sync', {});
hostGroups.value = await api.get('/api/hosts/groups');
notify(`Dynamic host groups synced: ${result.synced || 0}`);
}
async function openVCenterImport() {
vcenterImportOpen.value = true;
vcenterImportError.value = '';
@@ -2379,6 +2507,7 @@ async function openVCenterImport() {
vcenterTraceOpen.value = false;
try {
vcenterImportStatus.value = await api.get('/api/hosts/import/vcenter/status');
vcenterConnections.value = vcenterImportStatus.value.connections || vcenterConnections.value;
} catch (err) {
vcenterImportError.value = err.message;
}
@@ -2488,7 +2617,7 @@ async function importVCenterHosts(payload) {
}
function resetHostForm() {
Object.assign(hostForm, { id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null });
Object.assign(hostForm, { id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null, sourceType: 'manual', sourceConnectionId: null, sourceRef: '' });
}
function openHostModal(host = null) {
@@ -2529,11 +2658,11 @@ async function saveRunPlan() {
}
function resetRunPlanForm() {
Object.assign(runPlanForm, { id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [] });
Object.assign(runPlanForm, { id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [], hostGroupIds: [] });
}
function openRunPlanModal(runplan = null) {
if (runplan) Object.assign(runPlanForm, { ...runplan, hostIds: runplan.hostIds || [] });
if (runplan) Object.assign(runPlanForm, { ...runplan, hostIds: runplan.hostIds || [], hostGroupIds: runplan.hostGroupIds || [] });
else resetRunPlanForm();
runPlanModalOpen.value = true;
}

View File

@@ -4,7 +4,11 @@ export function createApi(getToken, onUnauthorized) {
async function request(path, options = {}) {
const token = getToken();
const isFormData = options.body instanceof FormData;
const response = await fetch(`${API_BASE}${path}`, {
const method = options.method || 'GET';
const url = `${API_BASE}${path}`;
let response;
try {
response = await fetch(url, {
...options,
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
@@ -12,6 +16,14 @@ export function createApi(getToken, onUnauthorized) {
...(options.headers || {})
}
});
} catch (err) {
const detail = err?.message ? `: ${err.message}` : '';
const error = new Error(`${method} ${path} could not reach the API${detail}`);
error.path = path;
error.method = method;
error.cause = err;
throw error;
}
if (response.status === 401) onUnauthorized?.();
if (response.status === 204) return null;
const data = await response.json().catch(() => ({}));
@@ -19,6 +31,8 @@ export function createApi(getToken, onUnauthorized) {
const error = new Error(data.error || `Request failed: ${response.status}`);
error.status = response.status;
error.payload = data;
error.path = path;
error.method = method;
throw error;
}
return data;

View File

@@ -0,0 +1,173 @@
<template>
<article class="glass-panel resource-table-panel host-groups-panel">
<div class="section-title">
<div>
<span class="eyebrow">HOST GROUPS</span>
<h3>Target collections</h3>
<p>Manual or rule-based groups that aggregate imported and manually created hosts.</p>
</div>
<div class="section-actions">
<button class="ghost-button compact" type="button" @click="$emit('sync-all')">
<RefreshCcw :size="15" />Sync dynamic
</button>
<button class="primary-action compact" type="button" @click="openModal()">
<Plus :size="15" />Add host group
</button>
</div>
</div>
<ResourceTable
:columns="columns"
:rows="hostGroups"
:total="hostGroups.length"
item-label="host groups"
empty-text="No host groups yet."
:page-count="1"
:page="1"
:page-size="hostGroups.length || 8"
>
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-mode="{ row }"><span :class="['status-pill', row.mode]">{{ row.mode }}</span></template>
<template #cell-memberCount="{ row }">{{ row.memberCount || row.hostIds?.length || 0 }} host(s)</template>
<template #cell-rules="{ row }">{{ ruleSummary(row) }}</template>
<template #cell-lastSyncedAt="{ row }">{{ row.lastSyncedAt ? new Date(row.lastSyncedAt).toLocaleString() : '-' }}</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button v-if="row.mode === 'dynamic'" class="ghost-button compact" type="button" @click="$emit('sync', row.id)">Sync</button>
<button class="ghost-button compact" type="button" @click="openModal(row)">Edit</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', row.id)">Delete</button>
</div>
</template>
</ResourceTable>
<BaseModal
:open="modalOpen"
:title="form.id ? 'Update host group' : 'Create host group'"
eyebrow="HOST GROUPS"
description="Build reusable host targets for RunPlans and script tests."
wide
@close="modalOpen = false"
>
<form class="form-grid" @submit.prevent="save">
<label><span>Name</span><input v-model="form.name" required placeholder="Engineering" /></label>
<label><span>Mode</span><select v-model="form.mode"><option value="manual">Manual</option><option value="dynamic">Rule based</option></select></label>
<label><span>Visibility</span><select v-model="form.visibility"><option value="shared">Shared</option><option value="personal">Personal</option><option value="group">Group</option></select></label>
<label v-if="form.visibility === 'group'"><span>Group</span><select v-model="form.groupId"><option value="">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<label class="full"><span>Description</span><textarea v-model="form.description" rows="2" placeholder="What this collection targets"></textarea></label>
<section v-if="form.mode === 'manual'" class="full modal-host-picker">
<div class="list-header"><span>Manual members</span><strong>{{ form.hostIds.length }} selected</strong></div>
<div class="host-picker">
<label v-for="host in hosts" :key="host.id">
<input v-model="form.hostIds" type="checkbox" :value="host.id" />
<span><strong>{{ host.name }}</strong><small>{{ host.transport }} - {{ host.address }}</small></span>
</label>
</div>
</section>
<section v-else class="full rule-editor">
<div class="list-header"><span>Dynamic rules</span><strong>{{ form.rules.length }} rule(s)</strong></div>
<label><span>Match mode</span><select v-model="form.matchMode"><option value="all">All rules</option><option value="any">Any rule</option></select></label>
<div v-for="(rule, index) in form.rules" :key="index" class="rule-row">
<select v-model="rule.field">
<option value="name">Name</option>
<option value="fqdn">FQDN</option>
<option value="address">Address</option>
<option value="tags">Tags</option>
<option value="transport">Transport</option>
<option value="sourceType">Source</option>
<option value="osFamily">OS family</option>
</select>
<select v-model="rule.operator">
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="startsWith">starts with</option>
<option value="endsWith">ends with</option>
</select>
<input v-model="rule.value" required placeholder="ENG" />
<button class="ghost-button compact danger-text" type="button" @click="removeRule(index)">
<Trash2 :size="14" />
</button>
</div>
<button class="ghost-button compact" type="button" @click="addRule"><Plus :size="14" />Add rule</button>
</section>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Save :size="17" />Save host group</button>
</div>
</form>
</BaseModal>
</article>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { Plus, RefreshCcw, Save, Trash2 } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
import ResourceTable from '../ui/ResourceTable.vue';
defineProps({
hostGroups: { type: Array, default: () => [] },
hosts: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] }
});
const emit = defineEmits(['save', 'delete', 'sync', 'sync-all']);
const modalOpen = ref(false);
const form = reactive(defaultForm());
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'mode', label: 'Mode' },
{ key: 'memberCount', label: 'Members' },
{ key: 'rules', label: 'Rules', sortable: false },
{ key: 'lastSyncedAt', label: 'Last sync' },
{ key: 'actions', label: 'Actions', sortable: false }
];
function defaultForm() {
return {
id: null,
name: '',
description: '',
mode: 'manual',
matchMode: 'all',
rules: [{ field: 'name', operator: 'contains', value: 'ENG' }],
hostIds: [],
visibility: 'shared',
groupId: ''
};
}
function openModal(group = null) {
Object.assign(form, defaultForm(), group || {});
form.rules = (group?.rules?.length ? group.rules : defaultForm().rules).map((rule) => ({ ...rule }));
form.hostIds = [...(group?.hostIds || [])];
form.groupId = group?.groupId || '';
modalOpen.value = true;
}
function addRule() {
form.rules.push({ field: 'name', operator: 'contains', value: '' });
}
function removeRule(index) {
form.rules.splice(index, 1);
if (!form.rules.length) addRule();
}
function ruleSummary(group) {
if (group.mode === 'manual') return 'Manual membership';
return (group.rules || []).map((rule) => `${rule.field} ${rule.operator} "${rule.value}"`).join(` ${group.matchMode || 'all'} `) || 'No rules';
}
function save() {
emit('save', {
...form,
groupId: form.groupId || null,
rules: form.mode === 'dynamic' ? form.rules.filter((rule) => rule.value?.trim()) : [],
hostIds: form.mode === 'manual' ? form.hostIds : []
});
modalOpen.value = false;
}
</script>

View File

@@ -17,6 +17,15 @@
</div>
</div>
<div class="vcenter-filter-grid">
<label class="filter-value">
<span>vCenter system</span>
<select v-model="draft.connectionId">
<option :value="null">Configured default</option>
<option v-for="connection in connections" :key="connection.id" :value="connection.id">
{{ connection.name }} - {{ connection.baseUrl }}
</option>
</select>
</label>
<label>
<span>Field</span>
<select v-model="draft.filter.field">
@@ -49,8 +58,8 @@
<Search :size="16" />Preview matches
</button>
<span v-if="status" class="vcenter-status">
<span :class="['status-dot', status.configured ? 'success' : 'warning']"></span>
{{ status.configured ? `Connected settings for ${status.baseUrl}` : 'vCenter settings incomplete' }}
<span :class="['status-dot', activeStatus.configured ? 'success' : 'warning']"></span>
{{ activeStatus.configured ? `Using ${activeStatus.name || 'vCenter'} at ${activeStatus.baseUrl}` : 'vCenter settings incomplete' }}
</span>
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
<FileSearch :size="15" />View API trace
@@ -188,6 +197,7 @@ const props = defineProps({
previewRows: { type: Array, default: () => [] },
credentials: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
connections: { type: Array, default: () => [] },
loading: Boolean,
error: { type: String, default: '' },
status: { type: Object, default: null },
@@ -198,6 +208,7 @@ const props = defineProps({
const emit = defineEmits(['close', 'preview', 'import', 'open-trace']);
const draft = reactive({
connectionId: null,
filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' },
limit: 100,
credentialId: null,
@@ -210,6 +221,17 @@ const draft = reactive({
const selectedIds = ref([]);
const allSelected = computed(() => props.previewRows.length > 0 && selectedIds.value.length === props.previewRows.length);
const activeStatus = computed(() => {
if (draft.connectionId) {
const connection = props.connections.find((item) => item.id === draft.connectionId);
return {
name: connection?.name || 'Selected vCenter',
configured: Boolean(connection?.baseUrl && connection?.username),
baseUrl: connection?.baseUrl || ''
};
}
return props.status?.defaultConnection || props.status || {};
});
watch(() => props.previewRows, (rows) => {
selectedIds.value = rows.map((row) => row.id);
@@ -221,6 +243,7 @@ watch(() => props.open, (open) => {
function payloadBase() {
return {
connectionId: draft.connectionId || null,
filter: { ...draft.filter, value: draft.filter.value.trim() },
limit: Number(draft.limit) || 100
};

View File

@@ -3,20 +3,33 @@
:open="open"
title="Test / Run script"
eyebrow="SCRIPT EXPLORER"
:description="script ? `Run ${script.name} against one selected host with one selected vault credential.` : 'Run the selected script against one host.'"
:description="script ? `Run ${script.name} against one selected host or host group with one selected vault credential.` : 'Run the selected script against a host or host group.'"
wide
@close="$emit('close')"
>
<form class="modal-form script-test-form" @submit.prevent="execute">
<div class="script-test-grid">
<label>
<span>Target host</span>
<select v-model="hostId" required>
<span>Target type</span>
<select v-model="targetType">
<option value="host">Host</option>
<option value="group">Host group</option>
</select>
</label>
<label>
<span>{{ targetType === 'group' ? 'Target host group' : 'Target host' }}</span>
<select v-if="targetType === 'host'" v-model="hostId" required>
<option value="" disabled>Choose host</option>
<option v-for="host in hosts" :key="host.id" :value="host.id">
{{ host.name }} - {{ host.address }} ({{ host.transport }})
</option>
</select>
<select v-else v-model="hostGroupId" required>
<option value="" disabled>Choose host group</option>
<option v-for="group in hostGroups" :key="group.id" :value="group.id">
{{ group.name }} - {{ group.mode }} / {{ group.memberCount || 0 }} host(s)
</option>
</select>
</label>
<label>
<span>Vault credential</span>
@@ -36,7 +49,7 @@
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="$emit('close')">Close</button>
<button class="primary-action" type="submit" :disabled="running || !hostId || !credentialId">
<button class="primary-action" type="submit" :disabled="running || !targetSelected || !credentialId">
<Play :size="16" />{{ running ? 'Executing...' : 'Execute test' }}
</button>
</div>
@@ -67,6 +80,7 @@ const props = defineProps({
open: Boolean,
script: { type: Object, default: null },
hosts: { type: Array, default: () => [] },
hostGroups: { type: Array, default: () => [] },
credentials: { type: Array, default: () => [] },
job: { type: Object, default: null },
running: Boolean,
@@ -76,13 +90,17 @@ const props = defineProps({
const emit = defineEmits(['close', 'execute']);
const hostId = ref('');
const hostGroupId = ref('');
const targetType = ref('host');
const credentialId = ref('');
const selectedHost = computed(() => props.hosts.find((host) => host.id === hostId.value));
const selectedHostGroup = computed(() => props.hostGroups.find((group) => group.id === hostGroupId.value));
const selectedCredential = computed(() => props.credentials.find((credential) => credential.id === credentialId.value));
const targetSelected = computed(() => targetType.value === 'group' ? Boolean(hostGroupId.value) : Boolean(hostId.value));
const selectedHostSummary = computed(() => selectedHost.value
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${selectedHost.value.address}`
: 'No host selected');
: (selectedHostGroup.value ? `${selectedHostGroup.value.name} / ${selectedHostGroup.value.mode} / ${selectedHostGroup.value.memberCount || 0} host(s)` : 'No target selected'));
const selectedCredentialSummary = computed(() => selectedCredential.value
? `${selectedCredential.value.name}${selectedCredential.value.username ? ` as ${selectedCredential.value.username}` : ''}`
: 'No credential selected');
@@ -91,7 +109,7 @@ const terminalRows = computed(() => {
const lines = [
`POSHManager script test job ${props.job.id}`,
`Script: ${props.job.scriptName || props.script?.name || 'selected script'}`,
`Host: ${selectedHostSummary.value}`,
`Target: ${selectedHostSummary.value}`,
`Credential: ${selectedCredentialSummary.value}`,
`Status: ${props.job.status || 'queued'}`,
''
@@ -107,7 +125,9 @@ const terminalLineCount = computed(() => terminalRows.value.length);
watch(() => props.open, (open) => {
if (!open) return;
targetType.value = props.hosts.length ? 'host' : 'group';
hostId.value = props.hosts[0]?.id || '';
hostGroupId.value = props.hostGroups[0]?.id || '';
credentialId.value = selectedHost.value?.credentialId || props.credentials[0]?.id || '';
}, { immediate: true });
@@ -118,6 +138,10 @@ watch(hostId, () => {
});
function execute() {
emit('execute', { hostId: hostId.value, credentialId: credentialId.value });
emit('execute', {
hostId: targetType.value === 'host' ? hostId.value : '',
hostGroupId: targetType.value === 'group' ? hostGroupId.value : '',
credentialId: credentialId.value
});
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<article class="glass-panel form-panel intune-config-panel">
<div class="section-title">
<div>
<span class="eyebrow">CONFIG / VMWARE</span>
<h3>VMware vCenter</h3>
<p>Register one or more vCenter systems used for host imports and execution-time power checks.</p>
</div>
<button class="primary-action compact" type="button" @click="openModal()">
<CloudCog :size="15" />Add vCenter
</button>
</div>
<div class="intune-config-grid">
<section class="publisher-card">
<strong>Inventory sources</strong>
<p>Imported VMware hosts keep their source connection and VM id so runs can skip guests that are powered off.</p>
<div class="config-stat-row">
<span><b>{{ connections.length }}</b><small>systems</small></span>
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
<span><b>{{ testedCount }}</b><small>tested</small></span>
</div>
</section>
<section class="publisher-card graph-config-list">
<strong>vCenter systems</strong>
<div v-for="connection in connections" :key="connection.id" class="check-row">
<b>{{ connection.name }}</b>
<small>{{ connection.baseUrl }} - {{ connection.apiMode }} - {{ connection.lastTestStatus || 'untested' }}</small>
<span>
<button class="ghost-button compact" type="button" @click="openModal(connection)">Edit</button>
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">Test</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">Delete</button>
</span>
</div>
<p v-if="!connections.length" class="widget-empty">No saved vCenter systems configured. The legacy env/settings default can still be used by import.</p>
</section>
</div>
<BaseModal
:open="modalOpen"
:title="form.id ? 'Update vCenter system' : 'New vCenter system'"
eyebrow="CONFIG / VMWARE"
description="Store vCenter API credentials encrypted at rest. These connections are selectable during host import."
wide
@close="modalOpen = false"
>
<form class="form-grid" @submit.prevent="save">
<label><span>Name</span><input v-model="form.name" required placeholder="Production vCenter" /></label>
<label><span>Base URL</span><input v-model="form.baseUrl" required placeholder="https://vcenter.contoso.local" /></label>
<label><span>Username</span><input v-model="form.username" required placeholder="administrator@vsphere.local" /></label>
<label><span>{{ form.id ? 'Replace password' : 'Password' }}</span><input v-model="form.password" :required="!form.id" type="password" placeholder="Stored encrypted after save" /></label>
<label><span>API mode</span><select v-model="form.apiMode"><option value="auto">Auto</option><option value="api">vSphere /api</option><option value="rest">Legacy /rest</option></select></label>
<label><span>Timeout (ms)</span><input v-model.number="form.requestTimeoutMs" type="number" min="1000" max="120000" /></label>
<label><span>Visibility</span><select v-model="form.visibility"><option value="personal">Personal</option><option value="shared">Shared</option><option value="group">Group</option></select></label>
<label v-if="form.visibility === 'group'"><span>Group</span><select v-model="form.groupId"><option value="">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<label class="toggle modal-toggle"><input v-model="form.enabled" type="checkbox" /><span><strong>Enabled</strong><small>Allow this system to be used for import and power-state checks.</small></span></label>
<label class="toggle modal-toggle"><input v-model="form.allowUntrustedTls" type="checkbox" /><span><strong>Allow untrusted TLS</strong><small>Use only for internal/self-signed vCenter certificates.</small></span></label>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Save :size="17" />Save vCenter</button>
</div>
</form>
</BaseModal>
</article>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { CloudCog, Save } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
connections: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] }
});
const emit = defineEmits(['save', 'delete', 'test']);
const modalOpen = ref(false);
const form = reactive(defaultForm());
const enabledCount = computed(() => props.connections.filter((connection) => connection.enabled).length);
const testedCount = computed(() => props.connections.filter((connection) => connection.lastTestStatus).length);
function defaultForm() {
return {
id: null,
name: '',
baseUrl: '',
username: '',
password: '',
apiMode: 'auto',
requestTimeoutMs: 20000,
allowUntrustedTls: false,
enabled: true,
visibility: 'shared',
groupId: ''
};
}
function openModal(connection = null) {
Object.assign(form, defaultForm(), connection || {});
form.password = '';
form.groupId = connection?.groupId || '';
modalOpen.value = true;
}
function save() {
emit('save', { ...form, groupId: form.groupId || null });
modalOpen.value = false;
}
</script>

View File

@@ -4699,6 +4699,32 @@ body {
overflow: auto;
}
.rule-editor {
padding: 12px;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(0, 0, 0, .12);
}
.rule-editor > label {
max-width: 260px;
margin-bottom: 10px;
}
.rule-row {
display: grid;
grid-template-columns: minmax(130px, .8fr) minmax(130px, .8fr) minmax(180px, 1fr) auto;
gap: 10px;
align-items: end;
margin-bottom: 10px;
}
@media (max-width: 720px) {
.rule-row {
grid-template-columns: 1fr;
}
}
.log-toolbar select {
width: 170px;
min-height: 37px;
@@ -6925,7 +6951,7 @@ input:read-only {
.vcenter-filter-grid,
.vcenter-options-grid {
display: grid;
grid-template-columns: minmax(150px, .85fr) minmax(150px, .85fr) minmax(220px, 1.35fr) minmax(120px, .55fr);
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
align-items: end;
}

View File

@@ -1,6 +1,15 @@
import { hostSchema, vcenterImportSchema, vcenterPreviewSchema } from '../forms/schemas.js';
import { hostSchema, vcenterConnectionSchema, vcenterImportSchema, vcenterPreviewSchema } from '../forms/schemas.js';
import { createHost, deleteHost, findVisibleHostByIdentity, listHosts, updateHost } from '../models/hostModel.js';
import { buildHostPayloadFromVm, previewVCenterHosts, vcenterStatus } from '../services/vcenterService.js';
import { createHostGroup, deleteHostGroup, getVisibleHostGroup, listHostGroups, syncHostGroup, syncDynamicHostGroups, updateHostGroup } from '../models/hostGroupModel.js';
import {
createVCenterConnection,
deleteVCenterConnection,
getVCenterConnection,
listVCenterConnections,
recordVCenterConnectionTest,
updateVCenterConnection
} from '../models/vcenterConnectionModel.js';
import { buildHostPayloadFromVm, previewVCenterHosts, testVCenterConnection, vcenterStatus } from '../services/vcenterService.js';
export function index(req, res) {
res.json(listHosts(req.user.id));
@@ -23,15 +32,58 @@ export function destroy(req, res) {
res.status(204).end();
}
export function listGroups(req, res) {
res.json(listHostGroups(req.user.id));
}
export function createGroup(req, res) {
try {
res.status(201).json(createHostGroup(req.body, req.user.id));
} catch (error) {
res.status(400).json({ error: error.message });
}
}
export function updateGroup(req, res) {
try {
const group = updateHostGroup(req.params.groupId, req.body, req.user.id);
if (!group) return res.status(404).json({ error: 'Host group not found' });
res.json(group);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
export function deleteGroup(req, res) {
deleteHostGroup(req.params.groupId, req.user.id);
res.status(204).end();
}
export function syncGroup(req, res) {
const group = getVisibleHostGroup(req.params.groupId, req.user.id);
if (!group) return res.status(404).json({ error: 'Host group not found' });
if (group.mode !== 'dynamic') return res.status(400).json({ error: 'Only dynamic host groups can be synced.' });
res.json(syncHostGroup(group.id));
}
export function syncGroups(req, res) {
res.json(syncDynamicHostGroups());
}
export function vcenterImportStatus(req, res) {
res.json(vcenterStatus());
res.json({
defaultConnection: vcenterStatus(),
connections: listVCenterConnections(req.user.id)
});
}
export async function previewVCenterImport(req, res) {
const parsed = vcenterPreviewSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter preview payload' });
try {
res.json(await previewVCenterHosts(parsed.data));
const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null;
if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'vCenter connection not found' });
res.json(await previewVCenterHosts({ ...parsed.data, connection }));
} catch (err) {
res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
}
@@ -41,7 +93,9 @@ export async function importVCenterHosts(req, res) {
const parsed = vcenterImportSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter import payload' });
try {
const preview = await previewVCenterHosts(parsed.data);
const connection = parsed.data.connectionId ? getVCenterConnection(parsed.data.connectionId, req.user.id, true) : null;
if (parsed.data.connectionId && !connection) return res.status(404).json({ error: 'vCenter connection not found' });
const preview = await previewVCenterHosts({ ...parsed.data, connection });
const selected = new Set(parsed.data.vmIds || []);
const candidates = selected.size
? preview.candidates.filter((candidate) => selected.has(candidate.id))
@@ -64,3 +118,40 @@ export async function importVCenterHosts(req, res) {
res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
}
}
export function listVCenterConnectionRecords(req, res) {
res.json(listVCenterConnections(req.user.id));
}
export function createVCenterConnectionRecord(req, res) {
const parsed = vcenterConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter connection payload' });
if (!parsed.data.password) return res.status(400).json({ error: 'A vCenter password is required when creating a connection' });
res.status(201).json(createVCenterConnection(parsed.data, req.user.id));
}
export function updateVCenterConnectionRecord(req, res) {
const parsed = vcenterConnectionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid vCenter connection payload' });
const connection = updateVCenterConnection(req.params.connectionId, parsed.data, req.user.id);
if (!connection) return res.status(404).json({ error: 'vCenter connection not found' });
res.json(connection);
}
export function deleteVCenterConnectionRecord(req, res) {
deleteVCenterConnection(req.params.connectionId, req.user.id);
res.status(204).end();
}
export async function testVCenterConnectionRecord(req, res) {
const connection = getVCenterConnection(req.params.connectionId, req.user.id, true);
if (!connection) return res.status(404).json({ error: 'vCenter connection not found' });
try {
const result = await testVCenterConnection(connection);
recordVCenterConnectionTest(req.params.connectionId, 'success', result.message);
res.json(result);
} catch (err) {
recordVCenterConnectionTest(req.params.connectionId, 'failed', err.message);
res.status(err.statusCode || 502).json({ error: err.message, trace: err.trace || [] });
}
}

View File

@@ -93,10 +93,60 @@ export function migrate() {
credential_id TEXT REFERENCES credentials(id) ON DELETE SET NULL,
tags TEXT NOT NULL DEFAULT '[]',
notes TEXT,
source_type TEXT NOT NULL DEFAULT 'manual',
source_connection_id TEXT,
source_ref TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS vcenter_connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
username TEXT NOT NULL,
secret_cipher TEXT NOT NULL,
secret_iv TEXT NOT NULL,
secret_tag TEXT NOT NULL,
api_mode TEXT NOT NULL DEFAULT 'auto',
request_timeout_ms INTEGER NOT NULL DEFAULT 20000,
allow_untrusted_tls INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
last_test_status TEXT,
last_test_message TEXT,
last_test_at TEXT,
visibility TEXT NOT NULL DEFAULT 'shared',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS host_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
mode TEXT NOT NULL DEFAULT 'manual',
match_mode TEXT NOT NULL DEFAULT 'all',
rules TEXT NOT NULL DEFAULT '[]',
visibility TEXT NOT NULL DEFAULT 'shared',
owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
group_id TEXT REFERENCES groups(id) ON DELETE SET NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
last_synced_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS host_group_members (
host_group_id TEXT NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE,
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
source TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL,
PRIMARY KEY (host_group_id, host_id)
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
@@ -384,6 +434,12 @@ export function migrate() {
PRIMARY KEY (runplan_id, host_id)
);
CREATE TABLE IF NOT EXISTS runplan_host_groups (
runplan_id TEXT NOT NULL REFERENCES runplans(id) ON DELETE CASCADE,
host_group_id TEXT NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE,
PRIMARY KEY (runplan_id, host_group_id)
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL,
@@ -450,6 +506,11 @@ export function migrate() {
ensureColumn('hosts', 'visibility', "TEXT NOT NULL DEFAULT 'shared'");
ensureColumn('hosts', 'owner_user_id', 'TEXT REFERENCES users(id) ON DELETE SET NULL');
ensureColumn('hosts', 'group_id', 'TEXT REFERENCES groups(id) ON DELETE SET NULL');
// Host provenance is hidden from normal manual edit forms but used by
// automation gates such as vCenter power-state checks before execution.
ensureColumn('hosts', 'source_type', "TEXT NOT NULL DEFAULT 'manual'");
ensureColumn('hosts', 'source_connection_id', 'TEXT');
ensureColumn('hosts', 'source_ref', 'TEXT');
// Tenant write-back is opt-in per Graph connection. Default 0 so existing
// read-only connections can never be used to change a tenant by accident.
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');

View File

@@ -60,6 +60,22 @@ export const hostSchema = z.object({
tags: z.array(z.string()).default([]),
notes: z.string().optional(),
visibility: visibilitySchema.default('shared'),
groupId: z.string().nullable().optional(),
sourceType: z.enum(['manual', 'vmware']).optional().default('manual'),
sourceConnectionId: z.string().nullable().optional(),
sourceRef: z.string().max(300).optional().default('')
});
export const vcenterConnectionSchema = z.object({
name: z.string().min(1).max(120),
baseUrl: z.string().url(),
username: z.string().min(1).max(200),
password: z.string().optional().default(''),
apiMode: z.enum(['auto', 'api', 'rest']).optional().default('auto'),
requestTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional().default(20000),
allowUntrustedTls: z.boolean().optional().default(false),
enabled: z.boolean().optional().default(true),
visibility: visibilitySchema.default('shared'),
groupId: z.string().nullable().optional()
});
@@ -70,6 +86,7 @@ const vcenterFilterSchema = z.object({
});
export const vcenterPreviewSchema = z.object({
connectionId: z.string().nullable().optional(),
filter: vcenterFilterSchema.optional().default({}),
limit: z.coerce.number().int().min(1).max(500).optional().default(100)
});
@@ -84,6 +101,23 @@ export const vcenterImportSchema = vcenterPreviewSchema.extend({
tags: z.array(z.string().max(80)).optional().default([])
});
export const hostGroupRuleSchema = z.object({
field: z.enum(['name', 'fqdn', 'address', 'tags', 'transport', 'sourceType', 'osFamily']).default('name'),
operator: z.enum(['contains', 'equals', 'startsWith', 'endsWith']).default('contains'),
value: z.string().min(1).max(200)
});
export const hostGroupSchema = z.object({
name: z.string().min(1).max(160),
description: z.string().max(600).optional().default(''),
mode: z.enum(['manual', 'dynamic']).default('manual'),
matchMode: z.enum(['all', 'any']).optional().default('all'),
rules: z.array(hostGroupRuleSchema).optional().default([]),
hostIds: z.array(z.string()).optional().default([]),
visibility: visibilitySchema.default('shared'),
groupId: z.string().nullable().optional()
});
export const folderSchema = z.object({
parentId: z.string().nullable().optional(),
name: z.string().min(1),
@@ -310,8 +344,11 @@ const scriptPayloadSchema = z.object({
});
export const scriptTestRunSchema = z.object({
hostId: z.string().min(1),
hostId: z.string().optional().default(''),
hostGroupId: z.string().optional().default(''),
credentialId: z.string().min(1)
}).refine((value) => Boolean(value.hostId || value.hostGroupId), {
message: 'Choose a host or host group.'
});
const runPlanPayloadSchema = z.object({
@@ -323,7 +360,8 @@ const runPlanPayloadSchema = z.object({
groupId: z.string().nullable().optional(),
group_id: z.string().nullable().optional(),
parallel: z.boolean().or(z.number()).default(true),
hostIds: z.array(z.string()).optional()
hostIds: z.array(z.string()).optional(),
hostGroupIds: z.array(z.string()).optional()
});
export function normalizeScriptPayload(body) {
@@ -347,6 +385,7 @@ export function normalizeRunPlanPayload(body) {
visibility: parsed.visibility,
groupId: parsed.visibility === 'group' ? parsed.groupId ?? parsed.group_id ?? null : null,
parallel: Boolean(parsed.parallel),
hostIds: parsed.hostIds || []
hostIds: parsed.hostIds || [],
hostGroupIds: parsed.hostGroupIds || []
};
}

View File

@@ -9,6 +9,7 @@ import { logger } from './services/logger.js';
import { effectiveTrustedOrigins } from './models/settingsModel.js';
import { startCatalogWorker } from './services/catalogWorker.js';
import { startPromotionWorker } from './services/promotionWorker.js';
import { startHostGroupWorker } from './services/hostGroupWorker.js';
// Fail fast in production rather than ship with well-known default secrets.
// These defaults are fine for local development but are publicly known, so a
@@ -63,4 +64,5 @@ app.listen(config.port, () => {
logger.info(`POSHManager API listening on ${config.port}`);
startCatalogWorker();
startPromotionWorker();
startHostGroupWorker();
});

View File

@@ -0,0 +1,185 @@
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
import { hostGroupSchema } from '../forms/schemas.js';
import { camelHostGroup } from './mappers.js';
import { filterVisibleHostIds } from './hostModel.js';
function scopedGroupId(payload) {
return payload.visibility === 'group' ? payload.groupId || null : null;
}
function normalizeHostGroup(body) {
const parsed = hostGroupSchema.parse(body || {});
return {
...parsed,
groupId: scopedGroupId(parsed),
rules: parsed.mode === 'dynamic' ? parsed.rules : [],
hostIds: parsed.mode === 'manual' ? parsed.hostIds : []
};
}
export function listHostGroups(userId) {
return db.prepare(`
SELECT hg.*, g.name AS group_name,
COALESCE((SELECT COUNT(*) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), 0) AS member_count,
COALESCE((SELECT json_group_array(host_id) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), '[]') AS host_ids
FROM host_groups hg
LEFT JOIN groups g ON g.id = hg.group_id
WHERE ${visibleClause('hg')}
ORDER BY hg.name
`).all(userId, userId).map(camelHostGroup);
}
export function getVisibleHostGroup(groupId, userId) {
const row = db.prepare(`
SELECT hg.*, g.name AS group_name,
COALESCE((SELECT COUNT(*) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), 0) AS member_count,
COALESCE((SELECT json_group_array(host_id) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), '[]') AS host_ids
FROM host_groups hg
LEFT JOIN groups g ON g.id = hg.group_id
WHERE hg.id = ? AND ${visibleClause('hg')}
`).get(groupId, userId, userId);
return row ? camelHostGroup(row) : null;
}
export function createHostGroup(body, userId) {
const payload = normalizeHostGroup(body);
if (payload.mode === 'dynamic' && !payload.rules.length) throw new Error('Dynamic host groups require at least one rule.');
const groupId = id('hgrp');
db.prepare(`
INSERT INTO host_groups (
id, name, description, mode, match_mode, rules, visibility, owner_user_id,
group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
groupId,
payload.name,
payload.description,
payload.mode,
payload.matchMode,
json(payload.rules),
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
if (payload.mode === 'manual') replaceHostGroupMembers(groupId, filterVisibleHostIds(payload.hostIds, userId), 'manual');
else syncHostGroup(groupId);
return getVisibleHostGroup(groupId, userId);
}
export function updateHostGroup(groupId, body, userId) {
const existing = db.prepare(`SELECT * FROM host_groups WHERE id = ? AND ${visibleClause()}`).get(groupId, userId, userId);
if (!existing) return null;
const payload = normalizeHostGroup({
name: existing.name,
description: existing.description || '',
mode: existing.mode,
matchMode: existing.match_mode,
rules: parseJson(existing.rules, []),
visibility: existing.visibility,
groupId: existing.group_id,
...body
});
if (payload.mode === 'dynamic' && !payload.rules.length) throw new Error('Dynamic host groups require at least one rule.');
db.prepare(`
UPDATE host_groups
SET name = ?, description = ?, mode = ?, match_mode = ?, rules = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.description,
payload.mode,
payload.matchMode,
json(payload.rules),
payload.visibility,
payload.groupId,
now(),
groupId
);
if (payload.mode === 'manual') replaceHostGroupMembers(groupId, filterVisibleHostIds(payload.hostIds, userId), 'manual');
else syncHostGroup(groupId);
return getVisibleHostGroup(groupId, userId);
}
export function deleteHostGroup(groupId, userId) {
db.prepare(`DELETE FROM host_groups WHERE id = ? AND ${visibleClause()}`).run(groupId, userId, userId);
}
export function filterVisibleHostGroupIds(groupIds = [], userId) {
if (!groupIds.length) return [];
const visible = new Set(
db.prepare(`SELECT id FROM host_groups WHERE ${visibleClause()}`).all(userId, userId).map((row) => row.id)
);
return groupIds.filter((groupId) => visible.has(groupId));
}
export function syncHostGroup(groupId) {
const group = db.prepare('SELECT * FROM host_groups WHERE id = ?').get(groupId);
if (!group || group.mode !== 'dynamic') return { groupId, matched: 0, skipped: true };
const ownerId = group.owner_user_id || group.created_by;
const hosts = ownerId
? db.prepare(`SELECT * FROM hosts WHERE ${visibleClause()} ORDER BY name`).all(ownerId, ownerId)
: db.prepare("SELECT * FROM hosts WHERE visibility = 'shared' ORDER BY name").all();
const rules = parseJson(group.rules, []);
const matched = hosts.filter((host) => hostMatchesRules(host, rules, group.match_mode)).map((host) => host.id);
replaceHostGroupMembers(groupId, matched, 'rule');
db.prepare('UPDATE host_groups SET last_synced_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), groupId);
return { groupId, matched: matched.length, skipped: false };
}
export function syncDynamicHostGroups() {
const groups = db.prepare("SELECT id FROM host_groups WHERE mode = 'dynamic'").all();
const summary = { evaluated: 0, synced: 0, matched: 0, errors: 0 };
for (const group of groups) {
summary.evaluated += 1;
try {
const result = syncHostGroup(group.id);
if (!result.skipped) {
summary.synced += 1;
summary.matched += result.matched;
}
} catch {
summary.errors += 1;
}
}
return summary;
}
function replaceHostGroupMembers(groupId, hostIds, source) {
db.prepare('DELETE FROM host_group_members WHERE host_group_id = ?').run(groupId);
const stmt = db.prepare(`
INSERT OR IGNORE INTO host_group_members (host_group_id, host_id, source, created_at)
VALUES (?, ?, ?, ?)
`);
for (const hostId of [...new Set(hostIds)]) stmt.run(groupId, hostId, source, now());
}
export function hostMatchesRules(host, rules = [], matchMode = 'all') {
if (!rules.length) return false;
const results = rules.map((rule) => hostMatchesRule(host, rule));
return matchMode === 'any' ? results.some(Boolean) : results.every(Boolean);
}
function hostMatchesRule(host, rule) {
const values = hostRuleValues(host, rule.field);
const needle = String(rule.value || '').toLowerCase();
return values.some((value) => compareRuleValue(String(value || '').toLowerCase(), needle, rule.operator));
}
function hostRuleValues(host, field) {
if (field === 'tags') return parseJson(host.tags, []);
if (field === 'sourceType') return [host.source_type || 'manual'];
if (field === 'osFamily') return [host.os_family || ''];
return [host[field] || ''];
}
function compareRuleValue(value, needle, operator = 'contains') {
if (operator === 'equals') return value === needle;
if (operator === 'startsWith') return value.startsWith(needle);
if (operator === 'endsWith') return value.endsWith(needle);
return value.includes(needle);
}

View File

@@ -3,10 +3,11 @@ import { camelHost } from './mappers.js';
export function listHosts(userId) {
return db.prepare(`
SELECT h.*, c.name AS credential_name, g.name AS group_name
SELECT h.*, c.name AS credential_name, g.name AS group_name, vc.name AS source_connection_name
FROM hosts h
LEFT JOIN credentials c ON c.id = h.credential_id
LEFT JOIN groups g ON g.id = h.group_id
LEFT JOIN vcenter_connections vc ON vc.id = h.source_connection_id
WHERE ${visibleClause('h')}
ORDER BY h.name
`).all(userId, userId).map(camelHost);
@@ -20,8 +21,9 @@ export function createHost(payload, userId) {
const hostId = id('hst');
db.prepare(`
INSERT INTO hosts (id, name, fqdn, address, os_family, transport, port, credential_id, tags, notes,
visibility, owner_user_id, group_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
visibility, owner_user_id, group_id, source_type, source_connection_id, source_ref,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
hostId,
payload.name,
@@ -36,6 +38,9 @@ export function createHost(payload, userId) {
payload.visibility || 'shared',
userId,
scopedGroupId(payload),
payload.sourceType || 'manual',
payload.sourceConnectionId || null,
payload.sourceRef || '',
now(),
now()
);
@@ -69,7 +74,7 @@ export function updateHost(hostId, payload, userId) {
db.prepare(`
UPDATE hosts
SET name = ?, fqdn = ?, address = ?, os_family = ?, transport = ?, port = ?, credential_id = ?,
tags = ?, notes = ?, visibility = ?, group_id = ?, updated_at = ?
tags = ?, notes = ?, visibility = ?, group_id = ?, source_type = ?, source_connection_id = ?, source_ref = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name || existing.name,
@@ -83,6 +88,9 @@ export function updateHost(hostId, payload, userId) {
payload.notes ?? existing.notes,
visibility,
visibility === 'group' ? (payload.groupId ?? existing.group_id) : null,
payload.sourceType ?? existing.source_type ?? 'manual',
payload.sourceConnectionId ?? existing.source_connection_id,
payload.sourceRef ?? existing.source_ref ?? '',
now(),
hostId
);

View File

@@ -37,6 +37,53 @@ export function camelHost(row) {
ownerUserId: row.owner_user_id || null,
groupId: row.group_id || null,
groupName: row.group_name || null,
sourceType: row.source_type || 'manual',
sourceConnectionId: row.source_connection_id || null,
sourceConnectionName: row.source_connection_name || null,
sourceRef: row.source_ref || '',
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelVCenterConnection(row) {
return {
id: row.id,
name: row.name,
baseUrl: row.base_url,
username: row.username,
hasPassword: Boolean(row.secret_cipher),
apiMode: row.api_mode || 'auto',
requestTimeoutMs: row.request_timeout_ms || 20000,
allowUntrustedTls: Boolean(row.allow_untrusted_tls),
enabled: Boolean(row.enabled),
lastTestStatus: row.last_test_status || '',
lastTestMessage: row.last_test_message || '',
lastTestAt: row.last_test_at || '',
visibility: row.visibility || 'shared',
ownerUserId: row.owner_user_id || null,
groupId: row.group_id || null,
groupName: row.group_name || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelHostGroup(row) {
return {
id: row.id,
name: row.name,
description: row.description || '',
mode: row.mode || 'manual',
matchMode: row.match_mode || 'all',
rules: parseJson(row.rules, []),
hostIds: parseJson(row.host_ids, []),
memberCount: row.member_count || 0,
visibility: row.visibility || 'shared',
ownerUserId: row.owner_user_id || null,
groupId: row.group_id || null,
groupName: row.group_name || null,
lastSyncedAt: row.last_synced_at || '',
createdAt: row.created_at,
updatedAt: row.updated_at
};
@@ -142,6 +189,7 @@ export function camelRunPlan(row) {
groupName: row.group_name || null,
parallel: Boolean(row.parallel),
hostCount: row.host_count || 0,
hostGroupCount: row.host_group_count || 0,
createdAt: row.created_at,
updatedAt: row.updated_at
};

View File

@@ -2,11 +2,20 @@ import { db, id, now, visibleClause } from '../db.js';
import { normalizeRunPlanPayload } from '../forms/schemas.js';
import { camelRunPlan } from './mappers.js';
import { filterVisibleHostIds } from './hostModel.js';
import { filterVisibleHostGroupIds } from './hostGroupModel.js';
export function listRunPlans(userId) {
return db.prepare(`
SELECT r.*, s.name AS script_name, g.name AS group_name,
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
(SELECT COUNT(DISTINCT host_id) FROM (
SELECT rh.host_id AS host_id FROM runplan_hosts rh WHERE rh.runplan_id = r.id
UNION
SELECT hgm.host_id AS host_id
FROM runplan_host_groups rhg
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
WHERE rhg.runplan_id = r.id
)) AS host_count,
(SELECT COUNT(*) FROM runplan_host_groups rhg WHERE rhg.runplan_id = r.id) AS host_group_count
FROM runplans r
JOIN scripts s ON s.id = r.script_id
LEFT JOIN groups g ON g.id = r.group_id
@@ -23,13 +32,22 @@ export function createRunPlan(body, userId) {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(runplanId, payload.name, payload.description, payload.scriptId, payload.visibility, userId, payload.groupId, payload.parallel ? 1 : 0, now(), now());
replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
replaceRunPlanHostGroups(runplanId, filterVisibleHostGroupIds(payload.hostGroupIds, userId));
return loadRunPlan(runplanId);
}
export function loadRunPlan(runplanId) {
const runplan = db.prepare(`
SELECT r.*, s.name AS script_name, g.name AS group_name,
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
(SELECT COUNT(DISTINCT host_id) FROM (
SELECT rh.host_id AS host_id FROM runplan_hosts rh WHERE rh.runplan_id = r.id
UNION
SELECT hgm.host_id AS host_id
FROM runplan_host_groups rhg
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
WHERE rhg.runplan_id = r.id
)) AS host_count,
(SELECT COUNT(*) FROM runplan_host_groups rhg WHERE rhg.runplan_id = r.id) AS host_group_count
FROM runplans r
JOIN scripts s ON s.id = r.script_id
LEFT JOIN groups g ON g.id = r.group_id
@@ -37,7 +55,8 @@ export function loadRunPlan(runplanId) {
`).get(runplanId);
if (!runplan) return null;
const hostIds = db.prepare('SELECT host_id FROM runplan_hosts WHERE runplan_id = ? ORDER BY host_id').all(runplanId).map((row) => row.host_id);
return { ...camelRunPlan(runplan), hostIds };
const hostGroupIds = db.prepare('SELECT host_group_id FROM runplan_host_groups WHERE runplan_id = ? ORDER BY host_group_id').all(runplanId).map((row) => row.host_group_id);
return { ...camelRunPlan(runplan), hostIds, hostGroupIds };
}
export function loadVisibleRunPlan(runplanId, userId) {
@@ -48,12 +67,13 @@ export function loadVisibleRunPlan(runplanId, userId) {
export function updateRunPlan(runplanId, body, userId) {
const existing = db.prepare(`SELECT * FROM runplans WHERE id = ? AND ${visibleClause()}`).get(runplanId, userId, userId);
if (!existing) return null;
const payload = normalizeRunPlanPayload({ ...existing, ...body, hostIds: body.hostIds || undefined });
const payload = normalizeRunPlanPayload({ ...existing, ...body, hostIds: body.hostIds || undefined, hostGroupIds: body.hostGroupIds || undefined });
db.prepare(`
UPDATE runplans SET name = ?, description = ?, script_id = ?, visibility = ?, group_id = ?, parallel = ?, updated_at = ?
WHERE id = ?
`).run(payload.name, payload.description, payload.scriptId, payload.visibility, payload.groupId, payload.parallel ? 1 : 0, now(), runplanId);
if (body.hostIds) replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
if (body.hostGroupIds) replaceRunPlanHostGroups(runplanId, filterVisibleHostGroupIds(payload.hostGroupIds, userId));
return loadRunPlan(runplanId);
}
@@ -66,3 +86,9 @@ function replaceRunPlanHosts(runplanId, hostIds) {
const stmt = db.prepare('INSERT OR IGNORE INTO runplan_hosts (runplan_id, host_id) VALUES (?, ?)');
for (const hostId of hostIds) stmt.run(runplanId, hostId);
}
function replaceRunPlanHostGroups(runplanId, hostGroupIds) {
db.prepare('DELETE FROM runplan_host_groups WHERE runplan_id = ?').run(runplanId);
const stmt = db.prepare('INSERT OR IGNORE INTO runplan_host_groups (runplan_id, host_group_id) VALUES (?, ?)');
for (const groupId of hostGroupIds) stmt.run(runplanId, groupId);
}

View File

@@ -0,0 +1,135 @@
import { db, id, now, visibleClause } from '../db.js';
import { vcenterConnectionSchema } from '../forms/schemas.js';
import { decryptSecret, encryptSecret } from '../services/cryptoStore.js';
import { camelVCenterConnection } from './mappers.js';
function normalizeConnection(body, existing = {}) {
const parsed = vcenterConnectionSchema.parse({
...existing,
...body,
password: body.password || ''
});
return {
...parsed,
baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''),
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
export function listVCenterConnections(userId) {
return db.prepare(`
SELECT c.*, g.name AS group_name
FROM vcenter_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE ${visibleClause('c')}
ORDER BY c.name
`).all(userId, userId).map(camelVCenterConnection);
}
export function getVCenterConnection(connectionId, userId, includeSecret = false) {
if (!connectionId) return null;
const row = db.prepare(`
SELECT c.*, g.name AS group_name
FROM vcenter_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE c.id = ? AND ${visibleClause('c')}
`).get(connectionId, userId, userId);
if (!row) return null;
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row);
}
export function getVCenterConnectionSystem(connectionId, includeSecret = false) {
if (!connectionId) return null;
const row = db.prepare('SELECT * FROM vcenter_connections WHERE id = ?').get(connectionId);
if (!row) return null;
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row);
}
export function createVCenterConnection(body, userId) {
const payload = normalizeConnection(body);
const encrypted = encryptSecret(payload.password);
const connectionId = id('vc');
db.prepare(`
INSERT INTO vcenter_connections (
id, name, base_url, username, secret_cipher, secret_iv, secret_tag,
api_mode, request_timeout_ms, allow_untrusted_tls, enabled,
visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
connectionId,
payload.name,
payload.baseUrl,
payload.username,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.apiMode,
payload.requestTimeoutMs,
payload.allowUntrustedTls ? 1 : 0,
payload.enabled ? 1 : 0,
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return getVCenterConnection(connectionId, userId);
}
export function updateVCenterConnection(connectionId, body, userId) {
const existing = getVCenterConnection(connectionId, userId, true);
if (!existing) return null;
const payload = normalizeConnection(body, {
name: existing.name,
baseUrl: existing.base_url,
username: existing.username,
apiMode: existing.api_mode,
requestTimeoutMs: existing.request_timeout_ms,
allowUntrustedTls: Boolean(existing.allow_untrusted_tls),
enabled: Boolean(existing.enabled),
visibility: existing.visibility,
groupId: existing.group_id
});
const encrypted = payload.password ? encryptSecret(payload.password) : {
cipher: existing.secret_cipher,
iv: existing.secret_iv,
tag: existing.secret_tag
};
db.prepare(`
UPDATE vcenter_connections
SET name = ?, base_url = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
api_mode = ?, request_timeout_ms = ?, allow_untrusted_tls = ?, enabled = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.baseUrl,
payload.username,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.apiMode,
payload.requestTimeoutMs,
payload.allowUntrustedTls ? 1 : 0,
payload.enabled ? 1 : 0,
payload.visibility,
payload.groupId,
now(),
connectionId
);
return getVCenterConnection(connectionId, userId);
}
export function deleteVCenterConnection(connectionId, userId) {
db.prepare(`DELETE FROM vcenter_connections WHERE id = ? AND ${visibleClause()}`).run(connectionId, userId, userId);
}
export function recordVCenterConnectionTest(connectionId, status, message) {
db.prepare(`
UPDATE vcenter_connections
SET last_test_status = ?, last_test_message = ?, last_test_at = ?, updated_at = ?
WHERE id = ?
`).run(status, message || '', now(), now(), connectionId);
}

View File

@@ -1,12 +1,42 @@
import { Router } from 'express';
import { create, destroy, importVCenterHosts, index, previewVCenterImport, update, vcenterImportStatus } from '../controllers/hostController.js';
import {
create,
createGroup,
createVCenterConnectionRecord,
deleteGroup,
deleteVCenterConnectionRecord,
destroy,
importVCenterHosts,
index,
listGroups,
listVCenterConnectionRecords,
previewVCenterImport,
syncGroup,
syncGroups,
testVCenterConnectionRecord,
update,
updateGroup,
updateVCenterConnectionRecord,
vcenterImportStatus
} from '../controllers/hostController.js';
import { requireAuth } from '../middleware/auth.js';
export const hostRoutes = Router();
hostRoutes.get('/import/vcenter/status', requireAuth, vcenterImportStatus);
hostRoutes.get('/import/vcenter/connections', requireAuth, listVCenterConnectionRecords);
hostRoutes.post('/import/vcenter/connections', requireAuth, createVCenterConnectionRecord);
hostRoutes.put('/import/vcenter/connections/:connectionId', requireAuth, updateVCenterConnectionRecord);
hostRoutes.delete('/import/vcenter/connections/:connectionId', requireAuth, deleteVCenterConnectionRecord);
hostRoutes.post('/import/vcenter/connections/:connectionId/test', requireAuth, testVCenterConnectionRecord);
hostRoutes.post('/import/vcenter/preview', requireAuth, previewVCenterImport);
hostRoutes.post('/import/vcenter', requireAuth, importVCenterHosts);
hostRoutes.get('/groups', requireAuth, listGroups);
hostRoutes.post('/groups', requireAuth, createGroup);
hostRoutes.post('/groups/sync', requireAuth, syncGroups);
hostRoutes.put('/groups/:groupId', requireAuth, updateGroup);
hostRoutes.delete('/groups/:groupId', requireAuth, deleteGroup);
hostRoutes.post('/groups/:groupId/sync', requireAuth, syncGroup);
hostRoutes.get('/', requireAuth, index);
hostRoutes.post('/', requireAuth, create);
hostRoutes.put('/:id', requireAuth, update);

View File

@@ -0,0 +1,41 @@
import { getStringSetting } from '../models/settingsModel.js';
import { syncDynamicHostGroups } from '../models/hostGroupModel.js';
import { logger } from './logger.js';
let timer = null;
let running = false;
export function hostGroupSyncIntervalMinutes() {
const minutes = Number(getStringSetting('host_group_sync_interval_minutes', process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60'));
return Number.isFinite(minutes) ? minutes : 60;
}
export function startHostGroupWorker() {
const minutes = hostGroupSyncIntervalMinutes();
if (minutes <= 0) return null;
running = true;
scheduleNext(minutes);
logger.info(`host group sync scheduled every ${minutes} minute(s)`);
return timer;
}
export function stopHostGroupWorker() {
running = false;
if (timer) clearTimeout(timer);
timer = null;
}
function scheduleNext(minutes) {
if (!running || minutes <= 0) return;
timer = setTimeout(() => {
try {
const summary = syncDynamicHostGroups();
logger.info('host group sync complete', summary);
} catch (error) {
logger.error('host group sync failed', { error: error.message });
} finally {
scheduleNext(hostGroupSyncIntervalMinutes());
}
}, minutes * 60 * 1000);
timer.unref?.();
}

View File

@@ -8,6 +8,8 @@ import { logger } from './logger.js';
import { listExecutableAssets } from '../models/assetModel.js';
import { listExecutableCustomVariables } from '../models/variableModel.js';
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
import { getVCenterConnectionSystem } from '../models/vcenterConnectionModel.js';
import { getVCenterVmPowerState } from './vcenterService.js';
import { path } from '../utils/pathUtils.js';
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
@@ -45,12 +47,18 @@ export function executeRunPlan(runplanId, userId) {
const hosts = db.prepare(`
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
c.secret_cipher, c.secret_iv, c.secret_tag
FROM runplan_hosts rh
JOIN hosts h ON h.id = rh.host_id
FROM hosts h
LEFT JOIN credentials c ON c.id = h.credential_id
WHERE rh.runplan_id = ?
WHERE h.id IN (
SELECT host_id FROM runplan_hosts WHERE runplan_id = ?
UNION
SELECT hgm.host_id
FROM runplan_host_groups rhg
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
WHERE rhg.runplan_id = ?
)
ORDER BY h.name
`).all(runplanId);
`).all(runplanId, runplanId);
if (hosts.length === 0) throw new Error('RunPlan has no hosts');
const jobId = id('job');
@@ -95,7 +103,31 @@ export function executeScriptTest(scriptId, payload, userId) {
`).get(scriptId, userId, userId);
if (!script) throw new Error('Script not found');
const host = db.prepare(`
const hosts = payload.hostGroupId ? db.prepare(`
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
c.secret_cipher, c.secret_iv, c.secret_tag
FROM host_group_members hgm
JOIN host_groups hg ON hg.id = hgm.host_group_id
AND (
hg.visibility = 'shared'
OR hg.owner_user_id = ?
OR hg.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
JOIN hosts h ON h.id = hgm.host_id
AND (
h.visibility = 'shared'
OR h.owner_user_id = ?
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
JOIN credentials c ON c.id = ?
AND (
c.visibility = 'shared'
OR c.owner_user_id = ?
OR c.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
WHERE hgm.host_group_id = ?
ORDER BY h.name
`).all(userId, userId, userId, userId, payload.credentialId, userId, userId, payload.hostGroupId) : [db.prepare(`
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
c.secret_cipher, c.secret_iv, c.secret_tag
FROM hosts h
@@ -110,8 +142,8 @@ export function executeScriptTest(scriptId, payload, userId) {
OR h.owner_user_id = ?
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId);
if (!host) throw new Error('Visible host and credential are required');
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId)].filter(Boolean);
if (!hosts.length) throw new Error('Visible host or host group members and credential are required');
const jobId = id('job');
db.prepare(`
@@ -121,7 +153,8 @@ export function executeScriptTest(scriptId, payload, userId) {
db.prepare(`
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES (?, ?, ?, 'queued', NULL)
`).run(id('jhost'), jobId, host.id);
`);
for (const host of hosts) insertJobHost.run(id('jhost'), jobId, host.id);
const executionContext = {
jobId,
@@ -134,10 +167,10 @@ export function executeScriptTest(scriptId, payload, userId) {
assets: listExecutableAssets(userId)
};
appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
void runJob(jobId, script, [host], false, executionContext).catch((error) => {
for (const host of hosts) appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
void runJob(jobId, script, hosts, false, executionContext).catch((error) => {
logger.error('script test runner crashed', { jobId, error });
appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
for (const host of hosts) appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
});
@@ -168,6 +201,8 @@ async function runHost(jobId, script, host, executionContext) {
return;
}
if (!(await vCenterPowerPreflight(jobId, host))) return;
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
try {
@@ -183,6 +218,35 @@ async function runHost(jobId, script, host, executionContext) {
}
}
async function vCenterPowerPreflight(jobId, host) {
if ((host.source_type || 'manual') !== 'vmware') return true;
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;
}
const connection = getVCenterConnectionSystem(host.source_connection_id, true);
if (!connection) {
appendLog(jobId, host.id, 'system', `Host is VMware-sourced, but vCenter connection ${host.source_connection_id} no longer exists. Continuing without a power-state gate.`);
return true;
}
try {
const result = await getVCenterVmPowerState(connection, host.source_ref);
if (!result.checked) {
appendLog(jobId, host.id, 'system', `vCenter power-state check skipped: ${result.reason || 'not enough source metadata'}`);
return true;
}
appendLog(jobId, host.id, 'system', `vCenter power state for VM ${host.source_ref}: ${result.state || 'unknown'} (${result.apiProfile || 'vCenter API'})`);
if (result.state && result.state !== 'POWERED_ON') {
appendLog(jobId, host.id, 'system', `Skipping ${host.name}; VMware VM is not powered on.`);
finishHost(jobId, host.id, 'skipped', 0);
return false;
}
} catch (err) {
appendLog(jobId, host.id, 'system', `Unable to verify vCenter power state before execution: ${err.message}. Continuing without a power-state gate.`);
}
return true;
}
function buildWrapper(content, host, executionContext = {}) {
// Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
const runtimeVariables = runtimeVariableValues(host, executionContext);

View File

@@ -15,6 +15,7 @@ const API_PROFILES = {
sessionPath: '/api/session',
vmListPath: '/api/vcenter/vm',
vmNameParam: 'names',
vmDetailPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}`,
guestIdentityPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
guestNetworkingPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
},
@@ -24,6 +25,7 @@ const API_PROFILES = {
sessionPath: '/rest/com/vmware/cis/session',
vmListPath: '/rest/vcenter/vm',
vmNameParam: 'filter.names',
vmDetailPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}`,
guestIdentityPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
guestNetworkingPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
}
@@ -52,6 +54,20 @@ export function getVCenterSettings() {
};
}
export function settingsFromVCenterConnection(connection) {
if (!connection) return getVCenterSettings();
const apiMode = String(connection.api_mode || connection.apiMode || 'auto').toLowerCase();
return {
enabled: Boolean(connection.enabled ?? true),
baseUrl: normalizeBaseUrl(connection.base_url || connection.baseUrl),
username: connection.username || '',
password: connection.password || '',
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
requestTimeoutMs: Number(connection.request_timeout_ms || connection.requestTimeoutMs || 20000),
allowUntrustedTls: Boolean(connection.allow_untrusted_tls ?? connection.allowUntrustedTls)
};
}
export function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
@@ -69,6 +85,21 @@ export function vcenterStatus() {
};
}
export function vcenterConnectionStatus(connection) {
const settings = settingsFromVCenterConnection(connection);
return {
id: connection?.id || '',
name: connection?.name || 'Configured default',
enabled: settings.enabled,
configured: Boolean(settings.baseUrl && settings.username && settings.password),
baseUrl: settings.baseUrl,
username: settings.username,
apiMode: settings.apiMode,
requestTimeoutMs: settings.requestTimeoutMs,
allowUntrustedTls: settings.allowUntrustedTls
};
}
function assertConfigured(settings) {
if (!settings.enabled) throw new Error('VMware vCenter integration is disabled in Configuration.');
if (!settings.baseUrl) throw new Error('VMware vCenter base URL is missing. Set vcenter_base_url in Configuration.');
@@ -361,13 +392,16 @@ export function buildHostPayloadFromVm(candidate, options = {}) {
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
groupId: options.visibility === 'group' ? options.groupId || null : null,
sourceType: 'vmware',
sourceConnectionId: options.connectionId || null,
sourceRef: candidate.id || ''
};
}
export async function previewVCenterHosts(options = {}) {
const trace = [];
const settings = getVCenterSettings();
const settings = settingsFromVCenterConnection(options.connection);
try {
assertConfigured(settings);
} catch (err) {
@@ -405,7 +439,7 @@ export async function previewVCenterHosts(options = {}) {
});
return {
status: vcenterStatus(),
status: options.connection ? vcenterConnectionStatus(options.connection) : vcenterStatus(),
filter: options.filter || {},
count: candidates.length,
diagnostics,
@@ -414,6 +448,50 @@ export async function previewVCenterHosts(options = {}) {
};
}
export async function testVCenterConnection(connection) {
const trace = [];
const settings = settingsFromVCenterConnection(connection);
assertConfigured(settings);
const { profile, vmPayload } = await connectAndListVms(settings, {}, trace);
return {
status: vcenterConnectionStatus(connection),
apiProfile: profile.label,
count: unwrapList(vmPayload).length,
trace,
message: `Connected to ${settings.baseUrl} with ${profile.label}; ${unwrapList(vmPayload).length} VM summary row(s) returned.`
};
}
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);
assertConfigured(settings);
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
let lastError = null;
for (const mode of modes) {
const profile = API_PROFILES[mode];
try {
const sessionId = await createSession(settings, profile, trace);
const payload = await vcenterFetch(settings, profile.vmDetailPath(vmId), { sessionId, trace });
const value = payload?.value || payload || {};
return {
checked: true,
state: value.power_state || value.powerState || '',
apiProfile: profile.label
};
} catch (err) {
lastError = err;
if (settings.apiMode !== 'auto' || mode === modes.at(-1) || !shouldTryFallback(err)) throw err;
pushTraceInfo(trace, `Falling back after ${profile.label} power-state lookup failed`, {
mode,
error: err.message,
statusCode: err.statusCode
});
}
}
throw lastError || new VCenterTraceError('No vCenter API profile could be attempted for power-state lookup.', trace);
}
async function enrichVmsForPreview({ vms, settings, profile, sessionId, trace, filter, limit, diagnostics, candidates }) {
let index = 0;
async function worker() {

View File

@@ -35,6 +35,7 @@ export function settingDefinitions() {
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
{ key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') },
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
{ key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') },
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
{ key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') },
{ key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') },

View File

@@ -3,6 +3,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } = await load('../models/hostModel.js');
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
const { db } = await load('../db.js');
const owner = adminUserId();
const other = makeUser({ email: 'other-host@test.local' });
@@ -18,6 +21,23 @@ test('shared hosts are visible to everyone', () => {
assert.ok(listHosts(other).some((h) => h.id === host.id));
});
test('hosts preserve hidden source provenance', () => {
const host = createHost({
name: 'VC Imported',
address: 'vc-imported.local',
osFamily: 'windows',
transport: 'winrm',
tags: [],
visibility: 'shared',
sourceType: 'vmware',
sourceConnectionId: 'vc_test',
sourceRef: 'vm-42'
}, owner);
assert.equal(host.sourceType, 'vmware');
assert.equal(host.sourceConnectionId, 'vc_test');
assert.equal(host.sourceRef, 'vm-42');
});
test('a non-owner cannot update or delete a personal host', () => {
const host = createHost({ name: 'Locked', address: 'locked.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'personal' }, owner);
assert.equal(updateHost(host.id, { name: 'Hacked' }, other), null);
@@ -31,3 +51,39 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
const visibleToOther = filterVisibleHostIds([priv.id, shared.id], other);
assert.deepEqual(visibleToOther, [shared.id]);
});
test('manual host groups keep explicit members', () => {
const host = createHost({ name: 'ManualMember', address: 'manual-member.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
const group = createHostGroup({ name: 'Manual Group', mode: 'manual', hostIds: [host.id], visibility: 'shared' }, owner);
assert.equal(group.mode, 'manual');
assert.deepEqual(group.hostIds, [host.id]);
assert.equal(listHostGroups(owner).some((row) => row.id === group.id), true);
});
test('dynamic host groups sync by rule', () => {
const eng = createHost({ name: 'ENG-APP-01', address: 'eng-app-01.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
createHost({ name: 'FIN-APP-01', address: 'fin-app-01.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
const group = createHostGroup({
name: 'Engineering',
mode: 'dynamic',
matchMode: 'all',
rules: [{ field: 'name', operator: 'contains', value: 'ENG' }],
visibility: 'shared'
}, owner);
const synced = syncHostGroup(group.id);
const refreshed = listHostGroups(owner).find((row) => row.id === group.id);
assert.equal(synced.matched >= 1, true);
assert.equal(refreshed.hostIds.includes(eng.id), true);
assert.equal(refreshed.hostIds.some((hostId) => hostId !== eng.id && db.prepare('SELECT name FROM hosts WHERE id = ?').get(hostId)?.name === 'FIN-APP-01'), false);
});
test('runplans can target host groups', () => {
const script = db.prepare('SELECT id FROM scripts LIMIT 1').get();
const host = createHost({ name: 'RUN-GROUP-01', address: 'run-group-01.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
const group = createHostGroup({ name: 'Run Group', mode: 'manual', hostIds: [host.id], visibility: 'shared' }, owner);
const runplan = createRunPlan({ name: 'Grouped Run', scriptId: script.id, visibility: 'shared', hostIds: [], hostGroupIds: [group.id] }, owner);
const loaded = loadRunPlan(runplan.id);
assert.deepEqual(loaded.hostGroupIds, [group.id]);
assert.equal(loaded.hostCount, 1);
assert.equal(loaded.hostGroupCount, 1);
});

View File

@@ -107,13 +107,16 @@ test('buildHostPayloadFromVm attaches credential, tags, transport, and import no
guestFullName: 'Microsoft Windows Server 2022',
ipAddresses: ['10.42.1.20']
},
{ credentialId: 'cred_1', transport: 'winrm', tags: ['engineering'], visibility: 'group', groupId: 'grp_1' }
{ credentialId: 'cred_1', connectionId: 'vc_1', transport: 'winrm', tags: ['engineering'], visibility: 'group', groupId: 'grp_1' }
);
assert.equal(payload.credentialId, 'cred_1');
assert.equal(payload.transport, 'winrm');
assert.equal(payload.visibility, 'group');
assert.equal(payload.groupId, 'grp_1');
assert.equal(payload.sourceType, 'vmware');
assert.equal(payload.sourceConnectionId, 'vc_1');
assert.equal(payload.sourceRef, 'vm-42');
assert.ok(payload.tags.includes('vcenter:vm-42'));
assert.ok(payload.tags.includes('engineering'));
assert.match(payload.notes, /Imported from VMware vCenter VM vm-42/);