Implement user authentication and admin features in eMMC provisioning dashboard: add SQLite database for user management, create admin log functionality, and enhance session handling. Update README to reflect new public and admin access levels, and improve deployment scripts to support cloud-init images and portal files management.

This commit is contained in:
nearxos
2026-02-19 15:17:47 +02:00
parent 987e71c36e
commit 39aa042dc9
9 changed files with 1167 additions and 61 deletions

View File

@@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · CM4 Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
--border: #2d333b; --danger: #f87171; --warn: #fbbf24; --success: #00d4aa; --radius: 10px; --radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
.wrap { max-width: 1000px; margin: 0 auto; padding: 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.header h1 { font-size: 1.25rem; font-weight: 700; }
.header span { color: var(--text-dim); font-size: 0.9rem; }
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
.header a:hover { color: var(--accent); }
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); color: var(--bg-primary); }
.btn-primary:hover { background: var(--accent-dim); }
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
.btn-danger { background: rgba(248,113,113,0.2); color: var(--danger); }
.btn-danger:hover { background: rgba(248,113,113,0.3); }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
td { padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
.empty-msg { text-align: center; padding: 1rem; font-size: 0.9rem; color: var(--text-muted); }
details { margin-top: 0.5rem; }
summary { cursor: pointer; font-weight: 500; color: var(--text-dim); padding: 0.4rem 0; }
summary::-webkit-details-marker { display: none; }
summary::before { content: '▶'; display: inline-block; margin-right: 0.4rem; font-size: 0.6rem; transition: transform 0.2s; }
details[open] summary::before { transform: rotate(90deg); }
.actions-cell { white-space: nowrap; }
.actions-cell .btn { margin-right: 0.25rem; margin-bottom: 0.2rem; }
input[type="text"], input[type="password"], textarea, select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
textarea { width: 100%; min-height: 80px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; }
.golden-badge { font-size: 0.7rem; color: var(--accent); font-weight: 600; }
.portal-url { font-size: 0.8rem; color: var(--text-muted); word-break: break-all; margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<h1>Admin</h1>
<span>Logged in as <strong>{{ username }}</strong></span>
<a href="/">Deploy</a>
<a href="/logout">Log out</a>
</header>
<!-- Golden image -->
<div class="section">
<h2 class="section-title">Current deploy image (golden)</h2>
<p id="goldenInfo" class="mono" style="font-size:0.9rem;">Loading…</p>
</div>
<!-- Backups -->
<div class="section">
<h2 class="section-title">
Backup images
<button type="button" class="btn btn-outline btn-sm" id="refreshBackupsBtn">Refresh</button>
<button type="button" class="btn btn-outline btn-sm" id="uploadBackupBtn">Upload backup</button>
<input type="file" id="uploadBackupInput" accept=".img,.img.gz,.img.xz" style="display:none;" />
</h2>
<p id="backupsDirHint" class="mono" style="font-size:0.75rem; margin-bottom:0.5rem; color:var(--text-muted);"></p>
<table id="backupsTable">
<thead><tr><th>Name</th><th>Description</th><th>Size</th><th>Date</th><th>Actions</th></tr></thead>
<tbody id="backupsBody"></tbody>
</table>
<p id="backupsEmpty" class="empty-msg" style="display:none;">No backups. Capture from device on home page or upload here.</p>
</div>
<!-- Cloud-init images -->
<div class="section">
<h2 class="section-title">Cloud-init images <button type="button" class="btn btn-outline btn-sm" id="refreshCloudinitBtn">Refresh</button></h2>
<table id="cloudinitTable">
<thead><tr><th>Name</th><th>Description</th><th>Size</th><th>Date</th><th>Actions</th></tr></thead>
<tbody id="cloudinitBody"></tbody>
</table>
<p id="cloudinitEmpty" class="empty-msg" style="display:none;">No cloud-init images. Build one below.</p>
</div>
<!-- Build cloud-init (download latest + edit) -->
<div class="section">
<h2 class="section-title">Download & build cloud-init image</h2>
<p style="font-size:0.9rem; color:var(--text-dim); margin-bottom:0.75rem;">Download latest Raspberry Pi OS (arm64), inject cloud-init. Output goes to <strong>Cloud-init images</strong> above.</p>
<div style="margin-bottom:0.5rem;">
<label>Variant: </label>
<select id="buildVariant"><option value="lite">Lite</option><option value="full">Full</option></select>
<span id="buildRaspiosUrl" class="mono" style="margin-left:0.5rem;"></span>
</div>
<div style="margin-bottom:0.5rem;"><label><input type="checkbox" id="buildSetGolden" /> Set as golden after build</label></div>
<div style="margin-bottom:0.75rem;"><button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download & build</button></div>
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
<details style="margin-top:0.75rem;">
<summary>Edit cloud-init (user-data, meta-data, network-config)</summary>
<div style="margin-top:0.75rem;">
<p>Templates: <select id="buildTemplateSelect"><option value="">— Load —</option></select>
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save as template…</button></p>
<ul id="buildTemplateList" style="list-style:none; padding:0; font-size:0.85rem; margin-bottom:0.5rem;"></ul>
<label>user-data (YAML)</label>
<textarea id="buildUserData" rows="6" placeholder="#cloud-config..."></textarea>
<label style="display:block; margin-top:0.5rem;">meta-data (optional)</label>
<textarea id="buildMetaData" rows="2"></textarea>
<label style="display:block; margin-top:0.5rem;">network-config (optional)</label>
<textarea id="buildNetworkConfig" rows="4"></textarea>
</div>
</details>
</div>
<!-- Portal files (wget in cloud-init) -->
<div class="section">
<h2 class="section-title">Portal files (for wget in cloud-init) <button type="button" class="btn btn-outline btn-sm" id="uploadPortalBtn">Upload file</button><input type="file" id="uploadPortalInput" style="display:none;" /></h2>
<p class="mono" style="font-size:0.8rem; margin-bottom:0.5rem;">Files here are served at <strong id="portalBaseUrl">/files/</strong> — use in user-data e.g. <code>curl -fsSL "http://THIS_SERVER/files/bootstrap.sh" -o /tmp/bootstrap.sh</code></p>
<table id="portalTable"><thead><tr><th>File</th><th>Size</th><th>Actions</th></tr></thead><tbody id="portalBody"></tbody></table>
<p id="portalEmpty" class="empty-msg" style="display:none;">No files. Upload scripts or configs for first-boot.</p>
</div>
<!-- Users -->
<div class="section">
<h2 class="section-title">Admin users <button type="button" class="btn btn-outline btn-sm" id="addUserBtn">Add user</button></h2>
<div id="addUserForm" style="display:none; margin-bottom:0.75rem; padding:0.75rem; background:var(--bg-tertiary); border-radius:var(--radius-sm);">
<input type="text" id="newUsername" placeholder="Username" />
<input type="password" id="newPassword" placeholder="Password (min 6)" style="margin-left:0.25rem;" />
<button type="button" id="addUserSubmit" class="btn btn-primary btn-sm" style="margin-left:0.25rem;">Create</button>
<button type="button" id="addUserCancel" class="btn btn-outline btn-sm">Cancel</button>
</div>
<table id="usersTable"><thead><tr><th>Username</th><th>Created</th><th>Actions</th></tr></thead><tbody id="usersBody"></tbody></table>
</div>
<!-- Activity logs -->
<div class="section">
<h2 class="section-title">Activity logs <button type="button" class="btn btn-outline btn-sm" id="refreshLogsBtn">Refresh</button></h2>
<div id="adminLogs" class="mono" style="font-size:0.8rem; max-height:200px; overflow:auto;"></div>
</div>
</div>
<script>
function authFetch(url, opts) {
opts = opts || {};
return fetch(url, opts).then(function(r) {
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
return r;
});
}
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); }
function fetchGolden() {
authFetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
var el = document.getElementById('goldenInfo');
if (!d.present) { el.textContent = 'No golden image set. Set one from Backup images or Cloud-init images above.'; return; }
el.innerHTML = fmtSize(d.size) + ', updated ' + fmtDate(d.mtime) + (d.name ? ' — <span class="golden-badge">' + (d.source === 'cloudinit' ? 'Cloud-init' : 'Backup') + ': ' + escapeHtml(d.name) + '</span>' : '');
}).catch(function(){});
}
function renderBackups(backups, backupsDir) {
var tbody = document.getElementById('backupsBody');
var empty = document.getElementById('backupsEmpty');
var dirHint = document.getElementById('backupsDirHint');
if (backupsDir) dirHint.textContent = 'Dir: ' + backupsDir;
tbody.innerHTML = '';
if (!backups || backups.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
backups.forEach(function(b){
var isRaw = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
var shrink = isRaw ? '<button type="button" class="btn btn-outline btn-sm set-golden-backup" data-name="'+escapeHtml(b.name)+'">Set golden</button> <button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="'+escapeHtml(b.name)+'">Shrink</button> <button type="button" class="btn btn-outline btn-sm compress-btn" data-name="'+escapeHtml(b.name)+'">Compress</button> ' : '<button type="button" class="btn btn-outline btn-sm set-golden-backup" data-name="'+escapeHtml(b.name)+'">Set golden</button> ';
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(b.display_name||b.name)+'</td><td>'+escapeHtml(b.description||'')+'</td><td class="mono">'+fmtSize(b.size)+'</td><td class="mono">'+fmtDate(b.mtime)+'</td><td class="actions-cell">'+shrink+'<a href="/api/backups/'+encodeURIComponent(b.name)+'" download class="btn btn-outline btn-sm">Download</a> <button type="button" class="btn btn-outline btn-sm rename-backup" data-name="'+escapeHtml(b.name)+'">Rename</button> <button type="button" class="btn btn-outline btn-sm delete-backup" data-name="'+escapeHtml(b.name)+'">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.set-golden-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Set as golden?\n'+n)) return; authFetch('/api/backups/'+encodeURIComponent(n)+'/set-as-golden', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); } else alert(d.error); }).catch(function(){}); };
});
tbody.querySelectorAll('.shrink-btn').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Shrink (PiShrink)? '+n)) return; btn.disabled=true; authFetch('/api/backups/'+encodeURIComponent(n)+'/shrink', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }).finally(function(){ btn.disabled=false; }); };
});
tbody.querySelectorAll('.compress-btn').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Compress to .img.xz? '+n)) return; btn.disabled=true; authFetch('/api/backups/'+encodeURIComponent(n)+'/compress', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({format:'xz'}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }).finally(function(){ btn.disabled=false; }); };
});
tbody.querySelectorAll('.rename-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); var newName = prompt('New filename', n); if (!newName || !newName.trim()) return; authFetch('/api/backups/'+encodeURIComponent(n), { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({filename: newName.trim()}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }); };
});
tbody.querySelectorAll('.delete-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/backups/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); } else alert(d.error); }); };
});
}
function fetchBackups() {
authFetch('/api/backups').then(function(r){ return r.json(); }).then(function(d){ renderBackups(d.backups || [], d.backups_dir); }).catch(function(){});
}
function renderCloudinit(images) {
var tbody = document.getElementById('cloudinitBody');
var empty = document.getElementById('cloudinitEmpty');
tbody.innerHTML = '';
if (!images || images.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
images.forEach(function(b){
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(b.display_name||b.name)+'</td><td>'+escapeHtml(b.description||'')+'</td><td class="mono">'+fmtSize(b.size)+'</td><td class="mono">'+fmtDate(b.mtime)+'</td><td class="actions-cell"><button type="button" class="btn btn-outline btn-sm set-golden-cloudinit" data-name="'+escapeHtml(b.name)+'">Set golden</button> <a href="/api/cloudinit-images/'+encodeURIComponent(b.name)+'" download class="btn btn-outline btn-sm">Download</a> <button type="button" class="btn btn-outline btn-sm rename-cloudinit" data-name="'+escapeHtml(b.name)+'">Rename</button> <button type="button" class="btn btn-outline btn-sm delete-cloudinit" data-name="'+escapeHtml(b.name)+'">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.set-golden-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Set as golden?\n'+n)) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n)+'/set-as-golden', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchCloudinit(); fetchGolden(); } else alert(d.error); }).catch(function(){}); };
});
tbody.querySelectorAll('.rename-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); var newName = prompt('New filename', n); if (!newName || !newName.trim()) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n), { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({filename: newName.trim()}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchCloudinit(); else alert(d.error); }); };
});
tbody.querySelectorAll('.delete-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchCloudinit(); fetchGolden(); } else alert(d.error); }); };
});
}
function fetchCloudinit() {
authFetch('/api/cloudinit-images').then(function(r){ return r.json(); }).then(function(d){ renderCloudinit(d.images || []); }).catch(function(){});
}
function renderPortal(files, baseUrl) {
var tbody = document.getElementById('portalBody');
var empty = document.getElementById('portalEmpty');
document.getElementById('portalBaseUrl').textContent = baseUrl || '';
tbody.innerHTML = '';
if (!files || files.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
files.forEach(function(f){
var tr = document.createElement('tr');
tr.innerHTML = '<td><a href="'+escapeHtml(baseUrl+f.name)+'" target="_blank" rel="noopener">'+escapeHtml(f.name)+'</a></td><td class="mono">'+fmtSize(f.size)+'</td><td><button type="button" class="btn btn-outline btn-sm delete-portal" data-name="'+escapeHtml(f.name)+'">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.delete-portal').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/portal-files/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchPortal(); else alert(d.error); }); };
});
}
function fetchPortal() {
authFetch('/api/portal-files').then(function(r){ return r.json(); }).then(function(d){ renderPortal(d.files || [], d.base_url); }).catch(function(){});
}
function renderUsers(users) {
var tbody = document.getElementById('usersBody');
tbody.innerHTML = '';
(users || []).forEach(function(u){
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(u.username)+'</td><td class="mono">'+fmtDate(u.created_at)+'</td><td><button type="button" class="btn btn-outline btn-sm chpwd-btn" data-id="'+u.id+'" data-username="'+escapeHtml(u.username)+'">Change password</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.chpwd-btn').forEach(function(btn){
btn.onclick = function(){ var id = btn.getAttribute('data-id'); var username = btn.getAttribute('data-username'); var pw = prompt('New password for '+username+' (min 6 characters)'); if (!pw || pw.length < 6) { if(pw!==null) alert('Min 6 characters'); return; } authFetch('/api/admin/users/'+id+'/password', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({password: pw}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) alert('Password updated'); else alert(d.error); }); };
});
}
function fetchUsers() {
authFetch('/api/admin/users').then(function(r){ return r.json(); }).then(function(d){ renderUsers(d.users); }).catch(function(){});
}
function renderLogs(logs) {
var el = document.getElementById('adminLogs');
if (!logs || logs.length === 0) { el.textContent = 'No logs.'; return; }
el.innerHTML = logs.map(function(l){ return fmtDate(l.created_at) + ' ' + (l.username || '') + ' ' + escapeHtml(l.action) + (l.details ? ' ' + escapeHtml(l.details) : ''); }).join('\n');
}
function fetchLogs() {
authFetch('/api/admin/logs').then(function(r){ return r.json(); }).then(function(d){ renderLogs(d.logs); }).catch(function(){});
}
function fetchBuildStatus() {
authFetch('/api/build-cloudinit-status').then(function(r){ return r.json(); }).then(function(d){
var el = document.getElementById('buildCloudInitStatus');
var btn = document.getElementById('buildCloudInitBtn');
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if(btn) btn.disabled = busy;
if(d.phase === 'idle' && !d.message) el.textContent = '';
else if(d.phase === 'done') { el.textContent = 'Done: '+ (d.output_name||'') + ' — see Cloud-init images above.'; fetchCloudinit(); fetchGolden(); }
else if(d.phase === 'error') el.textContent = 'Error: ' + (d.error||'');
else el.textContent = (d.phase||'') + ': ' + (d.message||'');
if(busy) setTimeout(fetchBuildStatus, 5000);
}).catch(function(){});
}
function startBuild() {
var btn = document.getElementById('buildCloudInitBtn');
if(btn) btn.disabled = true;
var body = { variant: document.getElementById('buildVariant').value, set_as_golden_after: document.getElementById('buildSetGolden').checked };
var ud = document.getElementById('buildUserData').value.trim();
var md = document.getElementById('buildMetaData').value.trim();
var nc = document.getElementById('buildNetworkConfig').value.trim();
if(ud) body.user_data = ud; if(md) body.meta_data = md; if(nc) body.network_config = nc;
authFetch('/api/build-cloudinit', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r){ return r.json(); }).then(function(d){
if(d.ok) { document.getElementById('buildCloudInitStatus').textContent = 'Build started…'; setTimeout(fetchBuildStatus, 2000); }
else { alert(d.error); if(btn) btn.disabled = false; }
}).catch(function(){ if(btn) btn.disabled = false; });
}
function fetchTemplates() {
authFetch('/api/cloudinit-templates').then(function(r){ return r.json(); }).then(function(d){
var list = d.templates || [];
var sel = document.getElementById('buildTemplateSelect');
sel.innerHTML = '<option value="">— Load —</option>';
list.forEach(function(t){ var o = document.createElement('option'); o.value = t.id; o.textContent = t.name; sel.appendChild(o); });
var ul = document.getElementById('buildTemplateList');
ul.innerHTML = list.map(function(t){ return '<li>'+escapeHtml(t.name)+' <button type="button" class="btn btn-outline btn-sm load-tpl" data-id="'+t.id+'">Load</button> <button type="button" class="btn btn-outline btn-sm del-tpl" data-id="'+t.id+'">Delete</button></li>'; }).join('') || '<li>No templates</li>';
ul.querySelectorAll('.load-tpl').forEach(function(b){ b.onclick = function(){ authFetch('/api/cloudinit-templates/'+b.getAttribute('data-id')).then(function(r){ return r.json(); }).then(function(t){ document.getElementById('buildUserData').value = t.user_data||''; document.getElementById('buildMetaData').value = t.meta_data||''; document.getElementById('buildNetworkConfig').value = t.network_config||''; }); }; });
ul.querySelectorAll('.del-tpl').forEach(function(b){ b.onclick = function(){ if(!confirm('Delete template?')) return; authFetch('/api/cloudinit-templates/'+b.getAttribute('data-id'), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchTemplates(); }); }; });
}).catch(function(){});
}
function loadTemplateFromSelect() { var s = document.getElementById('buildTemplateSelect'); if(s && s.value) authFetch('/api/cloudinit-templates/'+s.value).then(function(r){ return r.json(); }).then(function(t){ document.getElementById('buildUserData').value = t.user_data||''; document.getElementById('buildMetaData').value = t.meta_data||''; document.getElementById('buildNetworkConfig').value = t.network_config||''; }); }
function saveTemplate() { var name = prompt('Template name'); if(!name || !name.trim()) return; var body = { name: name.trim(), user_data: document.getElementById('buildUserData').value, meta_data: document.getElementById('buildMetaData').value, network_config: document.getElementById('buildNetworkConfig').value }; authFetch('/api/cloudinit-templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchTemplates(); alert('Saved'); } else alert(d.error); }); }
document.getElementById('refreshBackupsBtn').onclick = fetchBackups;
document.getElementById('uploadBackupBtn').onclick = function(){ document.getElementById('uploadBackupInput').click(); };
document.getElementById('uploadBackupInput').onchange = function(){
var f = this.files && this.files[0];
if(!f) return;
var fd = new FormData(); fd.append('file', f);
authFetch('/api/backups/upload', { method: 'POST', body: fd }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); alert('Uploaded '+d.name); } else alert(d.error); }).catch(function(){});
this.value = '';
};
document.getElementById('refreshCloudinitBtn').onclick = fetchCloudinit;
document.getElementById('buildCloudInitBtn').onclick = startBuild;
document.getElementById('buildVariant').onchange = function(){ authFetch('/api/raspios-latest-url?variant='+encodeURIComponent(document.getElementById('buildVariant').value)).then(function(r){ return r.json(); }).then(function(d){ document.getElementById('buildRaspiosUrl').textContent = d.ok && d.filename ? d.filename : (d.error||''); }); };
document.getElementById('buildTemplateLoad').onclick = loadTemplateFromSelect;
document.getElementById('buildTemplateSave').onclick = saveTemplate;
document.getElementById('uploadPortalBtn').onclick = function(){ document.getElementById('uploadPortalInput').click(); };
document.getElementById('uploadPortalInput').onchange = function(){
var f = this.files && this.files[0];
if(!f) return;
var fd = new FormData(); fd.append('file', f);
authFetch('/api/portal-files/upload', { method: 'POST', body: fd }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchPortal(); alert('Uploaded. URL: '+d.url); } else alert(d.error); }).catch(function(){});
this.value = '';
};
document.getElementById('refreshLogsBtn').onclick = fetchLogs;
document.getElementById('addUserBtn').onclick = function(){ document.getElementById('addUserForm').style.display = 'block'; };
document.getElementById('addUserCancel').onclick = function(){ document.getElementById('addUserForm').style.display = 'none'; };
document.getElementById('addUserSubmit').onclick = function(){
var username = document.getElementById('newUsername').value.trim();
var password = document.getElementById('newPassword').value;
if(!username) { alert('Username required'); return; }
if(password.length < 6) { alert('Password min 6 characters'); return; }
authFetch('/api/admin/users', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username: username, password: password}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { document.getElementById('addUserForm').style.display = 'none'; document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; fetchUsers(); } else alert(d.error); });
};
fetchBackups(); fetchCloudinit(); fetchPortal(); fetchUsers(); fetchLogs(); fetchGolden(); fetchBuildStatus(); fetchTemplates();
authFetch('/api/raspios-latest-url').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('buildRaspiosUrl').textContent = d.ok && d.filename ? d.filename : (d.error||''); });
setInterval(fetchBuildStatus, 15000);
setInterval(fetchLogs, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deploy · CM4 Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e14;
--bg-secondary: #11151c;
--bg-card: #151a24;
--accent: #00d4aa;
--accent-dim: #00b894;
--text: #e6e8eb;
--text-dim: #8b949e;
--text-muted: #5c6370;
--border: #2d333b;
--danger: #f87171;
--warn: #fbbf24;
--success: #00d4aa;
--radius: 10px;
--radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.45; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.02em; }
.header a { color: var(--text-dim); text-decoration: none; font-size: 0.9rem; }
.header a:hover { color: var(--accent); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
}
.card-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.6rem; }
.status-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.status-pill { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; padding: 0.25rem 0.6rem; border-radius: 999px; }
.status-pill.idle { background: rgba(0,212,170,0.15); color: var(--accent); }
.status-pill.waiting_choice, .status-pill.rpiboot, .status-pill.flashing, .status-pill.backup { background: rgba(251,191,36,0.15); color: var(--warn); }
.status-pill.done { background: rgba(0,212,170,0.15); color: var(--success); }
.status-pill.error { background: rgba(248,113,113,0.15); color: var(--danger); }
.status-msg { flex: 1; min-width: 0; font-size: 0.9rem; }
.status-err { width: 100%; margin-top: 0.4rem; font-size: 0.8rem; color: var(--danger); }
.progress-track { margin-top: 0.5rem; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); transition: width 0.3s; }
.progress-fill.indeterminate { width: 35%; animation: slide 1.2s ease-in-out infinite; }
@keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
.device-item {
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
padding: 0.6rem 0.75rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm);
margin-bottom: 0.4rem;
}
.device-desc { font-size: 0.85rem; color: var(--text-dim); }
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); color: var(--bg-primary); }
.btn-primary:hover { background: var(--accent-dim); }
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
.golden-info { font-size: 0.85rem; color: var(--text-dim); }
.golden-info strong { color: var(--text); }
.empty-msg { text-align: center; padding: 1rem; font-size: 0.85rem; color: var(--text-muted); }
details { margin-top: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
summary { padding: 0.5rem 0.75rem; font-size: 0.85rem; font-weight: 500; color: var(--text-dim); cursor: pointer; list-style: none; }
summary::-webkit-details-marker { display: none; }
summary::before { content: '▶'; display: inline-block; margin-right: 0.4rem; font-size: 0.6rem; transition: transform 0.2s; }
details[open] summary::before { transform: rotate(90deg); }
details .inner { padding: 0.75rem; padding-top: 0; font-size: 0.8rem; color: var(--text-dim); border-top: 1px solid var(--border); }
.log-pre { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-sm); padding: 0.5rem 0.75rem; max-height: 160px; overflow: auto; white-space: pre-wrap; word-break: break-all; color: var(--text-muted); }
.steps-list { list-style: none; }
.steps-list li { display: flex; gap: 0.5rem; padding: 0.3rem 0; }
.steps-list .num { flex-shrink: 0; width: 1.2rem; height: 1.2rem; background: var(--bg-secondary); color: var(--text-dim); border-radius: 50%; font-size: 0.7rem; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; }
.steps-list strong { color: var(--text); }
.help-sub { font-weight: 600; color: var(--text); margin: 0.5rem 0 0.2rem 0; font-size: 0.85rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<h1>CM4 eMMC Provisioning</h1>
<a href="/admin">Admin</a>
</header>
<div class="grid">
<!-- Left: Status + Deploy -->
<div>
<div class="card" style="margin-bottom: 1rem;">
<h2 class="card-title">Status</h2>
<div id="status" class="status-row">
<span id="statusPill" class="status-pill idle">Idle</span>
<span id="statusMsg" class="status-msg">Waiting for device</span>
</div>
<div id="statusErr" class="status-err" style="display:none;"></div>
<div id="statusClearWrap" style="display:none; margin-top:0.5rem;"><button type="button" id="statusClearBtn" class="btn btn-outline btn-sm">Clear message</button></div>
<div id="progressWrap" class="progress-track" style="display:none;"><div id="progressFill" class="progress-fill"></div></div>
</div>
<div class="card">
<h2 class="card-title">Capture or deploy</h2>
<p id="shrinkOptionWrap" style="display:none; margin-bottom: 0.5rem; font-size: 0.8rem;"><label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label></p>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Use USB boot mode or register over network.</p>
</div>
</div>
<!-- Right: Golden + Log + Connect -->
<div>
<div class="card" style="margin-bottom: 1rem;">
<h2 class="card-title">Current deploy image</h2>
<p id="goldenInfo" class="golden-info">Loading…</p>
</div>
<details class="card" style="padding: 0;">
<summary>Recent log</summary>
<div class="inner"><pre id="log" class="log-pre"></pre></div>
</details>
<details class="card" style="padding: 0; margin-top: 1rem;">
<summary>How to connect</summary>
<div class="inner">
<p class="help-sub">USB boot</p>
<ol class="steps-list">
<li><span class="num">1</span> Set reTerminal to <strong>boot mode</strong> (eMMC disable jumper).</li>
<li><span class="num">2</span> Connect USB to host; choose <strong>Backup</strong> or <strong>Deploy</strong> above.</li>
<li><span class="num">3</span> Remove jumper and power cycle when done.</li>
</ol>
<p class="help-sub">Network</p>
<ol class="steps-list">
<li><span class="num">1</span> Enable network boot; device must reach this server.</li>
<li><span class="num">2</span> Boot with provisioning client; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div>
</details>
</div>
</div>
</div>
<script>
const phaseLabels = { idle: 'Idle', rpiboot: 'Connecting', waiting_choice: 'Choose action', flashing: 'Flashing', backup: 'Backing up', done: 'Done', error: 'Error' };
function renderStatus(data) {
const phase = data.phase || 'idle';
document.getElementById('statusPill').className = 'status-pill ' + phase;
document.getElementById('statusPill').textContent = phaseLabels[phase] || phase;
document.getElementById('statusMsg').textContent = data.message || '';
const err = document.getElementById('statusErr');
if (data.error) { err.textContent = data.error; err.style.display = 'block'; } else { err.style.display = 'none'; }
var clearWrap = document.getElementById('statusClearWrap');
if (clearWrap) clearWrap.style.display = (phase === 'error' ? 'block' : 'none');
const progress = data.progress;
const inProgress = ['rpiboot', 'flashing', 'backup'].includes(phase);
const wrap = document.getElementById('progressWrap');
const fill = document.getElementById('progressFill');
if (inProgress || (phase === 'done' && progress != null)) {
wrap.style.display = 'block';
fill.classList.remove('indeterminate');
fill.style.width = (progress != null ? progress + '%' : '35%');
if (progress == null) fill.classList.add('indeterminate');
} else wrap.style.display = 'none';
}
function renderPending(usb, network) {
const container = document.getElementById('pendingDevices');
const noPending = document.getElementById('noPending');
container.innerHTML = '';
let hasAny = false;
const shrinkWrap = document.getElementById('shrinkOptionWrap');
if (usb) {
hasAny = true;
shrinkWrap.style.display = 'block';
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">USB device — choose action</div><div><button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="usb" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
} else shrinkWrap.style.display = 'none';
(network || []).forEach(function(d) {
hasAny = true;
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div><div><button type="button" class="btn btn-outline btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
});
noPending.style.display = hasAny ? 'none' : 'block';
container.querySelectorAll('button[data-action]').forEach(function(btn) {
btn.onclick = function() {
const body = { source: btn.getAttribute('data-source'), action: btn.getAttribute('data-action') };
if (body.source === 'network') body.mac = btn.getAttribute('data-mac');
if (body.action === 'backup' && document.getElementById('shrinkAfterBackup').checked) body.shrink = true;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) { if (data.ok) { fetchPending(); fetchStatus(); } else alert(data.error || 'Failed'); })
.catch(function() { alert('Request failed'); });
};
});
}
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); }
function fetchStatus() { fetch('/api/status').then(function(r){ return r.json(); }).then(renderStatus).catch(function(){ renderStatus({ phase: 'error', message: 'Could not load status.' }); }); }
function fetchPending() { fetch('/api/pending-devices').then(function(r){ return r.json(); }).then(function(d){ renderPending(d.usb || null, d.network || []); }).catch(function(){ renderPending(null, []); }); }
function fetchLog() { fetch('/api/log').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('log').textContent = d.log || ''; }).catch(function(){}); }
function fetchGolden() {
fetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
const el = document.getElementById('goldenInfo');
if (!d.present) { el.innerHTML = 'No image set. An admin must set a backup or cloud-init image as golden for Deploy.'; return; }
let t = 'Ready for deploy: ' + fmtSize(d.size) + ', updated ' + fmtDate(d.mtime);
if (d.name) t += ' <br><strong>' + (d.source === 'cloudinit' ? 'Cloud-init' : 'Backup') + ':</strong> ' + escapeHtml(d.name);
el.innerHTML = t;
}).catch(function(){ document.getElementById('goldenInfo').textContent = 'Could not load.'; });
}
document.getElementById('statusClearBtn').addEventListener('click', function(){ fetch('/api/status-clear', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchStatus(); }); });
fetchStatus(); fetchLog(); fetchPending(); fetchGolden();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchGolden, 10000);
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin login · CM4 Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root { --bg: #0a0e14; --bg-card: #151a24; --accent: #00d4aa; --text: #e6e8eb; --text-dim: #8b949e; --border: #2d333b; --danger: #f87171; --radius: 10px; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.box { width: 100%; max-width: 360px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem; }
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
p.sub { font-size: 0.9rem; color: var(--text-dim); margin-bottom: 1.5rem; }
.err { font-size: 0.85rem; color: var(--danger); margin-bottom: 1rem; }
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.35rem; color: var(--text-dim); }
input[type="text"], input[type="password"] { width: 100%; padding: 0.6rem 0.75rem; font: inherit; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); margin-bottom: 1rem; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; padding: 0.65rem; font: inherit; font-weight: 600; background: var(--accent); color: var(--bg); border: none; border-radius: 6px; cursor: pointer; }
button:hover { background: #00b894; }
a.back { display: inline-block; margin-top: 1rem; font-size: 0.9rem; color: var(--text-dim); text-decoration: none; }
a.back:hover { color: var(--accent); }
</style>
</head>
<body>
<div class="box">
<h1>Admin login</h1>
<p class="sub">CM4 eMMC provisioning dashboard</p>
{% if error %}<p class="err">{{ error }}</p>{% endif %}
<form method="post" action="{{ url_for('login') }}">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username" value="{{ request.form.username or '' }}">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Log in</button>
</form>
<a href="{{ url_for('index') }}" class="back">← Back to deploy</a>
</div>
</body>
</html>