Initial
This commit is contained in:
@@ -48,20 +48,127 @@
|
||||
wide
|
||||
@close="modalOpen = false"
|
||||
>
|
||||
<form class="form-grid" @submit.prevent="save">
|
||||
<form class="form-grid host-group-form" @submit.prevent="save">
|
||||
<label><span>Name</span><input v-model="form.name" required placeholder="Engineering" /></label>
|
||||
<label><span>Mode</span><select v-model="form.mode"><option value="manual">Manual</option><option value="dynamic">Rule based</option></select></label>
|
||||
<label><span>Visibility</span><select v-model="form.visibility"><option value="shared">Shared</option><option value="personal">Personal</option><option value="group">Group</option></select></label>
|
||||
<label v-if="form.visibility === 'group'"><span>Group</span><select v-model="form.groupId"><option value="">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
|
||||
<label class="full"><span>Description</span><textarea v-model="form.description" rows="2" placeholder="What this collection targets"></textarea></label>
|
||||
|
||||
<section v-if="form.mode === 'manual'" class="full modal-host-picker">
|
||||
<div class="list-header"><span>Manual members</span><strong>{{ form.hostIds.length }} selected</strong></div>
|
||||
<div class="host-picker">
|
||||
<label v-for="host in hosts" :key="host.id">
|
||||
<input v-model="form.hostIds" type="checkbox" :value="host.id" />
|
||||
<span><strong>{{ host.name }}</strong><small>{{ host.transport }} - {{ host.address }}</small></span>
|
||||
<section v-if="form.mode === 'manual'" class="full host-member-browser">
|
||||
<div class="member-browser-header">
|
||||
<div>
|
||||
<span class="eyebrow">MANUAL MEMBERS</span>
|
||||
<h4>Host membership</h4>
|
||||
<p>Select exact hosts for this group. Imported host lists stay usable with search, filters, sorting, and pagination.</p>
|
||||
</div>
|
||||
<div class="member-selection-meter">
|
||||
<strong>{{ form.hostIds.length }}</strong>
|
||||
<span>selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="member-toolbar">
|
||||
<label class="member-search">
|
||||
<Search :size="15" />
|
||||
<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" />
|
||||
</button>
|
||||
</label>
|
||||
<label>
|
||||
<span>Source</span>
|
||||
<select v-model="memberSource">
|
||||
<option value="">All sources</option>
|
||||
<option v-for="source in sourceOptions" :key="source" :value="source">{{ sourceLabel(source) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Transport</span>
|
||||
<select v-model="memberTransport">
|
||||
<option value="">All transports</option>
|
||||
<option v-for="transport in transportOptions" :key="transport" :value="transport">{{ transport }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button :class="['ghost-button compact', { active: selectedOnly }]" type="button" @click="selectedOnly = !selectedOnly">
|
||||
<ListChecks :size="15" />Selected
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="member-results-bar">
|
||||
<span>{{ filteredHosts.length }} matching host(s)</span>
|
||||
<div>
|
||||
<button class="ghost-button compact" type="button" :disabled="!pagedHosts.length || pageAllSelected" @click="selectPage">
|
||||
Select page
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" :disabled="!pagedHosts.some((host) => form.hostIds.includes(host.id))" @click="clearPage">
|
||||
Clear page
|
||||
</button>
|
||||
<button class="ghost-button compact danger-text" type="button" :disabled="!form.hostIds.length" @click="form.hostIds = []">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="member-table-wrap">
|
||||
<table class="member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="select-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="pageAllSelected"
|
||||
:disabled="!pagedHosts.length"
|
||||
aria-label="Select all hosts on this page"
|
||||
@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>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="host in pagedHosts" :key="host.id" :class="{ selected: form.hostIds.includes(host.id) }">
|
||||
<td class="select-col">
|
||||
<input v-model="form.hostIds" type="checkbox" :value="host.id" :aria-label="`Select ${host.name}`" />
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ host.name }}</strong>
|
||||
<small>{{ host.fqdn || 'No FQDN recorded' }}</small>
|
||||
</td>
|
||||
<td><code>{{ host.address || '-' }}</code></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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="member-pagination">
|
||||
<label>
|
||||
<span>Rows</span>
|
||||
<select v-model.number="memberPageSize">
|
||||
<option :value="8">8</option>
|
||||
<option :value="12">12</option>
|
||||
<option :value="20">20</option>
|
||||
</select>
|
||||
</label>
|
||||
<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
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" :disabled="memberPage >= memberPageCount" @click="memberPage += 1">
|
||||
Next<ChevronRight :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -102,12 +209,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { Plus, RefreshCcw, Save, Trash2 } from '@lucide/vue';
|
||||
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';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
hostGroups: { type: Array, default: () => [] },
|
||||
hosts: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] }
|
||||
@@ -115,6 +222,14 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['save', 'delete', 'sync', 'sync-all']);
|
||||
const modalOpen = ref(false);
|
||||
const memberSearch = ref('');
|
||||
const memberSource = ref('');
|
||||
const memberTransport = ref('');
|
||||
const selectedOnly = ref(false);
|
||||
const memberSort = ref('name');
|
||||
const memberDirection = ref('asc');
|
||||
const memberPage = ref(1);
|
||||
const memberPageSize = ref(8);
|
||||
const form = reactive(defaultForm());
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
@@ -125,6 +240,46 @@ const columns = [
|
||||
{ key: 'actions', label: 'Actions', sortable: false }
|
||||
];
|
||||
|
||||
const sourceOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.sourceType || 'manual')));
|
||||
const transportOptions = computed(() => uniqueOptions(props.hosts.map((host) => host.transport).filter(Boolean)));
|
||||
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 (memberSource.value && (host.sourceType || 'manual') !== memberSource.value) return false;
|
||||
if (memberTransport.value && host.transport !== memberTransport.value) return false;
|
||||
if (!term) return true;
|
||||
return [
|
||||
host.name,
|
||||
host.fqdn,
|
||||
host.address,
|
||||
host.transport,
|
||||
host.sourceType,
|
||||
...(host.tags || [])
|
||||
].filter(Boolean).some((value) => String(value).toLowerCase().includes(term));
|
||||
}).sort((a, b) => {
|
||||
const left = sortableHostValue(a, memberSort.value);
|
||||
const right = sortableHostValue(b, memberSort.value);
|
||||
const result = left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return memberDirection.value === 'asc' ? result : -result;
|
||||
});
|
||||
});
|
||||
const memberPageCount = computed(() => Math.max(1, Math.ceil(filteredHosts.value.length / memberPageSize.value)));
|
||||
const pagedHosts = computed(() => {
|
||||
const start = (memberPage.value - 1) * memberPageSize.value;
|
||||
return filteredHosts.value.slice(start, start + memberPageSize.value);
|
||||
});
|
||||
const pageAllSelected = computed(() => Boolean(pagedHosts.value.length) && pagedHosts.value.every((host) => form.hostIds.includes(host.id)));
|
||||
|
||||
watch([memberSearch, memberSource, memberTransport, selectedOnly, memberPageSize], () => {
|
||||
memberPage.value = 1;
|
||||
});
|
||||
|
||||
watch(memberPageCount, (pageCount) => {
|
||||
if (memberPage.value > pageCount) memberPage.value = pageCount;
|
||||
});
|
||||
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: null,
|
||||
@@ -144,6 +299,13 @@ function openModal(group = null) {
|
||||
form.rules = (group?.rules?.length ? group.rules : defaultForm().rules).map((rule) => ({ ...rule }));
|
||||
form.hostIds = [...(group?.hostIds || [])];
|
||||
form.groupId = group?.groupId || '';
|
||||
memberSearch.value = '';
|
||||
memberSource.value = '';
|
||||
memberTransport.value = '';
|
||||
selectedOnly.value = false;
|
||||
memberSort.value = 'name';
|
||||
memberDirection.value = 'asc';
|
||||
memberPage.value = 1;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -161,6 +323,48 @@ function ruleSummary(group) {
|
||||
return (group.rules || []).map((rule) => `${rule.field} ${rule.operator} "${rule.value}"`).join(` ${group.matchMode || 'all'} `) || 'No rules';
|
||||
}
|
||||
|
||||
function uniqueOptions(values) {
|
||||
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
function sortableHostValue(host, key) {
|
||||
if (key === 'sourceType') return host.sourceType || 'manual';
|
||||
return String(host[key] || '');
|
||||
}
|
||||
|
||||
function toggleMemberSort(key) {
|
||||
if (memberSort.value === key) {
|
||||
memberDirection.value = memberDirection.value === 'asc' ? 'desc' : 'asc';
|
||||
return;
|
||||
}
|
||||
memberSort.value = key;
|
||||
memberDirection.value = 'asc';
|
||||
}
|
||||
|
||||
function memberSortIcon(key) {
|
||||
if (memberSort.value !== key) return ChevronsUpDown;
|
||||
return memberDirection.value === 'asc' ? ArrowUpAZ : ArrowDownAZ;
|
||||
}
|
||||
|
||||
function selectPage() {
|
||||
const selected = new Set(form.hostIds);
|
||||
for (const host of pagedHosts.value) selected.add(host.id);
|
||||
form.hostIds = [...selected];
|
||||
}
|
||||
|
||||
function clearPage() {
|
||||
const pageIds = new Set(pagedHosts.value.map((host) => host.id));
|
||||
form.hostIds = form.hostIds.filter((id) => !pageIds.has(id));
|
||||
}
|
||||
|
||||
function sourceLabel(source = 'manual') {
|
||||
return source === 'vmware' ? 'VMware' : 'Manual';
|
||||
}
|
||||
|
||||
function tagSummary(host) {
|
||||
return host.tags?.length ? host.tags.slice(0, 3).join(', ') : '-';
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', {
|
||||
...form,
|
||||
|
||||
Reference in New Issue
Block a user