Add GE-Enforce P3 manifest editor UI (Settings > Imaging PC Types)
All checks were successful
CI / backend (push) Successful in 1m41s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

The imaging-PC-type manifest editor, contributed as an ADR-010 settings card
(Integrations group) and an ADR-009 plugin-gated route
(/settings/imagingpctypes, hidden when geenforce is disabled).

- Scope list: every imaging PC type with phase, entry count, and published
  version (or "unpublished"). New PC Type button.
- Scope detail: ComputerType/MeasuringToolType mapping + description; Publish,
  Versions (with per-version Roll Back), Preview (draft JSON), Delete.
- Entry table: ordered with Move Up/Down (the ordering contract, not drag),
  Name/Type/Detection/Filters, Edit/Delete. Add Entry opens a typed modal whose
  fields switch on entry Type (MSI/EXE/... vs PS1 vs File vs Registry), with a
  detection block, comma-separated targeting filters, CMM version gate, payload
  source, and an Advanced disclosure for the inert ApplyMode/UpdateWindow and
  InUseCheck. RegValue is typed by RegType (DWord/QWord -> number).
- Simulator: "what would a PC get" - enter a machine profile, see which entries
  apply and which filter excluded the rest. Verified live: CMM version 2019 ->
  applies 2019 + untagged, filters 2016/2026 by _CmmVersion.

