Files
pxe-server/webapp/templates/backups.html
cproudlock 9d56a5369e Clonezilla back up / restore by machine number
Boot menu: "Clonezilla: back up this PC" and "restore this PC" ask for the
machine number and boot Clonezilla (64-bit with Debian shim, or 32-bit
i686 by cpuid) with ocs_repository=smb3://clonezilla:<pw>@server/clonezilla/,
ocs_prerun fetching clonezilla-scripts/pxe-cz.sh and ocs_live_run running
it. The script validates the machine number, saves
<machine>-<YYYY-MM-DD-HHMM> (server time), writes pxe-meta.txt, runs
ocs-chkimg, and restores only that machine's images with -k1 -r after a
typed YES. The old entry is "Clonezilla: manual".

Playbook: clonezilla Samba user for the [clonezilla] share only, password
from clonezilla_smb_password (validated, filled into the menu
placeholder), scripts installed to the web root.

Webapp: Clonezilla backups page lists image folders and zips grouped by
machine number with serial/model/version/check, serial mismatch flag,
delete, move to another machine, streamed tar download, disk space bar
and an explicit keep-last-N retention with preview. Tests and a VM test
plan added.
2026-10-15 15:26:30 -04:00

360 lines
22 KiB
HTML

{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}Clonezilla backups - PXE Manager{% endblock %}
{# Clonezilla backups, grouped by machine number (newest activity first):
image directories saved by the boot menu's "Clonezilla: back up this PC"
(or by hand) and the older machine-number .zip files. One line per
image; its details (disks, Clonezilla version, check) are in the
expandable body; Download is the row's action, Move and Delete are in
the menu and open confirm modals. #}
{% macro gb(n) -%}
{%- if n >= 1e12 %}{{ "%.2f"|format(n / 1e12) }} TB{% elif n >= 1e9 %}{{ "%.1f"|format(n / 1e9) }} GB{% else %}{{ "%.0f"|format(n / 1e6) }} MB{% endif -%}
{%- endmacro %}
{% macro status_chip(i) -%}
{%- if i.status == 'ok' %}{{ ui.chip('Checked', 'ok', 'Clonezilla image check passed' ~ (' ' ~ i.check_date if i.check_date else '')) }}
{%- elif i.status == 'check-failed' %}{{ ui.chip('Check failed', 'fail', 'The image check after saving failed: do not rely on this image') }}
{%- elif i.status == 'failed' %}{{ ui.chip('Save failed', 'fail', 'Clonezilla reported an error while saving: partial image') }}
{%- elif i.status == 'unchecked' %}{{ ui.chip('Not checked', 'warn', 'Saved, but the image check did not run') }}
{%- elif i.status == 'in-progress' %}{{ ui.chip('In progress?', 'run', 'No pxe-meta.txt yet and changed in the last 6 hours: probably still being written') }}
{%- else %}{{ ui.chip('Manual', 'neutral', 'Saved without the PXE menu script: no check result recorded') }}{% endif -%}
{%- endmacro %}
{% block extra_head %}
<style>
.igrid { display: grid; grid-template-columns: 186px minmax(0, 1fr) 140px 84px 118px 14px; align-items: center;
gap: 0 14px; padding: 11px 184px 11px 24px; font-size: 14px; min-height: 30px; }
.ihead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; min-height: 0; }
.i-when { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.i-serial.odd { color: var(--warn-text); font-weight: 600; }
.i-size { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.mach { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 14px 24px 10px; border-top: 1px solid var(--line-2); background: var(--surface-2); cursor: pointer; }
details.mgroup > summary { list-style: none; }
details.mgroup > summary::-webkit-details-marker { display: none; }
.mach h3 { margin: 0; font-size: 15px; font-weight: 600; }
.mach .sp { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ibody { padding: 2px 24px 16px 208px; font-size: 13px; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; }
.ibody dt { color: var(--muted); font-weight: 400; }
.ibody dd { margin: 0; overflow-wrap: anywhere; }
.bl-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
.bnote { padding: 18px 24px; font-size: 13px; color: var(--text-2); display: flex; flex-direction: column; gap: 6px; }
.bnote p { margin: 0; }
.space { padding: 16px 24px; display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
.space .row1 { display: flex; flex-wrap: wrap; gap: 4px 16px; align-items: baseline; }
.sbar { height: 10px; border-radius: 5px; background: var(--surface-3); box-shadow: inset 0 0 0 1px var(--line-2); overflow: hidden; }
.sbar > i { display: block; height: 100%; background: var(--ok); }
.sbar.warn > i { background: var(--act-track); }
.plan { padding: 16px 24px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
.plan ul { margin: 0; padding-left: 18px; max-height: 18rem; overflow-y: auto; }
.plan .btns { display: flex; gap: 8px; flex-wrap: wrap; }
.setform { width: min(340px, calc(100vw - 32px)); padding: 14px; gap: 10px; }
.setform label { display: flex; flex-direction: column; gap: 4px; }
@container (max-width: 1000px) {
.igrid { grid-template-columns: 186px minmax(0, 1fr) 118px 14px; }
.igrid .i-serial, .igrid .i-size { display: none; }
.ibody { padding-left: 24px; }
}
@container (max-width: 640px) {
.igrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 11px 16px; gap: 2px 10px; }
.igrid .i-when { grid-column: 1 / -1; }
.igrid .i-size { display: none; }
.ihead { display: none; }
.ui-row > .act { position: static; display: flex; justify-content: flex-end; padding: 0 16px 10px; }
.ibody { padding: 2px 16px 14px; grid-template-columns: minmax(0, 1fr); }
.ibody dt { margin-top: 4px; }
.mach, .space, .plan { padding-left: 16px; padding-right: 16px; }
}
</style>
{% endblock %}
{% block content %}
{% set nmach = groups | rejectattr('machine', 'equalto', '') | list | length %}
{% call ui.head('Clonezilla backups', 'Tools', image_count ~ ' image' ~ ('s' if image_count != 1) ~ ' and ' ~ (backups | length) ~ ' zip' ~ ('s' if backups | length != 1) ~ ' for ' ~ nmach ~ ' machine' ~ ('s' if nmach != 1) ~ ' on the share') %}
<details class="more">
<summary class="ui-btn">Settings</summary>
<form class="menu setform" method="post" action="{{ url_for('clonezilla_settings') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<label for="keepLast" class="sub">Keep last N images per machine (0 = off)
<input id="keepLast" type="number" name="keep_last" min="0" max="100" value="{{ cz_settings.keep_last }}" class="field" required></label>
<label for="warnPct" class="sub">Warn when the share is this full (%)
<input id="warnPct" type="number" name="warn_percent" min="50" max="99" value="{{ cz_settings.warn_percent }}" class="field" required></label>
<div class="sub" style="white-space: normal">Nothing is deleted automatically: Apply retention shows what would go and asks first.</div>
<button type="submit" class="ui-btn pri">Save settings</button>
</form>
</details>
{% if cz_settings.keep_last %}
<a class="ui-btn" href="{{ url_for('clonezilla_retention_preview') }}">Apply retention...</a>
{% endif %}
<button type="button" class="ui-btn pri" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload backup</button>
{% endcall %}
<div class="ui-stack">
{% if plan is not none %}
<section class="panel plan" aria-labelledby="plan-h">
<h2 id="plan-h" class="sec" style="font-size: 15px; margin: 0">Apply retention: keep the last {{ plan_keep }} image{{ 's' if plan_keep != 1 }} per machine</h2>
{% if plan %}
<div class="notice bad">These {{ plan | length }} image{{ 's' if plan | length != 1 }} ({{ gb(plan | sum(attribute='size')) }}) will be DELETED. This cannot be undone.</div>
<p class="sub" style="margin: 0">Kept per machine: the {{ plan_keep }} newest images that saved and passed (or skipped) the check. Newer failed or in-progress images are kept too. Images with no machine number are never touched.</p>
<ul>
{% for p in plan %}<li><span class="mono">{{ p.name }}</span> <span class="sub">machine {{ p.machine }} | {{ p.when | timestamp_fmt }} | {{ gb(p.size) }}</span></li>{% endfor %}
</ul>
<form method="post" action="{{ url_for('clonezilla_retention_apply') }}" onsubmit="return confirm('Delete {{ plan | length }} image(s)? This cannot be undone.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% for p in plan %}<input type="hidden" name="name" value="{{ p.name }}">{% endfor %}
<div class="btns">
<a class="ui-btn pri" href="{{ url_for('clonezilla_backups') }}">Cancel</a>
<button type="submit" class="ui-btn danger">Delete {{ plan | length }} image{{ 's' if plan | length != 1 }}</button>
</div>
</form>
{% else %}
<div class="notice info">Nothing to delete: no machine has more than {{ plan_keep }} good image{{ 's' if plan_keep != 1 }}.</div>
<div class="btns"><a class="ui-btn" href="{{ url_for('clonezilla_backups') }}">Back</a></div>
{% endif %}
</section>
{% endif %}
{% if space %}
<section class="panel space" aria-labelledby="sp-h">
<h2 id="sp-h" class="vh">Share disk space</h2>
<div class="row1">
<strong>Share disk space</strong>
<span>{{ gb(space.used) }} used of {{ gb(space.total) }} ({{ "%.0f"|format(space.percent) }}%)</span>
<span class="sub">{{ gb(space.free) }} free</span>
{% if space.warn %}{{ ui.chip('Over ' ~ space.warn_percent ~ '%', 'warn') }}{% endif %}
</div>
<div class="sbar{{ ' warn' if space.warn }}" role="meter" aria-label="Share disk space used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ '%.0f'|format(space.percent) }}"><i style="width: {{ '%.1f'|format(space.percent) }}%"></i></div>
{% if space.warn %}<div class="notice">The share is {{ "%.0f"|format(space.percent) }}% full (warning at {{ space.warn_percent }}%). Delete old images, or set Keep last N in Settings and Apply retention. A backup that runs out of space fails part-way.</div>{% endif %}
</section>
{% endif %}
{% if mismatches %}
<div class="notice" role="status">{{ mismatches }} machine{{ 's have' if mismatches != 1 else ' has' }} images with different serial numbers (marked Serial mismatch below): a typo in the machine number, or the PC was replaced. Move the wrong images to the right machine number.</div>
{% endif %}
<section class="panel ui-list" aria-labelledby="bl-h">
<h2 id="bl-h" class="vh">Backups by machine</h2>
{% if groups %}
<div class="igrid ihead" aria-hidden="true"><span>Saved</span><span>Model</span><span class="i-serial">Serial</span><span class="i-size">Size</span><span>Check</span><span></span></div>
{% for g in groups %}
{% set label = g.machine or 'No machine number' %}
<details class="mgroup" {% if loop.index <= 6 or g.mismatch %}open{% endif %} data-machine="{{ g.machine }}">
<summary class="mach">
<h3 class="{{ 'mono' if g.machine }}">{{ label }}</h3>
<span class="sub">{{ g.images | length }} image{{ 's' if g.images | length != 1 }}{% if g.zips %}, {{ g.zips | length }} zip{{ 's' if g.zips | length != 1 }}{% endif %} | {{ gb(g.size) }}</span>
<span class="sp">
{% if g.mismatch %}{{ ui.chip('Serial mismatch', 'warn', 'Serials: ' ~ (g.serials | join(', '))) }}<span class="sub mono">{{ g.serials | join(', ') }}</span>{% endif %}
<span class="chev" aria-hidden="true">&#9662;</span>
</span>
</summary>
{% for i in g.images %}
<div class="ui-row irow" data-name="{{ i.name }}">
<details>
<summary class="igrid">
<span class="i-when mono">{{ i.when | timestamp_fmt }}</span>
<span class="tip" data-tip="{{ i.name }}"><span class="trunc">{{ i.model or i.name }}</span></span>
<span class="i-serial mono tip{{ ' odd' if i.serial_differs }}" data-tip="{{ (i.serial ~ (' (differs from the newest image of this machine)' if i.serial_differs else '')) if i.serial else 'no serial recorded' }}"><span class="trunc">{{ i.serial or '-' }}</span></span>
<span class="i-size">{{ gb(i.size) }}</span>
<span>{{ status_chip(i) }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<dl class="ibody">
<dt>Image</dt><dd class="mono">{{ i.name }}</dd>
<dt>Machine</dt><dd><span class="mono">{{ i.machine or '-' }}</span> <span class="sub">({{ {'meta': 'from pxe-meta.txt', 'name': 'from the folder name'}.get(i.machine_source, 'unknown: move it to a machine number') }})</span></dd>
<dt>PC</dt><dd>{{ i.manufacturer }} {{ i.model or '-' }} | serial <span class="mono">{{ i.serial or '-' }}</span>{% if i.serial_differs %} {{ ui.chip('Different serial', 'warn', 'Not the serial of the newest image of this machine') }}{% endif %}</dd>
<dt>Size</dt><dd>{{ gb(i.size) }}</dd>
<dt>Disk</dt><dd><span class="mono">{{ i.disks | join(' ') or '-' }}</span>{% if i.disk_model %} | {{ i.disk_model }}{% endif %}{% if i.disk_size %} | {{ gb(i.disk_size) }}{% endif %}</dd>
<dt>Partitions</dt><dd class="mono">{{ i.parts | join(' ') or '-' }}</dd>
<dt>Clonezilla</dt><dd>{{ i.clonezilla or '-' }}{% if i.bits %} | {{ i.bits }}-bit{% endif %}{% if i.firmware %} | {{ i.firmware | upper }}{% endif %}</dd>
<dt>Saved</dt><dd>{{ i.date_text or (i.when | timestamp_fmt) }}{% if i.save == 'failed' %} | <strong>save failed</strong>{% endif %}</dd>
<dt>Check</dt><dd>{{ {'ok': 'passed', 'failed': 'FAILED', 'not-run': 'not run'}.get(i.check, 'no result recorded') }}{% if i.check_date %} ({{ i.check_date }}){% endif %}</dd>
</dl>
</details>
<div class="act">
<a class="ui-btn" href="{{ url_for('clonezilla_image_tar', name=i.name) }}" title="Download the image folder as a .tar (streamed)">Download .tar</a>
{% call ui.more(i.name) %}
<button type="button" class="copy-btn" data-copy-text="{{ i.name }}">Copy image name</button>
<button type="button" data-bs-toggle="modal" data-bs-target="#moveModal" data-name="{{ i.name }}" data-machine="{{ i.machine }}">Move to another machine...</button>
<div class="sep"></div>
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#imageDeleteModal"
data-name="{{ i.name }}" data-machine="{{ label }}">Delete image...</button>
{% endcall %}
</div>
</div>
{% endfor %}
{% for b in g.zips %}
<div class="ui-row zrow">
<div class="igrid">
<span class="i-when mono">{{ b.modified | timestamp_fmt }}</span>
<span class="tip" data-tip="{{ b.filename }}"><span class="trunc mono">{{ b.filename }}</span></span>
<span class="i-serial sub">-</span>
<span class="i-size">{{ gb(b.size) }}</span>
<span>{{ ui.chip('Zip', 'neutral', 'Uploaded .zip backup') }}</span>
<span></span>
</div>
<div class="act">
<a class="ui-btn" href="{{ url_for('clonezilla_download', filename=b.filename) }}">Download</a>
{% call ui.more(b.machine) %}
<button type="button" class="copy-btn" data-copy-text="{{ b.filename }}">Copy file name</button>
<div class="sep"></div>
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}">Delete backup...</button>
{% endcall %}
</div>
</div>
{% endfor %}
</details>
{% endfor %}
{% else %}
<div class="bl-empty">No backups yet. Boot a PC from the network and pick <strong>Clonezilla: back up this PC</strong>, or upload a .zip.</div>
{% endif %}
</section>
<section class="panel bnote" aria-labelledby="bn-h">
<h2 id="bn-h" class="sec" style="font-size: 15px">How backups are named</h2>
<p>The boot menu's <strong>Clonezilla: back up this PC</strong> asks for the machine number and saves <span class="mono">&lt;machine&gt;-&lt;YYYY-MM-DD-HHMM&gt;</span> (for example <span class="mono">6501-2026-10-15-1403</span>) to <span class="mono">\\pxe-server\clonezilla</span>, then checks it. <strong>Clonezilla: restore this PC</strong> lists only that machine's images, newest first.</p>
<p>Images saved by hand (manual mode) are grouped by the start of their folder name; move them to a machine number if they show under No machine number. Uploaded zips are named with the machine number, for example <span class="mono">6501.zip</span>.</p>
</section>
</div>
<!-- Upload Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadTitle">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h3 class="modal-title" id="uploadTitle">Upload backup</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-field mb-0">
<label for="backupFile" class="form-label">Backup file (.zip)</label>
<input type="file" class="form-control" id="backupFile" name="backup_file"
accept=".zip" required>
<div class="form-text">
Name the file with the machine number (e.g. <span class="mono">6501.zip</span>).
A file with the same name as an existing backup is refused unless you tick the box below.
</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="backupOverwrite">
<label class="form-check-label" for="backupOverwrite">Replace the existing backup with the same name</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>
</form>
</div>
</div>
</div>
<!-- Delete zip Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteTitle">
<div class="modal-dialog">
<div class="modal-content">
<form id="deleteForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h3 class="modal-title" id="deleteTitle">Delete backup</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p>
<p class="muted mb-0">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="ui-btn danger">Delete</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete image Confirmation Modal -->
<div class="modal fade" id="imageDeleteModal" tabindex="-1" aria-labelledby="imageDeleteTitle">
<div class="modal-dialog">
<div class="modal-content">
<form id="imageDeleteForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h3 class="modal-title" id="imageDeleteTitle">Delete image</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Delete the image <strong class="mono" id="imageDeleteName"></strong> of machine <strong id="imageDeleteMachine"></strong>?</p>
<p class="muted mb-0">The whole image folder is removed from the share. This cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="ui-btn danger">Delete image</button>
</div>
</form>
</div>
</div>
</div>
<!-- Move image Modal -->
<div class="modal fade" id="moveModal" tabindex="-1" aria-labelledby="moveTitle">
<div class="modal-dialog">
<div class="modal-content">
<form id="moveForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h3 class="modal-title" id="moveTitle">Move image to another machine</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-2">Image <strong class="mono" id="moveName"></strong></p>
<div class="form-field mb-0">
<label for="moveMachine" class="form-label">New machine number</label>
<input type="text" class="form-control mono" id="moveMachine" name="machine" required maxlength="32"
pattern="[A-Za-z0-9][A-Za-z0-9\-]{0,31}" autocomplete="off" spellcheck="false">
<div class="form-text">Letters, digits and dash. The folder is renamed to <span class="mono">&lt;machine&gt;-&lt;same date&gt;</span> and its pxe-meta.txt updated, so restore by machine number finds it.</div>
</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">Move</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
var btn = event.relatedTarget;
var filename = btn.getAttribute('data-filename');
var machine = btn.getAttribute('data-machine');
document.getElementById('deleteMachine').textContent = machine;
document.getElementById('deleteForm').action = "{{ url_for('clonezilla_delete', filename='__F__') }}".replace('__F__', encodeURIComponent(filename));
});
document.getElementById('imageDeleteModal').addEventListener('show.bs.modal', function (event) {
var btn = event.relatedTarget;
var name = btn.getAttribute('data-name');
document.getElementById('imageDeleteName').textContent = name;
document.getElementById('imageDeleteMachine').textContent = btn.getAttribute('data-machine');
document.getElementById('imageDeleteForm').action = "{{ url_for('clonezilla_image_delete', name='__N__') }}".replace('__N__', encodeURIComponent(name));
});
document.getElementById('moveModal').addEventListener('show.bs.modal', function (event) {
var btn = event.relatedTarget;
var name = btn.getAttribute('data-name');
document.getElementById('moveName').textContent = name;
var inp = document.getElementById('moveMachine');
inp.value = btn.getAttribute('data-machine') || '';
document.getElementById('moveForm').action = "{{ url_for('clonezilla_image_move', name='__N__') }}".replace('__N__', encodeURIComponent(name));
setTimeout(function () { inp.focus(); inp.select(); }, 200);
});
</script>
{% endblock %}