<message>Eliminate network boot options from the dashboard, including API endpoints and UI elements, to streamline the provisioning process for USB boot only. Update messages and documentation to reflect the removal of network boot functionality, ensuring clarity for users. Adjust the cloud-init build process and related templates to focus solely on USB boot mode, enhancing the overall user experience and simplifying the workflow.
1034 lines
46 KiB
HTML
1034 lines
46 KiB
HTML
<!DOCTYPE html>
|
|
<!-- Revision: 2 -->
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>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</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; 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. 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</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) — 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 & 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 & 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>
|
|
</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="0x1">eMMC only</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';
|
|
}
|
|
|
|
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 : '0x1';
|
|
}
|
|
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 !== 'idle' || (d.message && d.message.trim())) ? '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?force=1', { 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?force=1', { 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>
|