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

686 lines
25 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);
}
.backup-name-edit, .backup-desc-edit {
cursor: text;
padding: 0.2rem 0.35rem;
margin: -0.2rem -0.35rem;
border-radius: 4px;
}
.backup-name-edit:hover, .backup-desc-edit:hover { background: var(--bg-tertiary); }
.backup-name-edit input, .backup-desc-edit input, .backup-desc-edit textarea {
width: 100%;
min-width: 120px;
background: var(--bg-tertiary);
border: 1px solid var(--accent);
color: var(--text);
padding: 0.25rem 0.4rem;
border-radius: 4px;
font: inherit;
}
.backup-desc-edit textarea { min-height: 2em; resize: vertical; }
.backups-table .actions-cell { white-space: nowrap; }
.backups-table .btn-sm { padding: 0.35rem 0.6rem; font-size: 0.8rem; }
.golden-badge { font-size: 0.7rem; color: var(--accent); font-weight: 600; }
.backups-table a.download-link { margin-right: 0.5rem; }
.backup-deploy-hint {
font-size: 0.9rem;
color: var(--text-dim);
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
border-left: 3px solid var(--accent);
}
.placeholder-actions {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-tertiary);
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.9rem;
}
.placeholder-actions .btns { display: flex; gap: 0.5rem; }
.placeholder-actions .btn { opacity: 0.5; pointer-events: none; }
/* ----- 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. Capture (Backup) or Deploy -->
<section class="section">
<h2 class="section-title">Capture image or deploy</h2>
<p class="backup-deploy-hint">To <strong>capture (backup)</strong> an image from a device: connect it in USB boot mode or register over network. When the device appears below, click <strong>Backup</strong> to save its eMMC to a file. To write an image to a device, click <strong>Deploy</strong> (requires a golden image).</p>
<div id="pendingDevices"></div>
<div id="noPendingPlaceholder" class="placeholder-actions" style="display:none;">
<span>Connect a device to see:</span>
<span class="btns">
<button type="button" class="btn btn-outline btn-sm" disabled>Backup</button>
<button type="button" class="btn btn-primary btn-sm" disabled>Deploy</button>
</span>
<span>— USB boot mode or network registration</span>
</div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Connect reTerminal in USB boot mode (eMMC disable jumper + USB to host) or register a network-booted device — then the <strong>Backup</strong> and <strong>Deploy</strong> buttons will appear above.</p>
</section>
<!-- 3. Saved backups -->
<section class="section">
<h2 class="section-title">Saved backups</h2>
<p id="goldenHint" class="backups-mono" style="margin-bottom:0.75rem;font-size:0.8rem;"></p>
<table class="backups-table" id="backupsTable">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Size</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="backupsBody"></tbody>
</table>
<p id="backupsEmpty" class="empty-msg" style="display:none;">No backups yet. Capture one from a device (Backup above), then set it as golden for future deploys.</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);
});
const placeholder = document.getElementById('noPendingPlaceholder');
noPending.style.display = hasAny ? 'none' : 'block';
if (placeholder) placeholder.style.display = hasAny ? 'none' : 'flex';
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.dataset.name = b.name;
const displayName = (b.display_name || b.name);
const desc = (b.description || '');
tr.innerHTML =
'<td class="backup-name-edit" data-field="name" title="Click to rename">' + escapeHtml(displayName) + '</td>' +
'<td class="backup-desc-edit" data-field="description" title="Click to add description">' + escapeHtml(desc) + '</td>' +
'<td class="backups-mono">' + fmtSize(b.size) + '</td>' +
'<td class="backups-mono">' + fmtDate(b.mtime) + '</td>' +
'<td class="actions-cell">' +
'<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' +
'<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' +
'<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a>' +
'</td>';
tbody.appendChild(tr);
});
bindBackupEdits();
bindSetGolden();
bindRenameFile();
}
function bindRenameFile() {
document.querySelectorAll('.rename-file-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
const newName = prompt('New filename (e.g. production-v1.img)', name);
if (newName == null || newName.trim() === '') return;
const n = newName.trim();
if (!/\.(img|img\.gz)$/i.test(n)) { alert('Filename must end with .img or .img.gz'); return; }
if (n === name) return;
fetch('/api/backups/' + encodeURIComponent(name), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: n }) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) fetchBackups();
else alert(data.error || 'Failed');
})
.catch(function() { alert('Request failed'); });
};
});
}
function bindBackupEdits() {
document.querySelectorAll('.backup-name-edit[data-field], .backup-desc-edit[data-field]').forEach(function(cell) {
if (cell._bound) return;
cell._bound = true;
cell.addEventListener('click', function() {
if (cell.querySelector('input, textarea')) return;
const field = cell.getAttribute('data-field');
const row = cell.closest('tr');
const filename = row.dataset.name;
const isDesc = field === 'description';
const current = cell.textContent.trim();
const input = document.createElement(isDesc ? 'textarea' : 'input');
input.type = isDesc ? 'text' : 'text';
input.value = current;
input.placeholder = isDesc ? 'Add a description…' : 'Name';
cell.textContent = '';
cell.appendChild(input);
input.focus();
if (isDesc) input.rows = 2;
function save() {
const val = input.value.trim();
const body = isDesc ? { description: val } : { name: val || filename };
fetch('/api/backups/' + encodeURIComponent(filename), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); }
else { alert(data.error || 'Failed'); cell.innerHTML = escapeHtml(current); }
})
.catch(function() { cell.innerHTML = escapeHtml(current); });
}
input.addEventListener('blur', save);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !isDesc) { e.preventDefault(); input.blur(); }
});
});
});
}
function bindSetGolden() {
document.querySelectorAll('.set-golden-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
if (!confirm('Set this backup as the golden image? Future deploys will use it.\n\n' + name)) return;
fetch('/api/backups/' + encodeURIComponent(name) + '/set-as-golden', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); fetchGoldenInfo(); }
else alert(data.error || 'Failed');
})
.catch(function() { alert('Request failed'); });
};
});
}
function fetchGoldenInfo() {
fetch('/api/golden-info').then(function(r) { return r.json(); }).then(function(d) {
const el = document.getElementById('goldenHint');
el.textContent = d.present ? ('Golden image: ' + fmtSize(d.size) + ', updated ' + fmtDate(d.mtime)) : 'No golden image set. Capture a backup and click "Set as golden" to use it for Deploy.';
}).catch(function() {});
}
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();
fetchGoldenInfo();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchBackups, 5000);
setInterval(fetchGoldenInfo, 10000);
</script>
</body>
</html>