Initial
This commit is contained in:
256
client/src/components/ui/TargetBrowser.vue
Normal file
256
client/src/components/ui/TargetBrowser.vue
Normal 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>
|
||||
Reference in New Issue
Block a user