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.
299 lines
20 KiB
HTML
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>
|