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

View File

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

View File

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

View File

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

View File

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