Restyled to the same row pattern (one line, details in an expandable body, one action plus a menu, tokens for light and dark): - hardware coverage, audit log (family chips, destructive actions in red, client-side filters, first 300 rows), Clonezilla backups, enrollment packages (active package marked and undeletable, MCL flags with a visible legend) - image Overview, OS and packages, Unattend (sticky save bar), Import - Dell, iPXE, Blancco and BPRT update pages; Dell history links to the rollback review instead of rolling back on one click - boot menu editor (tabs, rows with a menu, lint chips that jump to the line, tokens instead of hard-coded dark colours) and the imaging session detail page Fixes found on the way: - BPRT batch Activate never asked to confirm: a hidden input named 'confirm' shadowed window.confirm in the inline handler, so the form submitted without a prompt. It now calls window.confirm. - Backup upload refused when a backup with that name exists, unless 'Replace the existing backup' is ticked (it overwrote silently). - app.js: em dashes in comments replaced (plain ASCII).
456 lines
25 KiB
HTML
456 lines
25 KiB
HTML
{% extends "base.html" %}
|
|
{% import '_ui.html' as ui %}
|
|
{% block title %}{{ friendly_name }} - OS and Packages{% endblock %}
|
|
|
|
{# OS and Packages tab. Each list is a <table> whose rows are drawn as one
|
|
line plus an expandable body, because static/app.js removes a row with
|
|
.remove-row by its closest <tr> and renumbers .order-num in the tbody.
|
|
Edits are staged in the page; the save bar's Review and save posts only
|
|
the sections that changed (views/images.py os_preview / os_save). #}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
.osp-list, .osp-list tbody, .osp-list tr, .osp-list td { display: block; }
|
|
.osp-list { width: 100%; border-collapse: collapse; margin: 0; }
|
|
.osp-list td { padding: 0; }
|
|
.osp-list tr.row-new > td { background: var(--edit-soft); }
|
|
.osp-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
|
|
.osp-phead h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
|
.osp-phead .sp { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
|
.osp-note { font-size: 13px; color: var(--text-2); padding: 12px 24px; border-top: 1px solid var(--line); line-height: 1.55; }
|
|
.osp-list td.osp-empty { padding: 20px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); font-size: 14px; }
|
|
.osp-head.og, .osp-head.pg, .osp-head.mg { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px !important; padding-bottom: 6px !important; }
|
|
.osp-list .num { color: var(--muted); font-size: 13px; }
|
|
.osp-list .chips { display: flex; gap: 6px; flex-wrap: nowrap; min-width: 0; }
|
|
.osp-list .name { font-weight: 600; }
|
|
|
|
/* Operating systems */
|
|
.og { display: grid; grid-template-columns: 26px minmax(0, 1fr) 54px 150px 190px minmax(0, 1fr) 14px; align-items: center;
|
|
gap: 0 14px; padding: 11px 76px 11px 24px; font-size: 14px; min-height: 24px; }
|
|
/* Packages */
|
|
.pg { display: grid; grid-template-columns: 26px minmax(0, 1.2fr) minmax(0, 1fr) 64px 82px 92px 14px; align-items: center;
|
|
gap: 0 14px; padding: 11px 130px 11px 24px; font-size: 14px; min-height: 24px; }
|
|
/* Hardware models (inputs on the row, no body) */
|
|
.mg { display: grid; grid-template-columns: 26px minmax(0, 1fr) minmax(0, 1.3fr) 190px 84px; align-items: center;
|
|
gap: 0 14px; padding: 8px 24px; font-size: 14px; }
|
|
.mg .field { width: 100%; min-width: 0; height: 32px; font-size: 13px; }
|
|
.mg .ents { display: flex; align-items: center; gap: 8px; white-space: nowrap; min-width: 0; }
|
|
.mg > :last-child { justify-self: end; }
|
|
|
|
.obody { padding: 2px 24px 16px 64px; font-size: 13px; }
|
|
.obody dl { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 16px; }
|
|
.obody dt { color: var(--muted); font-weight: 400; }
|
|
.obody dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
.osp-list .act { display: flex; align-items: center; gap: 10px; }
|
|
.osp-list .act .form-switch { margin: 0; padding-left: 2.5em; min-height: 0; display: flex; align-items: center; }
|
|
.osp-list .act .form-check-input { margin-top: 0; cursor: pointer; }
|
|
|
|
.osp-models > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px; }
|
|
.osp-models > summary h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
|
.osp-models .explain { padding: 0 24px 14px; font-size: 13px; color: var(--text-2); line-height: 1.6; max-width: 980px; }
|
|
.osp-models .explain p { margin: 0 0 8px; }
|
|
.osp-intro { margin: 0; font-size: 14px; color: var(--text-2); line-height: 1.6; max-width: 980px; }
|
|
|
|
.savebar { position: sticky; bottom: 12px; z-index: 50; display: none; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px;
|
|
padding: 12px 12px 12px 20px; border-radius: 12px; background: var(--savebar-bg); border: 1px solid var(--savebar-edge);
|
|
color: var(--savebar-text); box-shadow: 0 8px 24px rgba(0,0,0,.25); }
|
|
.dirty .savebar { display: flex; }
|
|
.savebar .ui-btn { background: transparent; color: var(--savebar-text); border-color: #8E9AAB; }
|
|
.savebar .ui-btn.pri { background: var(--savebar-pri); border-color: var(--savebar-pri); color: var(--on-savebar-pri); }
|
|
.savebar .dirty-flag { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
|
|
.savebar .dirty-flag .dot { background: var(--act); }
|
|
|
|
@container (max-width: 1000px) {
|
|
.og { grid-template-columns: 26px minmax(0, 1fr) 150px 190px 14px; }
|
|
.og .c-id, .og .c-dest { display: none; }
|
|
.pg { grid-template-columns: 26px minmax(0, 1fr) 82px 92px 14px; }
|
|
.pg .c-file, .pg .c-id { display: none; }
|
|
.mg { grid-template-columns: 26px minmax(0, 1fr) minmax(0, 1fr) auto 84px; }
|
|
.mg .ents .sub { display: none; }
|
|
.obody { padding-left: 24px; }
|
|
}
|
|
@container (max-width: 600px) {
|
|
.og, .pg { grid-template-columns: 22px auto minmax(0, 1fr) 14px; padding: 11px 56px 11px 16px; gap: 6px 10px; }
|
|
.pg { padding-right: 108px; }
|
|
.og > .tip:not(.c-dest), .pg > .tip:not(.c-file) { grid-column: 2 / 4; grid-row: 1; }
|
|
.og .c-st, .pg .c-en { grid-column: 2; grid-row: 2; }
|
|
.og .c-wim, .pg .c-disk { grid-column: 3; grid-row: 2; flex-wrap: wrap; }
|
|
.og .chev, .pg .chev { grid-column: 4; grid-row: 1; }
|
|
.osp-head { display: none; }
|
|
.osp-list .ui-row > .act { right: 8px; gap: 4px; }
|
|
.mg { grid-template-columns: 22px minmax(0, 1fr) auto; padding: 10px 16px; gap: 6px 10px; }
|
|
.mg .c-model { grid-column: 2; grid-row: 1; }
|
|
.mg .c-fam { grid-column: 2 / -1; grid-row: 2; }
|
|
.mg .ents { grid-column: 2; grid-row: 3; padding: 0; }
|
|
.mg > :last-child { grid-column: 3; grid-row: 1; }
|
|
.obody { padding: 2px 16px 14px; }
|
|
.osp-phead, .osp-note, .osp-models > summary, .osp-models .explain { padding-left: 16px; padding-right: 16px; }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{% set header_actions %}
|
|
{% if last_save %}
|
|
<form method="POST" action="{{ url_for('images.os_undo', image_type=image_type) }}" class="d-inline"
|
|
onsubmit="return confirm('Put back {{ last_save.files | map(attribute='name') | join(', ') }} as they were before the save of {{ last_save.ts | local_dt }}?');">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="ui-btn" title="Last save: {{ last_save.ts | local_dt }}">Undo last save</button>
|
|
</form>
|
|
{% else %}
|
|
<span class="sub">Undo last save: nothing saved from this page yet</span>
|
|
{% endif %}
|
|
{% endset %}
|
|
{% set header_subtitle = 'Deploy/Control' %}
|
|
{% include "_image_header.html" %}
|
|
|
|
<div class="ui-stack" id="ospPage">
|
|
<p class="osp-intro">
|
|
What PESetup puts on the disk besides drivers: the Windows image from
|
|
<span class="mono">OperatingSystem.json</span>, then the offline servicing packages from
|
|
<span class="mono">packages.json</span>. Edits are staged until <strong>Review and save</strong>,
|
|
which shows the exact diff of every file first.
|
|
</p>
|
|
|
|
{# ==================== Operating system ==================== #}
|
|
<section class="panel ui-list" data-section="operating_systems" aria-labelledby="osp-os-h">
|
|
<div class="osp-phead">
|
|
<h2 id="osp-os-h">Operating system</h2>
|
|
{{ ui.chip(oses | length, 'neutral') }}
|
|
<span class="sp"><span class="sub mono">OperatingSystem.json</span></span>
|
|
</div>
|
|
<div class="og osp-head" aria-hidden="true"><span>#</span><span>Windows</span><span class="c-id">osId</span><span class="c-st">Status</span><span class="c-wim">install.wim</span><span class="c-dest">Folder</span><span></span></div>
|
|
<table class="osp-list" id="ospOsTable">
|
|
<tbody>
|
|
{% for r in oses %}
|
|
{% set osv = r.osv %}
|
|
{% set idx = osv.wimindex if osv.wimindex is defined else (r.entry.wimindex if r.entry.wimindex is defined else '-') %}
|
|
{% set size_differs = osv.wim and osv.wim.size and r.wim.exists and osv.wim.size != r.wim.size %}
|
|
<tr data-json='{{ r.entry | tojson_ordered }}'><td class="ui-row">
|
|
<details>
|
|
<summary class="og">
|
|
<span class="num order-num">{{ loop.index }}</span>
|
|
<span class="tip" data-tip="{{ osv.productName or osv.marketingName or 'Windows' }}{% if osv.versionNumber %} {{ osv.versionNumber }}{% endif %}"><span class="trunc"><span class="name">{{ osv.productName or osv.marketingName or 'Windows' }}</span> <span class="muted">{{ osv.versionNumber }}</span></span></span>
|
|
<span class="mono c-id">{{ osv.id }}</span>
|
|
<span class="chips c-st">
|
|
{% if osv.isActive %}{{ ui.chip('Active', 'ok') }}{% else %}{{ ui.chip('Inactive', 'off') }}{% endif %}
|
|
{% if os_selection is not none and osv.id | string == os_selection | string %}{{ ui.chip('Selected', 'w11', 'user_selections.json OperatingSystemSelection') }}{% endif %}
|
|
</span>
|
|
<span class="chips c-wim">{% if r.wim.exists %}{{ ui.chip('On disk', 'ok') }}{% else %}{{ ui.chip('Not on disk', 'err') }}{% endif %}{% if size_differs %}{{ ui.chip('Size differs', 'warn') }}{% endif %}</span>
|
|
<span class="tip right c-dest" data-tip="{{ r.dest or '(no DestinationDir)' }}"><span class="trunc mono sub">{{ r.dest or '(no DestinationDir)' }}</span></span>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="obody"><dl>
|
|
<dt>Windows</dt><dd>{{ osv.productName or '-' }}{% if osv.marketingName %} ({{ osv.marketingName }}){% endif %}</dd>
|
|
<dt>Version</dt><dd>{{ osv.versionNumber or '-' }}{% if osv.buildNumber %}, build {{ osv.buildNumber }}{% endif %}, osId <span class="mono">{{ osv.id }}</span></dd>
|
|
<dt>DestinationDir</dt><dd class="mono">{{ r.dest or '(no DestinationDir)' }}</dd>
|
|
<dt>install.wim</dt><dd>{% if r.wim.exists %}On disk, {{ r.wim.size | filesizeformat }}, modified {{ r.wim.mtime | local_dt }}{% else %}<strong>install.wim not on disk</strong>{% endif %}, image index {{ idx }}</dd>
|
|
{% if size_differs %}<dt>Size</dt><dd>Differs from the JSON: OperatingSystem.json says {{ osv.wim.size | filesizeformat }} ({{ osv.wim.size }} bytes)</dd>{% endif %}
|
|
</dl></div>
|
|
</details>
|
|
<div class="act">
|
|
{% call ui.more('operating system ' ~ loop.index) %}<button type="button" class="remove-row del">Remove entry</button>{% endcall %}
|
|
</div>
|
|
</td></tr>
|
|
{% endfor %}
|
|
<tr class="empty-row" {% if oses %}style="display:none"{% endif %}><td class="osp-empty">No operating system entries. PESetup has nothing to apply.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="osp-note">
|
|
PESetup applies the image index of <span class="mono">install.wim</span> from the entry's folder
|
|
(<span class="mono">*destinationdir*</span> is this image's share).
|
|
{% if os_selection is not none %}The entry marked Selected is the one the media was built for
|
|
(<span class="mono">OperatingSystemSelection = {{ os_selection }}</span>).{% endif %}
|
|
</div>
|
|
</section>
|
|
|
|
{# ==================== Packages ==================== #}
|
|
<section class="panel ui-list" data-section="packages" aria-labelledby="osp-pk-h">
|
|
<div class="osp-phead">
|
|
<h2 id="osp-pk-h">Packages</h2>
|
|
{{ ui.chip(packages | length, 'neutral') }}
|
|
<span class="sp">
|
|
<span class="sub mono">packages.json</span>
|
|
<button type="button" class="ui-btn" data-bs-toggle="modal" data-bs-target="#packageUploadModal">Upload file</button>
|
|
</span>
|
|
</div>
|
|
<div class="pg osp-head" aria-hidden="true"><span>#</span><span>Package</span><span class="c-file">File</span><span class="c-id">osId</span><span class="c-en">Enabled</span><span class="c-disk">On disk</span><span></span></div>
|
|
<table class="osp-list" id="ospPkgTable">
|
|
<tbody>
|
|
{% for r in packages %}
|
|
{% set p = r.entry %}
|
|
{% set fname = p.fileName or p.FileName or '' %}
|
|
{% set pdest = p.destinationDir or p.DestinationDir or '' %}
|
|
<tr data-json='{{ p | tojson_ordered }}'><td class="ui-row">
|
|
<details>
|
|
<summary class="pg">
|
|
<span class="num order-num">{{ loop.index }}</span>
|
|
<span class="tip" data-tip="{{ p.name or '(no name)' }}"><span class="trunc name">{{ p.name or '(no name)' }}</span></span>
|
|
<span class="tip c-file" data-tip="{{ fname }}"><span class="trunc mono sub">{{ fname }}</span></span>
|
|
<span class="mono c-id">{{ p.osId }}</span>
|
|
<span class="c-en pkg-state">{% if p.enabled %}{{ ui.chip('Enabled', 'ok') }}{% else %}{{ ui.chip('Disabled', 'off') }}{% endif %}</span>
|
|
<span class="c-disk">{% if r.file.exists %}{{ ui.chip('On disk', 'ok') }}{% else %}{{ ui.chip('Missing', 'err') }}{% endif %}</span>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="obody"><dl>
|
|
{% if p.comment %}<dt>Comment</dt><dd>{{ p.comment }}</dd>{% endif %}
|
|
<dt>File</dt><dd class="mono">{{ fname or '-' }}</dd>
|
|
<dt>DestinationDir</dt><dd class="mono">{{ pdest or '-' }}</dd>
|
|
<dt>On disk</dt><dd>{% if r.file.exists %}Yes, {{ r.file.size | filesizeformat }}{% else %}<strong>No</strong>: the file is not in that folder{% endif %}</dd>
|
|
<dt>osId</dt><dd class="mono">{{ p.osId }}</dd>
|
|
</dl></div>
|
|
</details>
|
|
<div class="act">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input pkg-enabled" type="checkbox" role="switch"
|
|
aria-label="Enabled: {{ p.name or fname }}" {% if p.enabled %}checked{% endif %}>
|
|
</div>
|
|
{% call ui.more('package ' ~ loop.index) %}<button type="button" class="remove-row del">Remove entry</button>{% endcall %}
|
|
</div>
|
|
</td></tr>
|
|
{% endfor %}
|
|
<tr class="empty-row" {% if packages %}style="display:none"{% endif %}><td class="osp-empty">No packages configured.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="osp-note">
|
|
A package is applied when it is enabled and its osId list contains the OS being installed.
|
|
Removing an entry here leaves the file on disk.
|
|
</div>
|
|
</section>
|
|
|
|
{# ==================== Hardware models (user_selections.json) ==================== #}
|
|
{% set n_none = models | selectattr('fam.entries', 'equalto', 0) | list | length %}
|
|
<details class="panel ui-list osp-models" data-section="hardware_models" {% if not models %}open{% endif %}>
|
|
<summary>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
<h2>Hardware models</h2>
|
|
{{ ui.chip(models | length, 'neutral') }}
|
|
{% if n_none %}{{ ui.chip(n_none ~ ' with no driver entry', 'warn') }}{% endif %}
|
|
<span class="sub">Media build selection, not used while imaging</span>
|
|
</summary>
|
|
<div class="explain">
|
|
<p>
|
|
Media Creator Lite saved this list when the media was built: the driver families (Family ID) whose packs it
|
|
downloaded, and the OS it built for. <span class="mono">scripts/Download-Drivers.ps1</span> and
|
|
<span class="mono">Upload-Image.ps1</span> read it to choose which driver packs and OS to fetch and upload.
|
|
</p>
|
|
<p>
|
|
PESetup never reads this file: at imaging it matches the PC's WMI model against
|
|
<a href="{{ image_tab_url(image_type, 'drivers') }}">HardwareDriver.json</a> alone, so adding a model here does not
|
|
give a PC drivers. Driver entries counts map entries with the same <span class="mono">family</span>.
|
|
</p>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 10px; align-items: center">
|
|
<span class="sub mono">Tools/user_selections.json</span>
|
|
<button type="button" class="ui-btn" id="ospAddModel" {% if not has_selections %}disabled{% endif %}>Add model</button>
|
|
</div>
|
|
</div>
|
|
<div class="mg osp-head" aria-hidden="true"><span>#</span><span class="c-model">Model</span><span class="c-fam">Family ID</span><span class="ents">Driver entries</span><span></span></div>
|
|
<table class="osp-list" id="ospModelTable">
|
|
<tbody>
|
|
{% for r in models %}
|
|
<tr data-json='{{ r.m | tojson_ordered }}'><td class="ui-row">
|
|
<div class="mg">
|
|
<span class="num order-num">{{ loop.index }}</span>
|
|
<input type="text" class="field c-model" data-field="Model" value="{{ r.m.Model }}" aria-label="Model">
|
|
<input type="text" class="field c-fam" data-field="Id" value="{{ r.m.Id }}" aria-label="Family ID">
|
|
<span class="ents tip right"{% if r.fam.entries %} data-tip="{{ r.fam.on_disk }} of {{ r.fam.entries }} zips on disk"{% endif %}>
|
|
{% if r.fam.entries %}
|
|
{{ ui.chip(r.fam.entries ~ ' entr' ~ ('ies' if r.fam.entries != 1 else 'y'), 'ok' if r.fam.on_disk == r.fam.entries else 'warn') }}
|
|
<span class="sub">{{ r.fam.on_disk }} zip{{ 's' if r.fam.on_disk != 1 }} on disk</span>
|
|
{% else %}
|
|
{{ ui.chip('None', 'off') }}
|
|
{% endif %}
|
|
</span>
|
|
<button type="button" class="ui-btn ghost sm remove-row" aria-label="Remove model {{ r.m.Model }}">Remove</button>
|
|
</div>
|
|
</td></tr>
|
|
{% endfor %}
|
|
<tr class="empty-row" {% if models %}style="display:none"{% endif %}><td class="osp-empty">{% if has_selections %}No hardware models selected.{% else %}No user_selections.json for this image.{% endif %}</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</details>
|
|
|
|
<div class="savebar" role="region" aria-label="Unsaved changes">
|
|
<span class="dirty-flag" id="ospDirty" role="status"><span class="dot" aria-hidden="true"></span> Unsaved changes</span>
|
|
<div style="display: flex; gap: 8px">
|
|
<button type="button" class="ui-btn" id="ospDiscard">Discard</button>
|
|
<button type="button" class="ui-btn pri" id="ospReview" disabled>Review and save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Package upload modal -->
|
|
<div class="modal fade" id="packageUploadModal" tabindex="-1" aria-labelledby="pkgUploadTitle">
|
|
<div class="modal-dialog">
|
|
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title" id="pkgUploadTitle">Upload package</h3>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="form-field">
|
|
<label class="form-label" for="pkgUploadFile">Package file</label>
|
|
<input type="file" class="form-control" name="package_file" id="pkgUploadFile" required>
|
|
<div class="form-text">Lands in <span class="mono">{{ image_type }}/Deploy/Packages/</span>.</div>
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="form-label" for="pkgUploadDest">Destination directory (optional)</label>
|
|
<input type="text" class="form-control mono" name="destination_dir" id="pkgUploadDest"
|
|
placeholder="*destinationdir*\Packages">
|
|
<div class="form-text">Setting this also appends an entry to <span class="mono">packages.json</span>.</div>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
|
|
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="ui-btn pri">Upload</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
// OS and Packages: stage edits in the tables, then Review and save posts
|
|
// only the sections that changed (views/images.py os_preview / os_save).
|
|
(function () {
|
|
var dirty = {};
|
|
var reviewBtn = document.getElementById('ospReview');
|
|
var head = document.querySelector('.image-head');
|
|
var page = document.getElementById('ospPage');
|
|
|
|
function markDirty(section) {
|
|
dirty[section] = true;
|
|
reviewBtn.disabled = false;
|
|
if (head) head.classList.add('dirty');
|
|
if (page) page.classList.add('dirty');
|
|
}
|
|
|
|
function rowsOf(tableId) {
|
|
return Array.prototype.slice.call(
|
|
document.querySelectorAll('#' + tableId + ' tbody tr:not(.empty-row)'));
|
|
}
|
|
|
|
function baseObject(row) {
|
|
try { return JSON.parse(row.getAttribute('data-json') || '{}'); } catch (e) { return {}; }
|
|
}
|
|
|
|
function refreshEmpty(table) {
|
|
var empty = table.querySelector('.empty-row');
|
|
if (!empty) return;
|
|
empty.style.display = table.querySelectorAll('tbody tr:not(.empty-row)').length ? 'none' : '';
|
|
}
|
|
|
|
// Each card carries data-section; any edit inside it marks that section.
|
|
document.querySelectorAll('[data-section]').forEach(function (card) {
|
|
var section = card.getAttribute('data-section');
|
|
card.addEventListener('input', function () { markDirty(section); });
|
|
card.addEventListener('change', function (e) {
|
|
markDirty(section);
|
|
// Keep the row's Enabled chip in step with its switch.
|
|
if (e.target.classList.contains('pkg-enabled')) {
|
|
var st = e.target.closest('tr').querySelector('.pkg-state');
|
|
if (st) st.innerHTML = e.target.checked
|
|
? '<span class="chip c-ok">Enabled</span>' : '<span class="chip c-off">Disabled</span>';
|
|
}
|
|
});
|
|
card.addEventListener('click', function (e) {
|
|
// app.js removes the row and renumbers (delegated on document, so it
|
|
// runs after this); refresh the empty row once it is gone.
|
|
if (e.target.closest('.remove-row')) {
|
|
markDirty(section);
|
|
var table = e.target.closest('table');
|
|
setTimeout(function () { refreshEmpty(table); }, 0);
|
|
}
|
|
});
|
|
});
|
|
|
|
var addBtn = document.getElementById('ospAddModel');
|
|
if (addBtn) {
|
|
addBtn.addEventListener('click', function () {
|
|
var tbody = document.querySelector('#ospModelTable tbody');
|
|
var tr = document.createElement('tr');
|
|
tr.className = 'row-new';
|
|
tr.innerHTML =
|
|
'<td class="ui-row"><div class="mg">' +
|
|
'<span class="num order-num"></span>' +
|
|
'<input type="text" class="field c-model" data-field="Model" placeholder="Model name" aria-label="Model">' +
|
|
'<input type="text" class="field c-fam" data-field="Id" placeholder="Driver family ID" aria-label="Family ID">' +
|
|
'<span class="ents"><span class="chip c-edit">New</span></span>' +
|
|
'<button type="button" class="ui-btn ghost sm remove-row" aria-label="Remove this model">Remove</button>' +
|
|
'</div></td>';
|
|
tbody.insertBefore(tr, tbody.querySelector('.empty-row'));
|
|
rowsOf('ospModelTable').forEach(function (r, i) { r.querySelector('.order-num').textContent = i + 1; });
|
|
refreshEmpty(tbody.closest('table'));
|
|
markDirty('hardware_models');
|
|
tr.querySelector('input').focus();
|
|
});
|
|
}
|
|
|
|
function collect() {
|
|
var body = {};
|
|
if (dirty.operating_systems) {
|
|
body.operating_systems = rowsOf('ospOsTable').map(baseObject);
|
|
}
|
|
if (dirty.packages) {
|
|
// "enabled" keeps the file's own type: the MCL packages.json uses 1/0
|
|
// (PESetup reads it as a number), ours true/false. A row whose switch
|
|
// was not touched is sent exactly as it came.
|
|
var rows = rowsOf('ospPkgTable');
|
|
var numeric = rows.some(function (row) { return typeof baseObject(row).enabled === 'number'; });
|
|
body.packages = rows.map(function (row) {
|
|
var obj = baseObject(row);
|
|
var sw = row.querySelector('.pkg-enabled');
|
|
if (sw && sw.checked !== sw.defaultChecked) {
|
|
var asNumber = typeof obj.enabled === 'number' || (obj.enabled === undefined && numeric);
|
|
obj.enabled = asNumber ? (sw.checked ? 1 : 0) : sw.checked;
|
|
}
|
|
return obj;
|
|
});
|
|
}
|
|
if (dirty.hardware_models) {
|
|
// Keep each row's other keys and key order; only Model/Id change.
|
|
body.hardware_models = rowsOf('ospModelTable').map(function (row) {
|
|
var obj = row.getAttribute('data-json') ? baseObject(row) : { Id: '', Model: '' };
|
|
row.querySelectorAll('[data-field]').forEach(function (inp) {
|
|
obj[inp.getAttribute('data-field')] = inp.value.trim();
|
|
});
|
|
return obj;
|
|
});
|
|
}
|
|
return body;
|
|
}
|
|
|
|
reviewBtn.addEventListener('click', function () {
|
|
PxeImage.reviewAndSave({
|
|
title: 'Review changes: OS and Packages',
|
|
previewUrl: '{{ url_for("images.os_preview", image_type=image_type) }}',
|
|
saveUrl: '{{ url_for("images.os_save", image_type=image_type) }}',
|
|
body: collect(),
|
|
onSaved: function () { dirty = {}; window.location.reload(); }
|
|
});
|
|
});
|
|
|
|
var discardBtn = document.getElementById('ospDiscard');
|
|
if (discardBtn) {
|
|
discardBtn.addEventListener('click', function () {
|
|
if (!confirm('Discard the unsaved changes on this page?')) return;
|
|
dirty = {};
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
window.addEventListener('beforeunload', function (e) {
|
|
if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ''; }
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|