Files
reterminal-dm4/chromium-setup/emmc-provisioning/dashboard/templates/index.html

521 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CM4 eMMC 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;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Portal Styling Guide tokens */
:root {
--bg-primary: #0a0e14;
--bg-secondary: #11151c;
--bg-tertiary: #1a1f2b;
--bg-card: #151a24;
--accent-primary: #00d4aa;
--accent-secondary: #00b894;
--accent-glow: rgba(0, 212, 170, 0.15);
--text-primary: #e6e8eb;
--text-secondary: #8b949e;
--text-muted: #5c6370;
--border-color: #2d333b;
--danger: #ff6b6b;
--danger-glow: rgba(255, 107, 107, 0.15);
--warning: #ffd93d;
--success: #00d4aa;
--gradient-accent: linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(circle at 20% 20%, rgba(0, 212, 170, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(0, 184, 148, 0.03) 0%, transparent 50%),
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
pointer-events: none;
z-index: -1;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
z-index: 1000;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 40px;
height: 40px;
background: var(--gradient-accent);
border-radius: 10px;
box-shadow: 0 4px 20px var(--accent-glow);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.logo-title {
font-size: 1.5rem;
font-weight: 600;
background: var(--gradient-accent);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.main {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
padding-top: calc(2rem + 72px);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .label {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.subtitle { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1.5rem; }
.steps { list-style: none; }
.steps li {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-color);
}
.steps li:last-child { border-bottom: none; }
.step-num {
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.8rem;
}
.step-num.done { background: var(--gradient-accent); color: var(--bg-primary); }
.status-box {
padding: 1rem 1.25rem;
border-radius: 12px;
margin-bottom: 0.75rem;
border: 1px solid var(--border-color);
}
.status-box.idle { background: var(--accent-glow); border-color: var(--accent-primary); }
.status-box.rpiboot,
.status-box.flashing,
.status-box.backup,
.status-box.waiting_choice { background: rgba(255, 217, 61, 0.1); border-color: var(--warning); }
.status-box.done { background: rgba(0, 212, 170, 0.12); border-color: var(--success); }
.status-box.error { background: var(--danger-glow); border-color: var(--danger); }
.status-phase {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.35rem;
}
.status-box.idle .status-phase { color: var(--accent-primary); }
.status-box.rpiboot .status-phase,
.status-box.flashing .status-phase,
.status-box.backup .status-phase,
.status-box.waiting_choice .status-phase { color: var(--warning); }
.status-box.done .status-phase { color: var(--success); }
.status-box.error .status-phase { color: var(--danger); }
.status-message { color: var(--text-primary); font-size: 0.95rem; }
.status-error { color: var(--danger); font-size: 0.9rem; margin-top: 0.5rem; }
.status-updated { color: var(--text-muted); font-size: 0.75rem; margin-top: 0.5rem; }
.progress-wrap {
margin-top: 0.75rem;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--gradient-accent);
border-radius: 3px;
transition: width 0.4s ease;
}
.progress-bar.indeterminate {
width: 40%;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
.log-box {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
max-height: 180px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-muted);
}
.log-box:empty::before { content: 'No flash log yet.'; color: var(--text-muted); }
.backups-list { list-style: none; }
.backups-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-color);
gap: 0.75rem;
}
.backups-list li:last-child { border-bottom: none; }
.backups-list a {
color: var(--accent-primary);
text-decoration: none;
font-weight: 500;
}
.backups-list a:hover { text-decoration: underline; }
.backups-meta { color: var(--text-muted); font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
.pending-device {
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.pending-device .label { font-weight: 500; color: var(--text-primary); }
.pending-device .actions { display: flex; gap: 0.5rem; }
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
font-family: 'Outfit', sans-serif;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.btn-primary {
background: var(--gradient-accent);
color: var(--bg-primary);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.pending-device .btn { padding: 0.5rem 1rem; font-size: 0.85rem; }
code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
background: var(--bg-tertiary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
color: var(--accent-primary);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-size: 0.9rem;
}
details summary {
cursor: pointer;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
@media (max-width: 768px) {
.header { padding: 1rem; flex-direction: column; gap: 0.5rem; text-align: center; }
.main { padding: 1rem; padding-top: calc(1rem + 100px); }
}
</style>
</head>
<body>
<header class="header">
<div class="logo">
<div class="logo-icon"></div>
<h1 class="logo-title">CM4 eMMC Provisioning</h1>
</div>
</header>
<main class="main">
<p class="subtitle">reTerminal DM4 — deploy or backup via USB boot mode or network boot</p>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Connect the device</span></h2>
</div>
<p style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 0.75rem;">Choose one:</p>
<p style="font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">USB boot mode</p>
<ol class="steps">
<li><span class="step-num">1</span> Set the reTerminal to <strong>boot mode</strong>: fit the <strong>eMMC disable</strong> jumper (e.g. J2 / nRPIBOOT).</li>
<li><span class="step-num">2</span> Connect the reTerminals <strong>USB slave</strong> port to the Proxmox host. Power on. The device will appear in “Device detected” below; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
<li><span class="step-num">3</span> When done, remove the jumper and power cycle so it boots from eMMC.</li>
</ol>
<p style="font-size: 0.9rem; font-weight: 600; margin: 1rem 0 0.25rem 0;">Network boot</p>
<ol class="steps">
<li><span class="step-num">1</span> Enable network boot on the CM4 (e.g. <code>BOOT_ORDER=0x21</code>) and ensure it can reach this server.</li>
<li><span class="step-num">2</span> Boot the device over the network with an environment that runs the <strong>provisioning client</strong> (register + poll for action). It will show under “Device detected (Network)”. Choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Device detected — choose action</span></h2>
</div>
<p id="pendingHint" style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.75rem;">When a device is detected (USB boot mode or network boot), it will appear below. Choose <strong>Backup</strong> to save its eMMC to a file, or <strong>Deploy</strong> to write the golden image to it.</p>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-state" style="display: none;">No device waiting. Connect a reTerminal in USB boot mode, or ensure a network-booted device has registered.</p>
<h3 style="font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin: 1rem 0 0.5rem 0;">Saved backups</h3>
<ul id="backupsList" class="backups-list"></ul>
<p id="backupsEmpty" class="empty-state" style="display: none;">No backups yet.</p>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Deployment status</span></h2>
</div>
<div id="status" class="status-box idle">
<div class="status-phase">Idle</div>
<div class="status-message">Waiting for reTerminal in boot mode.</div>
<div id="statusError" class="status-error" style="display:none;"></div>
<div id="statusUpdated" class="status-updated"></div>
<div id="progressWrap" class="progress-wrap" style="display:none;">
<div id="progressBar" class="progress-bar"></div>
</div>
</div>
<details style="margin-top: 0.75rem;">
<summary>Recent flash log</summary>
<div id="log" class="log-box" style="margin-top: 0.5rem;"></div>
</details>
</div>
</main>
<script>
const statusEl = document.getElementById('status');
const phaseEl = statusEl.querySelector('.status-phase');
const messageEl = statusEl.querySelector('.status-message');
const errorEl = document.getElementById('statusError');
const updatedEl = document.getElementById('statusUpdated');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const logEl = document.getElementById('log');
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';
statusEl.className = 'status-box ' + phase;
phaseEl.textContent = phaseLabels[phase] || phase;
messageEl.textContent = data.message || '';
if (data.error) {
errorEl.textContent = data.error;
errorEl.style.display = 'block';
} else {
errorEl.style.display = 'none';
}
if (data.updated) {
updatedEl.textContent = 'Updated: ' + data.updated;
updatedEl.style.display = 'block';
} else {
updatedEl.style.display = 'none';
}
const progress = data.progress;
const inProgress = phase === 'rpiboot' || phase === 'flashing' || phase === 'backup';
const showProgress = progress != null && (inProgress || phase === 'done');
if (showProgress) {
progressWrap.style.display = 'block';
progressBar.classList.remove('indeterminate');
progressBar.style.width = (progress === null ? 0 : progress) + '%';
if (progress === null && inProgress) {
progressBar.classList.add('indeterminate');
progressBar.style.width = '40%';
}
} else if (inProgress) {
progressWrap.style.display = 'block';
progressBar.classList.add('indeterminate');
progressBar.style.width = '40%';
} else {
progressWrap.style.display = 'none';
}
}
function renderPendingDevices(usb, network) {
const container = document.getElementById('pendingDevices');
const noPending = document.getElementById('noPending');
container.innerHTML = '';
let hasAny = false;
if (usb) {
hasAny = true;
const div = document.createElement('div');
div.className = 'pending-device';
div.innerHTML = '<span class="label">Device connected (USB boot mode)</span><span class="actions"><button type="button" class="btn btn-secondary" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></span>';
container.appendChild(div);
}
(network || []).forEach(function(d) {
hasAny = true;
const div = document.createElement('div');
div.className = 'pending-device';
div.innerHTML = '<span class="label">Device (Network): ' + escapeHtml(d.ip || '') + ' — ' + escapeHtml(d.mac || '') + '</span><span class="actions"><button type="button" class="btn btn-secondary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></span>';
container.appendChild(div);
});
noPending.style.display = hasAny ? 'none' : 'block';
container.querySelectorAll('button[data-action]').forEach(function(btn) {
btn.onclick = function() {
const source = btn.getAttribute('data-source');
const action = btn.getAttribute('data-action');
const mac = btn.getAttribute('data-mac');
const body = { source: source, action: action };
if (mac) body.mac = mac;
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 fetchPending() {
fetch('/api/pending-devices').then(function(r) { return r.json(); }).then(function(data) {
renderPendingDevices(data.usb || null, data.network || []);
}).catch(function() { renderPendingDevices(null, []); });
}
function fetchStatus() {
fetch('/api/status')
.then(r => r.json())
.then(renderStatus)
.catch(() => renderStatus({ phase: 'error', message: 'Could not load status.' }));
}
function fetchLog() {
fetch('/api/log')
.then(r => r.json())
.then(data => { logEl.textContent = data.log || ''; })
.catch(() => { logEl.textContent = ''; });
}
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 fetchBackups() {
fetch('/api/backups')
.then(r => r.json())
.then(data => {
const list = document.getElementById('backupsList');
const empty = document.getElementById('backupsEmpty');
list.innerHTML = '';
const backups = data.backups || [];
if (backups.length === 0) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
backups.forEach(b => {
const li = document.createElement('li');
li.innerHTML = '<a href="/api/backups/' + encodeURIComponent(b.name) + '" download>' + escapeHtml(b.name) + '</a>' +
'<span class="backups-meta">' + fmtSize(b.size) + ' · ' + fmtDate(b.mtime) + '</span>';
list.appendChild(li);
});
})
.catch(() => {});
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
fetchStatus();
fetchLog();
fetchPending();
fetchBackups();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchBackups, 5000);
</script>
</body>
</html>