Files
reterminal-dm4/emmc-provisioning/dashboard/templates/home.html
nearxos 16bfc1e0e1 Enhance cloud-init scripts and dashboard for improved USB boot functionality</message>
<message>Update the bootstrap script to ensure hostname resolution by adding entries to /etc/hosts, preventing "sudo: unable to resolve host" errors. Modify user-data.bootstrap to include the same hostname resolution logic. Revise dashboard templates to reflect the new project name "GNSS Guard Provisioning" and improve user interface elements related to USB boot operations, including clearer instructions and status messages. These changes enhance the overall user experience and streamline the provisioning process.
2026-02-24 08:50:32 +02:00

315 lines
20 KiB
HTML

<!DOCTYPE html>
<!-- Revision: 2 -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deploy · GNSS Guard 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%); } }
.firstboot-progress-wrap { margin-top: 0.6rem; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
.firstboot-progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent-dim), var(--accent)); border-radius: 3px; transition: width 0.4s ease-out; }
.firstboot-progress-fill.animated { background: linear-gradient(90deg, var(--accent-dim), var(--accent) 50%, var(--accent-dim)); background-size: 200% 100%; animation: firstboot-shimmer 1.5s ease-in-out infinite; }
@keyframes firstboot-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
.firstboot-step-label { font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.35rem; }
.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>GNSS Guard 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 in USB boot mode.</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 id="firstBootCard" class="card" style="margin-bottom: 1rem; display:none;">
<h2 class="card-title">First-boot progress</h2>
<p id="firstBootStepLabel" class="firstboot-step-label">Step 0 of 13</p>
<div id="firstBootProgressWrap" class="firstboot-progress-wrap" style="display:none;">
<div id="firstBootProgressFill" class="firstboot-progress-fill" style="width:0%;"></div>
</div>
<div id="firstBootStatus" class="status-row" style="margin-top: 0.5rem;">
<span id="firstBootStep" class="status-pill idle"></span>
<span id="firstBootMsg" class="status-msg"></span>
</div>
<p id="firstBootIpWrap" style="margin-top: 0.5rem; font-size: 0.9rem; display:none;"><strong>Device IP:</strong> <code id="firstBootIp" class="mono"></code></p>
</div>
<div class="card">
<h2 class="card-title">Deploy and Backup</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.</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;" open>
<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;" open>
<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 device 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>
</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 || (phase === 'idle' ? 'Waiting for Device in USB boot mode.' : '');
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 or Deploy</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></div>';
container.appendChild(el);
} else shrinkWrap.style.display = 'none';
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.' }); }); }
const FIRST_BOOT_TOTAL_STEPS = 13;
function renderFirstBootStatus(data) {
const phase = data.phase || 'idle';
const card = document.getElementById('firstBootCard');
if (phase === 'idle' || phase === '') { card.style.display = 'none'; return; }
card.style.display = 'block';
const stepEl = document.getElementById('firstBootStep');
const msgEl = document.getElementById('firstBootMsg');
const stepLabelEl = document.getElementById('firstBootStepLabel');
const progressWrap = document.getElementById('firstBootProgressWrap');
const progressFill = document.getElementById('firstBootProgressFill');
const ipWrap = document.getElementById('firstBootIpWrap');
const ipEl = document.getElementById('firstBootIp');
var stepNum = parseInt(data.step, 10) || 0;
if (phase === 'done') stepNum = FIRST_BOOT_TOTAL_STEPS;
var progress = stepNum > 0 ? (stepNum / FIRST_BOOT_TOTAL_STEPS * 100) : 0;
var inProgress = (phase === 'started' || phase === 'running');
stepLabelEl.textContent = 'Step ' + stepNum + ' of ' + FIRST_BOOT_TOTAL_STEPS + (data.step_name ? ' · ' + data.step_name : '');
if (inProgress || phase === 'done' || phase === 'error') {
progressWrap.style.display = 'block';
progressFill.style.width = progress + '%';
if (inProgress) progressFill.classList.add('animated'); else progressFill.classList.remove('animated');
} else {
progressWrap.style.display = 'none';
}
stepEl.textContent = data.step ? 'Step ' + data.step : (phase === 'done' ? 'Done' : phase);
stepEl.className = 'status-pill ' + (phase === 'done' ? 'done' : phase === 'error' ? 'error' : 'flashing');
msgEl.textContent = data.message || (phase === 'done' && data.ip ? 'First-boot finished. Device IP: ' + data.ip : '');
if (phase === 'done' && data.ip) {
ipWrap.style.display = 'block';
ipEl.textContent = data.ip;
} else {
ipWrap.style.display = 'none';
}
}
function fetchFirstBootStatus() {
fetch('/api/first-boot-status').then(function(r){ return r.json(); }).then(renderFirstBootStatus).catch(function(){});
}
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){
var logEl = document.getElementById('log');
if (!logEl) return;
logEl.textContent = d.log || '';
logEl.scrollTop = logEl.scrollHeight;
}).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.'; });
}
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(); }); });
fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpLeases(); fetchFirstBootStatus();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchFirstBootStatus, 3000);
setInterval(fetchGolden, 10000);
setInterval(fetchDhcpLeases, 10000);
</script>
</body>
</html>