Move GE-Enforce to its own top-level section; fix overflow + input theming
GE-Enforce is a large operational surface (manifest authoring + fleet compliance), not a setting, and it was squished in the settings two-pane shell. Promote it to a dedicated full-width top-level section: - New sidebar entry "GE-Enforce" (plugin get_navigation_items, shield icon, auto-gated to the enabled plugin) instead of two Settings > Integrations cards. - Tabbed shell GeEnforceLayout.vue (Manifests | Enforcement Reports) with full-width children under AppLayout, not the narrow settings rail. - Views moved settings/ -> geenforce/ (ManifestEditor.vue, EnforcementReports.vue). Theming + overflow fixes (the "chaotic / cut off / different inputs" report): - Inputs/selects/textareas now match the stock settings look (border, radius, --bg, focus color) instead of browser defaults. - No horizontal overflow: editor grid uses minmax(0,1fr) + min-width:0 on children, collapses to one column under 1000px; entry table and reports table scroll inside their own overflow-x containers; detail actions wrap. Verified at 1280px: no page overflow, detail pane + tables fit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,30 @@
|
||||
/**
|
||||
* GE-Enforce plugin routes.
|
||||
*
|
||||
* The imaging-PC-type manifest editor lives under /settings. meta.plugin =
|
||||
* 'geenforce' so the ADR-009 router guard hides it when the plugin is disabled.
|
||||
* Admin-only, like the other settings pages.
|
||||
* A top-level section (not under /settings) - the manifest editor + fleet
|
||||
* reports are a large operational surface, so they get their own full-width
|
||||
* shell with tabs. meta.plugin = 'geenforce' so the ADR-009 guard hides the
|
||||
* section when the plugin is disabled. Admin-only.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'settings/imagingpctypes',
|
||||
name: 'imagingpctypes',
|
||||
component: () => import('../../views/settings/ImagingPCTypes.vue'),
|
||||
path: 'geenforce',
|
||||
component: () => import('../../views/geenforce/GeEnforceLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' },
|
||||
children: [
|
||||
{ path: '', redirect: '/geenforce/manifests' },
|
||||
{
|
||||
path: 'manifests',
|
||||
name: 'geenforce-manifests',
|
||||
component: () => import('../../views/geenforce/ManifestEditor.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
||||
},
|
||||
{
|
||||
path: 'settings/enforcementreports',
|
||||
name: 'enforcementreports',
|
||||
component: () => import('../../views/settings/EnforcementReports.vue'),
|
||||
path: 'reports',
|
||||
name: 'geenforce-reports',
|
||||
component: () => import('../../views/geenforce/EnforcementReports.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
142
frontend/src/views/geenforce/EnforcementReports.vue
Normal file
142
frontend/src/views/geenforce/EnforcementReports.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="enforcement-reports">
|
||||
<div class="page-header"><h1>Enforcement Reports</h1></div>
|
||||
<div v-if="error" class="alert-error">{{ error }}</div>
|
||||
<p class="intro">
|
||||
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
|
||||
up the current published manifest; status shows self-heal and failures.
|
||||
</p>
|
||||
|
||||
<div class="filters">
|
||||
<input v-model="filterHost" placeholder="Filter hostname" @keyup.enter="load" />
|
||||
<input v-model="filterScope" placeholder="Filter PC type" @keyup.enter="load" />
|
||||
<button class="btn" @click="load">Filter</button>
|
||||
<button class="btn" @click="clearFilters">Clear</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th><th>PC type</th><th>Received</th><th>Version</th>
|
||||
<th>Status</th><th>Installed</th><th>Skipped</th><th>Failed</th>
|
||||
<th>Last check-in</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in reports" :key="r.reportid">
|
||||
<td>{{ r.hostname }}</td>
|
||||
<td>{{ r.scopename }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="r.receivedlatest ? 'badge-success' : 'badge-warning'">
|
||||
{{ r.receivedlatest ? 'yes' : 'behind' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="dim">{{ r.appliedversion ?? '-' }} / {{ r.latestversion ?? '-' }}</td>
|
||||
<td><span class="badge" :class="statusClass(r.status)">{{ r.status }}</span></td>
|
||||
<td>{{ r.installed }}</td>
|
||||
<td class="dim">{{ r.skipped }}</td>
|
||||
<td :class="{ 'fail-count': r.failed }">{{ r.failed }}</td>
|
||||
<td class="dim">{{ formatDate(r.lastcheckin || r.receivedat) }}</td>
|
||||
<td><button class="btn btn-small" @click="openDetail(r.reportid)">Detail</button></td>
|
||||
</tr>
|
||||
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail modal -->
|
||||
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
|
||||
<div class="modal modal-wide">
|
||||
<h2>{{ detail.hostname }} - {{ detail.scopename }}</h2>
|
||||
<p class="dim">Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
|
||||
status {{ detail.status }}</p>
|
||||
<table>
|
||||
<thead><tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(result, i) in detail.results" :key="i">
|
||||
<td>{{ result.entryname }}</td>
|
||||
<td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
|
||||
<td>{{ result.selfhealed ? 'yes' : '' }}</td>
|
||||
<td class="dim">{{ result.exitcode ?? '' }}</td>
|
||||
<td class="dim">{{ result.message }}</td>
|
||||
</tr>
|
||||
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="modal-actions"><button class="btn" @click="detail = null">Close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '../../api'
|
||||
|
||||
const reports = ref([])
|
||||
const detail = ref(null)
|
||||
const error = ref('')
|
||||
const filterHost = ref('')
|
||||
const filterScope = ref('')
|
||||
|
||||
function payload(response) { return response.data.data }
|
||||
|
||||
async function load() {
|
||||
const params = {}
|
||||
if (filterHost.value) params.hostname = filterHost.value
|
||||
if (filterScope.value) params.scopename = filterScope.value
|
||||
try {
|
||||
reports.value = payload(await api.get('/geenforce/reports', { params }))
|
||||
error.value = ''
|
||||
} catch (e) { error.value = 'Failed to load reports' }
|
||||
}
|
||||
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
|
||||
async function openDetail(reportid) {
|
||||
try {
|
||||
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
|
||||
} catch (e) { error.value = 'Failed to load report detail' }
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
|
||||
}
|
||||
function actionClass(action) {
|
||||
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
|
||||
filtered: '' }[action] || ''
|
||||
}
|
||||
function formatDate(value) { return value ? new Date(value).toLocaleString() : '' }
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.enforcement-reports { max-width: 1100px; }
|
||||
.enforcement-reports input, .enforcement-reports select {
|
||||
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; }
|
||||
.btn-small { padding: 0.15rem 0.5rem; font-size: 0.78rem; }
|
||||
.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;
|
||||
align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: var(--bg-card); padding: 1.25rem; border-radius: 8px; min-width: 340px;
|
||||
max-height: 90vh; overflow-y: auto; }
|
||||
.modal-wide { min-width: 640px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; margin-top: 0.75rem; }
|
||||
</style>
|
||||
43
frontend/src/views/geenforce/GeEnforceLayout.vue
Normal file
43
frontend/src/views/geenforce/GeEnforceLayout.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="geenforce-section">
|
||||
<div class="section-header">
|
||||
<h1>GE-Enforce</h1>
|
||||
<p class="section-sub">Desired-state install manifests for imaging PC types, and the fleet's reported results.</p>
|
||||
</div>
|
||||
<nav class="section-tabs">
|
||||
<router-link to="/geenforce/manifests" class="tab">Manifests</router-link>
|
||||
<router-link to="/geenforce/reports" class="tab">Enforcement Reports</router-link>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Tabbed shell for the GE-Enforce section. Children render the manifest editor
|
||||
// and the fleet-compliance reports full-width (not squeezed into the settings rail).
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.geenforce-section { max-width: 1400px; }
|
||||
.section-header { margin-bottom: 0.5rem; }
|
||||
.section-header h1 { margin: 0; }
|
||||
.section-sub { color: var(--text-light); margin: 0.25rem 0 0; }
|
||||
.section-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 1rem 0 1.25rem;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.5rem 0.9rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.router-link-active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
632
frontend/src/views/geenforce/ManifestEditor.vue
Normal file
632
frontend/src/views/geenforce/ManifestEditor.vue
Normal file
@@ -0,0 +1,632 @@
|
||||
<template>
|
||||
<div class="imaging-pctypes">
|
||||
<div class="page-header">
|
||||
<h1>Imaging PC Types</h1>
|
||||
<button class="btn btn-primary" @click="openNewScope">+ New PC Type</button>
|
||||
</div>
|
||||
<p class="intro">
|
||||
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>
|
||||
|
||||
<div v-if="error" class="alert-error">{{ error }}</div>
|
||||
<div v-if="notice" class="alert-ok">{{ notice }}</div>
|
||||
|
||||
<div class="config-row card">
|
||||
<label class="grow">On-share export root
|
||||
<input v-model="shareRoot" placeholder="\\server\share\dt\shopfloor or /path" />
|
||||
</label>
|
||||
<button class="btn" @click="saveConfig">Save share root</button>
|
||||
<span class="config-hint">Used by Export to Share. During Milestone 1 the
|
||||
engine still reads these files; export is your push to the fleet.</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid">
|
||||
<!-- Scope list -->
|
||||
<aside class="scope-list card">
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
<!-- Selected scope -->
|
||||
<section v-if="detail" class="scope-detail card">
|
||||
<div class="detail-header">
|
||||
<h2>{{ detail.scopename }}</h2>
|
||||
<div class="detail-actions">
|
||||
<button class="btn btn-primary" @click="publish">Publish</button>
|
||||
<button class="btn" @click="exportShare">Export to Share</button>
|
||||
<button class="btn" @click="toggleVersions">Versions</button>
|
||||
<button class="btn" @click="loadPreview">Preview</button>
|
||||
<button class="btn btn-danger" @click="deleteScope">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mapping -->
|
||||
<div class="mapping-row">
|
||||
<label>Computer Type ID
|
||||
<input type="number" v-model.number="detail.computertypeid" />
|
||||
</label>
|
||||
<label>Measuring Tool Type ID
|
||||
<input type="number" v-model.number="detail.measuringtooltypeid" />
|
||||
</label>
|
||||
<label class="grow">Description
|
||||
<input type="text" v-model="detail.description" />
|
||||
</label>
|
||||
<button class="btn" @click="saveScope">Save</button>
|
||||
</div>
|
||||
|
||||
<!-- Versions panel -->
|
||||
<div v-if="showVersions" class="versions-panel">
|
||||
<h3>Published versions</h3>
|
||||
<table>
|
||||
<thead><tr><th>Version</th><th>Published</th><th>Notes</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="v in versions" :key="v.versionnumber">
|
||||
<td>v{{ v.versionnumber }} <span v-if="v.iscurrent" class="badge badge-success">current</span></td>
|
||||
<td>{{ formatDate(v.publishedat) }}</td>
|
||||
<td>{{ v.notes }}</td>
|
||||
<td><button v-if="!v.iscurrent" class="btn btn-small"
|
||||
@click="rollback(v.versionnumber)">Roll back</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Entries -->
|
||||
<div class="entries-header">
|
||||
<h3>Manifest entries ({{ detail.entries.length }})</h3>
|
||||
<button class="btn btn-primary btn-small" @click="openNewEntry">+ Add entry</button>
|
||||
</div>
|
||||
<p class="order-hint">Order is execution order. Put config-restore entries after their installer.</p>
|
||||
<div class="table-scroll">
|
||||
<table class="entries-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Name</th><th>Type</th><th>Detection</th><th>Filters</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, index) in detail.entries" :key="entry.entryid">
|
||||
<td class="order-cell">
|
||||
<button class="btn-move" :disabled="index === 0" @click="moveEntry(index, -1)">↑</button>
|
||||
<button class="btn-move" :disabled="index === detail.entries.length - 1" @click="moveEntry(index, 1)">↓</button>
|
||||
</td>
|
||||
<td>{{ entry.Name }}</td>
|
||||
<td><span class="badge badge-info">{{ entry.Type }}</span></td>
|
||||
<td class="dim">{{ entry.DetectionMethod || 'always' }}</td>
|
||||
<td class="dim">{{ filterSummary(entry) }}</td>
|
||||
<td class="row-actions">
|
||||
<button class="btn btn-small" @click="openEditEntry(entry)">Edit</button>
|
||||
<button class="btn btn-small btn-danger" @click="deleteEntry(entry)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!detail.entries.length"><td colspan="6" class="empty">No entries.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Simulator -->
|
||||
<div class="simulator">
|
||||
<h3>Simulate: what would a PC get?</h3>
|
||||
<div class="sim-inputs">
|
||||
<input v-model="sim.subtype" placeholder="subtype" />
|
||||
<input v-model="sim.hostname" placeholder="hostname" />
|
||||
<input v-model="sim.machinenumber" placeholder="machine #" />
|
||||
<input v-model="sim.cmmversion" placeholder="CMM version" />
|
||||
<button class="btn" @click="runSimulate">Run</button>
|
||||
</div>
|
||||
<div v-if="simResult" class="sim-result">
|
||||
<div><strong>Applies ({{ simResult.applied.length }}):</strong>
|
||||
{{ simResult.applied.join(', ') || 'none' }}</div>
|
||||
<div class="dim"><strong>Filtered:</strong>
|
||||
<span v-for="f in simResult.filtered" :key="f.name">
|
||||
{{ f.name }} ({{ f.filteredby.join(',') }});
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="scope-detail card empty-detail">
|
||||
Select a PC type, or create one.
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- New scope modal -->
|
||||
<div v-if="showNewScope" class="modal-overlay" @click.self="showNewScope = false">
|
||||
<div class="modal">
|
||||
<h2>New imaging PC type</h2>
|
||||
<label>Scope name (e.g. gea-shopfloor-cmm)
|
||||
<input v-model="newScope.scopename" />
|
||||
</label>
|
||||
<label>Phase
|
||||
<select v-model="newScope.phase">
|
||||
<option value="runtime">runtime</option>
|
||||
<option value="preinstall">preinstall</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @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-wide">
|
||||
<h2>{{ entryForm.entryid ? 'Edit entry' : 'New entry' }}</h2>
|
||||
<div class="form-grid">
|
||||
<label>Name<input v-model="entryForm.Name" /></label>
|
||||
<label>Type
|
||||
<select v-model="entryForm.Type">
|
||||
<option v-for="t in ENTRY_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- payload fields per type -->
|
||||
<template v-if="['MSI','EXE','CMD','BAT','INF'].includes(entryForm.Type)">
|
||||
<label>Installer (relative path)<input v-model="entryForm.Installer" /></label>
|
||||
<label>Install args<input v-model="entryForm.InstallArgs" /></label>
|
||||
</template>
|
||||
<template v-if="entryForm.Type === 'PS1'">
|
||||
<label>Script (relative path)<input v-model="entryForm.Script" /></label>
|
||||
<label>Args<input v-model="entryForm.Args" /></label>
|
||||
</template>
|
||||
<template v-if="entryForm.Type === 'File'">
|
||||
<label>Source (relative)<input v-model="entryForm.Source" /></label>
|
||||
<label>Destination (absolute)<input v-model="entryForm.Destination" /></label>
|
||||
</template>
|
||||
<template v-if="entryForm.Type === 'Registry'">
|
||||
<label>Reg path<input v-model="entryForm.RegPath" /></label>
|
||||
<label>Reg name<input v-model="entryForm.RegName" /></label>
|
||||
<label>Reg value<input v-model="entryForm.RegValue" /></label>
|
||||
<label>Reg type
|
||||
<select v-model="entryForm.RegType">
|
||||
<option v-for="t in REG_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- detection -->
|
||||
<label>Detection method
|
||||
<select v-model="entryForm.DetectionMethod">
|
||||
<option value="">(none: always installs)</option>
|
||||
<option v-for="m in DETECTION_METHODS" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Detection path<input v-model="entryForm.DetectionPath" /></label>
|
||||
<label>Detection name<input v-model="entryForm.DetectionName" /></label>
|
||||
<label>Detection value<input v-model="entryForm.DetectionValue" /></label>
|
||||
<label v-if="entryForm.DetectionMethod === 'pnputil'">Detection pattern
|
||||
<input v-model="entryForm.DetectionPattern" /></label>
|
||||
|
||||
<!-- filters -->
|
||||
<label>PC types (comma)<input v-model="entryForm.PCTypes" placeholder="* or gea-shopfloor-cmm,..." /></label>
|
||||
<label>Target hostnames (comma)<input v-model="entryForm.TargetHostnames" /></label>
|
||||
<label>Target machine numbers (comma)<input v-model="entryForm.TargetMachineNumbers" /></label>
|
||||
<label>CMM version gate<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label>
|
||||
|
||||
<label>Wait timeout (sec)<input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label>
|
||||
<label>Log file<input v-model="entryForm.LogFile" /></label>
|
||||
<label class="full">Comment<textarea v-model="entryForm._comment" rows="2" /></label>
|
||||
|
||||
<details class="full advanced" open>
|
||||
<summary>In-use handling + preinstall flags</summary>
|
||||
<label>Close running app before install (InUseCheck)
|
||||
<select v-model="entryForm.inuseBehavior">
|
||||
<option value="">(none)</option>
|
||||
<option v-for="b in INUSE_BEHAVIORS" :key="b" :value="b">{{ b }}</option>
|
||||
</select>
|
||||
</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 v-for="(proc, i) in entryForm.inuseProcesses" :key="i" class="proc-row">
|
||||
<input v-model="proc.name" placeholder="Name (no .exe)" />
|
||||
<input v-model="proc.exepath" placeholder="ExePath (optional)" />
|
||||
<input type="number" v-model.number="proc.timeout" placeholder="timeout s" />
|
||||
<button type="button" class="btn-move" @click="removeProcess(i)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full preinstall-flags">
|
||||
<span class="flags-label">Preinstall flags (preinstall phase only):</span>
|
||||
<label class="inline"><input type="checkbox" v-model="entryForm.PreEnrollment" /> PreEnrollment</label>
|
||||
<label class="inline"><input type="checkbox" v-model="entryForm.KillAfterDetection" /> KillAfterDetection</label>
|
||||
<label class="inline"><input type="checkbox" v-model="entryForm.PCTypesStrict" /> PCTypesStrict</label>
|
||||
</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 class="modal-actions">
|
||||
<button class="btn" @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-wide">
|
||||
<h2>Draft manifest preview</h2>
|
||||
<pre class="preview">{{ previewText }}</pre>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="showPreview = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '../../api'
|
||||
|
||||
const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
|
||||
const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
|
||||
const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
|
||||
'ValueMatches', 'pnputil', 'Always']
|
||||
const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
|
||||
|
||||
const scopes = ref([])
|
||||
const selectedId = ref(null)
|
||||
const detail = ref(null)
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
const shareRoot = ref('')
|
||||
|
||||
const showNewScope = ref(false)
|
||||
const newScope = ref({ scopename: '', phase: 'runtime' })
|
||||
|
||||
const showEntry = ref(false)
|
||||
const entryForm = ref({})
|
||||
|
||||
const showVersions = ref(false)
|
||||
const versions = ref([])
|
||||
|
||||
const showPreview = ref(false)
|
||||
const previewText = ref('')
|
||||
|
||||
const sim = ref({ subtype: '', hostname: '', machinenumber: '', cmmversion: '' })
|
||||
const simResult = ref(null)
|
||||
|
||||
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
|
||||
simResult.value = null
|
||||
try {
|
||||
detail.value = payload(await api.get(`/geenforce/scopes/${id}`))
|
||||
} catch (e) { error.value = 'Failed to load PC type' }
|
||||
}
|
||||
|
||||
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 blankEntry() {
|
||||
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
|
||||
inuseBehavior: '', inuseProcesses: [],
|
||||
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
|
||||
}
|
||||
function openNewEntry() {
|
||||
entryForm.value = blankEntry()
|
||||
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(p => ({
|
||||
name: p.Name, exepath: p.ExePath || '',
|
||||
timeout: p.GracefulCloseTimeoutSec ?? null }))
|
||||
form.PreEnrollment = !!entry.PreEnrollment
|
||||
form.KillAfterDetection = !!entry.KillAfterDetection
|
||||
form.PCTypesStrict = !!entry.PCTypesStrict
|
||||
entryForm.value = form
|
||||
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 splitList(value) {
|
||||
return (value || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
function buildEntryPayload() {
|
||||
const f = entryForm.value
|
||||
const out = { Name: f.Name, Type: f.Type }
|
||||
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
|
||||
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
|
||||
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
|
||||
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
|
||||
for (const key of scalars) {
|
||||
if (f[key] !== undefined && f[key] !== '' && f[key] !== null) out[key] = f[key]
|
||||
}
|
||||
if (f.DetectionMethod) out.DetectionMethod = f.DetectionMethod
|
||||
if (f.WaitTimeoutSec) out.WaitTimeoutSec = f.WaitTimeoutSec
|
||||
if (f.Type === 'Registry' && f.RegValue !== undefined && f.RegValue !== '') {
|
||||
out.RegValue = ['DWord', 'QWord'].includes(f.RegType) ? Number(f.RegValue) : f.RegValue
|
||||
}
|
||||
const pctypes = splitList(f.PCTypes)
|
||||
if (pctypes.length) out.PCTypes = pctypes
|
||||
const hostnames = splitList(f.TargetHostnames)
|
||||
if (hostnames.length) out.TargetHostnames = hostnames
|
||||
const machinenumbers = splitList(f.TargetMachineNumbers)
|
||||
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
|
||||
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
|
||||
if (f[flag]) out[flag] = true
|
||||
}
|
||||
if (f.inuseBehavior) {
|
||||
out.InUseCheck = {
|
||||
Behavior: f.inuseBehavior,
|
||||
Processes: (f.inuseProcesses || []).filter(p => p.name).map(p => {
|
||||
const pd = { Name: p.name }
|
||||
if (p.exepath) pd.ExePath = p.exepath
|
||||
if (p.timeout !== null && p.timeout !== '' && p.timeout !== undefined) {
|
||||
pd.GracefulCloseTimeoutSec = Number(p.timeout)
|
||||
}
|
||||
return pd
|
||||
}),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
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' }
|
||||
}
|
||||
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(e => e.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 = { ...sim.value }
|
||||
simResult.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(' ') || '-'
|
||||
}
|
||||
function formatDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : ''
|
||||
}
|
||||
|
||||
loadScopes()
|
||||
loadConfig()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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"]),
|
||||
.imaging-pctypes select,
|
||||
.imaging-pctypes textarea {
|
||||
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;
|
||||
}
|
||||
.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.active { background: var(--primary); color: white; }
|
||||
.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.15rem; }
|
||||
.scope-row.active .scope-meta { color: rgba(255,255,255,0.85); }
|
||||
.unpublished { color: var(--warning); }
|
||||
.scope-detail { padding: 1rem; }
|
||||
.empty-detail { color: var(--text-light); text-align: center; padding: 3rem; }
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.detail-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.mapping-row { display: flex; gap: 0.6rem; align-items: flex-end; margin: 0.75rem 0;
|
||||
flex-wrap: wrap; }
|
||||
.mapping-row label { font-size: 0.75rem; color: var(--text-light); display: flex;
|
||||
flex-direction: column; gap: 0.2rem; }
|
||||
.mapping-row .grow { flex: 1; min-width: 160px; }
|
||||
.versions-panel { background: var(--bg); padding: 0.75rem; border-radius: 6px; margin: 0.5rem 0; }
|
||||
.entries-header { display: flex; justify-content: space-between; align-items: center;
|
||||
margin-top: 1rem; }
|
||||
.order-hint { font-size: 0.75rem; color: var(--text-light); margin: 0.2rem 0 0.5rem; }
|
||||
.entries-table { width: 100%; }
|
||||
.order-cell { white-space: nowrap; }
|
||||
.btn-move { border: 1px solid var(--border); background: var(--bg-card); cursor: pointer;
|
||||
border-radius: 4px; padding: 0 0.35rem; }
|
||||
.btn-move:disabled { opacity: 0.3; cursor: default; }
|
||||
.dim { color: var(--text-light); font-size: 0.82rem; }
|
||||
.row-actions { display: flex; gap: 0.3rem; }
|
||||
.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; }
|
||||
.sim-inputs input { max-width: 130px; }
|
||||
.sim-result { margin-top: 0.5rem; font-size: 0.85rem; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex;
|
||||
align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: var(--bg-card); padding: 1.25rem; border-radius: 8px;
|
||||
min-width: 340px; max-height: 90vh; overflow-y: auto; }
|
||||
.modal-wide { min-width: 640px; }
|
||||
.modal label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.8rem;
|
||||
color: var(--text-light); margin-bottom: 0.5rem; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.advanced summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
.proc-head { display: flex; justify-content: space-between; align-items: center;
|
||||
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;
|
||||
margin-bottom: 0.3rem; }
|
||||
.preinstall-flags { margin-top: 0.5rem; }
|
||||
.flags-label { display: block; font-size: 0.75rem; color: var(--text-light); margin-bottom: 0.25rem; }
|
||||
.inline { flex-direction: row !important; align-items: center; gap: 0.3rem !important;
|
||||
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;
|
||||
overflow: auto; font-size: 0.78rem; }
|
||||
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
|
||||
</style>
|
||||
@@ -1,20 +1,36 @@
|
||||
<template>
|
||||
<div class="enforcement-reports">
|
||||
<div class="page-header"><h1>Enforcement Reports</h1></div>
|
||||
<div v-if="error" class="alert-error">{{ error }}</div>
|
||||
<p class="intro">
|
||||
<div class="page-header">
|
||||
<h2>Enforcement Reports</h2>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
|
||||
up the current published manifest; status shows self-heal and failures.
|
||||
</p>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="filters">
|
||||
<input v-model="filterHost" placeholder="Filter hostname" @keyup.enter="load" />
|
||||
<input v-model="filterScope" placeholder="Filter PC type" @keyup.enter="load" />
|
||||
<button class="btn" @click="load">Filter</button>
|
||||
<button class="btn" @click="clearFilters">Clear</button>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
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 class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -24,47 +40,63 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in reports" :key="r.reportid">
|
||||
<td>{{ r.hostname }}</td>
|
||||
<td>{{ r.scopename }}</td>
|
||||
<tr v-for="report in reports" :key="report.reportid">
|
||||
<td>{{ report.hostname }}</td>
|
||||
<td>{{ report.scopename }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="r.receivedlatest ? 'badge-success' : 'badge-warning'">
|
||||
{{ r.receivedlatest ? 'yes' : 'behind' }}
|
||||
<span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
|
||||
{{ report.receivedlatest ? 'yes' : 'behind' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="dim">{{ r.appliedversion ?? '-' }} / {{ r.latestversion ?? '-' }}</td>
|
||||
<td><span class="badge" :class="statusClass(r.status)">{{ r.status }}</span></td>
|
||||
<td>{{ r.installed }}</td>
|
||||
<td class="dim">{{ r.skipped }}</td>
|
||||
<td :class="{ 'fail-count': r.failed }">{{ r.failed }}</td>
|
||||
<td class="dim">{{ formatDate(r.lastcheckin || r.receivedat) }}</td>
|
||||
<td><button class="btn btn-small" @click="openDetail(r.reportid)">Detail</button></td>
|
||||
<td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td>
|
||||
<td><span class="badge" :class="statusClass(report.status)">{{ report.status }}</span></td>
|
||||
<td>{{ report.installed }}</td>
|
||||
<td class="muted">{{ report.skipped }}</td>
|
||||
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
|
||||
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail modal -->
|
||||
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
|
||||
<div class="modal modal-wide">
|
||||
<h2>{{ detail.hostname }} - {{ detail.scopename }}</h2>
|
||||
<p class="dim">Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
|
||||
status {{ detail.status }}</p>
|
||||
<div class="modal modal-report">
|
||||
<div class="modal-header">
|
||||
<h3>{{ detail.hostname }} - {{ detail.scopename }}</h3>
|
||||
<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>
|
||||
<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>
|
||||
<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><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
|
||||
<td>{{ result.selfhealed ? 'yes' : '' }}</td>
|
||||
<td class="dim">{{ result.exitcode ?? '' }}</td>
|
||||
<td class="dim">{{ result.message }}</td>
|
||||
<td class="muted">{{ result.exitcode ?? '' }}</td>
|
||||
<td class="muted">{{ result.message }}</td>
|
||||
</tr>
|
||||
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
|
||||
</tbody>
|
||||
</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>
|
||||
@@ -112,18 +144,18 @@ load()
|
||||
|
||||
<style scoped>
|
||||
.enforcement-reports { max-width: 1100px; }
|
||||
.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; }
|
||||
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.dim { color: var(--text-light); }
|
||||
.muted { color: var(--text-light); }
|
||||
.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; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex;
|
||||
align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: var(--bg-card); padding: 1.25rem; border-radius: 8px; min-width: 340px;
|
||||
max-height: 90vh; overflow-y: auto; }
|
||||
.modal-wide { min-width: 640px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; margin-top: 0.75rem; }
|
||||
.modal-report { max-width: 640px; }
|
||||
.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); }
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
<template>
|
||||
<div class="imaging-pctypes">
|
||||
<div class="page-header">
|
||||
<h1>Imaging PC Types</h1>
|
||||
<button class="btn btn-primary" @click="openNewScope">+ New PC Type</button>
|
||||
<h2>Imaging PC Types</h2>
|
||||
</div>
|
||||
<p class="intro">
|
||||
<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>
|
||||
|
||||
<div v-if="error" class="alert-error">{{ error }}</div>
|
||||
<div v-if="notice" class="alert-ok">{{ notice }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="notice" class="settings-success">{{ notice }}</div>
|
||||
|
||||
<div class="config-row card">
|
||||
<label class="grow">On-share export root
|
||||
<!-- 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>
|
||||
<button class="btn" @click="saveConfig">Save share root</button>
|
||||
<span class="config-hint">Used by Export to Share. During Milestone 1 the
|
||||
engine still reads these files; export is your push to the fleet.</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="saveConfig">Save share root</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid">
|
||||
<!-- Scope list -->
|
||||
<aside class="scope-list card">
|
||||
<div v-for="scope in scopes" :key="scope.scopeid"
|
||||
class="scope-row" :class="{ active: scope.scopeid === selectedId }"
|
||||
@click="selectScope(scope.scopeid)">
|
||||
<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'">
|
||||
@@ -38,122 +53,183 @@
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!scopes.length" class="empty">No PC types yet.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Selected scope -->
|
||||
<section v-if="detail" class="scope-detail card">
|
||||
<div class="detail-header">
|
||||
<h2>{{ detail.scopename }}</h2>
|
||||
<div class="detail-actions">
|
||||
<button class="btn btn-primary" @click="publish">Publish</button>
|
||||
<button class="btn" @click="exportShare">Export to Share</button>
|
||||
<button class="btn" @click="toggleVersions">Versions</button>
|
||||
<button class="btn" @click="loadPreview">Preview</button>
|
||||
<button class="btn btn-danger" @click="deleteScope">Delete</button>
|
||||
<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>
|
||||
|
||||
<!-- Mapping -->
|
||||
<div class="mapping-row">
|
||||
<label>Computer Type ID
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Computer Type ID</span>
|
||||
<input type="number" v-model.number="detail.computertypeid" />
|
||||
</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" />
|
||||
</label>
|
||||
<label class="grow">Description
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<input type="text" v-model="detail.description" />
|
||||
</label>
|
||||
<button class="btn" @click="saveScope">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="saveScope">Save</button>
|
||||
</div>
|
||||
|
||||
<!-- Versions panel -->
|
||||
<div v-if="showVersions" class="versions-panel">
|
||||
<h3>Published versions</h3>
|
||||
<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>
|
||||
<thead>
|
||||
<tr><th>Version</th><th>Published</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="v in versions" :key="v.versionnumber">
|
||||
<td>v{{ v.versionnumber }} <span v-if="v.iscurrent" class="badge badge-success">current</span></td>
|
||||
<td>{{ formatDate(v.publishedat) }}</td>
|
||||
<td>{{ v.notes }}</td>
|
||||
<td><button v-if="!v.iscurrent" class="btn btn-small"
|
||||
@click="rollback(v.versionnumber)">Roll back</button></td>
|
||||
<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="entries-header">
|
||||
<h3>Manifest entries ({{ detail.entries.length }})</h3>
|
||||
<button class="btn btn-primary btn-small" @click="openNewEntry">+ Add entry</button>
|
||||
<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="order-hint">Order is execution order. Put config-restore entries after their installer.</p>
|
||||
<table class="entries-table">
|
||||
<p class="setting-description">
|
||||
Order is execution order. Put config-restore entries after their installer.
|
||||
</p>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<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>
|
||||
<tbody>
|
||||
<tr v-for="(entry, index) in detail.entries" :key="entry.entryid">
|
||||
<td class="order-cell">
|
||||
<button class="btn-move" :disabled="index === 0" @click="moveEntry(index, -1)">↑</button>
|
||||
<button class="btn-move" :disabled="index === detail.entries.length - 1" @click="moveEntry(index, 1)">↓</button>
|
||||
<td class="actions">
|
||||
<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>{{ entry.Name }}</td>
|
||||
<td><span class="badge badge-info">{{ entry.Type }}</span></td>
|
||||
<td class="dim">{{ entry.DetectionMethod || 'always' }}</td>
|
||||
<td class="dim">{{ filterSummary(entry) }}</td>
|
||||
<td class="row-actions">
|
||||
<button class="btn btn-small" @click="openEditEntry(entry)">Edit</button>
|
||||
<button class="btn btn-small btn-danger" @click="deleteEntry(entry)">Delete</button>
|
||||
<td class="muted">{{ entry.DetectionMethod || 'always' }}</td>
|
||||
<td class="muted">{{ filterSummary(entry) }}</td>
|
||||
<td class="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="6" class="empty">No entries.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simulator -->
|
||||
<div class="simulator">
|
||||
<h3>Simulate: what would a PC get?</h3>
|
||||
<div class="sim-inputs">
|
||||
<input v-model="sim.subtype" placeholder="subtype" />
|
||||
<input v-model="sim.hostname" placeholder="hostname" />
|
||||
<input v-model="sim.machinenumber" placeholder="machine #" />
|
||||
<input v-model="sim.cmmversion" placeholder="CMM version" />
|
||||
<button class="btn" @click="runSimulate">Run</button>
|
||||
<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="sim.subtype" placeholder="subtype" /></label>
|
||||
</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><strong>Applies ({{ simResult.applied.length }}):</strong>
|
||||
{{ simResult.applied.join(', ') || 'none' }}</div>
|
||||
<div class="dim"><strong>Filtered:</strong>
|
||||
<span v-for="f in simResult.filtered" :key="f.name">
|
||||
{{ f.name }} ({{ f.filteredby.join(',') }});
|
||||
<div>
|
||||
<strong>Applies ({{ simResult.applied.length }}):</strong>
|
||||
{{ simResult.applied.join(', ') || 'none' }}
|
||||
</div>
|
||||
<div class="muted">
|
||||
<strong>Filtered:</strong>
|
||||
<span v-for="filtered in simResult.filtered" :key="filtered.name">
|
||||
{{ filtered.name }} ({{ filtered.filteredby.join(',') }});
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="scope-detail card empty-detail">
|
||||
</div>
|
||||
|
||||
<div v-else class="section-card empty-detail">
|
||||
Select a PC type, or create one.
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New scope modal -->
|
||||
<div v-if="showNewScope" class="modal-overlay" @click.self="showNewScope = false">
|
||||
<div class="modal">
|
||||
<h2>New imaging PC type</h2>
|
||||
<label>Scope name (e.g. gea-shopfloor-cmm)
|
||||
<input v-model="newScope.scopename" />
|
||||
<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>
|
||||
<label>Phase
|
||||
</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 class="modal-actions">
|
||||
<button class="btn" @click="showNewScope = false">Cancel</button>
|
||||
</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>
|
||||
@@ -161,97 +237,176 @@
|
||||
|
||||
<!-- Entry modal -->
|
||||
<div v-if="showEntry" class="modal-overlay" @click.self="showEntry = false">
|
||||
<div class="modal modal-wide">
|
||||
<h2>{{ entryForm.entryid ? 'Edit entry' : 'New entry' }}</h2>
|
||||
<div class="form-grid">
|
||||
<label>Name<input v-model="entryForm.Name" /></label>
|
||||
<label>Type
|
||||
<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="t in ENTRY_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
<option v-for="entryType in ENTRY_TYPES" :key="entryType" :value="entryType">{{ entryType }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- payload fields per type -->
|
||||
<template v-if="['MSI','EXE','CMD','BAT','INF'].includes(entryForm.Type)">
|
||||
<label>Installer (relative path)<input v-model="entryForm.Installer" /></label>
|
||||
<label>Install args<input v-model="entryForm.InstallArgs" /></label>
|
||||
</template>
|
||||
<template v-if="entryForm.Type === 'PS1'">
|
||||
<label>Script (relative path)<input v-model="entryForm.Script" /></label>
|
||||
<label>Args<input v-model="entryForm.Args" /></label>
|
||||
</template>
|
||||
<template v-if="entryForm.Type === 'File'">
|
||||
<label>Source (relative)<input v-model="entryForm.Source" /></label>
|
||||
<label>Destination (absolute)<input v-model="entryForm.Destination" /></label>
|
||||
</template>
|
||||
<template v-if="entryForm.Type === 'Registry'">
|
||||
<label>Reg path<input v-model="entryForm.RegPath" /></label>
|
||||
<label>Reg name<input v-model="entryForm.RegName" /></label>
|
||||
<label>Reg value<input v-model="entryForm.RegValue" /></label>
|
||||
<label>Reg 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" /></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" /></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="t in REG_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
<option v-for="regType in REG_TYPES" :key="regType" :value="regType">{{ regType }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- detection -->
|
||||
<label>Detection method
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Detection method</span>
|
||||
<select v-model="entryForm.DetectionMethod">
|
||||
<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>
|
||||
</label>
|
||||
<label>Detection path<input v-model="entryForm.DetectionPath" /></label>
|
||||
<label>Detection name<input v-model="entryForm.DetectionName" /></label>
|
||||
<label>Detection value<input v-model="entryForm.DetectionValue" /></label>
|
||||
<label v-if="entryForm.DetectionMethod === 'pnputil'">Detection pattern
|
||||
<input v-model="entryForm.DetectionPattern" /></label>
|
||||
</div>
|
||||
<div 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" /></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 -->
|
||||
<label>PC types (comma)<input v-model="entryForm.PCTypes" placeholder="* or gea-shopfloor-cmm,..." /></label>
|
||||
<label>Target hostnames (comma)<input v-model="entryForm.TargetHostnames" /></label>
|
||||
<label>Target machine numbers (comma)<input v-model="entryForm.TargetMachineNumbers" /></label>
|
||||
<label>CMM version gate<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>PC types (comma)</span>
|
||||
<input v-model="entryForm.PCTypes" placeholder="* or gea-shopfloor-cmm,..." />
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><span>Target hostnames (comma)</span><input v-model="entryForm.TargetHostnames" /></label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><span>Target machine numbers (comma)</span><input v-model="entryForm.TargetMachineNumbers" /></label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><span>CMM version gate</span><input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Wait timeout (sec)<input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label>
|
||||
<label>Log file<input v-model="entryForm.LogFile" /></label>
|
||||
<label class="full">Comment<textarea v-model="entryForm._comment" rows="2" /></label>
|
||||
<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>
|
||||
|
||||
<details class="full advanced" open>
|
||||
<div class="setting-row full-width">
|
||||
<label>
|
||||
<span>Comment</span>
|
||||
<textarea class="form-control" v-model="entryForm._comment" rows="2"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="advanced" open>
|
||||
<summary>In-use handling + preinstall flags</summary>
|
||||
<label>Close running app before install (InUseCheck)
|
||||
<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="b in INUSE_BEHAVIORS" :key="b" :value="b">{{ b }}</option>
|
||||
<option v-for="behavior in INUSE_BEHAVIORS" :key="behavior" :value="behavior">{{ behavior }}</option>
|
||||
</select>
|
||||
</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 v-for="(proc, i) in entryForm.inuseProcesses" :key="i" class="proc-row">
|
||||
<input v-model="proc.name" placeholder="Name (no .exe)" />
|
||||
<input v-model="proc.exepath" placeholder="ExePath (optional)" />
|
||||
<input type="number" v-model.number="proc.timeout" placeholder="timeout s" />
|
||||
<button type="button" class="btn-move" @click="removeProcess(i)">x</button>
|
||||
<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 class="full preinstall-flags">
|
||||
<div class="preinstall-flags">
|
||||
<span class="flags-label">Preinstall flags (preinstall phase only):</span>
|
||||
<label class="inline"><input type="checkbox" v-model="entryForm.PreEnrollment" /> PreEnrollment</label>
|
||||
<label class="inline"><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.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>
|
||||
</details>
|
||||
<details class="full advanced">
|
||||
|
||||
<details class="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>
|
||||
<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>
|
||||
</details>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="showEntry = false">Cancel</button>
|
||||
<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>
|
||||
@@ -259,11 +414,16 @@
|
||||
|
||||
<!-- Preview modal -->
|
||||
<div v-if="showPreview" class="modal-overlay" @click.self="showPreview = false">
|
||||
<div class="modal modal-wide">
|
||||
<h2>Draft manifest preview</h2>
|
||||
<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 class="modal-actions">
|
||||
<button class="btn" @click="showPreview = false">Close</button>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showPreview = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,9 +547,9 @@ function openEditEntry(entry) {
|
||||
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(p => ({
|
||||
name: p.Name, exepath: p.ExePath || '',
|
||||
timeout: p.GracefulCloseTimeoutSec ?? null }))
|
||||
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
|
||||
@@ -404,42 +564,42 @@ function removeProcess(index) {
|
||||
entryForm.value.inuseProcesses.splice(index, 1)
|
||||
}
|
||||
function splitList(value) {
|
||||
return (value || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
|
||||
}
|
||||
function buildEntryPayload() {
|
||||
const f = entryForm.value
|
||||
const out = { Name: f.Name, Type: f.Type }
|
||||
const form = entryForm.value
|
||||
const out = { Name: form.Name, Type: form.Type }
|
||||
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
|
||||
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
|
||||
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
|
||||
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
|
||||
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 (f.WaitTimeoutSec) out.WaitTimeoutSec = f.WaitTimeoutSec
|
||||
if (f.Type === 'Registry' && f.RegValue !== undefined && f.RegValue !== '') {
|
||||
out.RegValue = ['DWord', 'QWord'].includes(f.RegType) ? Number(f.RegValue) : f.RegValue
|
||||
if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
|
||||
if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
|
||||
if (form.Type === 'Registry' && form.RegValue !== undefined && form.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
|
||||
const hostnames = splitList(f.TargetHostnames)
|
||||
const hostnames = splitList(form.TargetHostnames)
|
||||
if (hostnames.length) out.TargetHostnames = hostnames
|
||||
const machinenumbers = splitList(f.TargetMachineNumbers)
|
||||
const machinenumbers = splitList(form.TargetMachineNumbers)
|
||||
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
|
||||
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 = {
|
||||
Behavior: f.inuseBehavior,
|
||||
Processes: (f.inuseProcesses || []).filter(p => p.name).map(p => {
|
||||
const pd = { Name: p.name }
|
||||
if (p.exepath) pd.ExePath = p.exepath
|
||||
if (p.timeout !== null && p.timeout !== '' && p.timeout !== undefined) {
|
||||
pd.GracefulCloseTimeoutSec = Number(p.timeout)
|
||||
Behavior: form.inuseBehavior,
|
||||
Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
|
||||
const processData = { Name: process.name }
|
||||
if (process.exepath) processData.ExePath = process.exepath
|
||||
if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
|
||||
processData.GracefulCloseTimeoutSec = Number(process.timeout)
|
||||
}
|
||||
return pd
|
||||
return processData
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -470,7 +630,7 @@ 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(e => e.entryid)
|
||||
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 })
|
||||
@@ -540,72 +700,158 @@ loadConfig()
|
||||
|
||||
<style scoped>
|
||||
.imaging-pctypes { max-width: 1200px; }
|
||||
.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 1fr; gap: 1rem; align-items: start; }
|
||||
.scope-list { padding: 0.5rem; }
|
||||
.scope-row { padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; }
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
/* Section header carrying an action button. */
|
||||
.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 */
|
||||
.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: white; }
|
||||
.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.15rem; }
|
||||
.scope-row.active .scope-meta { color: rgba(255,255,255,0.85); }
|
||||
.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); }
|
||||
.scope-detail { padding: 1rem; }
|
||||
.empty-detail { color: var(--text-light); text-align: center; padding: 3rem; }
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.detail-actions { display: flex; gap: 0.4rem; }
|
||||
.mapping-row { display: flex; gap: 0.6rem; align-items: flex-end; margin: 0.75rem 0;
|
||||
flex-wrap: wrap; }
|
||||
.mapping-row label { font-size: 0.75rem; color: var(--text-light); display: flex;
|
||||
flex-direction: column; gap: 0.2rem; }
|
||||
.mapping-row .grow { flex: 1; min-width: 160px; }
|
||||
.versions-panel { background: var(--bg); padding: 0.75rem; border-radius: 6px; margin: 0.5rem 0; }
|
||||
.entries-header { display: flex; justify-content: space-between; align-items: center;
|
||||
margin-top: 1rem; }
|
||||
.order-hint { font-size: 0.75rem; color: var(--text-light); margin: 0.2rem 0 0.5rem; }
|
||||
.entries-table { width: 100%; }
|
||||
.order-cell { white-space: nowrap; }
|
||||
.btn-move { border: 1px solid var(--border); background: var(--bg-card); cursor: pointer;
|
||||
border-radius: 4px; padding: 0 0.35rem; }
|
||||
.btn-move:disabled { opacity: 0.3; cursor: default; }
|
||||
.dim { color: var(--text-light); font-size: 0.82rem; }
|
||||
.row-actions { display: flex; gap: 0.3rem; }
|
||||
.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; }
|
||||
.sim-inputs input { max-width: 130px; }
|
||||
.sim-result { margin-top: 0.5rem; font-size: 0.85rem; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex;
|
||||
align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: var(--bg-card); padding: 1.25rem; border-radius: 8px;
|
||||
min-width: 340px; max-height: 90vh; overflow-y: auto; }
|
||||
.modal-wide { min-width: 640px; }
|
||||
.modal label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.8rem;
|
||||
color: var(--text-light); margin-bottom: 0.5rem; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.advanced summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
.proc-head { display: flex; justify-content: space-between; align-items: center;
|
||||
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;
|
||||
margin-bottom: 0.3rem; }
|
||||
|
||||
.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 */
|
||||
.sim-result {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Modals */
|
||||
.modal-entry { max-width: 720px; }
|
||||
.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); }
|
||||
|
||||
/* Advanced sections inside the entry modal */
|
||||
.advanced {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.advanced summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* In-use process editor */
|
||||
.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.5rem; }
|
||||
.flags-label { display: block; font-size: 0.75rem; color: var(--text-light); margin-bottom: 0.25rem; }
|
||||
.inline { flex-direction: row !important; align-items: center; gap: 0.3rem !important;
|
||||
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;
|
||||
overflow: auto; font-size: 0.78rem; }
|
||||
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Reorder / disabled buttons */
|
||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||
</style>
|
||||
|
||||
@@ -79,27 +79,15 @@ class GeEnforcePlugin(BasePlugin):
|
||||
'geenforce'),
|
||||
]
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
"""ADR-010: the imaging-PC-type manifest editor settings card."""
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Top-level sidebar section (GE-Enforce is a large operational surface,
|
||||
not a mere setting). The tabbed shell hosts Manifests + Reports."""
|
||||
return [
|
||||
{
|
||||
'group': 'Integrations',
|
||||
'to': '/settings/imagingpctypes',
|
||||
'icon': 'settings',
|
||||
'title': 'Imaging PC Types',
|
||||
'description': 'Edit imaging PC types and their GE-Enforce install '
|
||||
'manifests (apps, scripts, files, registry, gates); '
|
||||
'publish, roll back, and simulate.',
|
||||
'position': 30,
|
||||
},
|
||||
{
|
||||
'group': 'Integrations',
|
||||
'to': '/settings/enforcementreports',
|
||||
'icon': 'settings',
|
||||
'title': 'Enforcement Reports',
|
||||
'description': 'Fleet compliance: which PCs received the latest '
|
||||
'manifest and their self-heal / failure results.',
|
||||
'position': 31,
|
||||
'name': 'GE-Enforce',
|
||||
'icon': 'shield',
|
||||
'route': '/geenforce/manifests',
|
||||
'position': 46,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user