Enhance eMMC provisioning dashboard: add backup metadata management, implement backup renaming and setting golden image functionality, and improve UI for backup actions and descriptions.

This commit is contained in:
nearxos
2026-02-18 14:28:02 +02:00
parent 1b902d18e6
commit c42e7951d0
2 changed files with 275 additions and 10 deletions

View File

@@ -7,6 +7,7 @@ Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register,
import json import json
import os import os
import shutil
import time import time
from pathlib import Path from pathlib import Path
@@ -86,15 +87,55 @@ def _save_network_devices(data):
return False return False
BACKUPS_META_FILE = BACKUPS_DIR / "backups_meta.json"
def _load_backups_meta():
try:
if BACKUPS_META_FILE.is_file():
with open(BACKUPS_META_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_backups_meta(data):
try:
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
with open(BACKUPS_META_FILE, "w") as f:
json.dump(data, f, indent=2)
return True
except (PermissionError, OSError):
return False
def _safe_backup_name(name):
"""Reject path traversal and ensure it's a backup filename we manage."""
if not name or ".." in name or "/" in name or "\\" in name:
return False
if not (name.endswith(".img") or name.endswith(".img.gz")):
return False
return True
def list_backups(): def list_backups():
if not BACKUPS_DIR.is_dir(): if not BACKUPS_DIR.is_dir():
return [] return []
meta = _load_backups_meta()
out = [] out = []
for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file() and p.suffix in (".img", ".img.gz"): if p.is_file() and p.suffix in (".img", ".img.gz") and p.name != "backups_meta.json":
try: try:
st = p.stat() st = p.stat()
out.append({"name": p.name, "size": st.st_size, "mtime": st.st_mtime}) m = meta.get(p.name, {})
out.append({
"name": p.name,
"display_name": m.get("name") or p.name,
"description": m.get("description") or "",
"size": st.st_size,
"mtime": st.st_mtime,
})
except OSError: except OSError:
pass pass
return out return out
@@ -241,9 +282,65 @@ def api_backups():
return jsonify({"backups": list_backups()}) return jsonify({"backups": list_backups()})
@app.route("/api/backups/<path:name>") @app.route("/api/backups/<path:name>/set-as-golden", methods=["POST"])
def api_backup_set_as_golden(name):
"""Copy this backup to golden.img so it becomes the image used for Deploy."""
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid backup name"}), 400
path = BACKUPS_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "backup not found"}), 404
try:
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, GOLDEN_IMAGE)
return jsonify({"ok": True, "message": f"Golden image set from {name}"})
except (OSError, IOError) as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/backups/<path:name>", methods=["PATCH"])
def api_backup_update(name):
"""Update backup metadata (display name, description) or rename the file."""
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid backup name"}), 400
path = BACKUPS_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "backup not found"}), 404
body = request.get_json(force=True, silent=True) or {}
meta = _load_backups_meta()
entry = meta.get(name, {})
new_filename = (body.get("filename") or "").strip()
if new_filename:
if not _safe_backup_name(new_filename):
return jsonify({"ok": False, "error": "invalid new filename"}), 400
new_path = BACKUPS_DIR / new_filename
if new_path.exists() and new_path != path:
return jsonify({"ok": False, "error": "target filename already exists"}), 409
try:
path.rename(new_path)
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
meta[new_filename] = {"name": entry.get("name") or name, "description": entry.get("description") or ""}
if name in meta:
del meta[name]
name = new_filename
path = BACKUPS_DIR / name
else:
if "name" in body:
entry["name"] = (body.get("name") or "").strip() or path.name
if "description" in body:
entry["description"] = (body.get("description") or "").strip()
meta[name] = entry
if not _save_backups_meta(meta):
return jsonify({"ok": False, "error": "could not save metadata"}), 500
return jsonify({"ok": True, "name": name})
@app.route("/api/backups/<path:name>", methods=["GET"])
def api_backup_download(name): def api_backup_download(name):
if ".." in name or "/" in name or "\\" in name: if not _safe_backup_name(name):
return jsonify({"error": "invalid name"}), 400 return jsonify({"error": "invalid name"}), 400
path = BACKUPS_DIR / name path = BACKUPS_DIR / name
if not path.is_file(): if not path.is_file():
@@ -251,5 +348,17 @@ def api_backup_download(name):
return send_file(path, as_attachment=True, download_name=name) return send_file(path, as_attachment=True, download_name=name)
@app.route("/api/golden-info")
def api_golden_info():
"""Return whether golden image exists and its size/mtime for UI."""
if not GOLDEN_IMAGE.is_file():
return jsonify({"present": False})
try:
st = GOLDEN_IMAGE.stat()
return jsonify({"present": True, "size": st.st_size, "mtime": st.st_mtime})
except OSError:
return jsonify({"present": False})
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False) app.run(host="0.0.0.0", port=5000, debug=False)

