Add GE-Enforce export-to-share + fleet-compliance UI (Milestone 1 UX)
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:
@@ -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' }
|
||||
}
|
||||
]
|
||||
|
||||
120
frontend/src/views/settings/EnforcementReports.vue
Normal file
120
frontend/src/views/settings/EnforcementReports.vue
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user