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.
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" @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>
|