Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.
Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.
NOT READY FOR main. Open items:
- 10 new routes are absent from docs/api-inventory.json, so
test_api_inventory_parity fails. That gate SKIPS on the published mirror
(the test ships, its data file is scrubbed), which is why wilm never saw it.
- dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
- plugins/endpoints/plugin.py carries a whitespace-only re-indent.
Scope: product
224 lines
7.5 KiB
Vue
224 lines
7.5 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Site Policies</h2>
|
|
<button class="btn btn-primary" @click="openModal()">+ Add Site 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>Priority</th>
|
|
<th>Name</th>
|
|
<th>Description</th>
|
|
<th>Configuration policies</th>
|
|
<th>Source</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="p in policies" :key="p.businesspolicyid">
|
|
<td>{{ p.priority }}</td>
|
|
<td>{{ p.name }}</td>
|
|
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
|
|
<td>
|
|
<span v-for="it in p.items" :key="it.businesspolicyitemid" class="badge" style="margin-right:4px;">
|
|
{{ it.name }}
|
|
</span>
|
|
<span v-if="!p.items || p.items.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="6" style="text-align:center; color: var(--text-light);">No site 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 Site Policy' : 'Add Site 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>Priority <span class="hint">(lower runs first; first match wins per action)</span></label>
|
|
<input v-model.number="form.priority" type="number" class="form-control" />
|
|
</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>Configuration policies <span class="hint">(evaluated top to bottom)</span></label>
|
|
<div v-for="(it, index) in form.items" :key="index" class="item-row">
|
|
<span class="order-badge">{{ index + 1 }}</span>
|
|
<select v-model.number="it.configurationpolicyid" class="form-control">
|
|
<option :value="undefined" disabled>Select configuration policy...</option>
|
|
<option v-for="cp in configPolicies" :key="cp.configurationpolicyid" :value="cp.configurationpolicyid">
|
|
{{ cp.name }}
|
|
</option>
|
|
</select>
|
|
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === 0" @click="moveUp(index)">Up</button>
|
|
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === form.items.length - 1" @click="moveDown(index)">Down</button>
|
|
<button type="button" class="btn btn-danger btn-sm" @click="form.items.splice(index, 1)">Remove</button>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary btn-sm" @click="addItem">+ Add configuration policy</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 } from '@/api'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
const toast = useToast()
|
|
|
|
const policies = ref([])
|
|
const configPolicies = 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: '', priority: 100, items: [] }
|
|
}
|
|
|
|
onMounted(() => loadData())
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const [biz, config] = await Promise.all([
|
|
computersApi.policies.business.list(),
|
|
computersApi.policies.configuration.list()
|
|
])
|
|
policies.value = biz.data.data || []
|
|
configPolicies.value = config.data.data || []
|
|
} catch (err) {
|
|
console.error('Error loading business 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 || '',
|
|
priority: item.priority ?? 100,
|
|
items: (item.items || [])
|
|
.slice()
|
|
.sort((a, b) => a.sortorder - b.sortorder)
|
|
.map(it => ({ configurationpolicyid: it.configurationpolicyid }))
|
|
}
|
|
} else {
|
|
form.value = emptyForm()
|
|
}
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() { showModal.value = false; editing.value = null }
|
|
|
|
function addItem() {
|
|
form.value.items.push({ configurationpolicyid: undefined })
|
|
}
|
|
|
|
function moveUp(index) {
|
|
const items = form.value.items
|
|
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
|
|
}
|
|
|
|
function moveDown(index) {
|
|
const items = form.value.items
|
|
;[items[index + 1], items[index]] = [items[index], items[index + 1]]
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
error.value = ''
|
|
try {
|
|
const payload = {
|
|
name: form.value.name,
|
|
description: form.value.description,
|
|
priority: form.value.priority,
|
|
items: form.value.items.map((it, order) => ({
|
|
configurationpolicyid: it.configurationpolicyid,
|
|
sortorder: order
|
|
}))
|
|
}
|
|
if (editing.value) {
|
|
await computersApi.policies.business.update(editing.value.businesspolicyid, payload)
|
|
} else {
|
|
await computersApi.policies.business.create(payload)
|
|
}
|
|
closeModal()
|
|
loadData()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Failed to save site policy')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove(p) {
|
|
if (!confirm(`Delete site policy "${p.name}"?`)) return
|
|
try {
|
|
await computersApi.policies.business.remove(p.businesspolicyid)
|
|
loadData()
|
|
} catch (err) {
|
|
toast.error(apiError(err, 'Failed to delete'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
|
.order-badge {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
min-width: 24px; height: 24px; border-radius: 12px;
|
|
background: var(--border-color, #444);
|
|
}
|
|
</style>
|