Files
reterminal-dm4/emmc-provisioning/dashboard/templates/home.html
nearxos a915e5a405 Enhance dashboard UI for EEPROM update functionality
Update the home and index templates to include the new EEPROM update action in the device management interface. Modify status handling to reflect the addition of the 'eeprom_update' phase, ensuring proper user feedback during operations. Introduce a clear status button and improve the layout for device actions, enhancing overall user experience and interaction with the dashboard.
2026-02-21 16:18:07 +02:00

299 lines
20 KiB
HTML

<!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, .status-pill.eeprom_update { 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); }
.device-actions-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.eeprom-preset { padding: 0.3rem 0.5rem; font-size: 0.75rem; font-family: inherit; background: var(--bg-primary); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); max-width: 11rem; }
.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; }
.leases-table { width: 100%; font-size: 0.8rem; border-collapse: collapse; }
.leases-table th, .leases-table td { padding: 0.35rem 0.5rem; text-align: left; border-bottom: 1px solid var(--border); }
.leases-table th { color: var(--text-dim); font-weight: 600; }
.leases-table .mono { font-family: 'JetBrains Mono', monospace; }
</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>
<button type="button" id="statusClearBtn" class="btn btn-outline btn-sm" style="margin-left: auto;">Clear status</button>
</div>
<div id="statusErr" class="status-err" style="display:none;"></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="dhcpNetbootWrap" class="status-row" style="margin-bottom: 0.5rem; font-size: 0.85rem;"><span class="text-dim">Network boot (DHCP):</span> <span id="dhcpNetbootState"></span> <button type="button" id="dhcpNetbootDisableBtn" class="btn btn-outline btn-sm" style="display:none;">Disable network boot</button> <button type="button" id="dhcpNetbootEnableBtn" class="btn btn-outline btn-sm" style="display:none;">Enable network boot</button></p>
<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>DHCP leases</summary>
<div class="inner">
<p id="leasesNote" class="help-sub" style="margin-top:0;">Provisioning LAN (dnsmasq) — when dashboard runs on LXC.</p>
<div id="leasesContent"><table class="leases-table"><thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Expires</th></tr></thead><tbody id="leasesBody"></tbody></table></div>
<p id="leasesError" style="display:none; font-size: 0.8rem; color: var(--text-muted);"></p>
<p id="leasesEmpty" style="display:none; font-size: 0.85rem; color: var(--text-muted);">No leases file or no active leases.</p>
</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', eeprom_update: 'Updating EEPROM', 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'; }
if (phase === 'done') scheduleDoneClear();
const progress = data.progress;
const inProgress = ['rpiboot', 'flashing', 'backup', 'eeprom_update'].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 Backup, Deploy, or Update EEPROM</div><div class="device-actions-row"><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> <select class="eeprom-preset" title="Boot order"><option value="0xf21">eMMC first, then network</option><option value="0x1">eMMC only</option><option value="0xf12">Network first, then eMMC</option></select> <button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="eeprom_update">Update EEPROM</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> <button type="button" class="btn btn-outline btn-sm btn-disable-netboot" title="Stop advertising network boot via DHCP so devices boot from eMMC">Disable network boot</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 === 'eeprom_update' && body.source === 'usb') {
const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset');
body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0xf21';
}
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(); fetchDhcpNetboot(); } else alert(data.error || 'Failed'); })
.catch(function() { alert('Request failed'); });
};
});
container.querySelectorAll('button.btn-disable-netboot').forEach(function(btn) {
btn.onclick = function() {
fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }) })
.then(function(r) { return r.json(); })
.then(function(data) { if (data.ok) { fetchDhcpNetboot(); } 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.'; });
}
function fetchDhcpNetboot() {
fetch('/api/dhcp-network-boot').then(function(r){ return r.json(); }).then(function(d){
const stateEl = document.getElementById('dhcpNetbootState');
const disableBtn = document.getElementById('dhcpNetbootDisableBtn');
const enableBtn = document.getElementById('dhcpNetbootEnableBtn');
if (d.error) { stateEl.textContent = '—'; if (disableBtn) disableBtn.style.display = 'none'; if (enableBtn) enableBtn.style.display = 'none'; return; }
stateEl.textContent = d.enabled ? 'on' : 'off';
if (disableBtn) disableBtn.style.display = d.enabled ? 'inline-block' : 'none';
if (enableBtn) enableBtn.style.display = d.enabled ? 'none' : 'inline-block';
}).catch(function(){ document.getElementById('dhcpNetbootState').textContent = '—'; });
}
var doneClearTimer = null;
function scheduleDoneClear() {
if (doneClearTimer) return;
doneClearTimer = setTimeout(function(){ doneClearTimer = null; fetch('/api/status-clear', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchStatus(); }); }, 60000);
}
function fetchDhcpLeases() {
fetch('/api/dhcp-leases').then(function(r){ return r.json(); }).then(function(d){
const tbody = document.getElementById('leasesBody');
const errEl = document.getElementById('leasesError');
const emptyEl = document.getElementById('leasesEmpty');
errEl.style.display = 'none';
emptyEl.style.display = 'none';
if (d.error) { errEl.textContent = d.error; errEl.style.display = 'block'; tbody.innerHTML = ''; return; }
var rows = (d.leases || []).map(function(l){
var expStr = l.expiry ? (new Date(l.expiry*1000).toLocaleString()) : '—';
return '<tr><td class="mono">' + escapeHtml(l.ip) + '</td><td class="mono">' + escapeHtml(l.mac) + '</td><td>' + escapeHtml(l.hostname || '') + '</td><td>' + expStr + '</td></tr>';
}).join('');
tbody.innerHTML = rows || '';
if (!rows) emptyEl.style.display = 'block';
}).catch(function(){ document.getElementById('leasesError').textContent = 'Could not load leases.'; document.getElementById('leasesError').style.display = 'block'; });
}
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(); }); });
document.getElementById('dhcpNetbootDisableBtn').addEventListener('click', function(){
fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }) })
.then(function(r){ return r.json(); })
.then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); })
.catch(function(){ alert('Request failed'); });
});
document.getElementById('dhcpNetbootEnableBtn').addEventListener('click', function(){
fetch('/api/dhcp-network-boot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true }) })
.then(function(r){ return r.json(); })
.then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); })
.catch(function(){ alert('Request failed'); });
});
fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); fetchDhcpLeases();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchGolden, 10000);
setInterval(fetchDhcpNetboot, 10000);
setInterval(fetchDhcpLeases, 10000);
</script>
</body>
</html>