Add GE-Enforce export-to-share + fleet-compliance UI (Milestone 1 UX)
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s

Rounds out the Milestone 1 admin experience: author + publish in shopdb, push
to the share by a button, and see what the fleet actually did.

Export to share:
- GET/PUT /api/geenforce/config stores the on-share export root (Setting
  geenforce_share_root); POST /scopes/<id>/export-share writes the current
  published JSON to <shareroot>/<scope>/manifest.json (preinstall.json for the
  preinstall phase), backing up the existing file to _meta/history first.
  geenforce.publish gated. The engine and PCs are untouched - this is the safe
  Milestone 1 push whose rollback is restoring the history backup.
- Editor: a share-root config row + an "Export to Share" button per scope.
- 3 tests (config roundtrip, export writes the file, second export backs up).

Fleet-compliance UI (Settings > Enforcement Reports):
- New page over GET /reports + /reports/<id>: latest report per PC with
  received (applied vs latest published version), status (ok/selfhealed/failed),
  and install/skip/fail counts; row detail shows per-entry outcomes with
  self-heal flags, exit codes, and messages. Hostname/PC-type filters.
- ADR-010 settings card + ADR-009 plugin-gated route.

Full suite 883 green; frontend build + naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 18:06:33 -04:00
parent 7eb6cebeb5
commit adc5b7f69e
6 changed files with 285 additions and 1 deletions

View File

@@ -11,5 +11,11 @@ export default [
name: 'imagingpctypes',
component: () => import('../../views/settings/ImagingPCTypes.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
},
{
path: 'settings/enforcementreports',
name: 'enforcementreports',
component: () => import('../../views/settings/EnforcementReports.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
}
]

View File

@@ -0,0 +1,120 @@
<template>
<div class="enforcement-reports">
<div class="page-header"><h1>Enforcement Reports</h1></div>
<p class="intro">
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
up the current published manifest; status shows self-heal and failures.
</p>
<div class="filters">
<input v-model="filterHost" placeholder="Filter hostname" @keyup.enter="load" />
<input v-model="filterScope" placeholder="Filter PC type" @keyup.enter="load" />
<button class="btn" @click="load">Filter</button>
<button class="btn" @click="clearFilters">Clear</button>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Host</th><th>PC type</th><th>Received</th><th>Version</th>
<th>Status</th><th>Installed</th><th>Skipped</th><th>Failed</th>
<th>Last check-in</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="r in reports" :key="r.reportid">
<td>{{ r.hostname }}</td>
<td>{{ r.scopename }}</td>
<td>
<span class="badge" :class="r.receivedlatest ? 'badge-success' : 'badge-warning'">
{{ r.receivedlatest ? 'yes' : 'behind' }}
</span>
</td>
<td class="dim">{{ r.appliedversion ?? '-' }} / {{ r.latestversion ?? '-' }}</td>
<td><span class="badge" :class="statusClass(r.status)">{{ r.status }}</span></td>
<td>{{ r.installed }}</td>
<td class="dim">{{ r.skipped }}</td>
<td :class="{ 'fail-count': r.failed }">{{ r.failed }}</td>
<td class="dim">{{ formatDate(r.lastcheckin || r.receivedat) }}</td>
<td><button class="btn btn-small" @click="openDetail(r.reportid)">Detail</button></td>
</tr>
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
</tbody>
</table>
</div>
<!-- Detail modal -->
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
<div class="modal modal-wide">
<h2>{{ detail.hostname }} - {{ detail.scopename }}</h2>
<p class="dim">Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
status {{ detail.status }}</p>
<table>
<thead><tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr></thead>
<tbody>
<tr v-for="(result, i) in detail.results" :key="i">
<td>{{ result.entryname }}</td>
<td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
<td>{{ result.selfhealed ? 'yes' : '' }}</td>
<td class="dim">{{ result.exitcode ?? '' }}</td>
<td class="dim">{{ result.message }}</td>
</tr>
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
</tbody>
</table>
<div class="modal-actions"><button class="btn" @click="detail = null">Close</button></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '../../api'
const reports = ref([])
const detail = ref(null)
const filterHost = ref('')
const filterScope = ref('')
function payload(response) { return response.data.data }
async function load() {
const params = {}
if (filterHost.value) params.hostname = filterHost.value
if (filterScope.value) params.scopename = filterScope.value
reports.value = payload(await api.get('/geenforce/reports', { params }))
}
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
async function openDetail(reportid) {
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
}
function statusClass(status) {
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
}
function actionClass(action) {
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
filtered: '' }[action] || ''
}
function formatDate(value) { return value ? new Date(value).toLocaleString() : '' }
load()
</script>
<style scoped>
.enforcement-reports { max-width: 1100px; }
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.dim { color: var(--text-light); }
.fail-count { color: var(--danger); font-weight: 600; }
.btn-small { padding: 0.15rem 0.5rem; font-size: 0.78rem; }
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
.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-actions { display: flex; justify-content: flex-end; margin-top: 0.75rem; }
</style>

