Default fuzzy reconcile threshold to 0 and refuse merges when digit runs differ so numbered labels like Bay 311 never bind to Bay 310. Keep edit forms from clearing saved model fields when category filtering hides untyped rows. Includes in-progress computers policy and endpoints command feature work.
224 lines
7.6 KiB
Vue
224 lines
7.6 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Configuration Policies</h2>
|
|
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Description</th>
|
|
<th>Actions in policy</th>
|
|
<th>Source</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="p in policies" :key="p.configurationpolicyid">
|
|
<td>{{ p.name }}</td>
|
|
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
|
|
<td>
|
|
<span v-for="a in p.actions" :key="a.action" class="badge" style="margin-right:4px;">
|
|
{{ a.action }} ({{ a.mode }})
|
|
</span>
|
|
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
|
|
{{ p.source }}
|
|
</span>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
|
|
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="policies.length === 0">
|
|
<td colspan="5" style="text-align:center; color: var(--text-light);">No configuration policies</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="showModal" class="modal-overlay">
|
|
<div class="modal modal-lg">
|
|
<div class="modal-header">
|
|
<h3>{{ editing ? 'Edit Configuration Policy' : 'Add Configuration Policy' }}</h3>
|
|
</div>
|
|
<form @submit.prevent="save">
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label>Name *</label>
|
|
<input v-model="form.name" type="text" class="form-control" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description</label>
|
|
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Scope <span class="hint">(which endpoints this applies to)</span></label>
|
|
<div class="scope-builder">
|
|
<ScopeNodeEditor :node="form.scopejson" :pctypes="pctypes" :apps="apps" :removable="false" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Actions</label>
|
|
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
|
|
<select v-model="a.action" class="form-control">
|
|
<option :value="''" disabled>Select action...</option>
|
|
<option v-for="opt in actionCatalog" :key="opt.action" :value="opt.action">
|
|
{{ opt.label || opt.action }}
|
|
</option>
|
|
</select>
|
|
<select v-model="a.mode" class="form-control mode-select">
|
|
<option value="gate">Allow (gate)</option>
|
|
<option value="desired">Desired state</option>
|
|
</select>
|
|
<button type="button" class="btn btn-danger btn-sm" @click="form.actions.splice(index, 1)">Remove</button>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
|
|
</div>
|
|
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { computersApi, applicationsApi } from '@/api'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
import ScopeNodeEditor from './ScopeNodeEditor.vue'
|
|
|
|
const toast = useToast()
|
|
|
|
const policies = ref([])
|
|
const pctypes = ref([])
|
|
const apps = ref([])
|
|
const actionCatalog = ref([])
|
|
const loading = ref(true)
|
|
|
|
const showModal = ref(false)
|
|
const editing = ref(null)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
|
|
const form = ref(emptyForm())
|
|
|
|
function emptyForm() {
|
|
return { name: '', description: '', scopejson: { type: 'true' }, actions: [] }
|
|
}
|
|
|
|
onMounted(() => loadData())
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const [pol, types, appList, actions] = await Promise.all([
|
|
computersApi.policies.configuration.list(),
|
|
computersApi.types.list({ perpage: 200 }),
|
|
applicationsApi.listAll({ perpage: 200 }),
|
|
computersApi.policies.actions()
|
|
])
|
|
policies.value = pol.data.data || []
|
|
pctypes.value = types.data.data || []
|
|
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not an
|
|
// axios response, so read it directly. Reading .data on the array throws and
|
|
// rejects the whole Promise.all, blanking every dropdown.
|
|
apps.value = Array.isArray(appList) ? appList : (appList.data?.data || appList.data || [])
|
|
actionCatalog.value = actions.data.data || []
|
|
} catch (err) {
|
|
console.error('Error loading configuration policies:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openModal(item = null) {
|
|
editing.value = item
|
|
error.value = ''
|
|
if (item) {
|
|
form.value = {
|
|
name: item.name || '',
|
|
description: item.description || '',
|
|
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'true' },
|
|
actions: (item.actions || []).map(a => ({
|
|
action: a.action, mode: a.mode || 'gate',
|
|
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
|
|
}))
|
|
}
|
|
} else {
|
|
form.value = emptyForm()
|
|
}
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() { showModal.value = false; editing.value = null }
|
|
|
|
function addAction() {
|
|
form.value.actions.push({ action: '', mode: 'gate' })
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
error.value = ''
|
|
try {
|
|
const payload = {
|
|
name: form.value.name,
|
|
description: form.value.description,
|
|
scopejson: form.value.scopejson,
|
|
actions: form.value.actions
|
|
}
|
|
if (editing.value) {
|
|
await computersApi.policies.configuration.update(editing.value.configurationpolicyid, payload)
|
|
} else {
|
|
await computersApi.policies.configuration.create(payload)
|
|
}
|
|
closeModal()
|
|
loadData()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Failed to save policy')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove(p) {
|
|
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
|
|
try {
|
|
await computersApi.policies.configuration.remove(p.configurationpolicyid)
|
|
loadData()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to delete'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.scope-builder {
|
|
padding: 10px;
|
|
border: 1px solid var(--border-color, #444);
|
|
border-radius: 4px;
|
|
}
|
|
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
|
.mode-select { max-width: 160px; }
|
|
</style>
|