This commit is contained in:
2026-06-25 15:26:53 -05:00
parent 3fc94ae1c1
commit 94f4d8959d
3 changed files with 455 additions and 18 deletions

View File

@@ -0,0 +1,256 @@
<template>
<section class="full host-member-browser target-browser">
<div class="member-browser-header">
<div>
<span class="eyebrow">{{ eyebrow }}</span>
<h4>{{ title }}</h4>
<p>{{ description }}</p>
</div>
<div class="member-selection-meter">
<strong>{{ selectedIds.length }}</strong>
<span>selected</span>
</div>
</div>
<div :class="['member-toolbar', { 'target-browser-toolbar-compact': kind !== 'host' }]">
<label class="member-search">
<Search :size="15" />
<input v-model="search" :placeholder="searchPlaceholder" />
<button v-if="search" type="button" :aria-label="`Clear ${title} search`" @click="search = ''">
<X :size="13" />
</button>
</label>
<template v-if="kind === 'host'">
<label>
<span>Source</span>
<select v-model="source">
<option value="">All sources</option>
<option v-for="option in sourceOptions" :key="option" :value="option">{{ sourceLabel(option) }}</option>
</select>
</label>
<label>
<span>Transport</span>
<select v-model="transport">
<option value="">All transports</option>
<option v-for="option in transportOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
</template>
<button :class="['ghost-button compact', { active: selectedOnly }]" type="button" @click="selectedOnly = !selectedOnly">
<ListChecks :size="15" />Selected
</button>
</div>
<div class="member-results-bar">
<span>{{ filteredItems.length }} matching {{ itemLabel }}</span>
<div>
<button class="ghost-button compact" type="button" :disabled="!pagedItems.length || pageAllSelected" @click="selectPage">Select page</button>
<button class="ghost-button compact" type="button" :disabled="!pagedItems.some((item) => selectedIds.includes(item.id))" @click="clearPage">Clear page</button>
<button class="ghost-button compact danger-text" type="button" :disabled="!selectedIds.length" @click="updateSelection([])">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="!pagedItems.length"
:aria-label="`Select all ${itemLabel} on this page`"
@change="$event.target.checked ? selectPage() : clearPage()"
/>
</th>
<th v-for="column in columns" :key="column.key">
<button v-if="column.sortable !== false" type="button" @click="toggleSort(column.key)">
{{ column.label }} <component :is="sortIcon(column.key)" :size="13" />
</button>
<template v-else>{{ column.label }}</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pagedItems" :key="item.id" :class="{ selected: selectedIds.includes(item.id) }">
<td class="select-col">
<input
type="checkbox"
:checked="selectedIds.includes(item.id)"
:aria-label="`Select ${item.name}`"
@change="toggleItem(item.id, $event.target.checked)"
/>
</td>
<td v-for="column in columns" :key="column.key">
<template v-if="column.key === 'name'">
<strong>{{ item.name }}</strong>
<small>{{ secondaryText(item) }}</small>
</template>
<template v-else-if="column.key === 'address'"><code>{{ item.address || '-' }}</code></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>
<template v-else-if="column.key === 'memberCount'">{{ item.memberCount || item.hostIds?.length || 0 }} host(s)</template>
<template v-else-if="column.key === 'tags'"><span class="member-tags">{{ item.tags?.length ? item.tags.slice(0, 3).join(', ') : '-' }}</span></template>
<template v-else-if="column.key === 'rules'"><span class="member-tags">{{ groupRuleSummary(item) }}</span></template>
<template v-else>{{ item[column.key] || '-' }}</template>
</td>
</tr>
<tr v-if="!pagedItems.length">
<td :colspan="columns.length + 1" class="member-empty">No {{ itemLabel }} match the current filters.</td>
</tr>
</tbody>
</table>
</div>
<div class="member-pagination">
<label>
<span>Rows</span>
<select v-model.number="pageSize">
<option :value="8">8</option>
<option :value="12">12</option>
<option :value="20">20</option>
</select>
</label>
<span>Page {{ Math.min(page, pageCount) }} of {{ pageCount }}</span>
<div>
<button class="ghost-button compact" type="button" :disabled="page <= 1" @click="page -= 1">
<ChevronLeft :size="15" />Previous
</button>
<button class="ghost-button compact" type="button" :disabled="page >= pageCount" @click="page += 1">
Next<ChevronRight :size="15" />
</button>
</div>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { ArrowDownAZ, ArrowUpAZ, ChevronsUpDown, ChevronLeft, ChevronRight, ListChecks, Search, X } from '@lucide/vue';
const props = defineProps({
modelValue: { type: Array, default: () => [] },
items: { type: Array, default: () => [] },
kind: { type: String, default: 'host' },
eyebrow: { type: String, default: 'TARGETS' },
title: { type: String, required: true },
description: { type: String, default: '' },
searchPlaceholder: { type: String, default: 'Search targets...' }
});
const emit = defineEmits(['update:modelValue']);
const search = ref('');
const source = ref('');
const transport = ref('');
const selectedOnly = ref(false);
const sort = ref('name');
const direction = ref('asc');
const page = ref(1);
const pageSize = ref(8);
const selectedIds = computed(() => props.modelValue || []);
const itemLabel = computed(() => props.kind === 'group' ? 'host group(s)' : 'host(s)');
const columns = computed(() => props.kind === 'group'
? [
{ key: 'name', label: 'Group' },
{ key: 'mode', label: 'Mode' },
{ key: 'memberCount', label: 'Members' },
{ key: 'rules', label: 'Rules', sortable: false }
]
: [
{ key: 'name', label: 'Host' },
{ key: 'address', label: 'Address' },
{ key: 'transport', label: 'Transport' },
{ key: 'sourceType', label: 'Source' },
{ key: 'tags', label: 'Tags', sortable: false }
]);
const sourceOptions = computed(() => uniqueOptions(props.items.map((item) => item.sourceType || 'manual')));
const transportOptions = computed(() => uniqueOptions(props.items.map((item) => item.transport).filter(Boolean)));
const filteredItems = computed(() => {
const term = search.value.trim().toLowerCase();
const selected = new Set(selectedIds.value);
return props.items.filter((item) => {
if (selectedOnly.value && !selected.has(item.id)) 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 || [])];
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' });
return direction.value === 'asc' ? result : -result;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)));
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], () => {
page.value = 1;
});
watch(pageCount, (next) => {
if (page.value > next) page.value = next;
});
function updateSelection(ids) {
emit('update:modelValue', ids);
}
function toggleItem(id, checked) {
const next = new Set(selectedIds.value);
if (checked) next.add(id);
else next.delete(id);
updateSelection([...next]);
}
function selectPage() {
updateSelection([...new Set([...selectedIds.value, ...pagedItems.value.map((item) => item.id)])]);
}
function clearPage() {
const pageIds = new Set(pagedItems.value.map((item) => item.id));
updateSelection(selectedIds.value.filter((id) => !pageIds.has(id)));
}
function toggleSort(key) {
if (sort.value === key) {
direction.value = direction.value === 'asc' ? 'desc' : 'asc';
return;
}
sort.value = key;
direction.value = 'asc';
}
function sortIcon(key) {
if (sort.value !== key) return ChevronsUpDown;
return direction.value === 'asc' ? ArrowUpAZ : ArrowDownAZ;
}
function uniqueOptions(values) {
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
function sortableValue(item, key) {
if (key === 'sourceType') return item.sourceType || 'manual';
return String(item[key] || '');
}
function secondaryText(item) {
return props.kind === 'group'
? `${item.mode || 'manual'} / ${item.memberCount || item.hostIds?.length || 0} host(s)`
: item.fqdn || 'No FQDN recorded';
}
function sourceLabel(value = 'manual') {
return value === 'vmware' ? 'VMware' : 'Manual';
}
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';
}
</script>