View File

@@ -10,6 +10,16 @@
</p>
<div v-if="error" class="alert-error">{{ error }}</div>
<div v-if="notice" class="alert-ok">{{ notice }}</div>
<div class="config-row card">
<label class="grow">On-share export root
<input v-model="shareRoot" placeholder="\\server\share\dt\shopfloor or /path" />
</label>
<button class="btn" @click="saveConfig">Save share root</button>
<span class="config-hint">Used by Export to Share. During Milestone 1 the
engine still reads these files; export is your push to the fleet.</span>
</div>
<div class="editor-grid">
<!-- Scope list -->
@@ -36,6 +46,7 @@
<h2>{{ detail.scopename }}</h2>
<div class="detail-actions">
<button class="btn btn-primary" @click="publish">Publish</button>
<button class="btn" @click="exportShare">Export to Share</button>
<button class="btn" @click="toggleVersions">Versions</button>
<button class="btn" @click="loadPreview">Preview</button>
<button class="btn btn-danger" @click="deleteScope">Delete</button>
@@ -260,6 +271,8 @@ const scopes = ref([])
const selectedId = ref(null)
const detail = ref(null)
const error = ref('')
const notice = ref('')
const shareRoot = ref('')
const showNewScope = ref(false)
const newScope = ref({ scopename: '', phase: 'runtime' })
@@ -278,11 +291,28 @@ const simResult = ref(null)
function payload(response) { return response.data.data }
function flash(message) { notice.value = message; setTimeout(() => { notice.value = '' }, 4000) }
async function loadScopes() {
try {
scopes.value = payload(await api.get('/geenforce/scopes'))
} catch (e) { error.value = 'Failed to load PC types' }
}
async function loadConfig() {
try { shareRoot.value = payload(await api.get('/geenforce/config')).shareroot } catch (e) { /* optional */ }
}
async function saveConfig() {
try {
await api.put('/geenforce/config', { shareroot: shareRoot.value })
flash('Share root saved.')
} catch (e) { error.value = 'Failed to save share root' }
}
async function exportShare() {
try {
const result = payload(await api.post(`/geenforce/scopes/${detail.value.scopeid}/export-share`))
flash(`Exported to ${result.path}`)
} catch (e) { error.value = e.response?.data?.message || 'Export failed' }
}
async function selectScope(id) {
selectedId.value = id
@@ -453,6 +483,7 @@ function formatDate(value) {
}
loadScopes()
loadConfig()
</script>
<style scoped>
@@ -460,6 +491,14 @@ loadScopes()
.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; }
.alert-ok { background: var(--success); color: white; padding: 0.5rem 0.75rem;
border-radius: 4px; margin-bottom: 1rem; }
.config-row { display: flex; gap: 0.6rem; align-items: flex-end; padding: 0.6rem 0.75rem;
margin-bottom: 1rem; flex-wrap: wrap; }
.config-row label { font-size: 0.75rem; color: var(--text-light); display: flex;
flex-direction: column; gap: 0.2rem; }
.config-row .grow { flex: 1; min-width: 220px; }
.config-hint { font-size: 0.72rem; color: var(--text-light); flex-basis: 100%; }
.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; }