Initial
This commit is contained in:
@@ -213,6 +213,7 @@
|
||||
:open="scriptTestModalOpen"
|
||||
:script="scriptTestTarget"
|
||||
:hosts="hosts"
|
||||
:host-groups="hostGroups"
|
||||
:credentials="credentials"
|
||||
:job="scriptTestJob"
|
||||
:running="scriptTestRunning"
|
||||
@@ -331,10 +332,21 @@
|
||||
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
|
||||
<template #cell-address="{ row }"><code>{{ row.address }}</code></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>
|
||||
<template #cell-tags="{ row }"><span class="tag-row">{{ row.tags?.join(', ') || '-' }}</span></template>
|
||||
<template #cell-actions="{ row }"><button class="ghost-button compact" type="button" @click="openHostModal(row)">Edit</button></template>
|
||||
</ResourceTable>
|
||||
|
||||
<HostGroupsPanel
|
||||
:host-groups="hostGroups"
|
||||
:hosts="hosts"
|
||||
:groups="groups"
|
||||
@save="saveHostGroup"
|
||||
@delete="deleteHostGroup"
|
||||
@sync="syncHostGroup"
|
||||
@sync-all="syncHostGroups"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="view === 'credentials'" class="resource-page">
|
||||
@@ -406,7 +418,7 @@
|
||||
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
|
||||
<template #cell-scriptName="{ row }">{{ row.scriptName || 'No script selected' }}</template>
|
||||
<template #cell-visibility="{ row }"><span :class="['status-pill', row.visibility]">{{ row.visibility }}</span></template>
|
||||
<template #cell-hostCount="{ row }">{{ row.hostCount || row.hostIds?.length || 0 }} host(s)</template>
|
||||
<template #cell-hostCount="{ row }">{{ row.hostCount || row.hostIds?.length || 0 }} host(s)<span v-if="row.hostGroupCount"> · {{ row.hostGroupCount }} group(s)</span></template>
|
||||
<template #cell-mode="{ row }">{{ row.parallel ? 'Parallel' : 'Serial' }}</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="table-actions">
|
||||
@@ -422,6 +434,7 @@
|
||||
:preview-rows="vcenterPreviewRows"
|
||||
:credentials="credentials"
|
||||
:groups="groups"
|
||||
:connections="vcenterConnections"
|
||||
:loading="vcenterImportLoading"
|
||||
:error="vcenterImportError"
|
||||
:status="vcenterImportStatus"
|
||||
@@ -498,6 +511,15 @@
|
||||
<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="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">
|
||||
<div class="list-header"><span>Target host groups</span><strong>{{ runPlanForm.hostGroupIds.length }} selected</strong></div>
|
||||
<div class="host-picker">
|
||||
<label v-for="hostGroup in hostGroups" :key="hostGroup.id">
|
||||
<input v-model="runPlanForm.hostGroupIds" type="checkbox" :value="hostGroup.id" />
|
||||
<span><strong>{{ hostGroup.name }}</strong><small>{{ hostGroup.mode }} · {{ hostGroup.memberCount || 0 }} host(s)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full modal-host-picker">
|
||||
<div class="list-header"><span>Target hosts</span><strong>{{ runPlanForm.hostIds.length }} selected</strong></div>
|
||||
<div class="host-picker">
|
||||
@@ -585,6 +607,14 @@
|
||||
@test="testGraphConnection"
|
||||
/>
|
||||
|
||||
<VCenterConnectionsConfig
|
||||
:connections="vcenterConnections"
|
||||
:groups="groups"
|
||||
@save="saveVCenterConnection"
|
||||
@delete="deleteVCenterConnection"
|
||||
@test="testVCenterConnection"
|
||||
/>
|
||||
|
||||
<article :class="['glass-panel form-panel settings-layout', { 'panel-collapsed': panelCollapsed.applicationConfig }]">
|
||||
<div class="section-title"><h3>Application Config</h3><div class="section-actions"><SlidersHorizontal :size="18" /><button class="panel-toggle" type="button" @click="togglePanel('applicationConfig')"><component :is="panelCollapsed.applicationConfig ? ChevronDown : ChevronUp" :size="14" /></button></div></div>
|
||||
<p class="panel-intro">Database settings are editable here; environment-sourced values are shown so Docker configuration stays visible.</p>
|
||||
@@ -936,6 +966,7 @@ import AppSidebar from './components/AppSidebar.vue';
|
||||
import AppTopbar from './components/AppTopbar.vue';
|
||||
import ConfigSection from './components/settings/ConfigSection.vue';
|
||||
import IntuneGraphConfig from './components/settings/IntuneGraphConfig.vue';
|
||||
import VCenterConnectionsConfig from './components/settings/VCenterConnectionsConfig.vue';
|
||||
import HelpCenter from './components/help/HelpCenter.vue';
|
||||
import ParticleBackdrop from './components/ParticleBackdrop.vue';
|
||||
import BaseModal from './components/ui/BaseModal.vue';
|
||||
@@ -943,6 +974,7 @@ import ResourceTable from './components/ui/ResourceTable.vue';
|
||||
import ScriptExplorer from './components/scripts/ScriptExplorer.vue';
|
||||
import ScriptTestRunModal from './components/scripts/ScriptTestRunModal.vue';
|
||||
import VariableEditorModal from './components/scripts/VariableEditorModal.vue';
|
||||
import HostGroupsPanel from './components/hosts/HostGroupsPanel.vue';
|
||||
import VCenterImportModal from './components/hosts/VCenterImportModal.vue';
|
||||
import VCenterTraceModal from './components/hosts/VCenterTraceModal.vue';
|
||||
import DashboardView from './views/DashboardView.vue';
|
||||
@@ -1112,6 +1144,12 @@ const settingMetadata = {
|
||||
description: 'Master safety switch that controls whether RunPlans can execute scripts on target hosts.',
|
||||
section: 'Execution'
|
||||
},
|
||||
host_group_sync_interval_minutes: {
|
||||
label: 'Host Group Sync Interval',
|
||||
description: 'Minutes between background sync runs for dynamic host groups. Set 0 to disable the scheduler.',
|
||||
placeholder: '60',
|
||||
section: 'Execution'
|
||||
},
|
||||
vcenter_enabled: {
|
||||
label: 'VMware vCenter',
|
||||
description: 'Enable VM discovery and import through the VMware vCenter REST API.',
|
||||
@@ -1158,6 +1196,7 @@ const users = ref([]);
|
||||
const groups = ref([]);
|
||||
const credentials = ref([]);
|
||||
const hosts = ref([]);
|
||||
const hostGroups = ref([]);
|
||||
const folders = ref([]);
|
||||
const scripts = ref([]);
|
||||
const assetFolders = ref([]);
|
||||
@@ -1172,6 +1211,7 @@ const catalogApplications = ref([]);
|
||||
const changeRequests = ref([]);
|
||||
const reportingOverview = ref(null);
|
||||
const graphConnections = ref([]);
|
||||
const vcenterConnections = ref([]);
|
||||
const graphStatusResult = ref(null);
|
||||
const graphAuditResult = ref([]);
|
||||
const driftResult = ref(null);
|
||||
@@ -1198,9 +1238,9 @@ const loginForm = reactive({ email: 'admin@posh.local', password: 'change-me-now
|
||||
const scriptDraft = reactive({ id: null, folderId: null, name: '', description: '', content: '', visibility: 'personal', groupId: null });
|
||||
const scriptRenameForm = reactive({ name: '' });
|
||||
const scriptCloneForm = reactive({ name: '' });
|
||||
const hostForm = reactive({ id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null });
|
||||
const hostForm = reactive({ id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null, sourceType: 'manual', sourceConnectionId: null, sourceRef: '' });
|
||||
const credentialForm = reactive({ id: null, name: '', kind: 'username_password', username: '', secret: '', visibility: 'personal', groupId: null });
|
||||
const runPlanForm = reactive({ id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [] });
|
||||
const runPlanForm = reactive({ id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [], hostGroupIds: [] });
|
||||
const groupForm = reactive({ name: '', description: '' });
|
||||
const userForm = reactive({ email: '', displayName: '', password: '', role: 'user', groupIds: [] });
|
||||
const profileForm = reactive({ displayName: '', email: '', jobTitle: '', phone: '', timezone: '', avatarUrl: '' });
|
||||
@@ -1286,6 +1326,7 @@ const hostColumns = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'address', label: 'Address' },
|
||||
{ key: 'transport', label: 'Transport' },
|
||||
{ key: 'sourceType', label: 'Source' },
|
||||
{ key: 'credentialName', label: 'Credential' },
|
||||
{ key: 'tags', label: 'Tags', sortable: false },
|
||||
{ key: 'actions', label: 'Actions', sortable: false }
|
||||
@@ -1428,6 +1469,8 @@ const filteredHosts = computed(() => filterRows(hosts.value, hostFilters, (host)
|
||||
host.address,
|
||||
host.fqdn,
|
||||
host.transport,
|
||||
host.sourceType,
|
||||
host.sourceConnectionName,
|
||||
host.credentialName,
|
||||
host.tags?.join(' ')
|
||||
]));
|
||||
@@ -1618,21 +1661,51 @@ async function safeRefresh(label, promise, fallback, failures, quiet = false) {
|
||||
return await promise;
|
||||
} catch (err) {
|
||||
console.warn(`POSHManager refresh failed for ${label}`, err);
|
||||
if (!quiet) failures.push(`${label}: ${err.message || 'request failed'}`);
|
||||
if (!quiet) failures.push(normalizeRefreshFailure(label, err));
|
||||
return typeof fallback === 'function' ? fallback() : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRefreshFailure(label, err) {
|
||||
const message = err?.message || 'request failed';
|
||||
return {
|
||||
label,
|
||||
message,
|
||||
path: err?.path || '',
|
||||
status: err?.status || null,
|
||||
network: !err?.status
|
||||
};
|
||||
}
|
||||
|
||||
function formatRefreshFailures(failures) {
|
||||
const visibleFailures = failures.filter(Boolean);
|
||||
if (!visibleFailures.length) return '';
|
||||
const networkFailures = visibleFailures.filter((failure) => failure.network);
|
||||
if (networkFailures.length === visibleFailures.length) {
|
||||
const first = networkFailures[0];
|
||||
return `API is unreachable (${first.message}). Using last known data where available.`;
|
||||
}
|
||||
return `Some data did not refresh: ${visibleFailures.slice(0, 2).map((failure) => `${failure.label}: ${failure.message}`).join('; ')}`;
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
// Bootstrap all API-backed view state together so navigation does not trigger partial reload races.
|
||||
try {
|
||||
await api.get('/api/health');
|
||||
} catch (err) {
|
||||
console.warn('POSHManager refresh skipped because the API health probe failed', err);
|
||||
notify(`API is unreachable (${err.message || 'health probe failed'}). Using last known data where available.`);
|
||||
return;
|
||||
}
|
||||
const refreshFailures = [];
|
||||
const [meResult, boot, userRows, groupRows, credentialRows, hostRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, runplanRows, jobRows] = await Promise.all([
|
||||
api.get('/api/auth/me'),
|
||||
const [meResult, boot, userRows, groupRows, credentialRows, hostRows, hostGroupRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, vcenterRows, runplanRows, jobRows] = await Promise.all([
|
||||
safeRefresh('session', api.get('/api/auth/me'), () => ({ user: user.value }), refreshFailures),
|
||||
safeRefresh('bootstrap', api.get('/api/bootstrap'), () => ({ summary: summary.value, settings: settings.value }), refreshFailures),
|
||||
safeRefresh('users', api.get('/api/users'), () => users.value, refreshFailures, true),
|
||||
safeRefresh('groups', api.get('/api/groups'), () => groups.value, refreshFailures),
|
||||
safeRefresh('credentials', api.get('/api/credentials'), () => credentials.value, refreshFailures),
|
||||
safeRefresh('hosts', api.get('/api/hosts'), () => hosts.value, refreshFailures),
|
||||
safeRefresh('host groups', api.get('/api/hosts/groups'), () => hostGroups.value, refreshFailures),
|
||||
safeRefresh('folders', api.get('/api/folders'), () => folders.value, refreshFailures),
|
||||
safeRefresh('scripts', api.get('/api/scripts'), () => scripts.value, refreshFailures),
|
||||
safeRefresh('asset folders', api.get('/api/assets/folders'), () => assetFolders.value, refreshFailures),
|
||||
@@ -1642,6 +1715,7 @@ async function refreshAll() {
|
||||
safeRefresh('PSADT profiles', api.get('/api/psadt/profiles'), () => psadtProfiles.value, refreshFailures),
|
||||
safeRefresh('Intune deployments', api.get('/api/psadt/intune/deployments'), () => psadtIntuneDeployments.value, refreshFailures),
|
||||
safeRefresh('Graph connections', api.get('/api/graph/connections'), () => graphConnections.value, refreshFailures),
|
||||
safeRefresh('vCenter connections', api.get('/api/hosts/import/vcenter/connections'), () => vcenterConnections.value, refreshFailures),
|
||||
safeRefresh('RunPlans', api.get('/api/runplans'), () => runplans.value, refreshFailures),
|
||||
safeRefresh('jobs', api.get('/api/jobs'), () => jobs.value, refreshFailures)
|
||||
]);
|
||||
@@ -1654,6 +1728,7 @@ async function refreshAll() {
|
||||
groups.value = groupRows;
|
||||
credentials.value = credentialRows;
|
||||
hosts.value = hostRows;
|
||||
hostGroups.value = hostGroupRows;
|
||||
folders.value = folderRows;
|
||||
scripts.value = scriptRows;
|
||||
assetFolders.value = assetFolderRows;
|
||||
@@ -1663,6 +1738,7 @@ async function refreshAll() {
|
||||
psadtProfiles.value = psadtProfileRows;
|
||||
psadtIntuneDeployments.value = psadtIntuneRows;
|
||||
graphConnections.value = graphRows;
|
||||
vcenterConnections.value = vcenterRows;
|
||||
runplans.value = runplanRows;
|
||||
jobs.value = jobRows;
|
||||
if (selectedScript.value) {
|
||||
@@ -1671,7 +1747,8 @@ async function refreshAll() {
|
||||
} else if (scriptRows.length) {
|
||||
selectScript(scriptRows[0]);
|
||||
}
|
||||
if (refreshFailures.length) notify(`Some data did not refresh: ${refreshFailures.slice(0, 2).join('; ')}`);
|
||||
const refreshMessage = formatRefreshFailures(refreshFailures);
|
||||
if (refreshMessage) notify(refreshMessage);
|
||||
}
|
||||
|
||||
async function openHelpCenter() {
|
||||
@@ -2237,6 +2314,28 @@ async function testGraphConnection(id) {
|
||||
notify(result.message || 'Graph connection test completed');
|
||||
}
|
||||
|
||||
async function saveVCenterConnection(payload) {
|
||||
const body = { ...payload };
|
||||
const connectionId = body.id;
|
||||
delete body.id;
|
||||
if (connectionId) await api.put(`/api/hosts/import/vcenter/connections/${connectionId}`, body);
|
||||
else await api.post('/api/hosts/import/vcenter/connections', body);
|
||||
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
|
||||
notify('vCenter system saved');
|
||||
}
|
||||
|
||||
async function deleteVCenterConnection(id) {
|
||||
await api.delete(`/api/hosts/import/vcenter/connections/${id}`);
|
||||
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
|
||||
notify('vCenter system deleted');
|
||||
}
|
||||
|
||||
async function testVCenterConnection(id) {
|
||||
const result = await api.post(`/api/hosts/import/vcenter/connections/${id}/test`, {});
|
||||
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
|
||||
notify(result.message || 'vCenter connection test completed');
|
||||
}
|
||||
|
||||
async function linkGraphApp(payload) {
|
||||
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/link`, {
|
||||
connectionId: payload.connectionId,
|
||||
@@ -2371,6 +2470,35 @@ async function saveHost() {
|
||||
notify('Host saved');
|
||||
}
|
||||
|
||||
async function saveHostGroup(payload) {
|
||||
const body = { ...payload };
|
||||
const groupId = body.id;
|
||||
delete body.id;
|
||||
if (groupId) await api.put(`/api/hosts/groups/${groupId}`, body);
|
||||
else await api.post('/api/hosts/groups', body);
|
||||
hostGroups.value = await api.get('/api/hosts/groups');
|
||||
notify('Host group saved');
|
||||
}
|
||||
|
||||
async function deleteHostGroup(groupId) {
|
||||
await api.delete(`/api/hosts/groups/${groupId}`);
|
||||
hostGroups.value = await api.get('/api/hosts/groups');
|
||||
await refreshAll();
|
||||
notify('Host group deleted');
|
||||
}
|
||||
|
||||
async function syncHostGroup(groupId) {
|
||||
await api.post(`/api/hosts/groups/${groupId}/sync`, {});
|
||||
hostGroups.value = await api.get('/api/hosts/groups');
|
||||
notify('Host group synced');
|
||||
}
|
||||
|
||||
async function syncHostGroups() {
|
||||
const result = await api.post('/api/hosts/groups/sync', {});
|
||||
hostGroups.value = await api.get('/api/hosts/groups');
|
||||
notify(`Dynamic host groups synced: ${result.synced || 0}`);
|
||||
}
|
||||
|
||||
async function openVCenterImport() {
|
||||
vcenterImportOpen.value = true;
|
||||
vcenterImportError.value = '';
|
||||
@@ -2379,6 +2507,7 @@ async function openVCenterImport() {
|
||||
vcenterTraceOpen.value = false;
|
||||
try {
|
||||
vcenterImportStatus.value = await api.get('/api/hosts/import/vcenter/status');
|
||||
vcenterConnections.value = vcenterImportStatus.value.connections || vcenterConnections.value;
|
||||
} catch (err) {
|
||||
vcenterImportError.value = err.message;
|
||||
}
|
||||
@@ -2488,7 +2617,7 @@ async function importVCenterHosts(payload) {
|
||||
}
|
||||
|
||||
function resetHostForm() {
|
||||
Object.assign(hostForm, { id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null });
|
||||
Object.assign(hostForm, { id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null, sourceType: 'manual', sourceConnectionId: null, sourceRef: '' });
|
||||
}
|
||||
|
||||
function openHostModal(host = null) {
|
||||
@@ -2529,11 +2658,11 @@ async function saveRunPlan() {
|
||||
}
|
||||
|
||||
function resetRunPlanForm() {
|
||||
Object.assign(runPlanForm, { id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [] });
|
||||
Object.assign(runPlanForm, { id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [], hostGroupIds: [] });
|
||||
}
|
||||
|
||||
function openRunPlanModal(runplan = null) {
|
||||
if (runplan) Object.assign(runPlanForm, { ...runplan, hostIds: runplan.hostIds || [] });
|
||||
if (runplan) Object.assign(runPlanForm, { ...runplan, hostIds: runplan.hostIds || [], hostGroupIds: runplan.hostGroupIds || [] });
|
||||
else resetRunPlanForm();
|
||||
runPlanModalOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,26 @@ export function createApi(getToken, onUnauthorized) {
|
||||
async function request(path, options = {}) {
|
||||
const token = getToken();
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
const method = options.method || 'GET';
|
||||
const url = `${API_BASE}${path}`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
const detail = err?.message ? `: ${err.message}` : '';
|
||||
const error = new Error(`${method} ${path} could not reach the API${detail}`);
|
||||
error.path = path;
|
||||
error.method = method;
|
||||
error.cause = err;
|
||||
throw error;
|
||||
}
|
||||
if (response.status === 401) onUnauthorized?.();
|
||||
if (response.status === 204) return null;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
@@ -19,6 +31,8 @@ export function createApi(getToken, onUnauthorized) {
|
||||
const error = new Error(data.error || `Request failed: ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = data;
|
||||
error.path = path;
|
||||
error.method = method;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
|
||||
173
client/src/components/hosts/HostGroupsPanel.vue
Normal file
173
client/src/components/hosts/HostGroupsPanel.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<article class="glass-panel resource-table-panel host-groups-panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<span class="eyebrow">HOST GROUPS</span>
|
||||
<h3>Target collections</h3>
|
||||
<p>Manual or rule-based groups that aggregate imported and manually created hosts.</p>
|
||||
</div>
|
||||
<div class="section-actions">
|
||||
<button class="ghost-button compact" type="button" @click="$emit('sync-all')">
|
||||
<RefreshCcw :size="15" />Sync dynamic
|
||||
</button>
|
||||
<button class="primary-action compact" type="button" @click="openModal()">
|
||||
<Plus :size="15" />Add host group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResourceTable
|
||||
:columns="columns"
|
||||
:rows="hostGroups"
|
||||
:total="hostGroups.length"
|
||||
item-label="host groups"
|
||||
empty-text="No host groups yet."
|
||||
:page-count="1"
|
||||
:page="1"
|
||||
:page-size="hostGroups.length || 8"
|
||||
>
|
||||
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
|
||||
<template #cell-mode="{ row }"><span :class="['status-pill', row.mode]">{{ row.mode }}</span></template>
|
||||
<template #cell-memberCount="{ row }">{{ row.memberCount || row.hostIds?.length || 0 }} host(s)</template>
|
||||
<template #cell-rules="{ row }">{{ ruleSummary(row) }}</template>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</ResourceTable>
|
||||
|
||||
<BaseModal
|
||||
:open="modalOpen"
|
||||
:title="form.id ? 'Update host group' : 'Create host group'"
|
||||
eyebrow="HOST GROUPS"
|
||||
description="Build reusable host targets for RunPlans and script tests."
|
||||
wide
|
||||
@close="modalOpen = false"
|
||||
>
|
||||
<form class="form-grid" @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>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="full rule-editor">
|
||||
<div class="list-header"><span>Dynamic rules</span><strong>{{ form.rules.length }} rule(s)</strong></div>
|
||||
<label><span>Match mode</span><select v-model="form.matchMode"><option value="all">All rules</option><option value="any">Any rule</option></select></label>
|
||||
<div v-for="(rule, index) in form.rules" :key="index" class="rule-row">
|
||||
<select v-model="rule.field">
|
||||
<option value="name">Name</option>
|
||||
<option value="fqdn">FQDN</option>
|
||||
<option value="address">Address</option>
|
||||
<option value="tags">Tags</option>
|
||||
<option value="transport">Transport</option>
|
||||
<option value="sourceType">Source</option>
|
||||
<option value="osFamily">OS family</option>
|
||||
</select>
|
||||
<select v-model="rule.operator">
|
||||
<option value="contains">contains</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="startsWith">starts with</option>
|
||||
<option value="endsWith">ends with</option>
|
||||
</select>
|
||||
<input v-model="rule.value" required placeholder="ENG" />
|
||||
<button class="ghost-button compact danger-text" type="button" @click="removeRule(index)">
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="ghost-button compact" type="button" @click="addRule"><Plus :size="14" />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>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { Plus, RefreshCcw, Save, Trash2 } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
import ResourceTable from '../ui/ResourceTable.vue';
|
||||
|
||||
defineProps({
|
||||
hostGroups: { type: Array, default: () => [] },
|
||||
hosts: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save', 'delete', 'sync', 'sync-all']);
|
||||
const modalOpen = ref(false);
|
||||
const form = reactive(defaultForm());
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'mode', label: 'Mode' },
|
||||
{ key: 'memberCount', label: 'Members' },
|
||||
{ key: 'rules', label: 'Rules', sortable: false },
|
||||
{ key: 'lastSyncedAt', label: 'Last sync' },
|
||||
{ key: 'actions', label: 'Actions', sortable: false }
|
||||
];
|
||||
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
mode: 'manual',
|
||||
matchMode: 'all',
|
||||
rules: [{ field: 'name', operator: 'contains', value: 'ENG' }],
|
||||
hostIds: [],
|
||||
visibility: 'shared',
|
||||
groupId: ''
|
||||
};
|
||||
}
|
||||
|
||||
function openModal(group = null) {
|
||||
Object.assign(form, defaultForm(), group || {});
|
||||
form.rules = (group?.rules?.length ? group.rules : defaultForm().rules).map((rule) => ({ ...rule }));
|
||||
form.hostIds = [...(group?.hostIds || [])];
|
||||
form.groupId = group?.groupId || '';
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
form.rules.push({ field: 'name', operator: 'contains', value: '' });
|
||||
}
|
||||
|
||||
function removeRule(index) {
|
||||
form.rules.splice(index, 1);
|
||||
if (!form.rules.length) addRule();
|
||||
}
|
||||
|
||||
function ruleSummary(group) {
|
||||
if (group.mode === 'manual') return 'Manual membership';
|
||||
return (group.rules || []).map((rule) => `${rule.field} ${rule.operator} "${rule.value}"`).join(` ${group.matchMode || 'all'} `) || 'No rules';
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', {
|
||||
...form,
|
||||
groupId: form.groupId || null,
|
||||
rules: form.mode === 'dynamic' ? form.rules.filter((rule) => rule.value?.trim()) : [],
|
||||
hostIds: form.mode === 'manual' ? form.hostIds : []
|
||||
});
|
||||
modalOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -17,6 +17,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="vcenter-filter-grid">
|
||||
<label class="filter-value">
|
||||
<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">
|
||||
{{ connection.name }} - {{ connection.baseUrl }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Field</span>
|
||||
<select v-model="draft.filter.field">
|
||||
@@ -49,8 +58,8 @@
|
||||
<Search :size="16" />Preview matches
|
||||
</button>
|
||||
<span v-if="status" class="vcenter-status">
|
||||
<span :class="['status-dot', status.configured ? 'success' : 'warning']"></span>
|
||||
{{ status.configured ? `Connected settings for ${status.baseUrl}` : 'vCenter settings incomplete' }}
|
||||
<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
|
||||
@@ -188,6 +197,7 @@ const props = defineProps({
|
||||
previewRows: { type: Array, default: () => [] },
|
||||
credentials: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] },
|
||||
connections: { type: Array, default: () => [] },
|
||||
loading: Boolean,
|
||||
error: { type: String, default: '' },
|
||||
status: { type: Object, default: null },
|
||||
@@ -198,6 +208,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close', 'preview', 'import', 'open-trace']);
|
||||
|
||||
const draft = reactive({
|
||||
connectionId: null,
|
||||
filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' },
|
||||
limit: 100,
|
||||
credentialId: null,
|
||||
@@ -210,6 +221,17 @@ const draft = reactive({
|
||||
const selectedIds = ref([]);
|
||||
|
||||
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);
|
||||
return {
|
||||
name: connection?.name || 'Selected vCenter',
|
||||
configured: Boolean(connection?.baseUrl && connection?.username),
|
||||
baseUrl: connection?.baseUrl || ''
|
||||
};
|
||||
}
|
||||
return props.status?.defaultConnection || props.status || {};
|
||||
});
|
||||
|
||||
watch(() => props.previewRows, (rows) => {
|
||||
selectedIds.value = rows.map((row) => row.id);
|
||||
@@ -221,6 +243,7 @@ watch(() => props.open, (open) => {
|
||||
|
||||
function payloadBase() {
|
||||
return {
|
||||
connectionId: draft.connectionId || null,
|
||||
filter: { ...draft.filter, value: draft.filter.value.trim() },
|
||||
limit: Number(draft.limit) || 100
|
||||
};
|
||||
|
||||
@@ -3,20 +3,33 @@
|
||||
:open="open"
|
||||
title="Test / Run script"
|
||||
eyebrow="SCRIPT EXPLORER"
|
||||
:description="script ? `Run ${script.name} against one selected host with one selected vault credential.` : 'Run the selected script against one host.'"
|
||||
:description="script ? `Run ${script.name} against one selected host or host group with one selected vault credential.` : 'Run the selected script against a host or host group.'"
|
||||
wide
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<form class="modal-form script-test-form" @submit.prevent="execute">
|
||||
<div class="script-test-grid">
|
||||
<label>
|
||||
<span>Target host</span>
|
||||
<select v-model="hostId" required>
|
||||
<span>Target type</span>
|
||||
<select v-model="targetType">
|
||||
<option value="host">Host</option>
|
||||
<option value="group">Host group</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ targetType === 'group' ? 'Target host group' : 'Target host' }}</span>
|
||||
<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 }})
|
||||
</option>
|
||||
</select>
|
||||
<select v-else v-model="hostGroupId" required>
|
||||
<option value="" disabled>Choose host group</option>
|
||||
<option v-for="group in hostGroups" :key="group.id" :value="group.id">
|
||||
{{ group.name }} - {{ group.mode }} / {{ group.memberCount || 0 }} host(s)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Vault credential</span>
|
||||
@@ -36,7 +49,7 @@
|
||||
|
||||
<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 || !hostId || !credentialId">
|
||||
<button class="primary-action" type="submit" :disabled="running || !targetSelected || !credentialId">
|
||||
<Play :size="16" />{{ running ? 'Executing...' : 'Execute test' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -67,6 +80,7 @@ const props = defineProps({
|
||||
open: Boolean,
|
||||
script: { type: Object, default: null },
|
||||
hosts: { type: Array, default: () => [] },
|
||||
hostGroups: { type: Array, default: () => [] },
|
||||
credentials: { type: Array, default: () => [] },
|
||||
job: { type: Object, default: null },
|
||||
running: Boolean,
|
||||
@@ -76,13 +90,17 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close', 'execute']);
|
||||
|
||||
const hostId = ref('');
|
||||
const hostGroupId = ref('');
|
||||
const targetType = ref('host');
|
||||
const credentialId = ref('');
|
||||
|
||||
const selectedHost = computed(() => props.hosts.find((host) => host.id === hostId.value));
|
||||
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 selectedHostSummary = computed(() => selectedHost.value
|
||||
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${selectedHost.value.address}`
|
||||
: 'No host selected');
|
||||
: (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}` : ''}`
|
||||
: 'No credential selected');
|
||||
@@ -91,7 +109,7 @@ const terminalRows = computed(() => {
|
||||
const lines = [
|
||||
`POSHManager script test job ${props.job.id}`,
|
||||
`Script: ${props.job.scriptName || props.script?.name || 'selected script'}`,
|
||||
`Host: ${selectedHostSummary.value}`,
|
||||
`Target: ${selectedHostSummary.value}`,
|
||||
`Credential: ${selectedCredentialSummary.value}`,
|
||||
`Status: ${props.job.status || 'queued'}`,
|
||||
''
|
||||
@@ -107,7 +125,9 @@ const terminalLineCount = computed(() => terminalRows.value.length);
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (!open) return;
|
||||
targetType.value = props.hosts.length ? 'host' : 'group';
|
||||
hostId.value = props.hosts[0]?.id || '';
|
||||
hostGroupId.value = props.hostGroups[0]?.id || '';
|
||||
credentialId.value = selectedHost.value?.credentialId || props.credentials[0]?.id || '';
|
||||
}, { immediate: true });
|
||||
|
||||
@@ -118,6 +138,10 @@ watch(hostId, () => {
|
||||
});
|
||||
|
||||
function execute() {
|
||||
emit('execute', { hostId: hostId.value, credentialId: credentialId.value });
|
||||
emit('execute', {
|
||||
hostId: targetType.value === 'host' ? hostId.value : '',
|
||||
hostGroupId: targetType.value === 'group' ? hostGroupId.value : '',
|
||||
credentialId: credentialId.value
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
112
client/src/components/settings/VCenterConnectionsConfig.vue
Normal file
112
client/src/components/settings/VCenterConnectionsConfig.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<article class="glass-panel form-panel intune-config-panel">
|
||||
<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>
|
||||
</div>
|
||||
<button class="primary-action compact" type="button" @click="openModal()">
|
||||
<CloudCog :size="15" />Add vCenter
|
||||
</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>
|
||||
<div class="config-stat-row">
|
||||
<span><b>{{ connections.length }}</b><small>systems</small></span>
|
||||
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
|
||||
<span><b>{{ testedCount }}</b><small>tested</small></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="publisher-card graph-config-list">
|
||||
<strong>vCenter 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>
|
||||
<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>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BaseModal
|
||||
:open="modalOpen"
|
||||
:title="form.id ? 'Update vCenter system' : 'New vCenter system'"
|
||||
eyebrow="CONFIG / VMWARE"
|
||||
description="Store vCenter API credentials encrypted at rest. These connections are selectable during host import."
|
||||
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>{{ 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>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>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { CloudCog, Save } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
connections: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
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 testedCount = computed(() => props.connections.filter((connection) => connection.lastTestStatus).length);
|
||||
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
apiMode: 'auto',
|
||||
requestTimeoutMs: 20000,
|
||||
allowUntrustedTls: false,
|
||||
enabled: true,
|
||||
visibility: 'shared',
|
||||
groupId: ''
|
||||
};
|
||||
}
|
||||
|
||||
function openModal(connection = null) {
|
||||
Object.assign(form, defaultForm(), connection || {});
|
||||
form.password = '';
|
||||
form.groupId = connection?.groupId || '';
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', { ...form, groupId: form.groupId || null });
|
||||
modalOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -4699,6 +4699,32 @@ body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rule-editor {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, .12);
|
||||
}
|
||||
|
||||
.rule-editor > label {
|
||||
max-width: 260px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, .8fr) minmax(130px, .8fr) minmax(180px, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.rule-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.log-toolbar select {
|
||||
width: 170px;
|
||||
min-height: 37px;
|
||||
@@ -6925,7 +6951,7 @@ input:read-only {
|
||||
.vcenter-filter-grid,
|
||||
.vcenter-options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, .85fr) minmax(150px, .85fr) minmax(220px, 1.35fr) minmax(120px, .55fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user