Findings the reviewers verified, fixed here. The larger cluster they found is NOT fixed and is listed at the end. Frontend. reportImageUrl is called from the template, so it ran on every render with no in-flight guard: a report whose fetch was pending was re-requested, and because each resolution reassigns the map the whole grid re-rendered and re-fired the rest, costing up to N-squared requests for N thumbnails. Two blobs resolving for one key orphaned the first object URL where revoke could not reach it, and a failure was never recorded so a broken image retried forever. Guarded, deduplicated, failures remembered. Three CSS variables did not exist, so their hardcoded fallbacks always won: --bg-alt (the "No screenshot" tile, near-black in light mode), and --warning-text/-bg/-border (dark khaki on a dark modal). Replaced with real tokens. The DEV badge's literal #c62828 is var(--danger). Installer and launcher. The generated site installer ran docker compose with no -p, so the project name defaulted to the package folder - which carries the version. Installing the next version created a new project with new empty volumes and the site came up looking wiped, old data stranded under the old project. Pinned to shopdb-<channel>. Start-ShopDBDev deleted any existing -TargetDir that lacked a .git, recursively and without asking. For a script handed round as "save it anywhere and run it", a mistyped -TargetDir pointed at Documents would have destroyed it. It now only reclaims a directory that is empty or carries this launcher's own marker, and refuses otherwise. Dev secrets came from Get-Random, which is System.Random: a 32-bit seed, and all four values off one stream, so recovering one recovers the rest. They sign JWTs for a stack a pilot may point at real imported data. CSPRNG now, and the API port binds to loopback like the DB rather than every interface. Two of my own from earlier in this session. The operator-report size check sat below request.form, and the first access to that parses and spools the whole multipart - so it ran after the thing it prevents. Moved above. And the channel remap missed build-channel-installer.ps1 entirely, where it set BOTH channels to main: a "stable" package built from the trunk, the exact mislabel the remap was for. stable now resolves the newest v* tag. Also removed a zero-byte docs/ShopDB-Planner-Tasks-v2.csv that rode in. NOT FIXED - needs a decision, and it is the biggest thing the review found. Migrations 0006/0007 freeze the originating site's fleet convention into product seed: a shipped policy literally named "RPCA Shopfloor App Monitors", a hardcoded 'Shopfloor PC' type deciding who gets the shipped watch-list, and shipped rows the API refuses to edit or deactivate - including their launch paths. A site whose eDNC is on another drive cannot correct it and gets permanent false drift. Those are stamped migrations, so this gets harder to undo at every install. See the review notes before anyone upgrades a second site through 0006-0007. Suite 2314 passing, frontend 316, all three PowerShell files parse. Scope: product
319 lines
10 KiB
Vue
319 lines
10 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;">
|
|
{{ actionLabel(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">
|
|
<ActionPicker
|
|
v-model="a.action"
|
|
:command-actions="actionCatalog"
|
|
:monitors="monitors"
|
|
@monitor-created="onMonitorCreated"
|
|
/>
|
|
<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>
|
|
<p v-if="showDesiredWarning(a)" class="desired-warning">
|
|
Desired state keeps this app alive: on agent check-in the
|
|
server will try to START the process if a launch path is
|
|
mapped. Use gate to only report status with Start/Stop in the
|
|
Monitor UI.
|
|
</p>
|
|
</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, endpointsApi } from '@/api'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
import ScopeNodeEditor from './ScopeNodeEditor.vue'
|
|
import ActionPicker from './ActionPicker.vue'
|
|
|
|
const toast = useToast()
|
|
|
|
const policies = ref([])
|
|
const pctypes = ref([])
|
|
const apps = ref([])
|
|
const actionCatalog = ref([])
|
|
const monitors = 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 {
|
|
// allSettled, not all: one failing endpoint must not blank the others.
|
|
// A rejected Promise.all left PC types and apps empty when only monitors
|
|
// 500'd, which looked like data loss.
|
|
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
|
|
computersApi.policies.configuration.list(),
|
|
computersApi.types.list({ perpage: 200 }),
|
|
applicationsApi.listAll({ perpage: 200 }),
|
|
computersApi.policies.actions(),
|
|
endpointsApi.monitors.list()
|
|
])
|
|
|
|
const failures = []
|
|
|
|
if (pol.status === 'fulfilled') {
|
|
policies.value = pol.value.data.data || []
|
|
} else {
|
|
failures.push('policies')
|
|
console.error('Error loading policies:', pol.reason)
|
|
}
|
|
|
|
if (types.status === 'fulfilled') {
|
|
pctypes.value = types.value.data.data || []
|
|
} else {
|
|
failures.push('PC types')
|
|
console.error('Error loading PC types:', types.reason)
|
|
}
|
|
|
|
if (appList.status === 'fulfilled') {
|
|
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not
|
|
// an axios response, so read it directly.
|
|
const value = appList.value
|
|
apps.value = Array.isArray(value) ? value : (value.data?.data || value.data || [])
|
|
} else {
|
|
failures.push('applications')
|
|
console.error('Error loading applications:', appList.reason)
|
|
}
|
|
|
|
if (actions.status === 'fulfilled') {
|
|
actionCatalog.value = actions.value.data.data || []
|
|
} else {
|
|
failures.push('actions')
|
|
console.error('Error loading actions:', actions.reason)
|
|
}
|
|
|
|
if (monitorList.status === 'fulfilled') {
|
|
monitors.value = monitorList.value.data.data || []
|
|
} else {
|
|
failures.push('monitors')
|
|
console.error('Error loading monitors:', monitorList.reason)
|
|
}
|
|
|
|
if (failures.length) {
|
|
toast.error(`Failed to load: ${failures.join(', ')}. Some options may be unavailable.`)
|
|
}
|
|
} 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' })
|
|
}
|
|
|
|
function onMonitorCreated(monitor) {
|
|
// Add the new definition to the local catalog so its label resolves and it
|
|
// is searchable on other action rows without a full reload.
|
|
if (monitor && !monitors.value.some(m => m.endpointmonitorid === monitor.endpointmonitorid)) {
|
|
monitors.value.push(monitor)
|
|
}
|
|
}
|
|
|
|
function actionLabel(action) {
|
|
if (!action) return action
|
|
if (action.startsWith('monitor:')) {
|
|
const id = Number(action.slice('monitor:'.length))
|
|
const monitor = monitors.value.find(m => m.endpointmonitorid === id)
|
|
return monitor ? `Monitor: ${monitor.displayname}` : action
|
|
}
|
|
const command = actionCatalog.value.find(c => c.action === action)
|
|
return command ? (command.label || command.action) : action
|
|
}
|
|
|
|
function showDesiredWarning(action) {
|
|
return !!action && action.mode === 'desired'
|
|
&& !!action.action && action.action.startsWith('monitor:')
|
|
}
|
|
|
|
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>
|
|
.modal-lg {
|
|
max-width: 760px;
|
|
}
|
|
.modal-lg .modal-body {
|
|
max-height: 70vh;
|
|
overflow-y: auto;
|
|
}
|
|
.modal-lg :deep(.form-group) {
|
|
margin-bottom: 1.1rem;
|
|
}
|
|
.modal-lg :deep(.form-control) {
|
|
font-size: 14px;
|
|
padding: 0.55rem 0.7rem;
|
|
}
|
|
.scope-builder {
|
|
padding: 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
}
|
|
.action-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 6px; }
|
|
.mode-select { max-width: 160px; }
|
|
.desired-warning {
|
|
flex-basis: 100%;
|
|
margin: 2px 0 0;
|
|
padding: 6px 8px;
|
|
font-size: 0.85em;
|
|
color: var(--warning-dark);
|
|
background: var(--bg);
|
|
border: 1px solid var(--warning);
|
|
border-radius: 4px;
|
|
}
|
|
</style>
|