Files
shopdb-flask/plugins/geenforce/frontend/views/ManifestEditor.vue
cproudlock 23dc9fa379 ADR-013 Phase 4: relocate 4 self-contained plugin frontends
Relocate applications, geenforce, knowledgebase, and machines - each owns only
its own views dir, so a clean move to plugins/<name>/frontend/ (views/ +
routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its
vitest spec move with it (ManifestEditor imports it as a sibling).

Machinery fixes this batch surfaced:
- routes.gen.js codegen uses namespace imports (import * as p_x). A route file
  without a `toplevel` export is undefined on the namespace instead of a strict-
  ESM missing-binding build error.
- vitest gains a `pretest` stage so plugin-frontend specs (now under
  plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/.

Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper)
renders fully from its staged frontend. Build + 58 vitest + naming green.
2026-07-18 23:51:23 -04:00

1052 lines
42 KiB
Vue

<template>
<div class="imaging-pctypes">
<div class="page-header">
<h2>Imaging PC Types</h2>
</div>
<p class="setting-description">
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>
<details class="help-panel">
<summary>How this works</summary>
<div class="help-body">
<p><strong>A manifest is the desired state for a PC type</strong> - the
list of what should be installed. shopdb serves it; each PC checks
itself every cycle and re-installs anything that drifted (uninstalled,
corrupted, or overwritten). That is the "self-heal".</p>
<p><strong>Each entry</strong> is one action (install an app, copy a
file, write a registry value, run a script) plus a <em>detection</em>
rule that tells the PC whether it is already correct - if not, the
action runs. Entry order is execution order.</p>
<p><strong>Targeting</strong> narrows an entry: the fleet-wide
<code>common</code> manifest uses PC types to hit a subset; a per-type
manifest already only runs on its own type, so it shows just the niche
gate it needs (CMM version, a bay number, a subtype).</p>
<p>Full guide: <code>docs/GE-ENFORCE.md</code>.</p>
</div>
</details>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="notice" class="settings-success">{{ notice }}</div>
<!-- Export configuration -->
<div class="section-card">
<h3 class="section-title">Export configuration</h3>
<div class="setting-row full-width">
<label>
<span>On-share export root</span>
<input v-model="shareroot" placeholder="\\server\share\dt\shopfloor or /path" />
<small class="input-hint">
Used by Export to Share. During Milestone 1 the engine still reads
these files; export is your push to the fleet.
</small>
</label>
</div>
<button class="btn btn-primary" @click="saveConfig">Save share root</button>
</div>
<div class="editor-grid">
<!-- Scope list -->
<aside class="section-card scope-panel">
<div class="section-head">
<h3 class="section-title">PC Types</h3>
<button class="btn btn-sm btn-primary" @click="openNewScope">+ New PC Type</button>
</div>
<div class="scope-list">
<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>
</div>
</aside>
<!-- Selected scope -->
<div v-if="detail" class="scope-detail">
<!-- Identity and mapping -->
<div class="section-card">
<div class="section-head">
<h3 class="section-title">Identity &amp; Mapping</h3>
<div class="header-actions detail-actions">
<button class="btn btn-sm btn-primary" @click="publish">Publish</button>
<button class="btn btn-sm btn-secondary" @click="exportShare">Export to Share</button>
<button class="btn btn-sm btn-secondary" @click="toggleVersions">Versions</button>
<button class="btn btn-sm btn-secondary" @click="loadPreview">Preview</button>
<button class="btn btn-sm btn-danger" @click="deleteScope">Delete</button>
</div>
</div>
<p v-if="scopeSummary" class="scope-summary muted">{{ scopeSummary }}</p>
<div class="settings-grid">
<div class="setting-row">
<label>
<span>Computer Type ID</span>
<input type="number" v-model.number="detail.computertypeid" />
</label>
</div>
<div class="setting-row">
<label>
<span>Measuring Tool Type ID</span>
<input type="number" v-model.number="detail.measuringtooltypeid" />
</label>
</div>
<div class="setting-row">
<label>
<span>Description</span>
<input type="text" v-model="detail.description" />
</label>
</div>
</div>
<button class="btn btn-primary" @click="saveScope">Save</button>
</div>
<!-- Versions panel -->
<div v-if="showVersions" class="section-card">
<h3 class="section-title">Published versions</h3>
<div class="table-container">
<table>
<thead>
<tr><th>Version</th><th>Published</th><th>Notes</th><th></th></tr>
</thead>
<tbody>
<tr v-for="version in versions" :key="version.versionnumber">
<td>
v{{ version.versionnumber }}
<span v-if="version.iscurrent" class="badge badge-success">current</span>
</td>
<td class="muted">{{ formatDate(version.publishedat) }}</td>
<td class="muted">{{ version.notes }}</td>
<td class="actions">
<button
v-if="!version.iscurrent"
class="btn btn-sm btn-secondary"
@click="rollback(version.versionnumber)"
>Roll back</button>
</td>
</tr>
<tr v-if="!versions.length"><td colspan="4" class="empty">No published versions.</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Entries -->
<div class="section-card">
<div class="section-head">
<h3 class="section-title">Manifest entries ({{ detail.entries.length }})</h3>
<button class="btn btn-sm btn-primary" @click="openNewEntry">+ Add entry</button>
</div>
<p class="setting-description">
Order is execution order. Put config-restore entries after their installer.
</p>
<div class="table-container">
<table class="entries-table">
<thead>
<tr><th class="col-order">Order</th><th class="col-type">Type</th><th>Entry</th><th class="col-actions"></th></tr>
</thead>
<tbody>
<tr v-for="(entry, index) in detail.entries" :key="entry.entryid">
<td class="order-cell">
<button
class="btn btn-sm btn-secondary"
:disabled="index === 0"
@click="moveEntry(index, -1)"
>Up</button>
<button
class="btn btn-sm btn-secondary"
:disabled="index === detail.entries.length - 1"
@click="moveEntry(index, 1)"
>Down</button>
</td>
<td><span class="badge badge-info">{{ entry.Type }}</span></td>
<td>
<div class="entry-name">{{ entry.Name }}</div>
<div class="entry-desc muted">{{ describeEntry(entry) }}</div>
</td>
<td class="row-actions">
<button class="btn btn-sm btn-secondary" @click="openEditEntry(entry)">Edit</button>
<button class="btn btn-sm btn-danger" @click="deleteEntry(entry)">Delete</button>
</td>
</tr>
<tr v-if="!detail.entries.length"><td colspan="4" class="empty">No entries.</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Simulator -->
<div class="section-card">
<h3 class="section-title">Simulate: what would a PC get?</h3>
<div class="settings-grid">
<div class="setting-row">
<label><span>Subtype</span><input v-model="simulateInputs.subtype" placeholder="subtype" /></label>
</div>
<div class="setting-row">
<label><span>Hostname</span><input v-model="simulateInputs.hostname" placeholder="hostname" /></label>
</div>
<div class="setting-row">
<label><span>Machine #</span><input v-model="simulateInputs.machinenumber" placeholder="machine #" /></label>
</div>
<div class="setting-row">
<label><span>CMM version</span><input v-model="simulateInputs.cmmversion" placeholder="CMM version" /></label>
</div>
</div>
<button class="btn btn-primary" @click="runSimulate">Run</button>
<div v-if="simulateResult" class="simulate-result">
<div>
<strong>Applies ({{ simulateResult.applied.length }}):</strong>
{{ simulateResult.applied.join(', ') || 'none' }}
</div>
<div class="muted">
<strong>Filtered:</strong>
<span v-for="filtered in simulateResult.filtered" :key="filtered.name">
{{ filtered.name }} ({{ filtered.filteredby.join(',') }});
</span>
</div>
</div>
</div>
<!-- Compliance: install coverage for tracked-application entries -->
<div class="section-card">
<h3 class="section-title">Compliance: who has these installed?</h3>
<p class="setting-description">
Install counts for entries linked to a tracked application, from the
computers collector. Only entries with a tracked app appear.
</p>
<div v-if="compliance && compliance.computersplugin === false" class="muted compliance-note">
Install counts require the computers plugin.
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Entry</th>
<th>Tracked app</th>
<th>Expected version</th>
<th>Installed</th>
<th>On expected version</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<tr v-for="row in complianceRows" :key="row.entryid">
<td>{{ row.entryname }}</td>
<td>{{ row.appname }}</td>
<td class="muted">{{ row.expectedversion || '-' }}</td>
<td>{{ row.installedcount === null ? '-' : row.installedcount }}</td>
<td>
<span v-if="row.versionmatchcount === null" class="muted">-</span>
<span v-else>{{ row.versionmatchcount }}</span>
</td>
<td class="muted">{{ row.coveragenote }}</td>
</tr>
<tr v-if="!complianceRows.length">
<td colspan="6" class="empty">No entries are linked to a tracked application yet.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-else class="section-card empty-detail">
Select a PC type, or create one.
</div>
</div>
<!-- New scope modal -->
<div v-if="showNewScope" class="modal-overlay" @click.self="showNewScope = false">
<div class="modal">
<div class="modal-header">
<h3>New imaging PC type</h3>
<button class="modal-close" @click="showNewScope = false">x</button>
</div>
<div class="modal-body">
<div class="setting-row full-width">
<label>
<span>Scope name</span>
<input v-model="newScope.scopename" placeholder="e.g. gea-shopfloor-cmm" />
</label>
</div>
<div class="setting-row">
<label>
<span>Phase</span>
<select v-model="newScope.phase">
<option value="runtime">runtime</option>
<option value="preinstall">preinstall</option>
</select>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @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-entry">
<div class="modal-header">
<h3>{{ entryForm.entryid ? 'Edit entry' : 'New entry' }}</h3>
<button class="modal-close" @click="showEntry = false">x</button>
</div>
<div class="modal-body">
<div class="settings-grid">
<div class="setting-row">
<label><span>Name</span><input v-model="entryForm.Name" /></label>
</div>
<div class="setting-row">
<label>
<span>Type</span>
<select v-model="entryForm.Type">
<option v-for="entryType in availableEntryTypes" :key="entryType" :value="entryType">{{ entryType }}</option>
</select>
<small v-if="isPreinstallScope" class="input-hint">Preinstall runs at imaging and supports MSI/EXE only.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Tracked application (optional)</span>
<select v-model="entryForm.appid">
<option :value="null">(not linked)</option>
<option v-for="app in applications" :key="app.appid" :value="app.appid">{{ app.appname }}</option>
</select>
<small class="input-hint">Link this entry to a tracked Application in the catalog (metadata; not part of the manifest).</small>
</label>
</div>
</div>
<!-- payload fields per type -->
<div v-if="['MSI','EXE','CMD','BAT','INF'].includes(entryForm.Type)" class="settings-grid">
<div class="setting-row">
<label>
<span>Installer (relative path)</span>
<input v-model="entryForm.Installer" />
<small class="input-hint">Path under the scope payload folder on the share, or an inline payload attached below.</small>
</label>
</div>
<div class="setting-row">
<label><span>Install args</span><input v-model="entryForm.InstallArgs" /></label>
</div>
</div>
<div v-if="entryForm.Type === 'PS1'" class="settings-grid">
<div class="setting-row">
<label><span>Script (relative path)</span><input v-model="entryForm.Script" /></label>
</div>
<div class="setting-row">
<label><span>Args</span><input v-model="entryForm.Args" /></label>
</div>
</div>
<div v-if="entryForm.Type === 'File'" class="settings-grid">
<div class="setting-row">
<label>
<span>Source (relative)</span>
<input v-model="entryForm.Source" />
<small class="input-hint">Path under the scope payload folder on the share, or an inline payload attached below.</small>
</label>
</div>
<div class="setting-row">
<label><span>Destination (absolute)</span><input v-model="entryForm.Destination" /></label>
</div>
</div>
<div v-if="entryForm.Type === 'Registry'" class="settings-grid">
<div class="setting-row">
<label><span>Reg path</span><input v-model="entryForm.RegPath" /></label>
</div>
<div class="setting-row">
<label><span>Reg name</span><input v-model="entryForm.RegName" /></label>
</div>
<div class="setting-row">
<label><span>Reg value</span><input v-model="entryForm.RegValue" /></label>
</div>
<div class="setting-row">
<label>
<span>Reg type</span>
<select v-model="entryForm.RegType">
<option v-for="regType in REG_TYPES" :key="regType" :value="regType">{{ regType }}</option>
</select>
</label>
</div>
</div>
<!-- inline payload upload (only for a saved entry) -->
<div v-if="entryForm.entryid" class="setting-group">
<h3>Inline payload</h3>
<p class="setting-description">
Attach a small file (installer config, .reg, script) stored in
shopdb and shipped with this entry.
</p>
<div v-if="entryForm.haspayload" class="payload-current muted">
Current: {{ entryForm.payloadref }}
<span v-if="entryForm.payloadsha256">(sha256 {{ entryForm.payloadsha256.slice(0, 12) }})</span>
</div>
<div v-else class="payload-current muted">No payload attached.</div>
<div class="payload-upload">
<input type="file" @change="onPayloadFileChange" />
<button
type="button"
class="btn btn-sm btn-secondary"
:disabled="!payloadFile || payloadUploading"
@click="uploadPayload"
>{{ payloadUploading ? 'Uploading...' : 'Upload' }}</button>
</div>
<small class="input-hint">Inline payloads are capped at 1 MB and stored in shopdb.</small>
</div>
<!-- detection -->
<div class="setting-row">
<label>
<span>Detection method</span>
<select v-model="entryForm.DetectionMethod">
<option value="">(none: always installs)</option>
<option v-for="method in availableDetectionMethods" :key="method" :value="method">{{ method }}</option>
</select>
<small v-if="detectionMethodHint" class="input-hint">{{ detectionMethodHint }}</small>
<small v-if="isPreinstallScope" class="input-hint">Preinstall supports Registry/File detection only.</small>
</label>
</div>
<div v-if="entryForm.DetectionMethod" class="settings-grid">
<div class="setting-row">
<label><span>Detection path</span><input v-model="entryForm.DetectionPath" /></label>
</div>
<div class="setting-row">
<label><span>Detection name</span><input v-model="entryForm.DetectionName" /></label>
</div>
<div class="setting-row">
<label>
<span>Detection value</span>
<input v-model="entryForm.DetectionValue" />
<small class="input-hint">For FileVersion detection this is the expected version used by the Compliance panel.</small>
</label>
</div>
<div v-if="entryForm.DetectionMethod === 'pnputil'" class="setting-row">
<label><span>Detection pattern</span><input v-model="entryForm.DetectionPattern" /></label>
</div>
</div>
<!-- targeting: only the gates this scope actually uses, by default -->
<div class="setting-group">
<div class="section-head">
<h3>Targeting</h3>
<label class="flag-label toggle-all">
<input type="checkbox" v-model="showAllTargeting" /> Show all targeting options
</label>
</div>
<p class="setting-description">{{ targetingHint }}</p>
<div class="settings-grid">
<div class="setting-row" v-if="showAllTargeting || targetingGates.pctypes">
<label>
<span>PC types (comma)</span>
<input v-model="entryForm.PCTypes" placeholder="* or gea-shopfloor-cmm,..." />
<small class="input-hint">Which PC types this entry runs on. Needed on the fleet-wide common/preinstall manifests; a per-type manifest already only runs on its own type.</small>
</label>
</div>
<div class="setting-row" v-if="showAllTargeting || targetingGates.cmmversion">
<label>
<span>CMM version gate</span>
<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" />
<small class="input-hint">Install only on CMM bays resolved to this PC-DMIS version.</small>
</label>
</div>
<div class="setting-row" v-if="showAllTargeting || targetingGates.machinenumbers">
<label>
<span>Target machine numbers (comma)</span>
<input v-model="entryForm.TargetMachineNumbers" />
<small class="input-hint">Restrict to specific bay machine numbers.</small>
</label>
</div>
<div class="setting-row" v-if="showAllTargeting || targetingGates.hostnames">
<label>
<span>Target hostnames (comma)</span>
<input v-model="entryForm.TargetHostnames" />
<small class="input-hint">Restrict to specific hostnames (supports * wildcards).</small>
</label>
</div>
</div>
</div>
<div class="settings-grid">
<div class="setting-row">
<label><span>Wait timeout (sec)</span><input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label>
</div>
<div class="setting-row">
<label><span>Log file</span><input v-model="entryForm.LogFile" /></label>
</div>
</div>
<div class="setting-row full-width">
<label>
<span>Comment</span>
<textarea class="form-control" v-model="entryForm._comment" rows="2"></textarea>
</label>
</div>
<!-- In-use handling + preinstall flags -->
<div class="setting-group">
<h3>In-use handling and preinstall flags</h3>
<div class="setting-row">
<label>
<span>Close running app before install (InUseCheck)</span>
<select v-model="entryForm.inuseBehavior">
<option value="">(none)</option>
<option v-for="behavior in INUSE_BEHAVIORS" :key="behavior" :value="behavior">{{ behavior }}</option>
</select>
<small class="input-hint">What to do when a process below is running: Defer skips this cycle, CloseAndReopen restarts it, ForceClose kills it, ScheduleForReboot defers to next reboot.</small>
</label>
</div>
<div v-if="entryForm.inuseBehavior" class="process-editor">
<div class="process-head">
<span>Processes to close</span>
<button type="button" class="btn btn-sm btn-secondary" @click="addProcess">+ process</button>
</div>
<div v-for="(process, processIndex) in entryForm.inuseProcesses" :key="processIndex" class="process-row">
<input class="form-control" v-model="process.name" placeholder="Name (no .exe)" />
<input class="form-control" v-model="process.exepath" placeholder="ExePath (optional)" />
<input class="form-control" type="number" v-model.number="process.timeout" placeholder="timeout s" />
<button type="button" class="btn btn-sm btn-danger" @click="removeProcess(processIndex)">Remove</button>
</div>
</div>
<div v-if="isPreinstallScope" class="preinstall-flags">
<span class="flags-label">Preinstall flags (only take effect at imaging):</span>
<label class="flag-label"><input type="checkbox" v-model="entryForm.PreEnrollment" /> PreEnrollment</label>
<label class="flag-label"><input type="checkbox" v-model="entryForm.KillAfterDetection" /> KillAfterDetection</label>
<label class="flag-label"><input type="checkbox" v-model="entryForm.PCTypesStrict" /> PCTypesStrict</label>
</div>
</div>
<!-- Apply mode / update window -->
<div class="setting-group">
<h3>Apply mode / update window</h3>
<p class="input-hint">Not yet enforced by the engine.</p>
<div class="settings-grid">
<div class="setting-row">
<label><span>Apply mode</span><input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
</div>
<div class="setting-row">
<label><span>Update window</span><input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @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-entry">
<div class="modal-header">
<h3>Draft manifest preview</h3>
<button class="modal-close" @click="showPreview = false">x</button>
</div>
<div class="modal-body">
<pre class="preview">{{ previewText }}</pre>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showPreview = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import api from '@/api'
// Pure entry-form logic lives in entryForm.js (unit-tested by entryForm.spec.js);
// this component wires it to reactive state so the tests cover the shipped code.
import {
ENTRY_TYPES, REG_TYPES, DETECTION_METHODS, INUSE_BEHAVIORS,
blankEntry, buildEntryPayload as buildEntryPayloadPure,
describeEntry, availableEntryTypes as availableEntryTypesFor,
availableDetectionMethods as availableDetectionMethodsFor,
targetingGates as targetingGatesFor, targetingHint as targetingHintFor,
scopeSummary as scopeSummaryFor, detectionMethodHint as detectionMethodHintFor,
} from './entryForm'
const scopes = ref([])
const selectedId = ref(null)
const detail = ref(null)
const error = ref('')
const notice = ref('')
const shareroot = ref('')
// Phase-aware editing: the PREINSTALL runner implements only MSI/EXE types and
// Registry/File detection (other types/detections are silently skipped there),
// and only preinstall honors the preinstall flags. Restrict the form for a
// preinstall scope so an author cannot pick an option that would do nothing.
const isPreinstallScope = computed(() => detail.value?.phase === 'preinstall')
const availableEntryTypes = computed(() =>
availableEntryTypesFor(isPreinstallScope.value, entryForm.value?.Type))
const availableDetectionMethods = computed(() =>
availableDetectionMethodsFor(isPreinstallScope.value, entryForm.value?.DetectionMethod))
const detectionMethodHint = computed(() =>
detectionMethodHintFor(entryForm.value?.DetectionMethod))
const scopeSummary = computed(() => scopeSummaryFor(detail.value))
const showAllTargeting = ref(false)
const targetingGates = computed(() => targetingGatesFor(detail.value))
const targetingHint = computed(() => targetingHintFor(detail.value))
const showNewScope = ref(false)
const newScope = ref({ scopename: '', phase: 'runtime' })
const showEntry = ref(false)
const entryForm = ref({})
const applications = ref([])
const showVersions = ref(false)
const versions = ref([])
const showPreview = ref(false)
const previewText = ref('')
const simulateInputs = ref({ subtype: '', hostname: '', machinenumber: '', cmmversion: '' })
const simulateResult = ref(null)
const compliance = ref(null)
const complianceRows = computed(() => compliance.value?.rows || [])
const payloadFile = ref(null)
const payloadUploading = ref(false)
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
showVersions.value = false
simulateResult.value = null
compliance.value = null
try {
detail.value = payload(await api.get(`/geenforce/scopes/${id}`))
loadCompliance(id)
} catch (e) { error.value = 'Failed to load PC type' }
}
async function loadCompliance(id) {
try {
compliance.value = payload(await api.get(`/geenforce/scopes/${id}/compliance`))
} catch (e) { compliance.value = null }
}
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
try {
await api.delete(`/geenforce/scopes/${detail.value.scopeid}`)
detail.value = null
selectedId.value = null
await loadScopes()
} catch (e) { error.value = 'Delete failed' }
}
// -- entries --
function openNewEntry() {
entryForm.value = blankEntry()
payloadFile.value = null
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.inuseBehavior = entry.InUseCheck?.Behavior || ''
// Structured processes so ExePath + timeout survive an edit (not just Name).
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(process => ({
name: process.Name, exepath: process.ExePath || '',
timeout: process.GracefulCloseTimeoutSec ?? null }))
form.PreEnrollment = !!entry.PreEnrollment
form.KillAfterDetection = !!entry.KillAfterDetection
form.PCTypesStrict = !!entry.PCTypesStrict
entryForm.value = form
payloadFile.value = null
showEntry.value = true
}
function addProcess() {
if (!Array.isArray(entryForm.value.inuseProcesses)) entryForm.value.inuseProcesses = []
entryForm.value.inuseProcesses.push({ name: '', exepath: '', timeout: null })
}
function removeProcess(index) {
entryForm.value.inuseProcesses.splice(index, 1)
}
function buildEntryPayload() {
return buildEntryPayloadPure(entryForm.value)
}
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' }
}
function onPayloadFileChange(event) {
payloadFile.value = event.target.files?.[0] || null
}
async function uploadPayload() {
if (!entryForm.value.entryid || !payloadFile.value) return
payloadUploading.value = true
try {
const form = new FormData()
form.append('file', payloadFile.value)
const updated = payload(await api.post(
`/geenforce/entries/${entryForm.value.entryid}/payload`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } }))
entryForm.value.payloadsource = updated.payloadsource
entryForm.value.payloadref = updated.payloadref
entryForm.value.payloadsha256 = updated.payloadsha256
entryForm.value.haspayload = updated.haspayload
payloadFile.value = null
flash('Payload uploaded.')
await selectScope(detail.value.scopeid)
} catch (e) { error.value = e.response?.data?.message || 'Payload upload failed' }
finally { payloadUploading.value = false }
}
async function deleteEntry(entry) {
if (!confirm(`Delete entry "${entry.Name}"?`)) return
try {
await api.delete(`/geenforce/entries/${entry.entryid}`)
await selectScope(detail.value.scopeid)
await loadScopes()
} catch (e) { error.value = 'Delete entry failed' }
}
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(entry => entry.entryid)
;[order[index], order[target]] = [order[target], order[index]]
try {
await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order })
await selectScope(detail.value.scopeid)
} catch (e) { error.value = 'Reorder failed' }
}
// -- 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() {
try {
versions.value = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/versions`))
} catch (e) { error.value = 'Failed to load versions' }
}
async function rollback(versionnumber) {
if (!confirm(`Roll back to v${versionnumber}? PCs get this on their next cycle.`)) return
try {
await api.post(`/geenforce/scopes/${detail.value.scopeid}/rollback`, { versionnumber })
await loadVersions()
await loadScopes()
} catch (e) { error.value = 'Rollback failed' }
}
// -- simulate / preview --
async function runSimulate() {
try {
const params = { ...simulateInputs.value }
simulateResult.value = payload(await api.get(
`/geenforce/scopes/${detail.value.scopeid}/simulate`, { params }))
} catch (e) { error.value = 'Simulate failed' }
}
async function loadPreview() {
try {
const data = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/preview`))
previewText.value = JSON.stringify(data.manifest, null, 2)
showPreview.value = true
} catch (e) { error.value = 'Preview failed' }
}
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(' ') || '-'
}
// Plain-English one-liner: what this entry does, when it self-heals, who it
// applies to. Turns "eDNC | MSI | Registry | -" into intent an IT tech reads.
function formatDate(value) {
return value ? new Date(value).toLocaleString() : ''
}
async function loadApplications() {
try { applications.value = payload(await api.get('/geenforce/applications')) } catch (e) { /* optional */ }
}
loadScopes()
loadConfig()
loadApplications()
</script>
<style scoped>
.imaging-pctypes { max-width: 1200px; }
/* Two-pane master-detail; both children min-width:0 to kill overflow. */
.editor-grid {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
}
.editor-grid > * { min-width: 0; }
@media (max-width: 900px) {
.editor-grid { grid-template-columns: 1fr; }
.scope-panel { position: static; }
}
/* Section header carrying an action button/cluster. */
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
padding-bottom: 0.75rem;
}
.section-head .section-title {
border-bottom: none;
margin: 0;
padding: 0;
}
.detail-actions { flex-wrap: wrap; }
/* Scope list rail; active row tinted like the sidebar active link. */
.scope-panel { position: sticky; top: 1rem; }
.scope-list { display: flex; flex-direction: column; gap: 0.25rem; }
.scope-row {
padding: 0.5rem 0.6rem;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
}
.scope-row:hover { background: var(--bg); }
.scope-row.active { background: var(--primary); color: #fff; }
.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.25rem;
flex-wrap: wrap;
}
.scope-row.active .scope-meta { color: rgba(255, 255, 255, 0.85); }
.unpublished { color: var(--warning); }
.empty-detail {
color: var(--text-light);
text-align: center;
padding: 3rem 1.25rem;
}
.empty {
color: var(--text-light);
text-align: center;
padding: 1rem;
}
.muted { color: var(--text-light); }
/* Simulator result */
.simulate-result {
margin-top: 1rem;
font-size: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
/* Grid children must shrink so long paths never force page overflow. */
.settings-grid > .setting-row { min-width: 0; }
.settings-grid > .setting-row input,
.settings-grid > .setting-row select { width: 100%; box-sizing: border-box; }
/* Modals: wider entry/preview, still responsive (global max-width + width:100%). */
.modal-entry { max-width: min(1100px, 96vw); }
.toggle-all { font-weight: 400; font-size: 0.8rem; }
.entry-name { font-weight: 500; }
.entry-desc { font-size: 0.78rem; margin-top: 0.15rem; overflow-wrap: anywhere; }
.scope-summary { margin: 0 0 0.75rem 0; font-size: 0.85rem; }
/* Fixed layout so the Entry column flexes and the button columns never force
a horizontal scroll. */
.scope-detail .entries-table { width: 100%; table-layout: fixed; }
.entries-table th.col-order, .entries-table td.order-cell { width: 58px; }
.entries-table th.col-type { width: 64px; }
.entries-table th.col-actions, .entries-table td.row-actions { width: 128px; }
.order-cell { display: flex; flex-direction: column; gap: 0.2rem; }
.order-cell .btn { padding: 0.1rem 0.3rem; }
.row-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.help-panel { margin: 0 0 1rem 0; border: 1px solid var(--border);
border-radius: 6px; padding: 0.5rem 0.75rem; background: var(--bg-card); }
.help-panel summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; }
.help-body { margin-top: 0.5rem; }
.help-body p { margin: 0 0 0.5rem 0; font-size: 0.85rem; color: var(--text-light); }
.help-body code { font-size: 0.8rem; }
.modal-close {
background: transparent;
border: none;
color: var(--text-light);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.modal-close:hover { color: var(--text); }
/* Comment textarea matches the themed input look. */
.modal-body textarea.form-control {
background: var(--bg);
resize: vertical;
min-height: 2.5rem;
}
/* In-use process editor: repeatable rows, children can shrink. */
.process-editor { margin-bottom: 1rem; }
.process-head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: var(--text-light);
margin-bottom: 0.5rem;
}
.process-row {
display: grid;
grid-template-columns: 1fr 1.4fr 0.7fr auto;
gap: 0.4rem;
margin-bottom: 0.4rem;
align-items: center;
}
.process-row > * { min-width: 0; }
@media (max-width: 560px) {
.process-row { grid-template-columns: 1fr; }
}
/* Preinstall flags */
.preinstall-flags { margin-top: 0.75rem; }
.flags-label {
display: block;
font-size: 0.85rem;
color: var(--text-light);
margin-bottom: 0.5rem;
}
.flag-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-right: 1rem;
font-size: 0.9rem;
color: var(--text);
}
/* JSON preview */
.preview {
background: var(--bg);
padding: 0.75rem;
border-radius: 6px;
max-height: 60vh;
overflow: auto;
font-size: 0.8rem;
color: var(--text);
white-space: pre;
}
/* Reorder / disabled buttons */
.btn:disabled { opacity: 0.4; cursor: default; }
/* Inline payload upload */
.payload-current { font-size: 0.85rem; margin-bottom: 0.5rem; }
.payload-upload {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.payload-upload input[type="file"] { min-width: 0; font-size: 0.85rem; }
/* Compliance panel */
.compliance-note { margin-bottom: 0.75rem; }
</style>