Operators reported losing a part-filled form by clicking slightly outside it. Every data-entry modal closed on a backdrop click with no warning and no way back - the worst possible response to a misplaced click, and it happens most to someone adding their first records at a new site. Close-on-overlay is removed from 35 modals across 30 files: anything containing an input, textarea, select or v-model. They still close by Cancel or the X. Confirmation dialogs keep it, because a delete prompt holds nothing to lose and dismissing one by clicking away is the behaviour people expect. VendorsList shows the distinction - its edit form no longer closes that way, its delete confirmation still does. The shared Modal component now defaults closeOnOverlay to FALSE. Every current caller holds a form, a checkout, a stock adjustment or a map position being picked, and not one passed the prop, so all of them had the same fault. A modal that genuinely wants dismissing that way opts in explicitly. Also regroups the operator console menu, which had grown to numbers 1-9 plus three letters bolted on with no order to them. Actions are now grouped by what they touch, keyed by their first letter, and the old numbers still work so nobody who has used it for months is stopped by a rearrangement. The menu also warns when the server is not fully provisioned and names the key that fixes it, instead of reporting it as ordinary status lines that read as normal unless you already knew what to look for. That check is cached for the session because it shells out to flask twice and the answer does not change while somebody reads the screen.
1052 lines
42 KiB
Vue
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 & 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; }
|
|
.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>
|