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

@@ -511,24 +511,24 @@
<label v-if="runPlanForm.visibility === 'group'"><span>Group</span><select v-model="runPlanForm.groupId"><option :value="null">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label> <label v-if="runPlanForm.visibility === 'group'"><span>Group</span><select v-model="runPlanForm.groupId"><option :value="null">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="runPlanForm.parallel" type="checkbox" /><span><strong>Parallel execution</strong><small>Run against selected hosts concurrently.</small></span></label> <label class="toggle modal-toggle"><input v-model="runPlanForm.parallel" type="checkbox" /><span><strong>Parallel execution</strong><small>Run against selected hosts concurrently.</small></span></label>
<label class="full"><span>Description</span><textarea v-model="runPlanForm.description" rows="3" placeholder="Purpose, operator notes, approval context"></textarea></label> <label class="full"><span>Description</span><textarea v-model="runPlanForm.description" rows="3" placeholder="Purpose, operator notes, approval context"></textarea></label>
<div class="full modal-host-picker"> <TargetBrowser
<div class="list-header"><span>Target host groups</span><strong>{{ runPlanForm.hostGroupIds.length }} selected</strong></div> v-model="runPlanForm.hostGroupIds"
<div class="host-picker"> :items="hostGroups"
<label v-for="hostGroup in hostGroups" :key="hostGroup.id"> kind="group"
<input v-model="runPlanForm.hostGroupIds" type="checkbox" :value="hostGroup.id" /> eyebrow="HOST GROUP TARGETS"
<span><strong>{{ hostGroup.name }}</strong><small>{{ hostGroup.mode }} · {{ hostGroup.memberCount || 0 }} host(s)</small></span> title="Target host groups"
</label> description="Select dynamic or manual collections. Dynamic groups use their latest synced membership when the RunPlan executes."
</div> search-placeholder="Search groups, mode, rule text..."
</div> />
<div class="full modal-host-picker"> <TargetBrowser
<div class="list-header"><span>Target hosts</span><strong>{{ runPlanForm.hostIds.length }} selected</strong></div> v-model="runPlanForm.hostIds"
<div class="host-picker"> :items="hosts"
<label v-for="host in hosts" :key="host.id"> kind="host"
<input v-model="runPlanForm.hostIds" type="checkbox" :value="host.id" /> eyebrow="DIRECT HOST TARGETS"
<span><strong>{{ host.name }}</strong><small>{{ host.transport }} · {{ host.address }}</small></span> title="Target hosts"
</label> description="Select individual systems when this RunPlan needs exact one-off targets in addition to host groups."
</div> search-placeholder="Search host, FQDN, address, tags..."
</div> />
<div class="form-actions modal-actions full"> <div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="runPlanModalOpen = false">Cancel</button> <button class="ghost-button" type="button" @click="runPlanModalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Save :size="17" />{{ runPlanForm.id ? 'Update RunPlan' : 'Create RunPlan' }}</button> <button class="primary-action" type="submit"><Save :size="17" />{{ runPlanForm.id ? 'Update RunPlan' : 'Create RunPlan' }}</button>
@@ -971,6 +971,7 @@ import HelpCenter from './components/help/HelpCenter.vue';
import ParticleBackdrop from './components/ParticleBackdrop.vue'; import ParticleBackdrop from './components/ParticleBackdrop.vue';
import BaseModal from './components/ui/BaseModal.vue'; import BaseModal from './components/ui/BaseModal.vue';
import ResourceTable from './components/ui/ResourceTable.vue'; import ResourceTable from './components/ui/ResourceTable.vue';
import TargetBrowser from './components/ui/TargetBrowser.vue';
import ScriptExplorer from './components/scripts/ScriptExplorer.vue'; import ScriptExplorer from './components/scripts/ScriptExplorer.vue';
import ScriptTestRunModal from './components/scripts/ScriptTestRunModal.vue'; import ScriptTestRunModal from './components/scripts/ScriptTestRunModal.vue';
import VariableEditorModal from './components/scripts/VariableEditorModal.vue'; import VariableEditorModal from './components/scripts/VariableEditorModal.vue';

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>

View File

