Files
shopdb-flask/plugins/geenforce/frontend/views/ManifestEditor.vue
cproudlock 523e3e4ecc geenforce: give the manifest entry rows room to breathe
Every column in the entries table was sized to its button text with nothing
spare, so with flex-wrap on the action cell the Edit and Delete buttons
wrapped out of their 128px column and sat on top of the entry description.
The order column was tighter still, and its Up/Down buttons carried almost
no padding, which made them hard to hit as well as hard to read.

Widened the order, type and action columns to fit their contents on one
line, stopped the action cell wrapping, gave the buttons real padding, and
added vertical cell padding with top alignment so a two-line entry no
longer runs into the row beneath it.
2026-08-12 15:47:36 -04:00

1075 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">
<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">
<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; line-height: 1.35; }
.entry-desc {
font-size: 0.78rem;
margin-top: 0.3rem;
line-height: 1.45;
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; }
/* Every column was sized to the button text with nothing to spare, so the
action buttons wrapped out of their cell and landed on the description. The
widths below fit Edit + Delete on one line, and nowrap keeps them there. */
.entries-table th.col-order, .entries-table td.order-cell { width: 84px; }
.entries-table th.col-type { width: 76px; }
.entries-table th.col-actions, .entries-table td.row-actions { width: 172px; }
.entries-table td {
/* A row is two lines of text plus stacked buttons; without vertical padding
and top alignment the rows run into each other. */
padding: 0.6rem 0.6rem;
vertical-align: top;
}
.order-cell { display: flex; flex-direction: column; gap: 0.3rem; }
.order-cell .btn {
padding: 0.25rem 0.5rem;
width: 100%;
}
.row-actions {
display: flex;
gap: 0.4rem;
flex-wrap: nowrap;
white-space: nowrap;
}
.row-actions .btn { padding: 0.25rem 0.6rem; }
.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>