diff --git a/chromium-setup/emmc-provisioning/dashboard/app.py b/chromium-setup/emmc-provisioning/dashboard/app.py index acf4d1e..ab5ebe0 100644 --- a/chromium-setup/emmc-provisioning/dashboard/app.py +++ b/chromium-setup/emmc-provisioning/dashboard/app.py @@ -7,6 +7,7 @@ Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register, import json import os +import shutil import time from pathlib import Path @@ -86,15 +87,55 @@ def _save_network_devices(data): 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(): if not BACKUPS_DIR.is_dir(): return [] + meta = _load_backups_meta() out = [] 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: 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: pass return out @@ -241,9 +282,65 @@ def api_backups(): return jsonify({"backups": list_backups()}) -@app.route("/api/backups/") +@app.route("/api/backups//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/", 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/", methods=["GET"]) 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 path = BACKUPS_DIR / name if not path.is_file(): @@ -251,5 +348,17 @@ def api_backup_download(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__": app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/chromium-setup/emmc-provisioning/dashboard/templates/index.html b/chromium-setup/emmc-provisioning/dashboard/templates/index.html index 6c47d01..c9861ea 100644 --- a/chromium-setup/emmc-provisioning/dashboard/templates/index.html +++ b/chromium-setup/emmc-provisioning/dashboard/templates/index.html @@ -233,6 +233,50 @@ font-size: 0.85em; 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) ----- */ details { @@ -330,27 +374,39 @@ - +
-

Devices waiting for action

+

Capture image or deploy

+

To capture (backup) an image from a device: connect it in USB boot mode or register over network. When the device appears below, click Backup to save its eMMC to a file. To write an image to a device, click Deploy (requires a golden image).

- + +

Saved backups

+

- + + +
FileNameDescription Size DateActions
- +
@@ -455,7 +511,9 @@ container.appendChild(el); }); + const placeholder = document.getElementById('noPendingPlaceholder'); noPending.style.display = hasAny ? 'none' : 'block'; + if (placeholder) placeholder.style.display = hasAny ? 'none' : 'flex'; container.querySelectorAll('button[data-action]').forEach(function(btn) { btn.onclick = function() { @@ -486,9 +544,105 @@ empty.style.display = 'none'; backups.forEach(function(b) { const tr = document.createElement('tr'); - tr.innerHTML = '' + escapeHtml(b.name) + '' + fmtSize(b.size) + '' + fmtDate(b.mtime) + ''; + tr.dataset.name = b.name; + const displayName = (b.display_name || b.name); + const desc = (b.description || ''); + tr.innerHTML = + '' + escapeHtml(displayName) + '' + + '' + escapeHtml(desc) + '' + + '' + fmtSize(b.size) + '' + + '' + fmtDate(b.mtime) + '' + + '' + + ' ' + + ' ' + + 'Download' + + ''; 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() { @@ -520,10 +674,12 @@ fetchLog(); fetchPending(); fetchBackups(); + fetchGoldenInfo(); setInterval(fetchStatus, 2000); setInterval(fetchLog, 4000); setInterval(fetchPending, 2000); setInterval(fetchBackups, 5000); + setInterval(fetchGoldenInfo, 10000);