Initial
This commit is contained in:
@@ -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
118
README.md
@@ -29,7 +29,7 @@ The current UI is a Vue/Vite view layer over the Express API. All create/update/
|
||||
- PSADT Best Practice Verifier for saved scripts, rendered profiles, and pasted script content with severity scoring, line-aware findings, remediation text, and rule metadata.
|
||||
- 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
173
client/src/components/hosts/HostGroupsPanel.vue
Normal file
173
client/src/components/hosts/HostGroupsPanel.vue
Normal 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>
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
112
client/src/components/settings/VCenterConnectionsConfig.vue
Normal file
112
client/src/components/settings/VCenterConnectionsConfig.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || [] });
|
||||
}
|
||||
}
|
||||
|
||||
61
server/db.js
61
server/db.js
@@ -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');
|
||||
|
||||
@@ -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 || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
185
server/models/hostGroupModel.js
Normal file
185
server/models/hostGroupModel.js
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
135
server/models/vcenterConnectionModel.js
Normal file
135
server/models/vcenterConnectionModel.js
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
41
server/services/hostGroupWorker.js
Normal file
41
server/services/hostGroupWorker.js
Normal 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?.();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user