@@ -2818,6 +2818,7 @@ textarea:focus {
.psadt-function-list button { .psadt-function-list button {
min-width: 0; min-width: 0;
overflow: hidden;
padding: 10px; padding: 10px;
border: 1px solid rgba(156, 199, 232, .1); border: 1px solid rgba(156, 199, 232, .1);
border-radius: 11px; border-radius: 11px;
@@ -2833,14 +2834,22 @@ textarea:focus {
} }
.psadt-function-list code { .psadt-function-list code {
display: block;
max-width: 100%;
min-width: 0; min-width: 0;
color: var(--cyan); color: var(--cyan);
font: 800 10px "SFMono-Regular", Consolas, monospace; font: 800 10px "SFMono-Regular", Consolas, monospace;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.psadt-function-list span { .psadt-function-list span {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--subtle); color: var(--subtle);
font: 800 8px "SFMono-Regular", Consolas, monospace; font: 800 8px "SFMono-Regular", Consolas, monospace;
text-transform: uppercase; text-transform: uppercase;
@@ -4771,6 +4780,10 @@ body {
align-items: end; align-items: end;
} }
.member-toolbar.target-browser-toolbar-compact {
grid-template-columns: minmax(280px, 1fr) auto;
}
.member-toolbar label, .member-toolbar label,
.member-pagination label { .member-pagination label {
display: grid; display: grid;
@@ -4878,6 +4891,8 @@ body {
.member-table th button { .member-table th button {
width: 100%; width: 100%;
min-height: 30px;
padding: 6px 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@@ -6896,6 +6911,94 @@ input:read-only {
min-width: 118px; min-width: 118px;
} }
.form-modal .toggle,
.modal-form .toggle,
.grid-form .toggle {
min-height: 58px;
display: flex !important;
align-items: center;
gap: 12px;
padding: 11px 12px !important;
border: 1px solid rgba(156, 199, 232, .14) !important;
border-radius: 15px !important;
background:
linear-gradient(135deg, rgba(255, 255, 255, .055), rgba(88, 221, 255, .025)),
rgba(5, 13, 28, .46) !important;
}
.form-modal .toggle input[type="checkbox"],
.modal-form .toggle input[type="checkbox"],
.grid-form .toggle input[type="checkbox"] {
appearance: none;
position: relative;
flex: 0 0 42px;
width: 42px !important;
min-width: 42px !important;
max-width: 42px !important;
height: 24px !important;
min-height: 24px !important;
margin: 0 !important;
padding: 0 !important;
border: 1px solid rgba(156, 199, 232, .24) !important;
border-radius: 999px !important;
background: rgba(3, 9, 20, .8) !important;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, .34);
cursor: pointer;
}
.form-modal .toggle input[type="checkbox"]::after,
.modal-form .toggle input[type="checkbox"]::after,
.grid-form .toggle input[type="checkbox"]::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 999px;
background: rgba(218, 236, 255, .78);
box-shadow: 0 4px 10px rgba(0, 0, 0, .32);
transition: transform .18s ease, background .18s ease;
}
.form-modal .toggle input[type="checkbox"]:checked,
.modal-form .toggle input[type="checkbox"]:checked,
.grid-form .toggle input[type="checkbox"]:checked {
border-color: rgba(88, 221, 255, .54) !important;
background: linear-gradient(135deg, rgba(88, 221, 255, .85), rgba(166, 122, 255, .72)) !important;
}
.form-modal .toggle input[type="checkbox"]:checked::after,
.modal-form .toggle input[type="checkbox"]:checked::after,
.grid-form .toggle input[type="checkbox"]:checked::after {
transform: translateX(18px);
background: #fff;
}
.form-modal .host-picker input[type="checkbox"],
.modal-form .host-picker input[type="checkbox"],
.grid-form .host-picker input[type="checkbox"],
.form-modal .host-member-browser input[type="checkbox"],
.modal-form .host-member-browser input[type="checkbox"],
.grid-form .host-member-browser input[type="checkbox"] {
appearance: auto;
flex: 0 0 16px;
width: 16px !important;
min-width: 16px !important;
max-width: 16px !important;
height: 16px !important;
min-height: 16px !important;
margin: 0 !important;
padding: 0 !important;
accent-color: #58ddff;
}
.resource-toolbar .ghost-button.compact,
.resource-toolbar .primary-action.compact {
flex: 0 0 auto;
min-width: max-content;
}
.form-modal.wide-form-modal { .form-modal.wide-form-modal {
width: min(1120px, calc(100vw - 34px)); width: min(1120px, calc(100vw - 34px));
} }
@@ -7528,6 +7631,33 @@ input:read-only {
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
.topbar {
grid-template-columns: minmax(160px, 250px) minmax(180px, 1fr) auto;
gap: 10px;
padding-inline: 14px;
}
.topbar-actions .topbar-action-button {
width: 40px;
min-width: 40px;
height: 40px;
padding: 0;
justify-content: center;
font-size: 0;
}
.topbar-actions .topbar-action-button svg {
width: 17px;
height: 17px;
}
.help-center {
left: 12px !important;
top: 12px !important;
width: calc(100vw - 24px);
height: calc(100vh - 24px);
}
.deployment-section-body.three, .deployment-section-body.three,
.deployment-section-body.two, .deployment-section-body.two,
.return-code-row, .return-code-row,
@@ -7575,3 +7705,53 @@ input:read-only {
margin-left: 0; margin-left: 0;
} }
} }
/* Last-mile control guard: modal input rules must not resize checkboxes/switches. */
.dashboard-widget {
min-width: 0;
max-width: 100%;
}
.dashboard-widget::before {
inset: -80px 0 auto auto;
}
.dashboard-widget > *,
.widget-toolbar,
.dashboard-stat-widget {
min-width: 0;
max-width: 100%;
}
.form-modal .toggle input[type="checkbox"],
.modal-form .toggle input[type="checkbox"],
.grid-form .toggle input[type="checkbox"] {
appearance: none !important;
flex: 0 0 42px !important;
width: 42px !important;
min-width: 42px !important;
max-width: 42px !important;
height: 24px !important;
min-height: 24px !important;
max-height: 24px !important;
margin: 0 !important;
padding: 0 !important;
}
.form-modal .host-picker input[type="checkbox"],
.modal-form .host-picker input[type="checkbox"],
.grid-form .host-picker input[type="checkbox"],
.form-modal .host-member-browser input[type="checkbox"],
.modal-form .host-member-browser input[type="checkbox"],
.grid-form .host-member-browser input[type="checkbox"] {
appearance: auto !important;
flex: 0 0 16px !important;
width: 16px !important;
min-width: 16px !important;
max-width: 16px !important;
height: 16px !important;
min-height: 16px !important;
max-height: 16px !important;
margin: 0 !important;
padding: 0 !important;
}