Uses the P2 admin API; JWT+admin gated. Frontend build + naming green; full
backend suite 876 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 17:22:49 -04:00
parent d157502d5b
commit 7eb6cebeb5
3 changed files with 542 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
/**
* GE-Enforce plugin routes.
*
* The imaging-PC-type manifest editor lives under /settings. meta.plugin =
* 'geenforce' so the ADR-009 router guard hides it when the plugin is disabled.
* Admin-only, like the other settings pages.
*/
export default [
{
path: 'settings/imagingpctypes',
name: 'imagingpctypes',
component: () => import('../../views/settings/ImagingPCTypes.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
}
]

View File

@@ -0,0 +1,512 @@
<template>
<div class="imaging-pctypes">
<div class="page-header">
<h1>Imaging PC Types</h1>
<button class="btn btn-primary" @click="openNewScope">+ New PC Type</button>
</div>
<p class="intro">
Each imaging PC type owns a GE-Enforce install manifest. Edit the draft,
then Publish to ship it to PCs. Nothing reaches a PC until you publish.
</p>
<div v-if="error" class="alert-error">{{ error }}</div>
<div class="editor-grid">
<!-- Scope list -->
<aside class="scope-list card">
<div v-for="scope in scopes" :key="scope.scopeid"
class="scope-row" :class="{ active: scope.scopeid === selectedId }"
@click="selectScope(scope.scopeid)">
<div class="scope-name">{{ scope.scopename }}</div>
<div class="scope-meta">
<span class="badge" :class="scope.phase === 'preinstall' ? 'badge-warning' : 'badge-info'">
{{ scope.phase }}
</span>
<span>{{ scope.entrycount }} entries</span>
<span v-if="scope.publishedversion">v{{ scope.publishedversion }}</span>
<span v-else class="unpublished">unpublished</span>
</div>
</div>
<p v-if="!scopes.length" class="empty">No PC types yet.</p>
</aside>
<!-- Selected scope -->
<section v-if="detail" class="scope-detail card">
<div class="detail-header">
<h2>{{ detail.scopename }}</h2>
<div class="detail-actions">
<button class="btn btn-primary" @click="publish">Publish</button>
<button class="btn" @click="toggleVersions">Versions</button>
<button class="btn" @click="loadPreview">Preview</button>
<button class="btn btn-danger" @click="deleteScope">Delete</button>
</div>
</div>
<!-- Mapping -->
<div class="mapping-row">
<label>Computer Type ID
<input type="number" v-model.number="detail.computertypeid" />
</label>
<label>Measuring Tool Type ID
<input type="number" v-model.number="detail.measuringtooltypeid" />
</label>
<label class="grow">Description
<input type="text" v-model="detail.description" />
</label>
<button class="btn" @click="saveScope">Save</button>
</div>
<!-- Versions panel -->
<div v-if="showVersions" class="versions-panel">
<h3>Published versions</h3>
<table>
<thead><tr><th>Version</th><th>Published</th><th>Notes</th><th></th></tr></thead>
<tbody>
<tr v-for="v in versions" :key="v.versionnumber">
<td>v{{ v.versionnumber }} <span v-if="v.iscurrent" class="badge badge-success">current</span></td>
<td>{{ formatDate(v.publishedat) }}</td>
<td>{{ v.notes }}</td>
<td><button v-if="!v.iscurrent" class="btn btn-small"
@click="rollback(v.versionnumber)">Roll back</button></td>
</tr>
</tbody>
</table>
</div>
<!-- Entries -->
<div class="entries-header">
<h3>Manifest entries ({{ detail.entries.length }})</h3>
<button class="btn btn-primary btn-small" @click="openNewEntry">+ Add entry</button>
</div>
<p class="order-hint">Order is execution order. Put config-restore entries after their installer.</p>
<table class="entries-table">
<thead>
<tr><th>#</th><th>Name</th><th>Type</th><th>Detection</th><th>Filters</th><th></th></tr>
</thead>
<tbody>
<tr v-for="(entry, index) in detail.entries" :key="entry.entryid">
<td class="order-cell">
<button class="btn-move" :disabled="index === 0" @click="moveEntry(index, -1)">&uarr;</button>
<button class="btn-move" :disabled="index === detail.entries.length - 1" @click="moveEntry(index, 1)">&darr;</button>
</td>
<td>{{ entry.Name }}</td>
<td><span class="badge badge-info">{{ entry.Type }}</span></td>
<td class="dim">{{ entry.DetectionMethod || 'always' }}</td>
<td class="dim">{{ filterSummary(entry) }}</td>
<td class="row-actions">
<button class="btn btn-small" @click="openEditEntry(entry)">Edit</button>
<button class="btn btn-small btn-danger" @click="deleteEntry(entry)">Delete</button>
</td>
</tr>
<tr v-if="!detail.entries.length"><td colspan="6" class="empty">No entries.</td></tr>
</tbody>
</table>
<!-- Simulator -->
<div class="simulator">
<h3>Simulate: what would a PC get?</h3>
<div class="sim-inputs">
<input v-model="sim.subtype" placeholder="subtype" />
<input v-model="sim.hostname" placeholder="hostname" />
<input v-model="sim.machinenumber" placeholder="machine #" />
<input v-model="sim.cmmversion" placeholder="CMM version" />
<button class="btn" @click="runSimulate">Run</button>
</div>
<div v-if="simResult" class="sim-result">
<div><strong>Applies ({{ simResult.applied.length }}):</strong>
{{ simResult.applied.join(', ') || 'none' }}</div>
<div class="dim"><strong>Filtered:</strong>
<span v-for="f in simResult.filtered" :key="f.name">
{{ f.name }} ({{ f.filteredby.join(',') }});
</span>
</div>
</div>
</div>
</section>
<section v-else class="scope-detail card empty-detail">
Select a PC type, or create one.
</section>
</div>
<!-- New scope modal -->
<div v-if="showNewScope" class="modal-overlay" @click.self="showNewScope = false">
<div class="modal">
<h2>New imaging PC type</h2>
<label>Scope name (e.g. gea-shopfloor-cmm)
<input v-model="newScope.scopename" />
</label>
<label>Phase
<select v-model="newScope.phase">
<option value="runtime">runtime</option>
<option value="preinstall">preinstall</option>
</select>
</label>
<div class="modal-actions">
<button class="btn" @click="showNewScope = false">Cancel</button>
<button class="btn btn-primary" @click="createScope">Create</button>
</div>
</div>
</div>
<!-- Entry modal -->
<div v-if="showEntry" class="modal-overlay" @click.self="showEntry = false">
<div class="modal modal-wide">
<h2>{{ entryForm.entryid ? 'Edit entry' : 'New entry' }}</h2>
<div class="form-grid">
<label>Name<input v-model="entryForm.Name" /></label>
<label>Type
<select v-model="entryForm.Type">
<option v-for="t in ENTRY_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<!-- payload fields per type -->
<template v-if="['MSI','EXE','CMD','BAT','INF'].includes(entryForm.Type)">
<label>Installer (relative path)<input v-model="entryForm.Installer" /></label>
<label>Install args<input v-model="entryForm.InstallArgs" /></label>
</template>
<template v-if="entryForm.Type === 'PS1'">
<label>Script (relative path)<input v-model="entryForm.Script" /></label>
<label>Args<input v-model="entryForm.Args" /></label>
</template>
<template v-if="entryForm.Type === 'File'">
<label>Source (relative)<input v-model="entryForm.Source" /></label>
<label>Destination (absolute)<input v-model="entryForm.Destination" /></label>
</template>
<template v-if="entryForm.Type === 'Registry'">
<label>Reg path<input v-model="entryForm.RegPath" /></label>
<label>Reg name<input v-model="entryForm.RegName" /></label>
<label>Reg value<input v-model="entryForm.RegValue" /></label>
<label>Reg type
<select v-model="entryForm.RegType">
<option v-for="t in REG_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</label>
</template>
<!-- detection -->
<label>Detection method
<select v-model="entryForm.DetectionMethod">
<option value="">(none: always installs)</option>
<option v-for="m in DETECTION_METHODS" :key="m" :value="m">{{ m }}</option>
</select>
</label>
<label>Detection path<input v-model="entryForm.DetectionPath" /></label>
<label>Detection name<input v-model="entryForm.DetectionName" /></label>
<label>Detection value<input v-model="entryForm.DetectionValue" /></label>
<label v-if="entryForm.DetectionMethod === 'pnputil'">Detection pattern
<input v-model="entryForm.DetectionPattern" /></label>
<!-- filters -->
<label>PC types (comma)<input v-model="entryForm.PCTypes" placeholder="* or gea-shopfloor-cmm,..." /></label>
<label>Target hostnames (comma)<input v-model="entryForm.TargetHostnames" /></label>
<label>Target machine numbers (comma)<input v-model="entryForm.TargetMachineNumbers" /></label>
<label>CMM version gate<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label>
<label>Wait timeout (sec)<input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label>
<label>Payload source
<select v-model="entryForm.payloadsource">
<option value="smb">smb</option>
<option value="http">http</option>
<option value="inline">inline</option>
</select>
</label>
<label class="full">Comment<textarea v-model="entryForm._comment" rows="2" /></label>
<details class="full advanced">
<summary>Advanced (not yet enforced by the engine)</summary>
<label>Apply mode<input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
<label>Update window<input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
<label>InUseCheck behavior
<select v-model="entryForm.inuseBehavior">
<option value="">(none)</option>
<option v-for="b in INUSE_BEHAVIORS" :key="b" :value="b">{{ b }}</option>
</select>
</label>
<label>InUseCheck processes (comma of names)<input v-model="entryForm.inuseProcesses" /></label>
</details>
</div>
<div class="modal-actions">
<button class="btn" @click="showEntry = false">Cancel</button>
<button class="btn btn-primary" @click="saveEntry">Save</button>
</div>
</div>
</div>
<!-- Preview modal -->
<div v-if="showPreview" class="modal-overlay" @click.self="showPreview = false">
<div class="modal modal-wide">
<h2>Draft manifest preview</h2>
<pre class="preview">{{ previewText }}</pre>
<div class="modal-actions">
<button class="btn" @click="showPreview = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '../../api'
const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
'ValueMatches', 'pnputil', 'Always']
const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
const scopes = ref([])
const selectedId = ref(null)
const detail = ref(null)
const error = ref('')
const showNewScope = ref(false)
const newScope = ref({ scopename: '', phase: 'runtime' })
const showEntry = ref(false)
const entryForm = ref({})
const showVersions = ref(false)
const versions = ref([])
const showPreview = ref(false)
const previewText = ref('')
const sim = ref({ subtype: '', hostname: '', machinenumber: '', cmmversion: '' })
const simResult = ref(null)
function payload(response) { return response.data.data }
async function loadScopes() {
try {
scopes.value = payload(await api.get('/geenforce/scopes'))
} catch (e) { error.value = 'Failed to load PC types' }
}
async function selectScope(id) {
selectedId.value = id
showVersions.value = false
simResult.value = null
try {
detail.value = payload(await api.get(`/geenforce/scopes/${id}`))
} catch (e) { error.value = 'Failed to load PC type' }
}
function openNewScope() {
newScope.value = { scopename: '', phase: 'runtime' }
showNewScope.value = true
}
async function createScope() {
try {
const created = payload(await api.post('/geenforce/scopes', newScope.value))
showNewScope.value = false
await loadScopes()
selectScope(created.scopeid)
} catch (e) { error.value = e.response?.data?.message || 'Create failed' }
}
async function saveScope() {
try {
await api.put(`/geenforce/scopes/${detail.value.scopeid}`, {
description: detail.value.description,
computertypeid: detail.value.computertypeid,
measuringtooltypeid: detail.value.measuringtooltypeid,
})
await loadScopes()
} catch (e) { error.value = 'Save failed' }
}
async function deleteScope() {
if (!confirm(`Delete ${detail.value.scopename}? This removes its manifest.`)) return
await api.delete(`/geenforce/scopes/${detail.value.scopeid}`)
detail.value = null
selectedId.value = null
await loadScopes()
}
// -- entries --
function blankEntry() {
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
payloadsource: 'smb', inuseBehavior: '', inuseProcesses: '' }
}
function openNewEntry() {
entryForm.value = blankEntry()
showEntry.value = true
}
function openEditEntry(entry) {
const form = { ...entry }
form.PCTypes = (entry.PCTypes || []).join(', ')
form.TargetHostnames = (entry.TargetHostnames || []).join(', ')
form.TargetMachineNumbers = (entry.TargetMachineNumbers || []).join(', ')
form.RegValue = entry.RegValue !== undefined ? String(entry.RegValue) : ''
form.DetectionMethod = entry.DetectionMethod || ''
form.payloadsource = entry.payloadsource || 'smb'
form.inuseBehavior = entry.InUseCheck?.Behavior || ''
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(p => p.Name).join(', ')
entryForm.value = form
showEntry.value = true
}
function splitList(value) {
return (value || '').split(',').map(s => s.trim()).filter(Boolean)
}
function buildEntryPayload() {
const f = entryForm.value
const out = { Name: f.Name, Type: f.Type }
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
'ApplyMode', 'UpdateWindow', '_comment', 'payloadsource', 'payloadref']
for (const key of scalars) {
if (f[key] !== undefined && f[key] !== '' && f[key] !== null) out[key] = f[key]
}
if (f.DetectionMethod) out.DetectionMethod = f.DetectionMethod
if (f.WaitTimeoutSec) out.WaitTimeoutSec = f.WaitTimeoutSec
if (f.Type === 'Registry' && f.RegValue !== undefined && f.RegValue !== '') {
out.RegValue = ['DWord', 'QWord'].includes(f.RegType) ? Number(f.RegValue) : f.RegValue
}
const pctypes = splitList(f.PCTypes)
if (pctypes.length) out.PCTypes = pctypes
const hostnames = splitList(f.TargetHostnames)
if (hostnames.length) out.TargetHostnames = hostnames
const machinenumbers = splitList(f.TargetMachineNumbers)
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
if (f.inuseBehavior) {
out.InUseCheck = { Behavior: f.inuseBehavior,
Processes: splitList(f.inuseProcesses).map(name => ({ Name: name })) }
}
return out
}
async function saveEntry() {
const body = buildEntryPayload()
try {
if (entryForm.value.entryid) {
await api.put(`/geenforce/entries/${entryForm.value.entryid}`, body)
} else {
await api.post(`/geenforce/scopes/${detail.value.scopeid}/entries`, body)
}
showEntry.value = false
await selectScope(detail.value.scopeid)
await loadScopes()
} catch (e) { error.value = e.response?.data?.message || 'Save entry failed' }
}
async function deleteEntry(entry) {
if (!confirm(`Delete entry "${entry.Name}"?`)) return
await api.delete(`/geenforce/entries/${entry.entryid}`)
await selectScope(detail.value.scopeid)
await loadScopes()
}
async function moveEntry(index, direction) {
const entries = detail.value.entries
const target = index + direction
if (target < 0 || target >= entries.length) return
const order = entries.map(e => e.entryid)
;[order[index], order[target]] = [order[target], order[index]]
await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order })
await selectScope(detail.value.scopeid)
}
// -- publish / versions --
async function publish() {
const notes = prompt('Publish notes (optional):') ?? ''
try {
await api.post(`/geenforce/scopes/${detail.value.scopeid}/publish`, { notes })
await loadScopes()
await selectScope(detail.value.scopeid)
if (showVersions.value) loadVersions()
} catch (e) { error.value = e.response?.data?.message || 'Publish failed' }
}
async function toggleVersions() {
showVersions.value = !showVersions.value
if (showVersions.value) await loadVersions()
}
async function loadVersions() {
versions.value = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/versions`))
}
async function rollback(versionnumber) {
if (!confirm(`Roll back to v${versionnumber}? PCs get this on their next cycle.`)) return
await api.post(`/geenforce/scopes/${detail.value.scopeid}/rollback`, { versionnumber })
await loadVersions()
await loadScopes()
}
// -- simulate / preview --
async function runSimulate() {
const params = { ...sim.value }
simResult.value = payload(await api.get(
`/geenforce/scopes/${detail.value.scopeid}/simulate`, { params }))
}
async function loadPreview() {
const data = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/preview`))
previewText.value = JSON.stringify(data.manifest, null, 2)
showPreview.value = true
}
function filterSummary(entry) {
const parts = []
if (entry.PCTypes) parts.push(`pc:${entry.PCTypes.length}`)
if (entry.TargetMachineNumbers) parts.push(`mach:${entry.TargetMachineNumbers.length}`)
if (entry.TargetHostnames) parts.push(`host:${entry.TargetHostnames.length}`)
if (entry._CmmVersion) parts.push(`cmm:${entry._CmmVersion}`)
return parts.join(' ') || '-'
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : ''
}
loadScopes()
</script>
<style scoped>
.imaging-pctypes { max-width: 1200px; }
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
border-radius: 4px; margin-bottom: 1rem; }
.editor-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; align-items: start; }
.scope-list { padding: 0.5rem; }
.scope-row { padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; }
.scope-row:hover { background: var(--bg); }
.scope-row.active { background: var(--primary); color: white; }
.scope-name { font-weight: 600; font-size: 0.9rem; }
.scope-meta { display: flex; gap: 0.4rem; font-size: 0.72rem; align-items: center;
color: var(--text-light); margin-top: 0.15rem; }
.scope-row.active .scope-meta { color: rgba(255,255,255,0.85); }
.unpublished { color: var(--warning); }
.scope-detail { padding: 1rem; }
.empty-detail { color: var(--text-light); text-align: center; padding: 3rem; }
.detail-header { display: flex; justify-content: space-between; align-items: center; }
.detail-actions { display: flex; gap: 0.4rem; }
.mapping-row { display: flex; gap: 0.6rem; align-items: flex-end; margin: 0.75rem 0;
flex-wrap: wrap; }
.mapping-row label { font-size: 0.75rem; color: var(--text-light); display: flex;
flex-direction: column; gap: 0.2rem; }
.mapping-row .grow { flex: 1; min-width: 160px; }
.versions-panel { background: var(--bg); padding: 0.75rem; border-radius: 6px; margin: 0.5rem 0; }
.entries-header { display: flex; justify-content: space-between; align-items: center;
margin-top: 1rem; }
.order-hint { font-size: 0.75rem; color: var(--text-light); margin: 0.2rem 0 0.5rem; }
.entries-table { width: 100%; }
.order-cell { white-space: nowrap; }
.btn-move { border: 1px solid var(--border); background: var(--bg-card); cursor: pointer;
border-radius: 4px; padding: 0 0.35rem; }
.btn-move:disabled { opacity: 0.3; cursor: default; }
.dim { color: var(--text-light); font-size: 0.82rem; }
.row-actions { display: flex; gap: 0.3rem; }
.btn-small { padding: 0.15rem 0.5rem; font-size: 0.78rem; }
.simulator { margin-top: 1.25rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.sim-inputs { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.sim-inputs input { max-width: 130px; }
.sim-result { margin-top: 0.5rem; font-size: 0.85rem; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex;
align-items: center; justify-content: center; z-index: 100; }
.modal { background: var(--bg-card); padding: 1.25rem; border-radius: 8px;
min-width: 340px; max-height: 90vh; overflow-y: auto; }
.modal-wide { min-width: 640px; }
.modal label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.8rem;
color: var(--text-light); margin-bottom: 0.5rem; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
.form-grid .full { grid-column: 1 / -1; }
.advanced summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.75rem; }
.preview { background: var(--bg); padding: 0.75rem; border-radius: 6px; max-height: 60vh;
overflow: auto; font-size: 0.78rem; }
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
</style>