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

530 lines
17 KiB
HTML

<!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&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;
--accent-glow: rgba(0, 212, 170, 0.12);
--text: #e6e8eb;
--text-dim: #8b949e;
--text-muted: #5c6370;
--border: #2d333b;
--danger: #f87171;
--danger-bg: rgba(248, 113, 113, 0.1);
--warn: #fbbf24;
--warn-bg: rgba(251, 191, 36, 0.1);
--success: #00d4aa;
--success-bg: rgba(0, 212, 170, 0.1);
--radius: 12px;
--radius-sm: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Outfit', sans-serif;
background: var(--bg-primary);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
font-size: 15px;
}
/* ----- Layout ----- */
.wrap {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem;
}
.header {
margin-bottom: 1.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text);
letter-spacing: -0.02em;
}
.header p {
margin-top: 0.25rem;
font-size: 0.9rem;
color: var(--text-dim);
}
/* ----- Section blocks ----- */
.section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin-bottom: 0.75rem;
}
/* ----- Status block ----- */
.status-row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.status-pill {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.35rem 0.75rem;
border-radius: 999px;
}
.status-pill.idle { background: var(--accent-glow); color: var(--accent); }
.status-pill.rpiboot,
.status-pill.waiting_choice,
.status-pill.flashing,
.status-pill.backup { background: var(--warn-bg); color: var(--warn); }
.status-pill.done { background: var(--success-bg); color: var(--success); }
.status-pill.error { background: var(--danger-bg); color: var(--danger); }
.status-msg {
flex: 1;
min-width: 0;
font-size: 0.95rem;
color: var(--text);
}
.status-err {
width: 100%;
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--danger);
}
.status-meta {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.5rem;
}
.progress-track {
margin-top: 0.75rem;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-fill.indeterminate {
width: 35%;
animation: slide 1.2s ease-in-out infinite;
}
@keyframes slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
/* ----- Pending devices ----- */
.devices-list { list-style: none; }
.device-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
}
.device-item:last-child { margin-bottom: 0; }
.device-info { min-width: 0; }
.device-type {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
margin-bottom: 0.2rem;
}
.device-desc { font-size: 0.9rem; color: var(--text); }
.device-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.btn {
padding: 0.5rem 1rem;
font-size: 0.85rem;
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);
color: var(--bg-primary);
}
.empty-msg {
text-align: center;
padding: 1.5rem;
font-size: 0.9rem;
color: var(--text-muted);
}
/* ----- Backups table ----- */
.backups-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.backups-table th {
text-align: left;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.backups-table td {
padding: 0.6rem 0;
border-bottom: 1px solid var(--border);
color: var(--text);
}
.backups-table tr:last-child td { border-bottom: none; }
.backups-table a {
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
.backups-table a:hover { text-decoration: underline; }
.backups-mono {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
color: var(--text-muted);
}
/* ----- Help & Log (collapsible) ----- */
details {
margin-top: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
details[open] { border-color: var(--text-muted); }
summary {
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-dim);
cursor: pointer;
list-style: none;
user-select: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: '▶';
display: inline-block;
margin-right: 0.5rem;
font-size: 0.65rem;
transition: transform 0.2s;
}
details[open] summary::before { transform: rotate(90deg); }
details .inner {
padding: 1rem;
padding-top: 0;
font-size: 0.875rem;
color: var(--text-dim);
border-top: 1px solid var(--border);
}
.steps-list { list-style: none; }
.steps-list li {
display: flex;
gap: 0.6rem;
padding: 0.4rem 0;
}
.steps-list .num {
flex-shrink: 0;
width: 1.35rem;
height: 1.35rem;
background: var(--bg-tertiary);
color: var(--text-dim);
border-radius: 50%;
font-size: 0.75rem;
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.75rem 0 0.25rem 0; }
.log-pre {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-muted);
}
.log-pre:empty::before { content: 'No log output yet.'; }
@media (max-width: 640px) {
.wrap { padding: 1rem; }
.device-item { flex-direction: column; align-items: flex-start; }
.device-actions { width: 100%; justify-content: flex-end; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<h1>CM4 eMMC Provisioning</h1>
<p>Deploy or backup reTerminal via USB boot mode or network</p>
</header>
<!-- 1. Current status -->
<section class="section">
<h2 class="section-title">Current 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="statusMeta" class="status-meta" style="display:none;"></div>
<div id="progressWrap" class="progress-track" style="display:none;">
<div id="progressFill" class="progress-fill"></div>
</div>
</section>
<!-- 2. Devices waiting for action -->
<section class="section">
<h2 class="section-title">Devices waiting for action</h2>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-msg" style="display:none;">No devices. Connect reTerminal in USB boot mode or register over network.</p>
</section>
<!-- 3. Saved backups -->
<section class="section">
<h2 class="section-title">Saved backups</h2>
<table class="backups-table" id="backupsTable">
<thead>
<tr>
<th>File</th>
<th>Size</th>
<th>Date</th>
</tr>
</thead>
<tbody id="backupsBody"></tbody>
</table>
<p id="backupsEmpty" class="empty-msg" style="display:none;">No backups yet.</p>
</section>
<!-- 4. How to connect (collapsible) -->
<details class="section" style="padding:0;">
<summary>How to connect</summary>
<div class="inner">
<p class="help-sub">USB boot mode</p>
<ol class="steps-list">
<li><span class="num">1</span> Set reTerminal to <strong>boot mode</strong> (eMMC disable jumper, e.g. J2 / nRPIBOOT).</li>
<li><span class="num">2</span> Connect <strong>USB slave</strong> to the host and power on. The device appears above; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
<li><span class="num">3</span> When done, remove the jumper and power cycle to boot from eMMC.</li>
</ol>
<p class="help-sub">Network boot</p>
<ol class="steps-list">
<li><span class="num">1</span> Enable network boot (e.g. <code style="background:var(--bg-tertiary);padding:0.15rem 0.35rem;border-radius:4px;">BOOT_ORDER=0x21</code>) and ensure the device can reach this server.</li>
<li><span class="num">2</span> Boot with the provisioning client; it will show above. Choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div>
</details>
<!-- 5. Recent log (collapsible) -->
<details class="section" style="padding:0;">
<summary>Recent log</summary>
<div class="inner">
<pre id="log" class="log-pre"></pre>
</div>
</details>
</div>
<script>
const statusPill = document.getElementById('statusPill');
const statusMsg = document.getElementById('statusMsg');
const statusErr = document.getElementById('statusErr');
const statusMeta = document.getElementById('statusMeta');
const progressWrap = document.getElementById('progressWrap');
const progressFill = document.getElementById('progressFill');
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';
statusPill.className = 'status-pill ' + phase;
statusPill.textContent = phaseLabels[phase] || phase;
statusMsg.textContent = data.message || '';
if (data.error) {
statusErr.textContent = data.error;
statusErr.style.display = 'block';
} else {
statusErr.style.display = 'none';
}
if (data.updated) {
statusMeta.textContent = 'Updated ' + data.updated;
statusMeta.style.display = 'block';
} else {
statusMeta.style.display = 'none';
}
const progress = data.progress;
const inProgress = ['rpiboot', 'flashing', 'backup'].includes(phase);
if (inProgress || (phase === 'done' && progress != null)) {
progressWrap.style.display = 'block';
progressFill.classList.remove('indeterminate');
if (progress != null) {
progressFill.style.width = progress + '%';
} else {
progressFill.classList.add('indeterminate');
progressFill.style.width = '35%';
}
} else {
progressWrap.style.display = 'none';
}
}
function renderPending(usb, network) {
const container = document.getElementById('pendingDevices');
const noPending = document.getElementById('noPending');
container.innerHTML = '';
let hasAny = false;
if (usb) {
hasAny = true;
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup or Deploy</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
}
(network || []).forEach(function(d) {
hasAny = true;
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-info"><div class="device-type">Network</div><div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div></div><div class="device-actions"><button type="button" class="btn btn-outline" 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></div>';
container.appendChild(el);
});
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 renderBackups(backups) {
const tbody = document.getElementById('backupsBody');
const empty = document.getElementById('backupsEmpty');
tbody.innerHTML = '';
if (!backups || backups.length === 0) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
backups.forEach(function(b) {
const tr = document.createElement('tr');
tr.innerHTML = '<td><a href="/api/backups/' + encodeURIComponent(b.name) + '" download>' + escapeHtml(b.name) + '</a></td><td class="backups-mono">' + fmtSize(b.size) + '</td><td class="backups-mono">' + fmtDate(b.mtime) + '</td>';
tbody.appendChild(tr);
});
}
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 fetchBackups() {
fetch('/api/backups').then(function(r) { return r.json(); }).then(function(d) { renderBackups(d.backups || []); }).catch(function() {});
}
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 escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
fetchStatus();
fetchLog();
fetchPending();
fetchBackups();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchBackups, 5000);
</script>
</body>
</html>