Files
reterminal-dm4/emmc-provisioning/dashboard/templates/index.html
nearxos b1368b6e62 Add dismiss functionality for cloud-init build status</message>
<message>Implement a new API endpoint to clear the build status to idle, allowing users to dismiss messages after a build is cancelled, completed, or errored. Update the dashboard UI to include a dismiss link that appears under relevant conditions, enhancing user experience by providing a clearer interface for managing build statuses. Modify the JavaScript to handle the dismissal action and ensure proper status updates are reflected in the UI.
2026-02-23 10:28:58 +02:00

1045 lines
47 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,
.status-pill.eeprom_update { 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; align-items: center; }
.eeprom-preset { padding: 0.35rem 0.5rem; font-size: 0.85rem; font-family: inherit; background: var(--bg-tertiary); color: var(--text); border: 1px solid var(--border); border-radius: 6px; max-width: 12rem; }
.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>
<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="statusGoldenHint" class="backup-deploy-hint" style="display:none; margin-top:0.75rem;">
No golden image is required to <strong>capture</strong>. Connect a device in USB boot mode (or register over network); when it appears under “Capture image or deploy”, click <strong>Backup</strong> to save its image. Then set that backup as golden in the list below.
<button type="button" id="statusClearHintBtn" class="btn btn-outline btn-sm" style="margin-left:0.5rem;">Clear message</button>
</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>
<p id="shrinkOptionWrap" class="backup-deploy-hint" style="display:none; margin-top:0.5rem;">
<label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label> <span class="backups-mono" style="font-size:0.8rem;">(reduces size; requires PiShrink on host)</span>
</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
<button type="button" class="btn btn-outline btn-sm" id="refreshBackupsBtn" title="Reload list">Refresh</button>
<button type="button" class="btn btn-outline btn-sm" id="uploadImageBtn" title="Upload an image file">Upload image</button>
<input type="file" id="uploadImageInput" accept=".img,.img.gz,.img.xz,image/*" style="display:none;" />
</h2>
<p id="goldenHint" class="backups-mono" style="margin-bottom:0.25rem;font-size:0.8rem;"></p>
<p id="backupsDirHint" class="backups-mono" style="margin-bottom:0.75rem;font-size:0.75rem;color:var(--muted);"></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>
<!-- 3b. Build cloud-init image from official Raspberry Pi OS -->
<section class="section">
<h2 class="section-title">Build cloud-init image</h2>
<p class="backup-deploy-hint">Download the latest <strong>Raspberry Pi OS (arm64)</strong> from the official repository and inject cloud-init NoCloud files. The built image appears in Saved backups; you can then choose it and click <strong>Set as golden</strong> for deployment.</p>
<div style="margin-bottom:0.5rem;">
<label>Variant: </label>
<select id="buildVariant">
<option value="desktop" selected>Desktop (recommended)</option>
<option value="lite">Lite (no desktop)</option>
<option value="full">Full (desktop + software)</option>
</select>
<span id="buildRaspiosUrl" class="backups-mono" style="font-size:0.8rem; margin-left:0.5rem;"></span>
</div>
<div style="margin-bottom:0.5rem;">
<label>Image name (optional): </label>
<input type="text" id="buildImageName" placeholder="e.g. reterminal-kiosk" style="width:12rem;" />
<span class="backups-mono" style="font-size:0.8rem; margin-left:0.5rem;">+ date suffix (e.g. 20251204-143022)</span>
</div>
<div style="margin-bottom:0.5rem;">
<label><input type="checkbox" id="buildSetGolden" /> Set as golden image after build</label>
<span class="backups-mono" style="font-size:0.8rem;"> (use for Deploy without clicking manually)</span>
</div>
<div style="margin-bottom:0.75rem;">
<button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download &amp; build cloud-init image</button>
<button type="button" id="buildCloudInitCancelBtn" class="btn btn-outline" style="display:none; margin-left:0.5rem;">Cancel build</button>
</div>
<div id="buildCloudInitStatus" class="backups-mono" style="font-size:0.85rem; min-height:1.5em; margin-bottom:0.5rem;"></div>
<a href="#" id="buildCloudInitDismiss" style="display:none; font-size:0.85rem;">Dismiss</a>
<details style="margin-top:0.5rem;">
<summary>Cloud-init templates &amp; customize</summary>
<div class="inner" style="margin-top:0.5rem;">
<p><strong>Templates:</strong> <select id="buildTemplateSelect"><option value="">— Load a template —</option></select>
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
<button type="button" id="buildTemplateUpdate" class="btn btn-outline btn-sm" title="Save current content into the selected template">Update</button>
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save current as template…</button></p>
<ul id="buildTemplateList" class="backups-mono" style="font-size:0.85rem; list-style:none; padding:0;"></ul>
<label>user-data (YAML)</label>
<textarea id="buildUserData" rows="8" style="width:100%; font-family:monospace; font-size:0.85rem; margin-bottom:0.5rem;" placeholder="Leave empty to use default (remote bootstrap example)"></textarea>
<label>meta-data (optional)</label>
<textarea id="buildMetaData" rows="3" style="width:100%; font-family:monospace; font-size:0.85rem; margin-bottom:0.5rem;"></textarea>
<label>network-config (optional)</label>
<textarea id="buildNetworkConfig" rows="5" style="width:100%; font-family:monospace; font-size:0.85rem;"></textarea>
</div>
</details>
</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=0xf21</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',
eeprom_update: 'Updating EEPROM',
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';
}
var goldenHint = document.getElementById('statusGoldenHint');
if (goldenHint) {
var isGoldenError = phase === 'error' && /golden|Golden image/i.test((data.error || '') + (data.message || ''));
goldenHint.style.display = isGoldenError ? 'block' : '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', 'eeprom_update'].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;
const shrinkWrap = document.getElementById('shrinkOptionWrap');
if (usb) {
hasAny = true;
if (shrinkWrap) shrinkWrap.style.display = 'block';
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, Deploy, or Update EEPROM</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><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" data-source="usb" data-action="eeprom_update">Update EEPROM</button></div>';
container.appendChild(el);
} else {
if (shrinkWrap) shrinkWrap.style.display = 'none';
}
(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;
if (action === 'eeprom_update' && source === 'usb') {
const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset');
body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0xf21';
}
const shrinkCb = document.getElementById('shrinkAfterBackup');
if (action === 'backup' && shrinkCb && shrinkCb.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 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 || '');
const isRawImg = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
const shrinkBtn = isRawImg ? '<button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="' + escapeHtml(b.name) + '" title="Shrink image (PiShrink)">Shrink</button> ' : '';
const compressBtn = isRawImg ? '<button type="button" class="btn btn-outline btn-sm compress-btn" data-name="' + escapeHtml(b.name) + '" data-format="xz" title="Shrink and compress to .img.xz (minimum size)">Compress</button> ' : '';
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">' +
shrinkBtn +
compressBtn +
'<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> ' +
'<button type="button" class="btn btn-outline btn-sm delete-backup-btn" data-name="' + escapeHtml(b.name) + '" title="Delete this backup">Delete</button>' +
'</td>';
tbody.appendChild(tr);
});
bindBackupEdits();
bindSetGolden();
bindRenameFile();
bindShrink();
bindCompress();
bindDeleteBackup();
}
function bindDeleteBackup() {
document.querySelectorAll('.delete-backup-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
if (!confirm('Delete this backup? This cannot be undone.\n\n' + name)) return;
btn.disabled = true;
fetch('/api/backups/' + encodeURIComponent(name), { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); fetchGoldenInfo(); }
else alert(data.error || 'Failed');
})
.catch(function() { alert('Request failed'); })
.finally(function() { btn.disabled = false; });
};
});
}
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|img\.xz)$/i.test(n)) { alert('Filename must end with .img, .img.gz or .img.xz'); 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 bindShrink() {
document.querySelectorAll('.shrink-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
if (!confirm('Shrink this image with PiShrink? This reduces file size and may take a few minutes.\n\n' + name)) return;
btn.disabled = true;
btn.textContent = 'Shrinking…';
fetch('/api/backups/' + encodeURIComponent(name) + '/shrink', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); }
else alert(data.error || data.detail || 'Failed');
})
.catch(function() { alert('Request failed'); })
.finally(function() { btn.disabled = false; btn.textContent = 'Shrink'; });
};
});
}
function bindCompress() {
document.querySelectorAll('.compress-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
const format = btn.getAttribute('data-format') || 'xz';
if (!confirm('Shrink and compress this image to .img.' + format + '? This minimizes size and may take several minutes.\n\n' + name)) return;
btn.disabled = true;
btn.textContent = 'Compressing…';
fetch('/api/backups/' + encodeURIComponent(name) + '/compress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ format: format }) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); }
else alert(data.error || data.detail || 'Failed');
})
.catch(function() { alert('Request failed'); })
.finally(function() { btn.disabled = false; btn.textContent = 'Compress'; });
};
});
}
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 || []);
var dirEl = document.getElementById('backupsDirHint');
if (dirEl && d.backups_dir) dirEl.textContent = 'Stored in: ' + d.backups_dir;
}).catch(function() {});
}
function getBuildVariant() {
var sel = document.getElementById('buildVariant');
return (sel && sel.value) ? sel.value : 'desktop';
}
function fetchRaspiosUrl() {
var variant = getBuildVariant();
fetch('/api/raspios-latest-url?variant=' + encodeURIComponent(variant)).then(function(r) { return r.json(); }).then(function(d) {
var el = document.getElementById('buildRaspiosUrl');
if (el) el.textContent = d.ok && d.filename ? ('Latest: ' + d.filename) : (d.error || 'Could not resolve latest image URL');
}).catch(function() {});
}
function fetchCloudInitTemplates() {
fetch('/api/cloudinit-templates').then(function(r) { return r.json(); }).then(function(d) {
var list = d.templates || [];
var sel = document.getElementById('buildTemplateSelect');
var listEl = document.getElementById('buildTemplateList');
if (sel) {
sel.innerHTML = '<option value="">— Load a template —</option>';
list.forEach(function(t) {
var opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
sel.appendChild(opt);
});
}
if (listEl) {
listEl.innerHTML = list.map(function(t) {
return '<li><span>' + escapeHtml(t.name) + '</span> <button type="button" class="btn btn-outline btn-sm template-load-btn" data-id="' + escapeHtml(t.id) + '">Load</button> <button type="button" class="btn btn-outline btn-sm template-upd-btn" data-id="' + escapeHtml(t.id) + '" title="Save current content into this template">Update</button> <button type="button" class="btn btn-outline btn-sm template-del-btn" data-id="' + escapeHtml(t.id) + '">Delete</button></li>';
}).join('') || '<li>No templates saved.</li>';
listEl.querySelectorAll('.template-load-btn').forEach(function(btn) {
btn.onclick = function() { loadTemplate(btn.getAttribute('data-id')); };
});
listEl.querySelectorAll('.template-upd-btn').forEach(function(btn) {
btn.onclick = function() { updateTemplate(btn.getAttribute('data-id')); };
});
listEl.querySelectorAll('.template-del-btn').forEach(function(btn) {
btn.onclick = function() {
if (!confirm('Delete this template?')) return;
fetch('/api/cloudinit-templates/' + encodeURIComponent(btn.getAttribute('data-id')), { method: 'DELETE' })
.then(function(r) { return r.json(); }).then(function(data) { if (data.ok) fetchCloudInitTemplates(); });
};
});
}
}).catch(function() {});
}
function loadTemplate(id) {
fetch('/api/cloudinit-templates/' + encodeURIComponent(id)).then(function(r) { return r.json(); }).then(function(t) {
var ud = document.getElementById('buildUserData');
var md = document.getElementById('buildMetaData');
var nc = document.getElementById('buildNetworkConfig');
if (ud) ud.value = t.user_data || '';
if (md) md.value = t.meta_data || '';
if (nc) nc.value = t.network_config || '';
}).catch(function() {});
}
function updateTemplate(id) {
var ud = document.getElementById('buildUserData');
var md = document.getElementById('buildMetaData');
var nc = document.getElementById('buildNetworkConfig');
fetch('/api/cloudinit-templates/' + encodeURIComponent(id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
user_data: ud ? ud.value : '',
meta_data: md ? md.value : '',
network_config: nc ? nc.value : ''
})}).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) { fetchCloudInitTemplates(); alert('Template updated'); }
else alert(d.error || 'Update failed');
}).catch(function() { alert('Update failed'); });
}
function saveTemplate() {
var name = prompt('Template name');
if (!name || !name.trim()) return;
var ud = document.getElementById('buildUserData');
var md = document.getElementById('buildMetaData');
var nc = document.getElementById('buildNetworkConfig');
fetch('/api/cloudinit-templates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
name: name.trim(),
user_data: ud ? ud.value : '',
meta_data: md ? md.value : '',
network_config: nc ? nc.value : ''
})}).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) { fetchCloudInitTemplates(); alert('Saved as "' + name + '"'); }
else alert(d.error || 'Failed');
}).catch(function() { alert('Failed'); });
}
function fetchBuildStatus() {
fetch('/api/build-cloudinit-status').then(function(r) { return r.json(); }).then(function(d) {
var el = document.getElementById('buildCloudInitStatus');
var btn = document.getElementById('buildCloudInitBtn');
var cancelBtn = document.getElementById('buildCloudInitCancelBtn');
var dismissEl = document.getElementById('buildCloudInitDismiss');
if (!el) return;
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if (btn) btn.disabled = busy;
if (cancelBtn) { cancelBtn.style.display = busy ? 'inline-block' : 'none'; cancelBtn.disabled = false; }
if (dismissEl) dismissEl.style.display = (d.phase === 'done' || d.phase === 'error' || d.phase === 'cancelled') ? 'inline' : 'none';
if (d.phase === 'idle' && !d.message) {
el.textContent = '';
} else if (d.phase === 'done') {
el.textContent = 'Done: ' + (d.output_name || '') + ' — see Saved backups above. Click "Set as golden" next to it to use for Deploy. If missing, click Refresh.';
fetchBackups();
fetchGoldenInfo();
} else if (d.phase === 'error') {
el.textContent = 'Error: ' + (d.error || d.message || 'Unknown');
} else if (d.phase === 'cancelled') {
el.textContent = 'Build cancelled.';
if (btn) btn.disabled = false;
if (!window._buildClearScheduled) {
window._buildClearScheduled = true;
setTimeout(function() {
fetch('/api/build-cloudinit-status-clear', { method: 'POST', headers: { 'Content-Type': 'application/json' } }).then(function() { fetchBuildStatus(); }).finally(function() { window._buildClearScheduled = false; });
}, 3000);
}
} else {
window._buildClearScheduled = false;
el.textContent = (d.phase || '') + ': ' + (d.message || '');
}
if (busy) setTimeout(fetchBuildStatus, 5000);
}).catch(function() {});
}
function cancelBuildCloudInit() {
var cancelBtn = document.getElementById('buildCloudInitCancelBtn');
if (cancelBtn) cancelBtn.disabled = true;
document.getElementById('buildCloudInitStatus').textContent = 'Cancelling…';
fetch('/api/build-cloudinit-cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) setTimeout(fetchBuildStatus, 2000);
else alert(d.error || 'Cancel request failed');
})
.catch(function() { if (cancelBtn) cancelBtn.disabled = false; });
}
function startBuildCloudInit() {
var btn = document.getElementById('buildCloudInitBtn');
if (btn) btn.disabled = true;
var ud = document.getElementById('buildUserData');
var md = document.getElementById('buildMetaData');
var nc = document.getElementById('buildNetworkConfig');
var setGolden = document.getElementById('buildSetGolden');
var nameEl = document.getElementById('buildImageName');
var body = {
variant: getBuildVariant(),
set_as_golden_after: setGolden && setGolden.checked,
image_name: (nameEl && nameEl.value.trim()) ? nameEl.value.trim() : undefined,
user_data: (ud && ud.value.trim()) ? ud.value.trim() : undefined,
meta_data: (md && md.value.trim()) ? md.value.trim() : undefined,
network_config: (nc && nc.value.trim()) ? nc.value.trim() : undefined
};
fetch('/api/build-cloudinit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
document.getElementById('buildCloudInitStatus').textContent = 'Build started on host. Waiting…';
setTimeout(fetchBuildStatus, 2000);
} else {
alert(data.error || 'Failed to start build');
if (btn) btn.disabled = false;
}
})
.catch(function() { alert('Request failed'); if (btn) btn.disabled = false; });
}
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;
}
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(); });
});
var statusClearHintBtn = document.getElementById('statusClearHintBtn');
if (statusClearHintBtn) statusClearHintBtn.addEventListener('click', function() {
fetch('/api/status-clear', { method: 'POST' }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) fetchStatus(); });
});
var refreshBackupsBtn = document.getElementById('refreshBackupsBtn');
if (refreshBackupsBtn) refreshBackupsBtn.onclick = function() { fetchBackups(); fetchGoldenInfo(); };
var uploadImageBtn = document.getElementById('uploadImageBtn');
var uploadImageInput = document.getElementById('uploadImageInput');
if (uploadImageBtn && uploadImageInput) {
uploadImageBtn.onclick = function() { uploadImageInput.click(); };
uploadImageInput.onchange = function() {
var file = uploadImageInput.files && uploadImageInput.files[0];
if (!file) return;
var fd = new FormData();
fd.append('file', file);
uploadImageBtn.disabled = true;
fetch('/api/backups/upload', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) { fetchBackups(); fetchGoldenInfo(); alert('Uploaded: ' + (d.name || file.name)); }
else alert(d.error || 'Upload failed');
})
.catch(function() { alert('Upload failed'); })
.finally(function() { uploadImageBtn.disabled = false; uploadImageInput.value = ''; });
};
}
fetchStatus();
fetchLog();
fetchPending();
fetchBackups();
fetchGoldenInfo();
fetchRaspiosUrl();
fetchBuildStatus();
fetchCloudInitTemplates();
var buildBtn = document.getElementById('buildCloudInitBtn');
if (buildBtn) buildBtn.onclick = startBuildCloudInit;
var cancelBuildBtn = document.getElementById('buildCloudInitCancelBtn');
if (cancelBuildBtn) cancelBuildBtn.onclick = cancelBuildCloudInit;
var dismissBuildBtn = document.getElementById('buildCloudInitDismiss');
if (dismissBuildBtn) dismissBuildBtn.onclick = function(e) { e.preventDefault(); fetch('/api/build-cloudinit-status-clear', { method: 'POST', headers: { 'Content-Type': 'application/json' } }).then(function() { fetchBuildStatus(); }); };
var variantSel = document.getElementById('buildVariant');
if (variantSel) variantSel.onchange = fetchRaspiosUrl;
var templateLoadBtn = document.getElementById('buildTemplateLoad');
if (templateLoadBtn) templateLoadBtn.onclick = function() { var s = document.getElementById('buildTemplateSelect'); if (s && s.value) loadTemplate(s.value); };
var templateUpdateBtn = document.getElementById('buildTemplateUpdate');
if (templateUpdateBtn) templateUpdateBtn.onclick = function() { var s = document.getElementById('buildTemplateSelect'); if (s && s.value) updateTemplate(s.value); else alert('Select a template to update'); };
var templateSaveBtn = document.getElementById('buildTemplateSave');
if (templateSaveBtn) templateSaveBtn.onclick = saveTemplate;
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchBackups, 5000);
setInterval(fetchBuildStatus, 15000);
setInterval(fetchGoldenInfo, 10000);
</script>
</body>
</html>