View File

@@ -233,6 +233,50 @@
font-size: 0.85em; font-size: 0.85em;
color: var(--text-muted); color: var(--text-muted);
} }
.backup-name-edit, .backup-desc-edit {
cursor: text;
padding: 0.2rem 0.35rem;
margin: -0.2rem -0.35rem;
border-radius: 4px;
}
.backup-name-edit:hover, .backup-desc-edit:hover { background: var(--bg-tertiary); }
.backup-name-edit input, .backup-desc-edit input, .backup-desc-edit textarea {
width: 100%;
min-width: 120px;
background: var(--bg-tertiary);
border: 1px solid var(--accent);
color: var(--text);
padding: 0.25rem 0.4rem;
border-radius: 4px;
font: inherit;
}
.backup-desc-edit textarea { min-height: 2em; resize: vertical; }
.backups-table .actions-cell { white-space: nowrap; }
.backups-table .btn-sm { padding: 0.35rem 0.6rem; font-size: 0.8rem; }
.golden-badge { font-size: 0.7rem; color: var(--accent); font-weight: 600; }
.backups-table a.download-link { margin-right: 0.5rem; }
.backup-deploy-hint {
font-size: 0.9rem;
color: var(--text-dim);
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
border-left: 3px solid var(--accent);
}
.placeholder-actions {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-tertiary);
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.9rem;
}
.placeholder-actions .btns { display: flex; gap: 0.5rem; }
.placeholder-actions .btn { opacity: 0.5; pointer-events: none; }
/* ----- Help & Log (collapsible) ----- */ /* ----- Help & Log (collapsible) ----- */
details { details {
@@ -330,27 +374,39 @@
</div> </div>
</section> </section>
<!-- 2. Devices waiting for action --> <!-- 2. Capture (Backup) or Deploy -->
<section class="section"> <section class="section">
<h2 class="section-title">Devices waiting for action</h2> <h2 class="section-title">Capture image or deploy</h2>
<p class="backup-deploy-hint">To <strong>capture (backup)</strong> an image from a device: connect it in USB boot mode or register over network. When the device appears below, click <strong>Backup</strong> to save its eMMC to a file. To write an image to a device, click <strong>Deploy</strong> (requires a golden image).</p>
<div id="pendingDevices"></div> <div id="pendingDevices"></div>
<p id="noPending" class="empty-msg" style="display:none;">No devices. Connect reTerminal in USB boot mode or register over network.</p> <div id="noPendingPlaceholder" class="placeholder-actions" style="display:none;">
<span>Connect a device to see:</span>
<span class="btns">
<button type="button" class="btn btn-outline btn-sm" disabled>Backup</button>
<button type="button" class="btn btn-primary btn-sm" disabled>Deploy</button>
</span>
<span>— USB boot mode or network registration</span>
</div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Connect reTerminal in USB boot mode (eMMC disable jumper + USB to host) or register a network-booted device — then the <strong>Backup</strong> and <strong>Deploy</strong> buttons will appear above.</p>
</section> </section>
<!-- 3. Saved backups --> <!-- 3. Saved backups -->
<section class="section"> <section class="section">
<h2 class="section-title">Saved backups</h2> <h2 class="section-title">Saved backups</h2>
<p id="goldenHint" class="backups-mono" style="margin-bottom:0.75rem;font-size:0.8rem;"></p>
<table class="backups-table" id="backupsTable"> <table class="backups-table" id="backupsTable">
<thead> <thead>
<tr> <tr>
<th>File</th> <th>Name</th>
<th>Description</th>
<th>Size</th> <th>Size</th>
<th>Date</th> <th>Date</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="backupsBody"></tbody> <tbody id="backupsBody"></tbody>
</table> </table>
<p id="backupsEmpty" class="empty-msg" style="display:none;">No backups yet.</p> <p id="backupsEmpty" class="empty-msg" style="display:none;">No backups yet. Capture one from a device (Backup above), then set it as golden for future deploys.</p>
</section> </section>
<!-- 4. How to connect (collapsible) --> <!-- 4. How to connect (collapsible) -->
@@ -455,7 +511,9 @@
container.appendChild(el); container.appendChild(el);
}); });
const placeholder = document.getElementById('noPendingPlaceholder');
noPending.style.display = hasAny ? 'none' : 'block'; noPending.style.display = hasAny ? 'none' : 'block';
if (placeholder) placeholder.style.display = hasAny ? 'none' : 'flex';
container.querySelectorAll('button[data-action]').forEach(function(btn) { container.querySelectorAll('button[data-action]').forEach(function(btn) {
btn.onclick = function() { btn.onclick = function() {
@@ -486,9 +544,105 @@
empty.style.display = 'none'; empty.style.display = 'none';
backups.forEach(function(b) { backups.forEach(function(b) {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = '<td><a href="/api/backups/' + encodeURIComponent(b.name) + '" download>' + escapeHtml(b.name) + '</a></td><td class="backups-mono">' + fmtSize(b.size) + '</td><td class="backups-mono">' + fmtDate(b.mtime) + '</td>'; tr.dataset.name = b.name;
const displayName = (b.display_name || b.name);
const desc = (b.description || '');
tr.innerHTML =
'<td class="backup-name-edit" data-field="name" title="Click to rename">' + escapeHtml(displayName) + '</td>' +
'<td class="backup-desc-edit" data-field="description" title="Click to add description">' + escapeHtml(desc) + '</td>' +
'<td class="backups-mono">' + fmtSize(b.size) + '</td>' +
'<td class="backups-mono">' + fmtDate(b.mtime) + '</td>' +
'<td class="actions-cell">' +
'<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' +
'<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' +
'<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a>' +
'</td>';
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
bindBackupEdits();
bindSetGolden();
bindRenameFile();
}
function bindRenameFile() {
document.querySelectorAll('.rename-file-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
const newName = prompt('New filename (e.g. production-v1.img)', name);
if (newName == null || newName.trim() === '') return;
const n = newName.trim();
if (!/\.(img|img\.gz)$/i.test(n)) { alert('Filename must end with .img or .img.gz'); return; }
if (n === name) return;
fetch('/api/backups/' + encodeURIComponent(name), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: n }) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) fetchBackups();
else alert(data.error || 'Failed');
})
.catch(function() { alert('Request failed'); });
};
});
}
function bindBackupEdits() {
document.querySelectorAll('.backup-name-edit[data-field], .backup-desc-edit[data-field]').forEach(function(cell) {
if (cell._bound) return;
cell._bound = true;
cell.addEventListener('click', function() {
if (cell.querySelector('input, textarea')) return;
const field = cell.getAttribute('data-field');
const row = cell.closest('tr');
const filename = row.dataset.name;
const isDesc = field === 'description';
const current = cell.textContent.trim();
const input = document.createElement(isDesc ? 'textarea' : 'input');
input.type = isDesc ? 'text' : 'text';
input.value = current;
input.placeholder = isDesc ? 'Add a description…' : 'Name';
cell.textContent = '';
cell.appendChild(input);
input.focus();
if (isDesc) input.rows = 2;
function save() {
const val = input.value.trim();
const body = isDesc ? { description: val } : { name: val || filename };
fetch('/api/backups/' + encodeURIComponent(filename), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); }
else { alert(data.error || 'Failed'); cell.innerHTML = escapeHtml(current); }
})
.catch(function() { cell.innerHTML = escapeHtml(current); });
}
input.addEventListener('blur', save);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !isDesc) { e.preventDefault(); input.blur(); }
});
});
});
}
function bindSetGolden() {
document.querySelectorAll('.set-golden-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
if (!confirm('Set this backup as the golden image? Future deploys will use it.\n\n' + name)) return;
fetch('/api/backups/' + encodeURIComponent(name) + '/set-as-golden', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); fetchGoldenInfo(); }
else alert(data.error || 'Failed');
})
.catch(function() { alert('Request failed'); });
};
});
}
function fetchGoldenInfo() {
fetch('/api/golden-info').then(function(r) { return r.json(); }).then(function(d) {
const el = document.getElementById('goldenHint');
el.textContent = d.present ? ('Golden image: ' + fmtSize(d.size) + ', updated ' + fmtDate(d.mtime)) : 'No golden image set. Capture a backup and click "Set as golden" to use it for Deploy.';
}).catch(function() {});
} }
function fetchStatus() { function fetchStatus() {
@@ -520,10 +674,12 @@
fetchLog(); fetchLog();
fetchPending(); fetchPending();
fetchBackups(); fetchBackups();
fetchGoldenInfo();
setInterval(fetchStatus, 2000); setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000); setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000); setInterval(fetchPending, 2000);
setInterval(fetchBackups, 5000); setInterval(fetchBackups, 5000);
setInterval(fetchGoldenInfo, 10000);
</script> </script>
</body> </body>
</html> </html>