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:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user