This commit is contained in:
2026-06-25 16:51:56 -05:00
parent 94f4d8959d
commit 402bf6782a
25 changed files with 1323 additions and 103 deletions

View File

@@ -331,6 +331,7 @@
>
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-address="{ row }"><code>{{ row.address }}</code></template>
<template #cell-osFamily="{ row }"><span :class="['status-pill', row.osFamily || 'other']">{{ osFamilyLabel(row.osFamily) }}</span></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>
@@ -462,7 +463,7 @@
<form @submit.prevent="saveHost" class="form-grid">
<label><span>Display name</span><input v-model="hostForm.name" required placeholder="SQL-PROD-01" /></label>
<label><span>Address / FQDN</span><input v-model="hostForm.address" required placeholder="sql-prod-01.contoso.local" /></label>
<label><span>OS family</span><select v-model="hostForm.osFamily"><option value="windows">Windows</option><option value="linux">Linux</option><option value="network">Network</option><option value="api">API</option></select></label>
<label><span>Operating system type</span><select v-model="hostForm.osFamily"><option value="windows">Windows</option><option value="linux">Linux</option><option value="other">Other / unknown</option></select></label>
<label><span>Transport</span><select v-model="hostForm.transport"><option value="winrm">WinRM</option><option value="ssh">SSH</option><option value="local">Local</option><option value="api">API</option></select></label>
<label class="full"><span>Credential</span><select v-model="hostForm.credentialId"><option :value="null">No credential</option><option v-for="credential in credentials" :key="credential.id" :value="credential.id">{{ credential.name }}</option></select></label>
<label class="full"><span>Tags</span><input v-model="hostForm.tagsText" placeholder="production, database, east" /></label>
@@ -1326,6 +1327,7 @@ const availableThemes = [
const hostColumns = [
{ key: 'name', label: 'Name' },
{ key: 'address', label: 'Address' },
{ key: 'osFamily', label: 'OS' },
{ key: 'transport', label: 'Transport' },
{ key: 'sourceType', label: 'Source' },
{ key: 'credentialName', label: 'Credential' },
@@ -1469,6 +1471,7 @@ const filteredHosts = computed(() => filterRows(hosts.value, hostFilters, (host)
host.name,
host.address,
host.fqdn,
host.osFamily,
host.transport,
host.sourceType,
host.sourceConnectionName,
@@ -1945,6 +1948,10 @@ async function executeScriptTestRun(payload) {
scriptTestPollFailures = 0;
stopScriptTestPolling();
try {
if (!(await confirmLinuxCompatibility(`/api/scripts/${scriptTestTarget.value.id}/compatibility`, payload))) {
scriptTestRunning.value = false;
return;
}
const job = await api.post(`/api/scripts/${scriptTestTarget.value.id}/test-run`, payload);
scriptTestJob.value = await api.get(`/api/jobs/${job.id}`).catch(() => job);
startScriptTestPolling(job.id);
@@ -2471,6 +2478,12 @@ async function saveHost() {
notify('Host saved');
}
function osFamilyLabel(value) {
if (value === 'windows') return 'Windows';
if (value === 'linux') return 'Linux';
return 'Other';
}
async function saveHostGroup(payload) {
const body = { ...payload };
const groupId = body.id;
@@ -2669,6 +2682,7 @@ function openRunPlanModal(runplan = null) {
}
async function executeRunPlan(id) {
if (!(await confirmLinuxCompatibility(`/api/runplans/${id}/compatibility`))) return;
const job = await api.post(`/api/runplans/${id}/execute`, {});
await refreshAll();
await openJob(job.id);
@@ -2676,6 +2690,20 @@ async function executeRunPlan(id) {
notify('RunPlan queued');
}
async function confirmLinuxCompatibility(path, payload = null) {
const result = payload ? await api.post(path, payload) : await api.get(path);
const warnings = (result.findings || []).filter((finding) => finding.level === 'warning');
if (!result.hasLinuxTargets || !warnings.length) return true;
const lines = warnings.slice(0, 5).map((finding) => `- ${finding.title}${finding.line ? ` line ${finding.line}` : ''}: ${finding.message}`);
return window.confirm([
'This script is targeting one or more Linux hosts and POSHManager found possible compatibility issues.',
'',
...lines,
'',
'Continue anyway? The same warnings will be written to the job logs.'
].join('\n'));
}
async function openJob(id) {
selectedJob.value = await api.get(`/api/jobs/${id}`);
view.value = 'logs';

View File

@@ -8,23 +8,31 @@
</div>
<div class="section-actions">
<button class="ghost-button compact" type="button" @click="$emit('sync-all')">
<RefreshCcw :size="15" />Sync dynamic
<i class="mdi mdi-sync" aria-hidden="true"></i>Sync dynamic
</button>
<button class="primary-action compact" type="button" @click="openModal()">
<Plus :size="15" />Add host group
<i class="mdi mdi-plus" aria-hidden="true"></i>Add host group
</button>
</div>
</div>
<ResourceTable
:columns="columns"
:rows="hostGroups"
:total="hostGroups.length"
:rows="pagedHostGroups"
:total="filteredHostGroups.length"
:search="groupSearch"
:page="groupPage"
:page-size="groupPageSize"
:sort="groupSort"
:direction="groupDirection"
:page-count="groupPageCount"
search-placeholder="Search collections, rules, machines..."
item-label="host groups"
empty-text="No host groups yet."
:page-count="1"
:page="1"
:page-size="hostGroups.length || 8"
@update:search="groupSearch = $event"
@update:page="groupPage = $event"
@update:pageSize="groupPageSize = $event"
@sort="toggleGroupSort"
>
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-mode="{ row }"><span :class="['status-pill', row.mode]">{{ row.mode }}</span></template>
@@ -33,13 +41,93 @@
<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>
<button class="icon-button material-icon-button" type="button" title="View target collection" aria-label="View target collection" @click="openSummary(row)">
<i class="mdi mdi-magnify" aria-hidden="true"></i>
</button>
<button v-if="row.mode === 'dynamic'" class="icon-button material-icon-button" type="button" title="Sync dynamic group" aria-label="Sync dynamic group" @click="$emit('sync', row.id)">
<i class="mdi mdi-sync" aria-hidden="true"></i>
</button>
<button class="icon-button material-icon-button" type="button" title="Edit host group" aria-label="Edit host group" @click="openModal(row)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
<button class="icon-button material-icon-button danger-text" type="button" title="Delete host group" aria-label="Delete host group" @click="$emit('delete', row.id)">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</button>
</div>
</template>
</ResourceTable>
<BaseModal
:open="summaryOpen"
:title="summaryGroup ? summaryGroup.name : 'Target collection'"
eyebrow="TARGET COLLECTION"
description="Read-only rule and membership summary for this Host Group."
wide
@close="summaryOpen = false"
>
<section v-if="summaryGroup" class="host-group-summary-modal">
<div class="summary-metrics">
<span><strong>{{ summaryHosts.length }}</strong><small>assigned machines</small></span>
<span><strong>{{ osBreakdown.windows }}</strong><small>windows</small></span>
<span><strong>{{ osBreakdown.linux }}</strong><small>linux</small></span>
<span><strong>{{ osBreakdown.other }}</strong><small>other</small></span>
</div>
<div class="summary-rule-panel">
<div>
<span class="eyebrow">RULE SUMMARY</span>
<h4>{{ summaryGroup.mode === 'dynamic' ? 'Dynamic rule set' : 'Manual membership' }}</h4>
<p>{{ ruleSummary(summaryGroup) }}</p>
</div>
<span :class="['status-pill', summaryGroup.mode]">{{ summaryGroup.mode }}</span>
</div>
<div class="summary-actions">
<button class="ghost-button compact" type="button" :disabled="!summaryHosts.length" @click="exportSummary('csv')">
<i class="mdi mdi-file-delimited-outline" aria-hidden="true"></i>CSV
</button>
<button class="ghost-button compact" type="button" :disabled="!summaryHosts.length" @click="exportSummary('xls')">
<i class="mdi mdi-file-excel-outline" aria-hidden="true"></i>XLS
</button>
</div>
<div class="member-table-wrap readonly-members">
<table class="member-table">
<thead>
<tr>
<th>Hostname</th>
<th>IP address</th>
<th>OS</th>
<th>Transport</th>
<th>vCenter System</th>
</tr>
</thead>
<tbody>
<tr v-for="host in summaryHosts" :key="host.id">
<td>
<strong>{{ host.fqdn || host.name }}</strong>
<small>{{ host.name }}</small>
</td>
<td><code>{{ host.address || '-' }}</code></td>
<td><span :class="['status-pill', host.osFamily || 'other']">{{ osFamilyLabel(host.osFamily) }}</span></td>
<td><span class="status-pill neutral">{{ host.transport || '-' }}</span></td>
<td>{{ host.sourceConnectionName || (host.sourceType === 'vmware' ? 'Unknown VMware source' : '-') }}</td>
</tr>
<tr v-if="!summaryHosts.length">
<td colspan="5" class="member-empty">No machines are currently assigned to this collection.</td>
</tr>
</tbody>
</table>
</div>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="summaryOpen = false">
<i class="mdi mdi-close" aria-hidden="true"></i>Close
</button>
</div>
</section>
</BaseModal>
<BaseModal
:open="modalOpen"
:title="form.id ? 'Update host group' : 'Create host group'"
@@ -70,12 +158,21 @@
<div class="member-toolbar">
<label class="member-search">
<Search :size="15" />
<i class="mdi mdi-magnify" aria-hidden="true"></i>
<input v-model="memberSearch" placeholder="Search host, FQDN, address, tags..." />
<button v-if="memberSearch" type="button" aria-label="Clear host search" @click="memberSearch = ''">
<X :size="13" />
<i class="mdi mdi-close" aria-hidden="true"></i>
</button>
</label>
<label>
<span>OS</span>
<select v-model="memberOsFamily">
<option value="">All OS types</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="other">Other</option>
</select>
</label>
<label>
<span>Source</span>
<select v-model="memberSource">
@@ -91,7 +188,7 @@
</select>
</label>
<button :class="['ghost-button compact', { active: selectedOnly }]" type="button" @click="selectedOnly = !selectedOnly">
<ListChecks :size="15" />Selected
<i class="mdi mdi-format-list-checks" aria-hidden="true"></i>Selected
</button>
</div>
@@ -105,7 +202,7 @@
Clear page
</button>
<button class="ghost-button compact danger-text" type="button" :disabled="!form.hostIds.length" @click="form.hostIds = []">
Clear all
<i class="mdi mdi-close-circle-outline" aria-hidden="true"></i>Clear all
</button>
</div>
</div>
@@ -123,10 +220,11 @@
@change="$event.target.checked ? selectPage() : clearPage()"
/>
</th>
<th><button type="button" @click="toggleMemberSort('name')">Host <component :is="memberSortIcon('name')" :size="13" /></button></th>
<th><button type="button" @click="toggleMemberSort('address')">Address <component :is="memberSortIcon('address')" :size="13" /></button></th>
<th><button type="button" @click="toggleMemberSort('transport')">Transport <component :is="memberSortIcon('transport')" :size="13" /></button></th>
<th><button type="button" @click="toggleMemberSort('sourceType')">Source <component :is="memberSortIcon('sourceType')" :size="13" /></button></th>
<th><button type="button" @click="toggleMemberSort('name')">Host <i :class="memberSortIcon('name')" aria-hidden="true"></i></button></th>
<th><button type="button" @click="toggleMemberSort('address')">Address <i :class="memberSortIcon('address')" aria-hidden="true"></i></button></th>
<th><button type="button" @click="toggleMemberSort('osFamily')">OS <i :class="memberSortIcon('osFamily')" aria-hidden="true"></i></button></th>
<th><button type="button" @click="toggleMemberSort('transport')">Transport <i :class="memberSortIcon('transport')" aria-hidden="true"></i></button></th>
<th><button type="button" @click="toggleMemberSort('sourceType')">Source <i :class="memberSortIcon('sourceType')" aria-hidden="true"></i></button></th>
<th>Tags</th>
</tr>
</thead>
@@ -140,12 +238,13 @@
<small>{{ host.fqdn || 'No FQDN recorded' }}</small>
</td>
<td><code>{{ host.address || '-' }}</code></td>
<td><span :class="['status-pill', host.osFamily || 'other']">{{ osFamilyLabel(host.osFamily) }}</span></td>
<td><span class="status-pill neutral">{{ host.transport || '-' }}</span></td>
<td><span :class="['status-pill', host.sourceType === 'vmware' ? 'dynamic' : 'manual']">{{ sourceLabel(host.sourceType) }}</span></td>
<td><span class="member-tags">{{ tagSummary(host) }}</span></td>
</tr>
<tr v-if="!pagedHosts.length">
<td colspan="6" class="member-empty">No hosts match the current filters.</td>
<td colspan="7" class="member-empty">No hosts match the current filters.</td>
</tr>
</tbody>
</table>
@@ -163,10 +262,10 @@
<span>Page {{ Math.min(memberPage, memberPageCount) }} of {{ memberPageCount }}</span>
<div>
<button class="ghost-button compact" type="button" :disabled="memberPage <= 1" @click="memberPage -= 1">
<ChevronLeft :size="15" />Previous
<i class="mdi mdi-chevron-left" aria-hidden="true"></i>Previous
</button>
<button class="ghost-button compact" type="button" :disabled="memberPage >= memberPageCount" @click="memberPage += 1">
Next<ChevronRight :size="15" />
Next<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -193,15 +292,15 @@
</select>
<input v-model="rule.value" required placeholder="ENG" />
<button class="ghost-button compact danger-text" type="button" @click="removeRule(index)">
<Trash2 :size="14" />
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</button>
</div>
<button class="ghost-button compact" type="button" @click="addRule"><Plus :size="14" />Add rule</button>
<button class="ghost-button compact" type="button" @click="addRule"><i class="mdi mdi-plus" aria-hidden="true"></i>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>
<button class="primary-action" type="submit"><i class="mdi mdi-content-save-outline" aria-hidden="true"></i>Save host group</button>
</div>
</form>
</BaseModal>
@@ -210,7 +309,6 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { ArrowDownAZ, ArrowUpAZ, ChevronsUpDown, ChevronLeft, ChevronRight, ListChecks, Plus, RefreshCcw, Save, Search, Trash2, X } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
import ResourceTable from '../ui/ResourceTable.vue';
@@ -222,7 +320,15 @@ const props = defineProps({
const emit = defineEmits(['save', 'delete', 'sync', 'sync-all']);
const modalOpen = ref(false);
const summaryOpen = ref(false);
const summaryGroup = ref(null);
const groupSearch = ref('');
const groupSort = ref('name');
const groupDirection = ref('asc');
const groupPage = ref(1);
const groupPageSize = ref(8);
const memberSearch = ref('');
const memberOsFamily = ref('');
const memberSource = ref('');
const memberTransport = ref('');
const selectedOnly = ref(false);
@@ -242,11 +348,37 @@ const columns = [
const sourceOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.sourceType || 'manual')));
const transportOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.transport).filter(Boolean)));
const filteredHostGroups = computed(() => {
const term = groupSearch.value.trim().toLowerCase();
const rows = props.hostGroups.filter((group) => {
if (!term) return true;
return [
group.name,
group.description,
group.mode,
group.visibility,
group.groupName,
ruleSummary(group)
].filter(Boolean).some((value) => String(value).toLowerCase().includes(term));
});
return rows.sort((a, b) => {
const left = sortableGroupValue(a, groupSort.value);
const right = sortableGroupValue(b, groupSort.value);
const result = left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
return groupDirection.value === 'asc' ? result : -result;
});
});
const groupPageCount = computed(() => Math.max(1, Math.ceil(filteredHostGroups.value.length / groupPageSize.value)));
const pagedHostGroups = computed(() => {
const start = (groupPage.value - 1) * groupPageSize.value;
return filteredHostGroups.value.slice(start, start + groupPageSize.value);
});
const filteredHosts = computed(() => {
const term = memberSearch.value.trim().toLowerCase();
const selected = new Set(form.hostIds);
return props.hosts.filter((host) => {
if (selectedOnly.value && !selected.has(host.id)) return false;
if (memberOsFamily.value && (host.osFamily || 'other') !== memberOsFamily.value) return false;
if (memberSource.value && (host.sourceType || 'manual') !== memberSource.value) return false;
if (memberTransport.value && host.transport !== memberTransport.value) return false;
if (!term) return true;
@@ -254,6 +386,7 @@ const filteredHosts = computed(() => {
host.name,
host.fqdn,
host.address,
host.osFamily,
host.transport,
host.sourceType,
...(host.tags || [])
@@ -271,11 +404,30 @@ const pagedHosts = computed(() => {
return filteredHosts.value.slice(start, start + memberPageSize.value);
});
const pageAllSelected = computed(() => Boolean(pagedHosts.value.length) && pagedHosts.value.every((host) => form.hostIds.includes(host.id)));
const summaryHosts = computed(() => {
const ids = new Set(summaryGroup.value?.hostIds || []);
return props.hosts
.filter((host) => ids.has(host.id))
.sort((a, b) => String(a.fqdn || a.name).localeCompare(String(b.fqdn || b.name), undefined, { numeric: true, sensitivity: 'base' }));
});
const osBreakdown = computed(() => summaryHosts.value.reduce((counts, host) => {
const key = ['windows', 'linux'].includes(host.osFamily) ? host.osFamily : 'other';
counts[key] += 1;
return counts;
}, { windows: 0, linux: 0, other: 0 }));
watch([memberSearch, memberSource, memberTransport, selectedOnly, memberPageSize], () => {
watch([memberSearch, memberOsFamily, memberSource, memberTransport, selectedOnly, memberPageSize], () => {
memberPage.value = 1;
});
watch([groupSearch, groupPageSize], () => {
groupPage.value = 1;
});
watch(groupPageCount, (pageCount) => {
if (groupPage.value > pageCount) groupPage.value = pageCount;
});
watch(memberPageCount, (pageCount) => {
if (memberPage.value > pageCount) memberPage.value = pageCount;
});
@@ -309,6 +461,11 @@ function openModal(group = null) {
modalOpen.value = true;
}
function openSummary(group) {
summaryGroup.value = group;
summaryOpen.value = true;
}
function addRule() {
form.rules.push({ field: 'name', operator: 'contains', value: '' });
}
@@ -328,10 +485,24 @@ function uniqueOptions(values) {
}
function sortableHostValue(host, key) {
if (key === 'osFamily') return host.osFamily || 'other';
if (key === 'sourceType') return host.sourceType || 'manual';
return String(host[key] || '');
}
function sortableGroupValue(group, key) {
if (key === 'memberCount') return String(group.memberCount || group.hostIds?.length || 0).padStart(8, '0');
if (key === 'lastSyncedAt') return group.lastSyncedAt || '';
if (key === 'rules') return ruleSummary(group);
return String(group[key] || '');
}
function osFamilyLabel(value = 'other') {
if (value === 'windows') return 'Windows';
if (value === 'linux') return 'Linux';
return 'Other';
}
function toggleMemberSort(key) {
if (memberSort.value === key) {
memberDirection.value = memberDirection.value === 'asc' ? 'desc' : 'asc';
@@ -341,9 +512,18 @@ function toggleMemberSort(key) {
memberDirection.value = 'asc';
}
function toggleGroupSort(key) {
if (groupSort.value === key) {
groupDirection.value = groupDirection.value === 'asc' ? 'desc' : 'asc';
return;
}
groupSort.value = key;
groupDirection.value = 'asc';
}
function memberSortIcon(key) {
if (memberSort.value !== key) return ChevronsUpDown;
return memberDirection.value === 'asc' ? ArrowUpAZ : ArrowDownAZ;
if (memberSort.value !== key) return 'mdi mdi-swap-vertical';
return memberDirection.value === 'asc' ? 'mdi mdi-sort-alphabetical-ascending' : 'mdi mdi-sort-alphabetical-descending';
}
function selectPage() {
@@ -365,6 +545,62 @@ function tagSummary(host) {
return host.tags?.length ? host.tags.slice(0, 3).join(', ') : '-';
}
function exportSummary(format) {
if (!summaryGroup.value || !summaryHosts.value.length) return;
const rows = summaryHosts.value.map((host) => ({
Hostname: host.fqdn || host.name || '',
'IP address': host.address || '',
'vCenter System': host.sourceConnectionName || (host.sourceType === 'vmware' ? 'Unknown VMware source' : '')
}));
if (format === 'xls') {
downloadFile(`${safeFileName(summaryGroup.value.name)}-hosts.xls`, excelHtml(rows), 'application/vnd.ms-excel;charset=utf-8');
return;
}
downloadFile(`${safeFileName(summaryGroup.value.name)}-hosts.csv`, csvText(rows), 'text/csv;charset=utf-8');
}
function csvText(rows) {
const headers = ['Hostname', 'IP address', 'vCenter System'];
return [
headers.join(','),
...rows.map((row) => headers.map((header) => csvCell(row[header])).join(','))
].join('\n');
}
function excelHtml(rows) {
const headers = ['Hostname', 'IP address', 'vCenter System'];
return `<!doctype html><html><head><meta charset="utf-8"></head><body><table><thead><tr>${headers.map((header) => `<th>${htmlEscape(header)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((header) => `<td>${htmlEscape(row[header])}</td>`).join('')}</tr>`).join('')}</tbody></table></body></html>`;
}
function downloadFile(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function csvCell(value) {
const text = String(value ?? '');
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function htmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function safeFileName(value) {
return String(value || 'host-group').trim().replace(/[^A-Za-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '') || 'host-group';
}
function save() {
emit('save', {
...form,

View File

@@ -21,7 +21,7 @@
<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">
<option v-for="connection in importConnections" :key="connection.id" :value="connection.id">
{{ connection.name }} - {{ connection.baseUrl }}
</option>
</select>
@@ -55,14 +55,14 @@
</div>
<div class="wizard-actions">
<button class="ghost-button" type="button" :disabled="loading" @click="emitPreview">
<Search :size="16" />Preview matches
<i class="mdi mdi-magnify" aria-hidden="true"></i>Preview matches
</button>
<span v-if="status" class="vcenter-status">
<span :class="['status-dot', activeStatus.configured ? 'success' : 'warning']"></span>
{{ activeStatus.configured ? `Using ${activeStatus.name || 'vCenter'} at ${activeStatus.baseUrl}` : 'vCenter settings incomplete' }}
</span>
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
<FileSearch :size="15" />View API trace
<i class="mdi mdi-file-search-outline" aria-hidden="true"></i>View API trace
</button>
</div>
</section>
@@ -133,13 +133,13 @@
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
<span>{{ diagnostics.scanned }} VM(s) scanned for guest FQDN/IP details</span>
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
<FileSearch :size="15" />Trace
<i class="mdi mdi-file-search-outline" aria-hidden="true"></i>Trace
</button>
<small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small>
</div>
<div class="preview-toolbar">
<button class="ghost-button compact" type="button" :disabled="!previewRows.length" @click="toggleAll">
<CheckSquare :size="15" />{{ allSelected ? 'Clear selection' : 'Select all' }}
<i class="mdi mdi-checkbox-marked-outline" aria-hidden="true"></i>{{ allSelected ? 'Clear selection' : 'Select all' }}
</button>
<span>{{ selectedIds.length }} of {{ previewRows.length }} selected</span>
</div>
@@ -180,7 +180,7 @@
<div class="form-actions modal-actions">
<button class="ghost-button" type="button" @click="$emit('close')">Cancel</button>
<button class="primary-action" type="submit" :disabled="loading || !selectedIds.length">
<DownloadCloud :size="17" />Import {{ selectedIds.length || '' }} host{{ selectedIds.length === 1 ? '' : 's' }}
<i class="mdi mdi-cloud-download-outline" aria-hidden="true"></i>Import {{ selectedIds.length || '' }} host{{ selectedIds.length === 1 ? '' : 's' }}
</button>
</div>
</form>
@@ -189,7 +189,6 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { CheckSquare, DownloadCloud, FileSearch, Search } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
@@ -220,10 +219,11 @@ const draft = reactive({
});
const selectedIds = ref([]);
const importConnections = computed(() => props.connections.filter((connection) => connection.targetType !== 'host'));
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);
const connection = importConnections.value.find((item) => item.id === draft.connectionId);
return {
name: connection?.name || 'Selected vCenter',
configured: Boolean(connection?.baseUrl && connection?.username),
@@ -233,6 +233,12 @@ const activeStatus = computed(() => {
return props.status?.defaultConnection || props.status || {};
});
watch(importConnections, (connections) => {
if (draft.connectionId && !connections.some((connection) => connection.id === draft.connectionId)) {
draft.connectionId = null;
}
});
watch(() => props.previewRows, (rows) => {
selectedIds.value = rows.map((row) => row.id);
});

View File

@@ -21,7 +21,7 @@
<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 }})
{{ host.name }} - {{ host.address }} ({{ host.transport }} / {{ osFamilyLabel(host.osFamily) }})
</option>
</select>
<select v-else v-model="hostGroupId" required>
@@ -47,6 +47,11 @@
<span><KeyRound :size="16" />{{ selectedCredentialSummary }}</span>
</div>
<section v-if="linuxTargetCount" class="compatibility-callout">
<strong>Linux compatibility preflight</strong>
<p>{{ linuxTargetCount }} selected Linux target{{ linuxTargetCount === 1 ? '' : 's' }}. POSHManager will check for common Windows-only PowerShell patterns before execution and write findings to the job log.</p>
</section>
<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 || !targetSelected || !credentialId">
@@ -98,8 +103,14 @@ const selectedHost = computed(() => props.hosts.find((host) => host.id === hostI
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 selectedTargets = computed(() => {
if (targetType.value === 'host') return selectedHost.value ? [selectedHost.value] : [];
const ids = new Set(selectedHostGroup.value?.hostIds || []);
return props.hosts.filter((host) => ids.has(host.id));
});
const linuxTargetCount = computed(() => selectedTargets.value.filter((host) => host.osFamily === 'linux').length);
const selectedHostSummary = computed(() => selectedHost.value
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${selectedHost.value.address}`
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${osFamilyLabel(selectedHost.value.osFamily)} / ${selectedHost.value.address}`
: (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}` : ''}`
@@ -144,4 +155,10 @@ function execute() {
credentialId: credentialId.value
});
}
function osFamilyLabel(value = 'other') {
if (value === 'windows') return 'Windows';
if (value === 'linux') return 'Linux';
return 'Other';
}
</script>

View File

@@ -3,62 +3,89 @@
<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>
<h3>VMware connections</h3>
<p>Register vCenter inventory systems or standalone ESXi hosts for VMware-aware host management.</p>
</div>
<button class="primary-action compact" type="button" @click="openModal()">
<CloudCog :size="15" />Add vCenter
<i class="mdi mdi-server-network" aria-hidden="true"></i>Add VMware connection
</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>
<p>vCenter entries import VM inventory and power-state checks. Standalone host entries validate direct ESXi connections through the vSphere Web Services API.</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>{{ vcenterCount }}</b><small>vCenter</small></span>
<span><b>{{ standaloneCount }}</b><small>hosts</small></span>
<span><b>{{ testedCount }}</b><small>tested</small></span>
</div>
</section>
<section class="publisher-card graph-config-list">
<strong>vCenter systems</strong>
<strong>VMware 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>
<small>{{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ 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>
<button class="ghost-button compact" type="button" @click="openModal(connection)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit
</button>
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">
<i class="mdi mdi-lan-check" aria-hidden="true"></i>Test
</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>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>
<p v-if="!connections.length" class="widget-empty">No saved VMware systems configured. The legacy env/settings default can still be used by vCenter import.</p>
</section>
</div>
<BaseModal
:open="modalOpen"
:title="form.id ? 'Update vCenter system' : 'New vCenter system'"
:title="form.id ? 'Update VMware connection' : 'New VMware connection'"
eyebrow="CONFIG / VMWARE"
description="Store vCenter API credentials encrypted at rest. These connections are selectable during host import."
description="Store VMware credentials encrypted at rest. vCenter systems can import VM inventory; standalone hosts use the vSphere Web Services API for direct connection validation."
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>Connection type</span>
<select v-model="form.targetType">
<option value="vcenter">vCenter inventory</option>
<option value="host">Standalone ESXi host</option>
</select>
</label>
<label><span>Name</span><input v-model="form.name" required :placeholder="form.targetType === 'host' ? 'Rack ESXi host 01' : 'Production vCenter'" /></label>
<label><span>Base URL</span><input v-model="form.baseUrl" required :placeholder="baseUrlPlaceholder" /></label>
<label><span>Username</span><input v-model="form.username" required :placeholder="form.targetType === 'host' ? 'root or delegated ESXi account' : '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>API mode</span>
<select v-model="form.apiMode" :disabled="form.targetType === 'host'">
<option value="auto">Auto</option>
<option value="api">vSphere /api</option>
<option value="rest">Legacy /rest</option>
<option value="web-services">vSphere Web Services</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>
<label class="toggle modal-toggle"><input v-model="form.enabled" type="checkbox" /><span><strong>Enabled</strong><small>{{ enabledHelpText }}</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 VMware 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>
<button class="ghost-button" type="button" @click="modalOpen = false">
<i class="mdi mdi-close" aria-hidden="true"></i>Cancel
</button>
<button class="primary-action" type="submit">
<i class="mdi mdi-content-save-outline" aria-hidden="true"></i>Save VMware connection
</button>
</div>
</form>
</BaseModal>
@@ -66,8 +93,7 @@
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { CloudCog, Save } from '@lucide/vue';
import { computed, reactive, ref, watch } from 'vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
@@ -80,12 +106,27 @@ 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 vcenterCount = computed(() => props.connections.filter((connection) => connection.targetType !== 'host').length);
const standaloneCount = computed(() => props.connections.filter((connection) => connection.targetType === 'host').length);
const testedCount = computed(() => props.connections.filter((connection) => connection.lastTestStatus).length);
const baseUrlPlaceholder = computed(() => form.targetType === 'host' ? 'https://esxi01.contoso.local' : 'https://vcenter.contoso.local');
const enabledHelpText = computed(() => form.targetType === 'host'
? 'Allow this standalone host connection to be tested and used for direct VMware host workflows.'
: 'Allow this vCenter system to be used for import and power-state checks.');
watch(() => form.targetType, (targetType) => {
if (targetType === 'host') {
form.apiMode = 'web-services';
} else if (form.apiMode === 'web-services') {
form.apiMode = 'auto';
}
});
function defaultForm() {
return {
id: null,
name: '',
targetType: 'vcenter',
baseUrl: '',
username: '',
password: '',
@@ -100,6 +141,8 @@ function defaultForm() {
function openModal(connection = null) {
Object.assign(form, defaultForm(), connection || {});
form.targetType = connection?.targetType || 'vcenter';
form.apiMode = form.targetType === 'host' ? 'web-services' : (connection?.apiMode || 'auto');
form.password = '';
form.groupId = connection?.groupId || '';
modalOpen.value = true;
@@ -109,4 +152,8 @@ function save() {
emit('save', { ...form, groupId: form.groupId || null });
modalOpen.value = false;
}
function connectionTypeLabel(connection) {
return connection.targetType === 'host' ? 'Standalone ESXi host' : 'vCenter inventory';
}
</script>

View File

@@ -21,6 +21,15 @@
</button>
</label>
<template v-if="kind === 'host'">
<label>
<span>OS</span>
<select v-model="osFamily">
<option value="">All OS types</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="other">Other</option>
</select>
</label>
<label>
<span>Source</span>
<select v-model="source">
@@ -87,6 +96,7 @@
<small>{{ secondaryText(item) }}</small>
</template>
<template v-else-if="column.key === 'address'"><code>{{ item.address || '-' }}</code></template>
<template v-else-if="column.key === 'osFamily'"><span :class="['status-pill', item.osFamily || 'other']">{{ osFamilyLabel(item.osFamily) }}</span></template>
<template v-else-if="column.key === 'transport'"><span class="status-pill neutral">{{ item.transport || '-' }}</span></template>
<template v-else-if="column.key === 'sourceType'"><span :class="['status-pill', item.sourceType === 'vmware' ? 'dynamic' : 'manual']">{{ sourceLabel(item.sourceType) }}</span></template>
<template v-else-if="column.key === 'mode'"><span :class="['status-pill', item.mode]">{{ item.mode || '-' }}</span></template>
@@ -141,6 +151,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']);
const search = ref('');
const osFamily = ref('');
const source = ref('');
const transport = ref('');
const selectedOnly = ref(false);
@@ -161,6 +172,7 @@ const columns = computed(() => props.kind === 'group'
: [
{ key: 'name', label: 'Host' },
{ key: 'address', label: 'Address' },
{ key: 'osFamily', label: 'OS' },
{ key: 'transport', label: 'Transport' },
{ key: 'sourceType', label: 'Source' },
{ key: 'tags', label: 'Tags', sortable: false }
@@ -172,12 +184,13 @@ const filteredItems = computed(() => {
const selected = new Set(selectedIds.value);
return props.items.filter((item) => {
if (selectedOnly.value && !selected.has(item.id)) return false;
if (props.kind === 'host' && osFamily.value && (item.osFamily || 'other') !== osFamily.value) return false;
if (props.kind === 'host' && source.value && (item.sourceType || 'manual') !== source.value) return false;
if (props.kind === 'host' && transport.value && item.transport !== transport.value) return false;
if (!term) return true;
const values = props.kind === 'group'
? [item.name, item.mode, item.description, groupRuleSummary(item)]
: [item.name, item.fqdn, item.address, item.transport, item.sourceType, ...(item.tags || [])];
: [item.name, item.fqdn, item.address, item.osFamily, item.transport, item.sourceType, ...(item.tags || [])];
return values.filter(Boolean).some((value) => String(value).toLowerCase().includes(term));
}).sort((a, b) => {
const result = sortableValue(a, sort.value).localeCompare(sortableValue(b, sort.value), undefined, { numeric: true, sensitivity: 'base' });
@@ -188,7 +201,7 @@ const pageCount = computed(() => Math.max(1, Math.ceil(filteredItems.value.lengt
const pagedItems = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, (page.value - 1) * pageSize.value + pageSize.value));
const pageAllSelected = computed(() => Boolean(pagedItems.value.length) && pagedItems.value.every((item) => selectedIds.value.includes(item.id)));
watch([search, source, transport, selectedOnly, pageSize], () => {
watch([search, osFamily, source, transport, selectedOnly, pageSize], () => {
page.value = 1;
});
@@ -235,6 +248,7 @@ function uniqueOptions(values) {
}
function sortableValue(item, key) {
if (key === 'osFamily') return item.osFamily || 'other';
if (key === 'sourceType') return item.sourceType || 'manual';
return String(item[key] || '');
}
@@ -249,6 +263,12 @@ function sourceLabel(value = 'manual') {
return value === 'vmware' ? 'VMware' : 'Manual';
}
function osFamilyLabel(value = 'other') {
if (value === 'windows') return 'Windows';
if (value === 'linux') return 'Linux';
return 'Other';
}
function groupRuleSummary(item) {
if (item.mode === 'manual') return 'Manual membership';
return (item.rules || []).map((rule) => `${rule.field} ${rule.operator} ${rule.value}`).join(` ${item.matchMode || 'all'} `) || 'No rules';

View File

@@ -4024,6 +4024,28 @@ textarea:focus {
color: var(--cyan);
}
.compatibility-callout {
padding: 12px 14px;
border: 1px solid rgba(255, 196, 87, .24);
border-radius: 15px;
background:
linear-gradient(135deg, rgba(255, 196, 87, .13), rgba(88, 221, 255, .045)),
rgba(8, 13, 30, .72);
}
.compatibility-callout strong {
display: block;
color: #ffe7a6;
font: 800 12px var(--display-font);
}
.compatibility-callout p {
margin: 5px 0 0;
color: rgba(226, 237, 250, .78);
font-size: 12px;
line-height: 1.45;
}
.script-test-output {
min-height: 360px;
display: grid;
@@ -4678,6 +4700,111 @@ body {
flex-wrap: wrap;
}
.material-icon-button {
width: 34px;
height: 34px;
min-width: 34px;
padding: 0;
border: 1px solid rgba(156, 199, 232, .16);
border-radius: 11px;
display: inline-grid;
place-items: center;
color: rgba(220, 237, 255, .84);
background:
linear-gradient(180deg, rgba(255, 255, 255, .09), rgba(255, 255, 255, .035)),
rgba(6, 15, 30, .62);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055);
}
.material-icon-button:hover {
color: #fff;
border-color: rgba(88, 221, 255, .42);
background:
radial-gradient(circle at 24% 0, rgba(88, 221, 255, .18), transparent 42%),
rgba(8, 18, 38, .82);
}
.primary-action i,
.ghost-button i,
.material-icon-button i {
flex: 0 0 auto;
font-size: 16px;
line-height: 1;
}
.host-group-summary-modal {
display: grid;
gap: 14px;
}
.summary-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.summary-metrics span {
min-height: 74px;
padding: 13px;
border: 1px solid rgba(156, 199, 232, .13);
border-radius: 16px;
display: grid;
align-content: center;
gap: 5px;
background:
radial-gradient(circle at 12% 0, rgba(88, 221, 255, .12), transparent 40%),
linear-gradient(145deg, rgba(15, 32, 58, .74), rgba(5, 12, 28, .7));
}
.summary-metrics strong {
color: #fff;
font-size: 22px;
line-height: 1;
}
.summary-metrics small {
color: rgba(190, 215, 238, .72);
font: 800 9px/1.2 var(--display-font);
letter-spacing: .08em;
text-transform: uppercase;
}
.summary-rule-panel {
padding: 14px;
border: 1px solid rgba(156, 199, 232, .13);
border-radius: 16px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
background:
linear-gradient(135deg, rgba(88, 221, 255, .07), rgba(166, 122, 255, .055)),
rgba(4, 12, 28, .58);
}
.summary-rule-panel h4 {
margin: 3px 0 5px;
color: rgba(248, 251, 255, .96);
font-size: 15px;
}
.summary-rule-panel p {
margin: 0;
color: rgba(206, 222, 240, .74);
font-size: 12px;
}
.summary-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
}
.readonly-members {
max-height: 420px;
overflow: auto;
}
.vault-state {
display: inline-flex;
align-items: center;
@@ -4775,7 +4902,7 @@ body {
.member-toolbar {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(150px, 190px) minmax(150px, 190px) auto;
grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(120px, 160px)) auto;
gap: 10px;
align-items: end;
}
@@ -4803,7 +4930,8 @@ body {
position: relative;
}
.member-search svg {
.member-search svg,
.member-search > i {
position: absolute;
left: 13px;
bottom: 13px;
@@ -5000,6 +5128,16 @@ body {
}
@media (max-width: 720px) {
.summary-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-rule-panel,
.summary-actions {
align-items: stretch;
flex-direction: column;
}
.member-browser-header,
.member-results-bar,
.member-pagination {
@@ -5116,6 +5254,10 @@ body {
.status-pill.warn,
.status-pill.running { color: var(--amber); background: rgba(255, 189, 106, .1); border-color: rgba(255, 189, 106, .2); }
.status-pill.windows { color: #8be0ff; background: rgba(88, 221, 255, .1); border-color: rgba(88, 221, 255, .24); }
.status-pill.linux { color: #64e7bd; background: rgba(100, 231, 189, .1); border-color: rgba(100, 231, 189, .24); }
.status-pill.other { color: rgba(226, 237, 250, .72); background: rgba(156, 199, 232, .08); border-color: rgba(156, 199, 232, .18); }
.pagination {
display: flex;
align-items: center;