Rebuild GE-Enforce editor: themed (Fable redesign) + semantics-aware targeting
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s

Replace the generic field-dump editor with the Fable-coordinated redesign
(stock .section-card / .settings-grid / .setting-row / .table-container /
badge / btn primitives, opaque global .modal on --bg-card-solid, proper
modal-header/body/footer), then layer the manifest semantics on top:

- CONTEXTUAL targeting: an entry only shows the gates its scope actually uses,
  instead of every gate on every entry. Data-driven + scope-aware -
  common/preinstall (fleet-wide) show PC types; CMM shows the version gate;
  a scope whose entries use machine numbers (collections) shows those; hostnames
  show when used. A short explainer states why (a per-type manifest already runs
  only on its own type), and "Show all targeting options" reveals everything.
  Hidden gates keep their value on save (no data loss).
- Preserves all functionality: type-switched payload blocks, detection reveal,
  structured InUseCheck rows (name/ExePath/timeout), LogFile, preinstall
  checkboxes, simulate, publish/versions/rollback, export. Entry modal widened
  to min(1100px,96vw).

EnforcementReports adopts the themed redesign (filters, card > table-container,
badge-mapped statuses, global modal) with the error handling kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 22:15:48 -04:00
parent 3ac41c1556
commit 378af32c1d
2 changed files with 692 additions and 395 deletions

View File

