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', name: 'imagingpctypes',
component: () => import('../../views/settings/ImagingPCTypes.vue'), component: () => import('../../views/settings/ImagingPCTypes.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' } 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> </p>
<div v-if="error" class="alert-error">{{ error }}</div> <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"> <div class="editor-grid">
<!-- Scope list --> <!-- Scope list -->
@@ -36,6 +46,7 @@
<h2>{{ detail.scopename }}</h2> <h2>{{ detail.scopename }}</h2>
<div class="detail-actions"> <div class="detail-actions">
<button class="btn btn-primary" @click="publish">Publish</button> <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="toggleVersions">Versions</button>
<button class="btn" @click="loadPreview">Preview</button> <button class="btn" @click="loadPreview">Preview</button>
<button class="btn btn-danger" @click="deleteScope">Delete</button> <button class="btn btn-danger" @click="deleteScope">Delete</button>
@@ -260,6 +271,8 @@ const scopes = ref([])
const selectedId = ref(null) const selectedId = ref(null)
const detail = ref(null) const detail = ref(null)
const error = ref('') const error = ref('')
const notice = ref('')
const shareRoot = ref('')
const showNewScope = ref(false) const showNewScope = ref(false)
const newScope = ref({ scopename: '', phase: 'runtime' }) const newScope = ref({ scopename: '', phase: 'runtime' })
@@ -278,11 +291,28 @@ const simResult = ref(null)
function payload(response) { return response.data.data } function payload(response) { return response.data.data }
function flash(message) { notice.value = message; setTimeout(() => { notice.value = '' }, 4000) }
async function loadScopes() { async function loadScopes() {
try { try {
scopes.value = payload(await api.get('/geenforce/scopes')) scopes.value = payload(await api.get('/geenforce/scopes'))
} catch (e) { error.value = 'Failed to load PC types' } } 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) { async function selectScope(id) {
selectedId.value = id selectedId.value = id
@@ -453,6 +483,7 @@ function formatDate(value) {
} }
loadScopes() loadScopes()
loadConfig()
</script> </script>
<style scoped> <style scoped>
@@ -460,6 +491,14 @@ loadScopes()
.intro { color: var(--text-light); margin: 0 0 1rem 0; } .intro { color: var(--text-light); margin: 0 0 1rem 0; }
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem; .alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
border-radius: 4px; margin-bottom: 1rem; } 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; } .editor-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; align-items: start; }
.scope-list { padding: 0.5rem; } .scope-list { padding: 0.5rem; }
.scope-row { padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; } .scope-row { padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; }

View File

@@ -16,9 +16,11 @@ from flask_jwt_extended import jwt_required
from shopdb.api import ( from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission, db, success_response, error_response, ErrorCodes, require_permission,
service_token_authorized, service_token_authorized, Setting,
) )
SHAREROOT_SETTING = 'geenforce_share_root'
from ..models import ( from ..models import (
ManifestScope, ManifestEntry, ManifestPublishedVersion, ManifestScope, ManifestEntry, ManifestPublishedVersion,
ManifestEnforcementReport, ENTRY_TYPES, PHASES, ManifestEnforcementReport, ENTRY_TYPES, PHASES,
@@ -421,6 +423,49 @@ def get_version(scopeid, versionnumber):
'manifest': _json.loads(version.manifestjson)}) 'manifest': _json.loads(version.manifestjson)})
@geenforce_bp.route('/config', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_config():
"""Plugin config: the on-share export root (for export-to-share)."""
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
return success_response({'shareroot': setting.value if setting else ''})
@geenforce_bp.route('/config', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.publish')
def put_config():
payload = request.get_json(silent=True) or {}
Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(),
valuetype='string', category='geenforce',
description='On-share export root for GE-Enforce manifests')
db.session.commit()
return success_response({'shareroot': (payload.get('shareroot') or '').strip()})
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def export_share_route(scopeid):
"""Write the scope's current published JSON to the configured share root
(backing up the old file to _meta/history first)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
shareroot = setting.value if setting else ''
if not shareroot:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Configure the share root first', http_code=400)
try:
path = service.export_scope_to_share(scope.scopename, scope.phase,
shareroot)
except (ValueError, OSError) as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
return success_response({'path': path})
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST']) @geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
@jwt_required() @jwt_required()
@require_permission('geenforce.publish') @require_permission('geenforce.publish')

View File

@@ -92,6 +92,15 @@ class GeEnforcePlugin(BasePlugin):
'publish, roll back, and simulate.', 'publish, roll back, and simulate.',
'position': 30, 'position': 30,
}, },
{
'group': 'Integrations',
'to': '/settings/enforcementreports',
'icon': 'settings',
'title': 'Enforcement Reports',
'description': 'Fleet compliance: which PCs received the latest '
'manifest and their self-heal / failure results.',
'position': 31,
},
] ]
def init_app(self, app: Flask, db_instance) -> None: def init_app(self, app: Flask, db_instance) -> None:

View File

@@ -0,0 +1,65 @@
"""GE-Enforce export-to-share: config the share root, publish, write to share.
This is the Milestone 1 loop - author + publish in shopdb, then export the
published JSON to the on-share manifest with a history backup, engine untouched.
"""
import json
import os
from plugins.geenforce import service
def _scope_with_entry(app, name='gea-shopfloor-cmm'):
with app.app_context():
service.replace_scope_draft(name, 'runtime', {
'Version': '2.6',
'Applications': [{'Name': 'Alpha', 'Type': 'MSI',
'Installer': 'apps/a.msi'}]})
service.publish_scope(name, 'runtime', notes='v1')
scope = service.ManifestScope.query.filter_by(scopename=name).first()
scopeid = scope.scopeid
service.db.session.commit()
return scopeid
def test_export_requires_config(client, db, app, auth_headers):
scopeid = _scope_with_entry(app)
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
assert resp.status_code == 400 # no share root configured
def test_config_roundtrip_and_export(client, db, app, auth_headers, tmp_path):
scopeid = _scope_with_entry(app)
# Configure the share root.
put = client.put('/api/geenforce/config',
json={'shareroot': str(tmp_path)}, headers=auth_headers)
assert put.status_code == 200
got = client.get('/api/geenforce/config', headers=auth_headers)
assert got.get_json()['data']['shareroot'] == str(tmp_path)
# Export writes the published JSON to <shareroot>/<scope>/manifest.json.
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
path = resp.get_json()['data']['path']
assert os.path.isfile(path)
written = json.load(open(path))
assert [e['Name'] for e in written['Applications']] == ['Alpha']
def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path):
scopeid = _scope_with_entry(app)
client.put('/api/geenforce/config', json={'shareroot': str(tmp_path)},
headers=auth_headers)
# First export creates the file.
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
# Second export backs the old file into _meta/history.
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
historydir = tmp_path / '_meta' / 'history'
assert historydir.is_dir()
assert any(historydir.iterdir())