Initial
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function safeFileName(value) {
|
||||
return String(value || 'host-group').trim().replace(/[^A-Za-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '') || 'host-group';
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', {
|
||||
...form,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user