@@ -1,21 +1,36 @@
<template> <template>
<div class="enforcement-reports"> <div class="enforcement-reports">
<div class="page-header"><h1>Enforcement Reports</h1></div> <div class="page-header">
<div v-if="error" class="alert-error">{{ error }}</div> <h2>Enforcement Reports</h2>
<p class="intro"> </div>
<p class="setting-description">
Latest GE-Enforce result reported by each PC. "Received" means the PC picked Latest GE-Enforce result reported by each PC. "Received" means the PC picked
up the current published manifest; status shows self-heal and failures. up the current published manifest; status shows self-heal and failures.
</p> </p>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="filters"> <div class="filters">
<input v-model="filterHost" placeholder="Filter hostname" @keyup.enter="load" /> <input
<input v-model="filterScope" placeholder="Filter PC type" @keyup.enter="load" /> type="text"
<button class="btn" @click="load">Filter</button> class="form-control"
<button class="btn" @click="clearFilters">Clear</button> v-model="filterHost"
placeholder="Filter hostname"
@keyup.enter="load"
/>
<input
type="text"
class="form-control"
v-model="filterScope"
placeholder="Filter PC type"
@keyup.enter="load"
/>
<button class="btn btn-primary" @click="load">Filter</button>
<button class="btn btn-secondary" @click="clearFilters">Clear</button>
</div> </div>
<div class="card"> <div class="card">
<div class="table-scroll"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -25,21 +40,23 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="r in reports" :key="r.reportid"> <tr v-for="report in reports" :key="report.reportid">
<td>{{ r.hostname }}</td> <td>{{ report.hostname }}</td>
<td>{{ r.scopename }}</td> <td>{{ report.scopename }}</td>
<td> <td>
<span class="badge" :class="r.receivedlatest ? 'badge-success' : 'badge-warning'"> <span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
{{ r.receivedlatest ? 'yes' : 'behind' }} {{ report.receivedlatest ? 'yes' : 'behind' }}
</span> </span>
</td> </td>
<td class="dim">{{ r.appliedversion ?? '-' }} / {{ r.latestversion ?? '-' }}</td> <td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td>
<td><span class="badge" :class="statusClass(r.status)">{{ r.status }}</span></td> <td><span class="badge" :class="statusClass(report.status)">{{ report.status }}</span></td>
<td>{{ r.installed }}</td> <td>{{ report.installed }}</td>
<td class="dim">{{ r.skipped }}</td> <td class="muted">{{ report.skipped }}</td>
<td :class="{ 'fail-count': r.failed }">{{ r.failed }}</td> <td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
<td class="dim">{{ formatDate(r.lastcheckin || r.receivedat) }}</td> <td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
<td><button class="btn btn-small" @click="openDetail(r.reportid)">Detail</button></td> <td class="actions">
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
</td>
</tr> </tr>
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr> <tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
</tbody> </tbody>
@@ -49,24 +66,37 @@
<!-- Detail modal --> <!-- Detail modal -->
<div v-if="detail" class="modal-overlay" @click.self="detail = null"> <div v-if="detail" class="modal-overlay" @click.self="detail = null">
<div class="modal modal-wide"> <div class="modal modal-report">
<h2>{{ detail.hostname }} - {{ detail.scopename }}</h2> <div class="modal-header">
<p class="dim">Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }}; <h3>{{ detail.hostname }} - {{ detail.scopename }}</h3>
status {{ detail.status }}</p> <button class="modal-close" @click="detail = null">x</button>
</div>
<div class="modal-body">
<p class="setting-description">
Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
status {{ detail.status }}
</p>
<div class="table-container">
<table> <table>
<thead><tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr></thead> <thead>
<tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr>
</thead>
<tbody> <tbody>
<tr v-for="(result, i) in detail.results" :key="i"> <tr v-for="(result, index) in detail.results" :key="index">
<td>{{ result.entryname }}</td> <td>{{ result.entryname }}</td>
<td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td> <td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
<td>{{ result.selfhealed ? 'yes' : '' }}</td> <td>{{ result.selfhealed ? 'yes' : '' }}</td>
<td class="dim">{{ result.exitcode ?? '' }}</td> <td class="muted">{{ result.exitcode ?? '' }}</td>
<td class="dim">{{ result.message }}</td> <td class="muted">{{ result.message }}</td>
</tr> </tr>
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr> <tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
</tbody> </tbody>
</table> </table>
<div class="modal-actions"><button class="btn" @click="detail = null">Close</button></div> </div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="detail = null">Close</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -114,30 +144,18 @@ load()
<style scoped> <style scoped>
.enforcement-reports { max-width: 1100px; } .enforcement-reports { max-width: 1100px; }
.enforcement-reports input, .enforcement-reports select { .muted { color: var(--text-light); }
padding: 0.45rem 0.55rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font: inherit;
box-sizing: border-box;
}
.enforcement-reports input:focus { outline: none; border-color: var(--primary); }
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
border-radius: 4px; margin-bottom: 1rem; }
.table-scroll { overflow-x: auto; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.dim { color: var(--text-light); }
.fail-count { color: var(--danger); font-weight: 600; } .fail-count { color: var(--danger); font-weight: 600; }
.btn-small { padding: 0.15rem 0.5rem; font-size: 0.78rem; }
.empty { color: var(--text-light); text-align: center; padding: 1rem; } .empty { color: var(--text-light); text-align: center; padding: 1rem; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; .modal-report { max-width: 640px; }
align-items: center; justify-content: center; z-index: 100; } .modal-close {
.modal { background: var(--bg-card-solid); padding: 1.5rem; border-radius: 8px; background: transparent;
border: 1px solid var(--border); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); border: none;
min-width: 340px; max-height: 90vh; overflow-y: auto; } color: var(--text-light);
.modal-wide { width: min(820px, 94vw); } font-size: 1.1rem;
.modal-actions { display: flex; justify-content: flex-end; margin-top: 0.75rem; } line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.modal-close:hover { color: var(--text); }
</style> </style>

View File

@@ -1,32 +1,47 @@
<template> <template>
<div class="imaging-pctypes"> <div class="imaging-pctypes">
<div class="page-header"> <div class="page-header">
<h1>Imaging PC Types</h1> <h2>Imaging PC Types</h2>
<button class="btn btn-primary" @click="openNewScope">+ New PC Type</button>
</div> </div>
<p class="intro"> <p class="setting-description">
Each imaging PC type owns a GE-Enforce install manifest. Edit the draft, 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. then Publish to ship it to PCs. Nothing reaches a PC until you publish.
</p> </p>
<div v-if="error" class="alert-error">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<div v-if="notice" class="alert-ok">{{ notice }}</div> <div v-if="notice" class="settings-success">{{ notice }}</div>
<div class="config-row card"> <!-- Export configuration -->
<label class="grow">On-share export root <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" /> <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> </label>
<button class="btn" @click="saveConfig">Save share root</button> </div>
<span class="config-hint">Used by Export to Share. During Milestone 1 the <button class="btn btn-primary" @click="saveConfig">Save share root</button>
engine still reads these files; export is your push to the fleet.</span>
</div> </div>
<div class="editor-grid"> <div class="editor-grid">
<!-- Scope list --> <!-- Scope list -->
<aside class="scope-list card"> <aside class="section-card scope-panel">
<div v-for="scope in scopes" :key="scope.scopeid" <div class="section-head">
class="scope-row" :class="{ active: scope.scopeid === selectedId }" <h3 class="section-title">PC Types</h3>
@click="selectScope(scope.scopeid)"> <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-name">{{ scope.scopename }}</div>
<div class="scope-meta"> <div class="scope-meta">
<span class="badge" :class="scope.phase === 'preinstall' ? 'badge-warning' : 'badge-info'"> <span class="badge" :class="scope.phase === 'preinstall' ? 'badge-warning' : 'badge-info'">
@@ -38,124 +53,183 @@
</div> </div>
</div> </div>
<p v-if="!scopes.length" class="empty">No PC types yet.</p> <p v-if="!scopes.length" class="empty">No PC types yet.</p>
</div>
</aside> </aside>
<!-- Selected scope --> <!-- Selected scope -->
<section v-if="detail" class="scope-detail card"> <div v-if="detail" class="scope-detail">
<div class="detail-header"> <!-- Identity and mapping -->
<h2>{{ detail.scopename }}</h2> <div class="section-card">
<div class="detail-actions"> <div class="section-head">
<button class="btn btn-primary" @click="publish">Publish</button> <h3 class="section-title">Identity &amp; Mapping</h3>
<button class="btn" @click="exportShare">Export to Share</button> <div class="header-actions detail-actions">
<button class="btn" @click="toggleVersions">Versions</button> <button class="btn btn-sm btn-primary" @click="publish">Publish</button>
<button class="btn" @click="loadPreview">Preview</button> <button class="btn btn-sm btn-secondary" @click="exportShare">Export to Share</button>
<button class="btn btn-danger" @click="deleteScope">Delete</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>
</div> </div>
<div class="settings-grid">
<!-- Mapping --> <div class="setting-row">
<div class="mapping-row"> <label>
<label>Computer Type ID <span>Computer Type ID</span>
<input type="number" v-model.number="detail.computertypeid" /> <input type="number" v-model.number="detail.computertypeid" />
</label> </label>
<label>Measuring Tool Type ID </div>
<div class="setting-row">
<label>
<span>Measuring Tool Type ID</span>
<input type="number" v-model.number="detail.measuringtooltypeid" /> <input type="number" v-model.number="detail.measuringtooltypeid" />
</label> </label>
<label class="grow">Description </div>
<div class="setting-row">
<label>
<span>Description</span>
<input type="text" v-model="detail.description" /> <input type="text" v-model="detail.description" />
</label> </label>
<button class="btn" @click="saveScope">Save</button> </div>
</div>
<button class="btn btn-primary" @click="saveScope">Save</button>
</div> </div>
<!-- Versions panel --> <!-- Versions panel -->
<div v-if="showVersions" class="versions-panel"> <div v-if="showVersions" class="section-card">
<h3>Published versions</h3> <h3 class="section-title">Published versions</h3>
<div class="table-container">
<table> <table>
<thead><tr><th>Version</th><th>Published</th><th>Notes</th><th></th></tr></thead> <thead>
<tr><th>Version</th><th>Published</th><th>Notes</th><th></th></tr>
</thead>
<tbody> <tbody>
<tr v-for="v in versions" :key="v.versionnumber"> <tr v-for="version in versions" :key="version.versionnumber">
<td>v{{ v.versionnumber }} <span v-if="v.iscurrent" class="badge badge-success">current</span></td> <td>
<td>{{ formatDate(v.publishedat) }}</td> v{{ version.versionnumber }}
<td>{{ v.notes }}</td> <span v-if="version.iscurrent" class="badge badge-success">current</span>
<td><button v-if="!v.iscurrent" class="btn btn-small" </td>
@click="rollback(v.versionnumber)">Roll back</button></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>
<tr v-if="!versions.length"><td colspan="4" class="empty">No published versions.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Entries --> <!-- Entries -->
<div class="entries-header"> <div class="section-card">
<h3>Manifest entries ({{ detail.entries.length }})</h3> <div class="section-head">
<button class="btn btn-primary btn-small" @click="openNewEntry">+ Add entry</button> <h3 class="section-title">Manifest entries ({{ detail.entries.length }})</h3>
<button class="btn btn-sm btn-primary" @click="openNewEntry">+ Add entry</button>
</div> </div>
<p class="order-hint">Order is execution order. Put config-restore entries after their installer.</p> <p class="setting-description">
<div class="table-scroll"> Order is execution order. Put config-restore entries after their installer.
<table class="entries-table"> </p>
<div class="table-container">
<table>
<thead> <thead>
<tr><th>#</th><th>Name</th><th>Type</th><th>Detection</th><th>Filters</th><th></th></tr> <tr><th>Order</th><th>Name</th><th>Type</th><th>Detection</th><th>Filters</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(entry, index) in detail.entries" :key="entry.entryid"> <tr v-for="(entry, index) in detail.entries" :key="entry.entryid">
<td class="order-cell"> <td class="actions">
<button class="btn-move" :disabled="index === 0" @click="moveEntry(index, -1)">&uarr;</button> <button
<button class="btn-move" :disabled="index === detail.entries.length - 1" @click="moveEntry(index, 1)">&darr;</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>
<td>{{ entry.Name }}</td> <td>{{ entry.Name }}</td>
<td><span class="badge badge-info">{{ entry.Type }}</span></td> <td><span class="badge badge-info">{{ entry.Type }}</span></td>
<td class="dim">{{ entry.DetectionMethod || 'always' }}</td> <td class="muted">{{ entry.DetectionMethod || 'always' }}</td>
<td class="dim">{{ filterSummary(entry) }}</td> <td class="muted">{{ filterSummary(entry) }}</td>
<td class="row-actions"> <td class="actions">
<button class="btn btn-small" @click="openEditEntry(entry)">Edit</button> <button class="btn btn-sm btn-secondary" @click="openEditEntry(entry)">Edit</button>
<button class="btn btn-small btn-danger" @click="deleteEntry(entry)">Delete</button> <button class="btn btn-sm btn-danger" @click="deleteEntry(entry)">Delete</button>
</td> </td>
</tr> </tr>
<tr v-if="!detail.entries.length"><td colspan="6" class="empty">No entries.</td></tr> <tr v-if="!detail.entries.length"><td colspan="6" class="empty">No entries.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Simulator --> <!-- Simulator -->
<div class="simulator"> <div class="section-card">
<h3>Simulate: what would a PC get?</h3> <h3 class="section-title">Simulate: what would a PC get?</h3>
<div class="sim-inputs"> <div class="settings-grid">
<input v-model="sim.subtype" placeholder="subtype" /> <div class="setting-row">
<input v-model="sim.hostname" placeholder="hostname" /> <label><span>Subtype</span><input v-model="sim.subtype" placeholder="subtype" /></label>
<input v-model="sim.machinenumber" placeholder="machine #" />
<input v-model="sim.cmmversion" placeholder="CMM version" />
<button class="btn" @click="runSimulate">Run</button>
</div> </div>
<div class="setting-row">
<label><span>Hostname</span><input v-model="sim.hostname" placeholder="hostname" /></label>
</div>
<div class="setting-row">
<label><span>Machine #</span><input v-model="sim.machinenumber" placeholder="machine #" /></label>
</div>
<div class="setting-row">
<label><span>CMM version</span><input v-model="sim.cmmversion" placeholder="CMM version" /></label>
</div>
</div>
<button class="btn btn-primary" @click="runSimulate">Run</button>
<div v-if="simResult" class="sim-result"> <div v-if="simResult" class="sim-result">
<div><strong>Applies ({{ simResult.applied.length }}):</strong> <div>
{{ simResult.applied.join(', ') || 'none' }}</div> <strong>Applies ({{ simResult.applied.length }}):</strong>
<div class="dim"><strong>Filtered:</strong> {{ simResult.applied.join(', ') || 'none' }}
<span v-for="f in simResult.filtered" :key="f.name"> </div>
{{ f.name }} ({{ f.filteredby.join(',') }}); <div class="muted">
<strong>Filtered:</strong>
<span v-for="filtered in simResult.filtered" :key="filtered.name">
{{ filtered.name }} ({{ filtered.filteredby.join(',') }});
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</section> </div>
<section v-else class="scope-detail card empty-detail">
<div v-else class="section-card empty-detail">
Select a PC type, or create one. Select a PC type, or create one.
</section> </div>
</div> </div>
<!-- New scope modal --> <!-- New scope modal -->
<div v-if="showNewScope" class="modal-overlay" @click.self="showNewScope = false"> <div v-if="showNewScope" class="modal-overlay" @click.self="showNewScope = false">
<div class="modal"> <div class="modal">
<h2>New imaging PC type</h2> <div class="modal-header">
<label>Scope name (e.g. gea-shopfloor-cmm) <h3>New imaging PC type</h3>
<input v-model="newScope.scopename" /> <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> </label>
<label>Phase </div>
<div class="setting-row">
<label>
<span>Phase</span>
<select v-model="newScope.phase"> <select v-model="newScope.phase">
<option value="runtime">runtime</option> <option value="runtime">runtime</option>
<option value="preinstall">preinstall</option> <option value="preinstall">preinstall</option>
</select> </select>
</label> </label>
<div class="modal-actions"> </div>
<button class="btn" @click="showNewScope = false">Cancel</button> </div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showNewScope = false">Cancel</button>
<button class="btn btn-primary" @click="createScope">Create</button> <button class="btn btn-primary" @click="createScope">Create</button>
</div> </div>
</div> </div>
@@ -163,97 +237,201 @@
<!-- Entry modal --> <!-- Entry modal -->
<div v-if="showEntry" class="modal-overlay" @click.self="showEntry = false"> <div v-if="showEntry" class="modal-overlay" @click.self="showEntry = false">
<div class="modal modal-wide"> <div class="modal modal-entry">
<h2>{{ entryForm.entryid ? 'Edit entry' : 'New entry' }}</h2> <div class="modal-header">
<div class="form-grid"> <h3>{{ entryForm.entryid ? 'Edit entry' : 'New entry' }}</h3>
<label>Name<input v-model="entryForm.Name" /></label> <button class="modal-close" @click="showEntry = false">x</button>
<label>Type </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"> <select v-model="entryForm.Type">
<option v-for="t in ENTRY_TYPES" :key="t" :value="t">{{ t }}</option> <option v-for="entryType in ENTRY_TYPES" :key="entryType" :value="entryType">{{ entryType }}</option>
</select> </select>
</label> </label>
</div>
</div>
<!-- payload fields per type --> <!-- payload fields per type -->
<template v-if="['MSI','EXE','CMD','BAT','INF'].includes(entryForm.Type)"> <div v-if="['MSI','EXE','CMD','BAT','INF'].includes(entryForm.Type)" class="settings-grid">
<label>Installer (relative path)<input v-model="entryForm.Installer" /></label> <div class="setting-row">
<label>Install args<input v-model="entryForm.InstallArgs" /></label> <label><span>Installer (relative path)</span><input v-model="entryForm.Installer" /></label>
</template> </div>
<template v-if="entryForm.Type === 'PS1'"> <div class="setting-row">
<label>Script (relative path)<input v-model="entryForm.Script" /></label> <label><span>Install args</span><input v-model="entryForm.InstallArgs" /></label>
<label>Args<input v-model="entryForm.Args" /></label> </div>
</template> </div>
<template v-if="entryForm.Type === 'File'"> <div v-if="entryForm.Type === 'PS1'" class="settings-grid">
<label>Source (relative)<input v-model="entryForm.Source" /></label> <div class="setting-row">
<label>Destination (absolute)<input v-model="entryForm.Destination" /></label> <label><span>Script (relative path)</span><input v-model="entryForm.Script" /></label>
</template> </div>
<template v-if="entryForm.Type === 'Registry'"> <div class="setting-row">
<label>Reg path<input v-model="entryForm.RegPath" /></label> <label><span>Args</span><input v-model="entryForm.Args" /></label>
<label>Reg name<input v-model="entryForm.RegName" /></label> </div>
<label>Reg value<input v-model="entryForm.RegValue" /></label> </div>
<label>Reg type <div v-if="entryForm.Type === 'File'" class="settings-grid">
<div class="setting-row">
<label><span>Source (relative)</span><input v-model="entryForm.Source" /></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"> <select v-model="entryForm.RegType">
<option v-for="t in REG_TYPES" :key="t" :value="t">{{ t }}</option> <option v-for="regType in REG_TYPES" :key="regType" :value="regType">{{ regType }}</option>
</select> </select>
</label> </label>
</template> </div>
</div>
<!-- detection --> <!-- detection -->
<label>Detection method <div class="setting-row">
<label>
<span>Detection method</span>
<select v-model="entryForm.DetectionMethod"> <select v-model="entryForm.DetectionMethod">
<option value="">(none: always installs)</option> <option value="">(none: always installs)</option>
<option v-for="m in DETECTION_METHODS" :key="m" :value="m">{{ m }}</option> <option v-for="method in DETECTION_METHODS" :key="method" :value="method">{{ method }}</option>
</select> </select>
</label> </label>
<label>Detection path<input v-model="entryForm.DetectionPath" /></label> </div>
<label>Detection name<input v-model="entryForm.DetectionName" /></label> <div v-if="entryForm.DetectionMethod" class="settings-grid">
<label>Detection value<input v-model="entryForm.DetectionValue" /></label> <div class="setting-row">
<label v-if="entryForm.DetectionMethod === 'pnputil'">Detection pattern <label><span>Detection path</span><input v-model="entryForm.DetectionPath" /></label>
<input v-model="entryForm.DetectionPattern" /></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" /></label>
</div>
<div v-if="entryForm.DetectionMethod === 'pnputil'" class="setting-row">
<label><span>Detection pattern</span><input v-model="entryForm.DetectionPattern" /></label>
</div>
</div>
<!-- filters --> <!-- targeting: only the gates this scope actually uses, by default -->
<label>PC types (comma)<input v-model="entryForm.PCTypes" placeholder="* or gea-shopfloor-cmm,..." /></label> <div class="setting-group">
<label>Target hostnames (comma)<input v-model="entryForm.TargetHostnames" /></label> <div class="section-head">
<label>Target machine numbers (comma)<input v-model="entryForm.TargetMachineNumbers" /></label> <h3>Targeting</h3>
<label v-if="isCmmScope">CMM version gate<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label> <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>
<label>Wait timeout (sec)<input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label> <div class="settings-grid">
<label>Log file<input v-model="entryForm.LogFile" /></label> <div class="setting-row">
<label class="full">Comment<textarea v-model="entryForm._comment" rows="2" /></label> <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>
<details class="full advanced" open> <div class="setting-row full-width">
<summary>In-use handling + preinstall flags</summary> <label>
<label>Close running app before install (InUseCheck) <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"> <select v-model="entryForm.inuseBehavior">
<option value="">(none)</option> <option value="">(none)</option>
<option v-for="b in INUSE_BEHAVIORS" :key="b" :value="b">{{ b }}</option> <option v-for="behavior in INUSE_BEHAVIORS" :key="behavior" :value="behavior">{{ behavior }}</option>
</select> </select>
</label> </label>
<div class="full" v-if="entryForm.inuseBehavior">
<div class="proc-head">Processes to close
<button type="button" class="btn btn-small" @click="addProcess">+ process</button>
</div> </div>
<div v-for="(proc, i) in entryForm.inuseProcesses" :key="i" class="proc-row"> <div v-if="entryForm.inuseBehavior" class="process-editor">
<input v-model="proc.name" placeholder="Name (no .exe)" /> <div class="process-head">
<input v-model="proc.exepath" placeholder="ExePath (optional)" /> <span>Processes to close</span>
<input type="number" v-model.number="proc.timeout" placeholder="timeout s" /> <button type="button" class="btn btn-sm btn-secondary" @click="addProcess">+ process</button>
<button type="button" class="btn-move" @click="removeProcess(i)">x</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> </div>
<div class="full preinstall-flags"> <div class="preinstall-flags">
<span class="flags-label">Preinstall flags (preinstall phase only):</span> <span class="flags-label">Preinstall flags (preinstall phase only):</span>
<label class="inline"><input type="checkbox" v-model="entryForm.PreEnrollment" /> PreEnrollment</label> <label class="flag-label"><input type="checkbox" v-model="entryForm.PreEnrollment" /> PreEnrollment</label>
<label class="inline"><input type="checkbox" v-model="entryForm.KillAfterDetection" /> KillAfterDetection</label> <label class="flag-label"><input type="checkbox" v-model="entryForm.KillAfterDetection" /> KillAfterDetection</label>
<label class="inline"><input type="checkbox" v-model="entryForm.PCTypesStrict" /> PCTypesStrict</label> <label class="flag-label"><input type="checkbox" v-model="entryForm.PCTypesStrict" /> PCTypesStrict</label>
</div> </div>
</details>
<details class="full advanced">
<summary>Apply mode / update window (not yet enforced by the engine)</summary>
<label>Apply mode<input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
<label>Update window<input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
</details>
</div> </div>
<div class="modal-actions">
<button class="btn" @click="showEntry = false">Cancel</button> <!-- 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> <button class="btn btn-primary" @click="saveEntry">Save</button>
</div> </div>
</div> </div>
@@ -261,11 +439,16 @@
<!-- Preview modal --> <!-- Preview modal -->
<div v-if="showPreview" class="modal-overlay" @click.self="showPreview = false"> <div v-if="showPreview" class="modal-overlay" @click.self="showPreview = false">
<div class="modal modal-wide"> <div class="modal modal-entry">
<h2>Draft manifest preview</h2> <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> <pre class="preview">{{ previewText }}</pre>
<div class="modal-actions"> </div>
<button class="btn" @click="showPreview = false">Close</button> <div class="modal-footer">
<button class="btn btn-secondary" @click="showPreview = false">Close</button>
</div> </div>
</div> </div>
</div> </div>
@@ -285,13 +468,44 @@ const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForRe
const scopes = ref([]) const scopes = ref([])
const selectedId = ref(null) const selectedId = ref(null)
const detail = ref(null) const detail = ref(null)
// The CMM version gate (_CmmVersion) only applies to CMM scopes; hide it
// elsewhere so a printer/common entry form is not cluttered with an irrelevant field.
const isCmmScope = computed(() => /cmm/i.test(detail.value?.scopename || ''))
const error = ref('') const error = ref('')
const notice = ref('') const notice = ref('')
const shareRoot = ref('') const shareRoot = ref('')
// Show only the targeting gates a scope actually uses (a per-type manifest
// already runs on its own type, so PCTypes is noise there; CMM shows a version
// gate; collections shows machine numbers; the common/preinstall manifests use
// PCTypes to target subsets). "Show all" reveals every gate.
const showAllTargeting = ref(false)
const targetingGates = computed(() => {
const scope = detail.value
if (!scope) {
return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false }
}
const name = (scope.scopename || '').toLowerCase()
const entries = scope.entries || []
const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common'
return {
pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length),
cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion),
// Data-driven (a loose name match would wrongly flag 'nocollections').
machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length),
hostnames: entries.some(e => (e.TargetHostnames || []).length),
}
})
const targetingHint = computed(() => {
const scope = detail.value
if (!scope) return ''
if (scope.iscommon || scope.scopename === 'common') {
return 'Fleet-wide manifest: use PC types to target which types get this entry.'
}
if (scope.phase === 'preinstall') {
return 'Preinstall manifest: use PC types to target; preinstall flags apply here.'
}
return `This manifest already runs only on ${scope.scopename} PCs. `
+ 'Targeting below narrows within that (version, bay, or subtype).'
})
const showNewScope = ref(false) const showNewScope = ref(false)
const newScope = ref({ scopename: '', phase: 'runtime' }) const newScope = ref({ scopename: '', phase: 'runtime' })
@@ -392,9 +606,9 @@ function openEditEntry(entry) {
form.DetectionMethod = entry.DetectionMethod || '' form.DetectionMethod = entry.DetectionMethod || ''
form.inuseBehavior = entry.InUseCheck?.Behavior || '' form.inuseBehavior = entry.InUseCheck?.Behavior || ''
// Structured processes so ExePath + timeout survive an edit (not just Name). // Structured processes so ExePath + timeout survive an edit (not just Name).
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(p => ({ form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(process => ({
name: p.Name, exepath: p.ExePath || '', name: process.Name, exepath: process.ExePath || '',
timeout: p.GracefulCloseTimeoutSec ?? null })) timeout: process.GracefulCloseTimeoutSec ?? null }))
form.PreEnrollment = !!entry.PreEnrollment form.PreEnrollment = !!entry.PreEnrollment
form.KillAfterDetection = !!entry.KillAfterDetection form.KillAfterDetection = !!entry.KillAfterDetection
form.PCTypesStrict = !!entry.PCTypesStrict form.PCTypesStrict = !!entry.PCTypesStrict
@@ -409,42 +623,42 @@ function removeProcess(index) {
entryForm.value.inuseProcesses.splice(index, 1) entryForm.value.inuseProcesses.splice(index, 1)
} }
function splitList(value) { function splitList(value) {
return (value || '').split(',').map(s => s.trim()).filter(Boolean) return (value || '').split(',').map(item => item.trim()).filter(Boolean)
} }
function buildEntryPayload() { function buildEntryPayload() {
const f = entryForm.value const form = entryForm.value
const out = { Name: f.Name, Type: f.Type } const out = { Name: form.Name, Type: form.Type }
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source', const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath', 'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion', 'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment'] 'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
for (const key of scalars) { for (const key of scalars) {
if (f[key] !== undefined && f[key] !== '' && f[key] !== null) out[key] = f[key] if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key]
} }
if (f.DetectionMethod) out.DetectionMethod = f.DetectionMethod if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
if (f.WaitTimeoutSec) out.WaitTimeoutSec = f.WaitTimeoutSec if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
if (f.Type === 'Registry' && f.RegValue !== undefined && f.RegValue !== '') { if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') {
out.RegValue = ['DWord', 'QWord'].includes(f.RegType) ? Number(f.RegValue) : f.RegValue out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue
} }
const pctypes = splitList(f.PCTypes) const pctypes = splitList(form.PCTypes)
if (pctypes.length) out.PCTypes = pctypes if (pctypes.length) out.PCTypes = pctypes
const hostnames = splitList(f.TargetHostnames) const hostnames = splitList(form.TargetHostnames)
if (hostnames.length) out.TargetHostnames = hostnames if (hostnames.length) out.TargetHostnames = hostnames
const machinenumbers = splitList(f.TargetMachineNumbers) const machinenumbers = splitList(form.TargetMachineNumbers)
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) { for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
if (f[flag]) out[flag] = true if (form[flag]) out[flag] = true
} }
if (f.inuseBehavior) { if (form.inuseBehavior) {
out.InUseCheck = { out.InUseCheck = {
Behavior: f.inuseBehavior, Behavior: form.inuseBehavior,
Processes: (f.inuseProcesses || []).filter(p => p.name).map(p => { Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
const pd = { Name: p.name } const processData = { Name: process.name }
if (p.exepath) pd.ExePath = p.exepath if (process.exepath) processData.ExePath = process.exepath
if (p.timeout !== null && p.timeout !== '' && p.timeout !== undefined) { if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
pd.GracefulCloseTimeoutSec = Number(p.timeout) processData.GracefulCloseTimeoutSec = Number(process.timeout)
} }
return pd return processData
}), }),
} }
} }
@@ -475,7 +689,7 @@ async function moveEntry(index, direction) {
const entries = detail.value.entries const entries = detail.value.entries
const target = index + direction const target = index + direction
if (target < 0 || target >= entries.length) return if (target < 0 || target >= entries.length) return
const order = entries.map(e => e.entryid) const order = entries.map(entry => entry.entryid)
;[order[index], order[target]] = [order[target], order[index]] ;[order[index], order[target]] = [order[target], order[index]]
try { try {
await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order }) await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order })
@@ -545,95 +759,160 @@ loadConfig()
<style scoped> <style scoped>
.imaging-pctypes { max-width: 1200px; } .imaging-pctypes { max-width: 1200px; }
/* Match the standard settings input look (.setting-row input in style.css). */
.imaging-pctypes input:not([type="checkbox"]):not([type="radio"]), /* Two-pane master-detail; both children min-width:0 to kill overflow. */
.imaging-pctypes select, .editor-grid {
.imaging-pctypes textarea { display: grid;
padding: 0.45rem 0.55rem; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
border: 1px solid var(--border); gap: 1.25rem;
border-radius: 4px; align-items: start;
background: var(--bg); }
color: var(--text); .editor-grid > * { min-width: 0; }
font: inherit; @media (max-width: 900px) {
box-sizing: border-box; .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;
} }
.imaging-pctypes input:focus,
.imaging-pctypes select:focus,
.imaging-pctypes textarea:focus { outline: none; border-color: var(--primary); }
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
border-radius: 4px; margin-bottom: 1rem; }
.alert-ok { background: var(--success); color: white; padding: 0.5rem 0.75rem;
border-radius: 4px; margin-bottom: 1rem; }
.config-row { display: flex; gap: 0.6rem; align-items: flex-end; padding: 0.6rem 0.75rem;
margin-bottom: 1rem; flex-wrap: wrap; }
.config-row label { font-size: 0.75rem; color: var(--text-light); display: flex;
flex-direction: column; gap: 0.2rem; }
.config-row .grow { flex: 1; min-width: 220px; }
.config-hint { font-size: 0.72rem; color: var(--text-light); flex-basis: 100%; }
.editor-grid { display: grid; grid-template-columns: 260px minmax(0, 1fr);
gap: 1rem; align-items: start; }
@media (max-width: 1000px) { .editor-grid { grid-template-columns: 1fr; } }
.scope-list { padding: 0.5rem; min-width: 0; }
.scope-detail { min-width: 0; }
.table-scroll { overflow-x: auto; }
.scope-row { padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; }
.scope-row:hover { background: var(--bg); } .scope-row:hover { background: var(--bg); }
.scope-row.active { background: var(--primary); color: white; } .scope-row.active { background: var(--primary); color: #fff; }
.scope-name { font-weight: 600; font-size: 0.9rem; } .scope-name { font-weight: 600; font-size: 0.9rem; }
.scope-meta { display: flex; gap: 0.4rem; font-size: 0.72rem; align-items: center; .scope-meta {
color: var(--text-light); margin-top: 0.15rem; } display: flex;
.scope-row.active .scope-meta { color: rgba(255,255,255,0.85); } 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); } .unpublished { color: var(--warning); }
.scope-detail { padding: 1rem; }
.empty-detail { color: var(--text-light); text-align: center; padding: 3rem; } .empty-detail {
.detail-header { display: flex; justify-content: space-between; align-items: center; } color: var(--text-light);
.detail-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; } text-align: center;
.mapping-row { display: flex; gap: 0.6rem; align-items: flex-end; margin: 0.75rem 0; padding: 3rem 1.25rem;
flex-wrap: wrap; } }
.mapping-row label { font-size: 0.75rem; color: var(--text-light); display: flex; .empty {
flex-direction: column; gap: 0.2rem; } color: var(--text-light);
.mapping-row .grow { flex: 1; min-width: 160px; } text-align: center;
.versions-panel { background: var(--bg); padding: 0.75rem; border-radius: 6px; margin: 0.5rem 0; } padding: 1rem;
.entries-header { display: flex; justify-content: space-between; align-items: center; }
margin-top: 1rem; } .muted { color: var(--text-light); }
.order-hint { font-size: 0.75rem; color: var(--text-light); margin: 0.2rem 0 0.5rem; }
.entries-table { width: 100%; } /* Simulator result */
.order-cell { white-space: nowrap; } .sim-result {
.btn-move { border: 1px solid var(--border); background: var(--bg-card); cursor: pointer; margin-top: 1rem;
border-radius: 4px; padding: 0 0.35rem; } font-size: 0.9rem;
.btn-move:disabled { opacity: 0.3; cursor: default; } display: flex;
.dim { color: var(--text-light); font-size: 0.82rem; } flex-direction: column;
.row-actions { display: flex; gap: 0.3rem; } gap: 0.35rem;
.btn-small { padding: 0.15rem 0.5rem; font-size: 0.78rem; } }
.simulator { margin-top: 1.25rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.sim-inputs { display: flex; gap: 0.4rem; flex-wrap: wrap; } /* Grid children must shrink so long paths never force page overflow. */
.sim-inputs input { max-width: 130px; } .settings-grid > .setting-row { min-width: 0; }
.sim-result { margin-top: 0.5rem; font-size: 0.85rem; } .settings-grid > .setting-row input,
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; .settings-grid > .setting-row select { width: 100%; box-sizing: border-box; }
align-items: center; justify-content: center; z-index: 100; }
/* Opaque surface (--bg-card is translucent in dark mode - would show the page /* Modals: wider entry/preview, still responsive (global max-width + width:100%). */
through a floating modal); matches the shared Modal.vue. */ .modal-entry { max-width: min(1100px, 96vw); }
.modal { background: var(--bg-card-solid); padding: 1.5rem; border-radius: 8px; .toggle-all { font-weight: 400; font-size: 0.8rem; }
border: 1px solid var(--border); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); .modal-close {
min-width: 340px; max-height: 90vh; overflow-y: auto; } background: transparent;
.modal-wide { width: min(1100px, 96vw); } border: none;
.modal label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.82rem; color: var(--text-light);
color: var(--text-light); margin-bottom: 0.6rem; } font-size: 1.1rem;
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); line-height: 1;
gap: 0.1rem 1.5rem; } cursor: pointer;
.form-grid .full { grid-column: 1 / -1; } padding: 0.25rem 0.5rem;
.advanced summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; } }
.proc-head { display: flex; justify-content: space-between; align-items: center; .modal-close:hover { color: var(--text); }
font-size: 0.78rem; color: var(--text-light); margin: 0.25rem 0; }
.proc-row { display: grid; grid-template-columns: 1fr 1.4fr 0.6fr auto; gap: 0.3rem; /* Comment textarea matches the themed input look. */
margin-bottom: 0.3rem; } .modal-body textarea.form-control {
.preinstall-flags { margin-top: 0.5rem; } background: var(--bg);
.flags-label { display: block; font-size: 0.75rem; color: var(--text-light); margin-bottom: 0.25rem; } resize: vertical;
.inline { flex-direction: row !important; align-items: center; gap: 0.3rem !important; min-height: 2.5rem;
display: inline-flex !important; margin-right: 1rem; } }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.75rem; }
.preview { background: var(--bg); padding: 0.75rem; border-radius: 6px; max-height: 60vh; /* In-use process editor: repeatable rows, children can shrink. */
overflow: auto; font-size: 0.78rem; } .process-editor { margin-bottom: 1rem; }
.empty { color: var(--text-light); text-align: center; padding: 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; }
</style> </style>