Remove obsolete audio and buzzer control documentation files, including detailed guides and HTML interfaces, to streamline the repository and eliminate redundancy. This cleanup enhances maintainability and focuses on essential resources for the reTerminal DM4 audio and buzzer functionalities.

This commit is contained in:
nearxos
2026-02-20 15:39:39 +02:00
parent 9656771d5a
commit 58d9144752
101 changed files with 80 additions and 193 deletions

View File

@@ -0,0 +1,53 @@
# CM4 Provisioning Dashboard
Flask web UI for CM4 eMMC provisioning: **public home** (deploy only) and **admin** (login required) for images, cloud-init, portal files, and users.
## Public home (`/`)
- **No login.** Anyone can:
- See current status (idle / connecting / flashing / backup / done / error).
- **Deploy** or **Backup** when a device is connected (USB boot or network).
- See which image is set as **golden** (used for Deploy).
- View recent log and “How to connect” steps.
Layout: compact two columns (status + deploy on the left; golden info, log, and connect on the right).
## Admin (`/admin`)
**Login required.** First user: open `/login`, enter any username and a password (min 6 characters) to create the first admin account.
Admin can:
- **Backup images** (in `backups/`): upload, rename, delete, shrink/compress, **set as golden**, download.
- **Cloud-init images** (in `cloudinit-images/`): list, set as golden, rename, delete, download.
- **Build cloud-init image**: download latest Raspberry Pi OS (arm64), inject cloud-init, edit user-data/meta-data/network-config, use templates. Output goes to cloud-init images; optionally set as golden after build.
- **Portal files** (in `portal-files/`): upload files that are served at `/files/<name>` so cloud-init can `wget` or `curl` them on first boot (e.g. `curl -fsSL "http://SERVER/files/bootstrap.sh" -o /tmp/bootstrap.sh`).
- **Golden image**: any backup or cloud-init image can be set as golden (no single “golden file”; choose from either list).
- **Admin users**: add users, change password.
- **Activity logs**: view recent admin actions.
Backup images and cloud-init images live in **separate folders** (`backups/` and `cloudinit-images/`).
## Run locally (development)
```bash
cd dashboard
pip install -r requirements.txt # Flask, werkzeug
python3 app.py
# Open http://localhost:5000 (home), http://localhost:5000/admin (login first)
```
## Run in LXC (Proxmox)
Deploy with `scripts/deploy-to-proxmox.sh`; it installs the dashboard and pushes `home.html`, `login.html`, `admin.html`. Optional: create `/opt/cm4-provisioning/dashboard.env` with `CM4_DASHBOARD_SECRET_KEY=<random>` so session logins persist across restarts (deploy script can create this automatically).
## Environment (optional)
- `CM4_PROVISIONING_DIR` base path (default: `/var/lib/cm4-provisioning`).
- `CM4_BACKUPS_DIR` backups directory (default: `…/backups`).
- `CM4_CLOUDINIT_IMAGES_DIR` cloud-init built images (default: `…/cloudinit-images`).
- `CM4_PORTAL_FILES_DIR` files served at `/files/` (default: `…/portal-files`).
- `CM4_GOLDEN_IMAGE` path to golden image (symlink to chosen backup or cloud-init image).
- `CM4_DASHBOARD_SECRET_KEY` secret for session cookies (set in production).
- `CM4_DASHBOARD_DB` SQLite path for admin users and logs (default: `…/dashboard.db`).
- `CM4_STATUS_FILE`, `CM4_LOG_FILE`, etc. as before for status and build.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
[Unit]
Description=CM4 eMMC Provisioning Dashboard
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/cm4-provisioning/dashboard
ExecStart=/usr/bin/python3 -m flask --app app run --host=0.0.0.0 --port=5000
Restart=on-failure
RestartSec=5
Environment=FLASK_ENV=production
# Data dir (portal-files, backups, etc.); override in dashboard.env if needed
Environment=CM4_PROVISIONING_DIR=/var/lib/cm4-provisioning
EnvironmentFile=-/opt/cm4-provisioning/dashboard.env
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,2 @@
Flask>=2.0
werkzeug>=2.0

View File

@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · CM4 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; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
--border: #2d333b; --danger: #f87171; --warn: #fbbf24; --success: #00d4aa; --radius: 10px; --radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
.wrap { max-width: 1000px; margin: 0 auto; padding: 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.header h1 { font-size: 1.25rem; font-weight: 700; }
.header span { color: var(--text-dim); font-size: 0.9rem; }
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
.header a:hover { color: var(--accent); }
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
.nav a:hover { color: var(--accent); }
.nav a.active { color: var(--accent); font-weight: 600; }
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; 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); }
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
.btn-danger { background: rgba(248,113,113,0.2); color: var(--danger); }
.btn-danger:hover { background: rgba(248,113,113,0.3); }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
td { padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
.empty-msg { text-align: center; padding: 1rem; font-size: 0.9rem; color: var(--text-muted); }
details { margin-top: 0.5rem; }
summary { cursor: pointer; font-weight: 500; color: var(--text-dim); padding: 0.4rem 0; }
summary::-webkit-details-marker { display: none; }
summary::before { content: '▶'; display: inline-block; margin-right: 0.4rem; font-size: 0.6rem; transition: transform 0.2s; }
details[open] summary::before { transform: rotate(90deg); }
.actions-cell { white-space: nowrap; }
.actions-cell .btn { margin-right: 0.25rem; margin-bottom: 0.2rem; }
input[type="text"], input[type="password"], textarea, select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
textarea { width: 100%; min-height: 80px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; }
.golden-badge { font-size: 0.7rem; color: var(--accent); font-weight: 600; }
.portal-url { font-size: 0.8rem; color: var(--text-muted); word-break: break-all; margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<div>
<h1>Admin</h1>
<nav class="nav" style="margin-top:0.35rem;">
<a href="/admin" class="active">Admin</a>
<a href="/admin/portal-files">Portal files</a>
<a href="/admin/cloudinit-build">Cloud-init build</a>
</nav>
</div>
<span>Logged in as <strong>{{ username }}</strong></span>
<a href="/">Deploy</a>
<a href="/logout">Log out</a>
</header>
<!-- Golden image -->
<div class="section">
<h2 class="section-title">Current deploy image (golden)</h2>
<p id="goldenInfo" class="mono" style="font-size:0.9rem;">Loading…</p>
</div>
<!-- Backups -->
<div class="section">
<h2 class="section-title">
Backup images
<button type="button" class="btn btn-outline btn-sm" id="refreshBackupsBtn">Refresh</button>
<button type="button" class="btn btn-outline btn-sm" id="uploadBackupBtn">Upload backup</button>
<input type="file" id="uploadBackupInput" accept=".img,.img.gz,.img.xz" style="display:none;" />
</h2>
<p id="backupsDirHint" class="mono" style="font-size:0.75rem; margin-bottom:0.5rem; color:var(--text-muted);"></p>
<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. Capture from device on home page or upload here.</p>
</div>
<!-- Cloud-init images -->
<div class="section">
<h2 class="section-title">Cloud-init images <button type="button" class="btn btn-outline btn-sm" id="refreshCloudinitBtn">Refresh</button></h2>
<table id="cloudinitTable">
<thead><tr><th>Name</th><th>Description</th><th>Size</th><th>Date</th><th>Actions</th></tr></thead>
<tbody id="cloudinitBody"></tbody>
</table>
<p id="cloudinitEmpty" class="empty-msg" style="display:none;">No cloud-init images. Build one below.</p>
</div>
<!-- Users -->
<div class="section">
<h2 class="section-title">Admin users <button type="button" class="btn btn-outline btn-sm" id="addUserBtn">Add user</button></h2>
<div id="addUserForm" style="display:none; margin-bottom:0.75rem; padding:0.75rem; background:var(--bg-tertiary); border-radius:var(--radius-sm);">
<input type="text" id="newUsername" placeholder="Username" />
<input type="password" id="newPassword" placeholder="Password (min 6)" style="margin-left:0.25rem;" />
<button type="button" id="addUserSubmit" class="btn btn-primary btn-sm" style="margin-left:0.25rem;">Create</button>
<button type="button" id="addUserCancel" class="btn btn-outline btn-sm">Cancel</button>
</div>
<table id="usersTable"><thead><tr><th>Username</th><th>Created</th><th>Actions</th></tr></thead><tbody id="usersBody"></tbody></table>
</div>
<!-- Activity logs -->
<div class="section">
<h2 class="section-title">Activity logs <button type="button" class="btn btn-outline btn-sm" id="refreshLogsBtn">Refresh</button></h2>
<div id="adminLogs" class="mono" style="font-size:0.8rem; max-height:200px; overflow:auto;"></div>
</div>
</div>
<script>
function authFetch(url, opts) {
opts = opts || {};
return fetch(url, opts).then(function(r) {
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
return r;
});
}
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
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 fetchGolden() {
authFetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
var el = document.getElementById('goldenInfo');
if (!d.present) { el.textContent = 'No golden image set. Set one from Backup images or Cloud-init images above.'; return; }
el.innerHTML = fmtSize(d.size) + ', updated ' + fmtDate(d.mtime) + (d.name ? ' — <span class="golden-badge">' + (d.source === 'cloudinit' ? 'Cloud-init' : 'Backup') + ': ' + escapeHtml(d.name) + '</span>' : '');
}).catch(function(){});
}
function renderBackups(backups, backupsDir) {
var tbody = document.getElementById('backupsBody');
var empty = document.getElementById('backupsEmpty');
var dirHint = document.getElementById('backupsDirHint');
if (backupsDir) dirHint.textContent = 'Dir: ' + backupsDir;
tbody.innerHTML = '';
if (!backups || backups.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
backups.forEach(function(b){
var isRaw = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
var shrink = isRaw ? '<button type="button" class="btn btn-outline btn-sm set-golden-backup" data-name="'+escapeHtml(b.name)+'">Set golden</button> <button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="'+escapeHtml(b.name)+'">Shrink</button> <button type="button" class="btn btn-outline btn-sm compress-btn" data-name="'+escapeHtml(b.name)+'">Compress</button> ' : '<button type="button" class="btn btn-outline btn-sm set-golden-backup" data-name="'+escapeHtml(b.name)+'">Set golden</button> ';
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(b.display_name||b.name)+'</td><td>'+escapeHtml(b.description||'')+'</td><td class="mono">'+fmtSize(b.size)+'</td><td class="mono">'+fmtDate(b.mtime)+'</td><td class="actions-cell">'+shrink+'<a href="/api/backups/'+encodeURIComponent(b.name)+'" download class="btn btn-outline btn-sm">Download</a> <button type="button" class="btn btn-outline btn-sm rename-backup" data-name="'+escapeHtml(b.name)+'">Rename</button> <button type="button" class="btn btn-outline btn-sm delete-backup" data-name="'+escapeHtml(b.name)+'">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.set-golden-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Set as golden?\n'+n)) return; authFetch('/api/backups/'+encodeURIComponent(n)+'/set-as-golden', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); } else alert(d.error); }).catch(function(){}); };
});
tbody.querySelectorAll('.shrink-btn').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Shrink (PiShrink)? '+n)) return; btn.disabled=true; authFetch('/api/backups/'+encodeURIComponent(n)+'/shrink', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }).finally(function(){ btn.disabled=false; }); };
});
tbody.querySelectorAll('.compress-btn').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Compress to .img.xz? '+n)) return; btn.disabled=true; authFetch('/api/backups/'+encodeURIComponent(n)+'/compress', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({format:'xz'}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }).finally(function(){ btn.disabled=false; }); };
});
tbody.querySelectorAll('.rename-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); var newName = prompt('New filename', n); if (!newName || !newName.trim()) return; authFetch('/api/backups/'+encodeURIComponent(n), { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({filename: newName.trim()}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchBackups(); else alert(d.error); }); };
});
tbody.querySelectorAll('.delete-backup').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/backups/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); } else alert(d.error); }); };
});
}
function fetchBackups() {
authFetch('/api/backups').then(function(r){ return r.json(); }).then(function(d){ renderBackups(d.backups || [], d.backups_dir); }).catch(function(){});
}
function renderCloudinit(images) {
var tbody = document.getElementById('cloudinitBody');
var empty = document.getElementById('cloudinitEmpty');
tbody.innerHTML = '';
if (!images || images.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
images.forEach(function(b){
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(b.display_name||b.name)+'</td><td>'+escapeHtml(b.description||'')+'</td><td class="mono">'+fmtSize(b.size)+'</td><td class="mono">'+fmtDate(b.mtime)+'</td><td class="actions-cell"><button type="button" class="btn btn-outline btn-sm set-golden-cloudinit" data-name="'+escapeHtml(b.name)+'">Set golden</button> <a href="/api/cloudinit-images/'+encodeURIComponent(b.name)+'" download class="btn btn-outline btn-sm">Download</a> <button type="button" class="btn btn-outline btn-sm rename-cloudinit" data-name="'+escapeHtml(b.name)+'">Rename</button> <button type="button" class="btn btn-outline btn-sm delete-cloudinit" data-name="'+escapeHtml(b.name)+'">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.set-golden-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Set as golden?\n'+n)) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n)+'/set-as-golden', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchCloudinit(); fetchGolden(); } else alert(d.error); }).catch(function(){}); };
});
tbody.querySelectorAll('.rename-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); var newName = prompt('New filename', n); if (!newName || !newName.trim()) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n), { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({filename: newName.trim()}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchCloudinit(); else alert(d.error); }); };
});
tbody.querySelectorAll('.delete-cloudinit').forEach(function(btn){
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/cloudinit-images/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchCloudinit(); fetchGolden(); } else alert(d.error); }); };
});
}
function fetchCloudinit() {
authFetch('/api/cloudinit-images').then(function(r){ return r.json(); }).then(function(d){ renderCloudinit(d.images || []); }).catch(function(){});
}
function renderUsers(users) {
var tbody = document.getElementById('usersBody');
tbody.innerHTML = '';
(users || []).forEach(function(u){
var tr = document.createElement('tr');
tr.innerHTML = '<td>'+escapeHtml(u.username)+'</td><td class="mono">'+fmtDate(u.created_at)+'</td><td><button type="button" class="btn btn-outline btn-sm chpwd-btn" data-id="'+u.id+'" data-username="'+escapeHtml(u.username)+'">Change password</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.chpwd-btn').forEach(function(btn){
btn.onclick = function(){ var id = btn.getAttribute('data-id'); var username = btn.getAttribute('data-username'); var pw = prompt('New password for '+username+' (min 6 characters)'); if (!pw || pw.length < 6) { if(pw!==null) alert('Min 6 characters'); return; } authFetch('/api/admin/users/'+id+'/password', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({password: pw}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) alert('Password updated'); else alert(d.error); }); };
});
}
function fetchUsers() {
authFetch('/api/admin/users').then(function(r){ return r.json(); }).then(function(d){ renderUsers(d.users); }).catch(function(){});
}
function renderLogs(logs) {
var el = document.getElementById('adminLogs');
if (!logs || logs.length === 0) { el.textContent = 'No logs.'; return; }
el.innerHTML = logs.map(function(l){ return fmtDate(l.created_at) + ' ' + (l.username || '') + ' ' + escapeHtml(l.action) + (l.details ? ' ' + escapeHtml(l.details) : ''); }).join('\n');
}
function fetchLogs() {
authFetch('/api/admin/logs').then(function(r){ return r.json(); }).then(function(d){ renderLogs(d.logs); }).catch(function(){});
}
document.getElementById('refreshBackupsBtn').onclick = fetchBackups;
document.getElementById('uploadBackupBtn').onclick = function(){ document.getElementById('uploadBackupInput').click(); };
document.getElementById('uploadBackupInput').onchange = function(){
var f = this.files && this.files[0];
if(!f) return;
var fd = new FormData(); fd.append('file', f);
authFetch('/api/backups/upload', { method: 'POST', body: fd }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { fetchBackups(); fetchGolden(); alert('Uploaded '+d.name); } else alert(d.error); }).catch(function(){});
this.value = '';
};
document.getElementById('refreshCloudinitBtn').onclick = fetchCloudinit;
document.getElementById('refreshLogsBtn').onclick = fetchLogs;
document.getElementById('addUserBtn').onclick = function(){ document.getElementById('addUserForm').style.display = 'block'; };
document.getElementById('addUserCancel').onclick = function(){ document.getElementById('addUserForm').style.display = 'none'; };
document.getElementById('addUserSubmit').onclick = function(){
var username = document.getElementById('newUsername').value.trim();
var password = document.getElementById('newPassword').value;
if(!username) { alert('Username required'); return; }
if(password.length < 6) { alert('Password min 6 characters'); return; }
authFetch('/api/admin/users', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username: username, password: password}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) { document.getElementById('addUserForm').style.display = 'none'; document.getElementById('newUsername').value = ''; document.getElementById('newPassword').value = ''; fetchUsers(); } else alert(d.error); });
};
fetchBackups(); fetchCloudinit(); fetchUsers(); fetchLogs(); fetchGolden();
setInterval(fetchLogs, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloud-init build · Admin · CM4 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; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
--border: #2d333b; --radius: 10px; --radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
.wrap { max-width: 1000px; margin: 0 auto; padding: 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.header h1 { font-size: 1.25rem; font-weight: 700; }
.header span { color: var(--text-dim); font-size: 0.9rem; }
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
.nav a:hover { color: var(--accent); }
.nav a.active { color: var(--accent); font-weight: 600; }
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
.header a:hover { color: var(--accent); }
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; }
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; 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); }
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
.tabs { display: flex; gap: 0.25rem; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.tabs button { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-dim); cursor: pointer; font: inherit; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tabs button:hover { color: var(--text); }
.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
textarea { width: 100%; min-height: 220px; resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.5rem; border-radius: 4px; }
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
input[type="text"], select { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
label { display: inline-block; margin-right: 0.5rem; }
ul { list-style: none; padding: 0; }
ul li { margin-bottom: 0.35rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<div>
<h1>Cloud-init build</h1>
<nav class="nav" style="margin-top:0.35rem;">
<a href="/admin">Admin</a>
<a href="/admin/portal-files">Portal files</a>
<a href="/admin/cloudinit-build" class="active">Cloud-init build</a>
</nav>
</div>
<span>Logged in as <strong>{{ username }}</strong></span>
<a href="/">Deploy</a>
<a href="/logout">Log out</a>
</header>
<div class="section">
<h2 class="section-title">Download &amp; build cloud-init image</h2>
<p style="font-size:0.9rem; color:var(--text-dim); margin-bottom:0.75rem;">Download latest Raspberry Pi OS (arm64), inject cloud-init. Output goes to <strong>Cloud-init images</strong> on the Admin page.</p>
<div style="margin-bottom:0.5rem;">
<label>Variant:</label>
<select id="buildVariant"><option value="lite">Lite</option><option value="full">Full</option></select>
<span id="buildRaspiosUrl" class="mono" style="margin-left:0.5rem;"></span>
</div>
<div style="margin-bottom:0.5rem;"><label><input type="checkbox" id="buildSetGolden" /> Set as golden after build</label></div>
<div style="margin-bottom:0.75rem;"><button type="button" id="buildCloudInitBtn" class="btn btn-primary">Download &amp; build</button></div>
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
</div>
<div class="section">
<h2 class="section-title">Edit cloud-init files (templates)</h2>
<p style="font-size:0.85rem; color:var(--text-dim); margin-bottom:0.75rem;">Choose a file to edit below. These contents are injected into the image when you run the build.</p>
<div class="tabs">
<button type="button" class="tab-btn active" data-tab="user-data">user-data</button>
<button type="button" class="tab-btn" data-tab="meta-data">meta-data</button>
<button type="button" class="tab-btn" data-tab="network-config">network-config</button>
</div>
<div id="pane-user-data" class="tab-pane active">
<label>user-data (YAML, #cloud-config)</label>
<textarea id="buildUserData" rows="12" placeholder="#cloud-config..."></textarea>
</div>
<div id="pane-meta-data" class="tab-pane">
<label>meta-data (optional)</label>
<textarea id="buildMetaData" rows="8" placeholder="instance-id: ..."></textarea>
</div>
<div id="pane-network-config" class="tab-pane">
<label>network-config (optional)</label>
<textarea id="buildNetworkConfig" rows="8" placeholder="version: 2..."></textarea>
</div>
<div style="margin-top:0.75rem;">
<span>Templates:</span>
<select id="buildTemplateSelect"><option value="">— Load —</option></select>
<button type="button" id="buildTemplateLoad" class="btn btn-outline btn-sm">Load</button>
<button type="button" id="buildTemplateSave" class="btn btn-outline btn-sm">Save as template…</button>
</div>
<ul id="buildTemplateList" style="margin-top:0.5rem; font-size:0.85rem;"></ul>
</div>
</div>
<script>
function authFetch(url, opts) {
opts = opts || {};
return fetch(url, opts).then(function(r) {
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
return r;
});
}
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.onclick = function() {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-pane').forEach(function(p) { p.classList.remove('active'); });
btn.classList.add('active');
var tab = btn.getAttribute('data-tab');
var pane = document.getElementById('pane-' + tab);
if (pane) pane.classList.add('active');
};
});
function fetchBuildStatus() {
authFetch('/api/build-cloudinit-status').then(function(r) { return r.json(); }).then(function(d) {
var el = document.getElementById('buildCloudInitStatus');
var btn = document.getElementById('buildCloudInitBtn');
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if (btn) btn.disabled = busy;
if (d.phase === 'idle' && !d.message) el.textContent = '';
else if (d.phase === 'done') { el.textContent = 'Done: ' + (d.output_name || '') + ' — see Admin page Cloud-init images.'; }
else if (d.phase === 'error') el.textContent = 'Error: ' + (d.error || '');
else el.textContent = (d.phase || '') + ': ' + (d.message || '');
if (busy) setTimeout(fetchBuildStatus, 5000);
}).catch(function() {});
}
function startBuild() {
var btn = document.getElementById('buildCloudInitBtn');
if (btn) btn.disabled = true;
var body = { variant: document.getElementById('buildVariant').value, set_as_golden_after: document.getElementById('buildSetGolden').checked };
body.user_data = document.getElementById('buildUserData').value.trim();
body.meta_data = document.getElementById('buildMetaData').value.trim();
body.network_config = document.getElementById('buildNetworkConfig').value.trim();
authFetch('/api/build-cloudinit', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) { document.getElementById('buildCloudInitStatus').textContent = 'Build started…'; setTimeout(fetchBuildStatus, 2000); }
else { alert(d.error); if (btn) btn.disabled = false; }
}).catch(function() { if (btn) btn.disabled = false; });
}
function fetchTemplates() {
authFetch('/api/cloudinit-templates').then(function(r) { return r.json(); }).then(function(d) {
var list = d.templates || [];
var sel = document.getElementById('buildTemplateSelect');
sel.innerHTML = '<option value="">— Load —</option>';
list.forEach(function(t) { var o = document.createElement('option'); o.value = t.id; o.textContent = t.name; sel.appendChild(o); });
var ul = document.getElementById('buildTemplateList');
ul.innerHTML = list.map(function(t) {
return '<li>' + escapeHtml(t.name) + ' <button type="button" class="btn btn-outline btn-sm load-tpl" data-id="' + t.id + '">Load</button> <button type="button" class="btn btn-outline btn-sm del-tpl" data-id="' + t.id + '">Delete</button></li>';
}).join('') || '<li>No templates</li>';
ul.querySelectorAll('.load-tpl').forEach(function(b) {
b.onclick = function() {
authFetch('/api/cloudinit-templates/' + b.getAttribute('data-id')).then(function(r) { return r.json(); }).then(function(t) {
document.getElementById('buildUserData').value = t.user_data || '';
document.getElementById('buildMetaData').value = t.meta_data || '';
document.getElementById('buildNetworkConfig').value = t.network_config || '';
});
};
});
ul.querySelectorAll('.del-tpl').forEach(function(b) {
b.onclick = function() {
if (!confirm('Delete template?')) return;
authFetch('/api/cloudinit-templates/' + b.getAttribute('data-id'), { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) fetchTemplates(); });
};
});
}).catch(function() {});
}
function loadTemplateFromSelect() {
var s = document.getElementById('buildTemplateSelect');
if (s && s.value) authFetch('/api/cloudinit-templates/' + s.value).then(function(r) { return r.json(); }).then(function(t) {
document.getElementById('buildUserData').value = t.user_data || '';
document.getElementById('buildMetaData').value = t.meta_data || '';
document.getElementById('buildNetworkConfig').value = t.network_config || '';
});
}
function saveTemplate() {
var name = prompt('Template name');
if (!name || !name.trim()) return;
var body = { name: name.trim(), user_data: document.getElementById('buildUserData').value, meta_data: document.getElementById('buildMetaData').value, network_config: document.getElementById('buildNetworkConfig').value };
authFetch('/api/cloudinit-templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) { fetchTemplates(); alert('Saved'); } else alert(d.error); });
}
document.getElementById('buildCloudInitBtn').onclick = startBuild;
document.getElementById('buildVariant').onchange = function() {
authFetch('/api/raspios-latest-url?variant=' + encodeURIComponent(document.getElementById('buildVariant').value)).then(function(r) { return r.json(); }).then(function(d) {
document.getElementById('buildRaspiosUrl').textContent = (d.ok && d.filename) ? d.filename : (d.error || '');
});
};
document.getElementById('buildTemplateLoad').onclick = loadTemplateFromSelect;
document.getElementById('buildTemplateSave').onclick = saveTemplate;
fetchBuildStatus();
fetchTemplates();
authFetch('/api/raspios-latest-url').then(function(r) { return r.json(); }).then(function(d) {
document.getElementById('buildRaspiosUrl').textContent = (d.ok && d.filename) ? d.filename : (d.error || '');
});
setInterval(fetchBuildStatus, 15000);
</script>
</body>
</html>

View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deploy · CM4 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-card: #151a24;
--accent: #00d4aa;
--accent-dim: #00b894;
--text: #e6e8eb;
--text-dim: #8b949e;
--text-muted: #5c6370;
--border: #2d333b;
--danger: #f87171;
--warn: #fbbf24;
--success: #00d4aa;
--radius: 10px;
--radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.45; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.02em; }
.header a { color: var(--text-dim); text-decoration: none; font-size: 0.9rem; }
.header a:hover { color: var(--accent); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
}
.card-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.6rem; }
.status-row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.status-pill { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; padding: 0.25rem 0.6rem; border-radius: 999px; }
.status-pill.idle { background: rgba(0,212,170,0.15); color: var(--accent); }
.status-pill.waiting_choice, .status-pill.rpiboot, .status-pill.flashing, .status-pill.backup { background: rgba(251,191,36,0.15); color: var(--warn); }
.status-pill.done { background: rgba(0,212,170,0.15); color: var(--success); }
.status-pill.error { background: rgba(248,113,113,0.15); color: var(--danger); }
.status-msg { flex: 1; min-width: 0; font-size: 0.9rem; }
.status-err { width: 100%; margin-top: 0.4rem; font-size: 0.8rem; color: var(--danger); }
.progress-track { margin-top: 0.5rem; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); transition: width 0.3s; }
.progress-fill.indeterminate { width: 35%; animation: slide 1.2s ease-in-out infinite; }
@keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
.device-item {
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
padding: 0.6rem 0.75rem; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-sm);
margin-bottom: 0.4rem;
}
.device-desc { font-size: 0.85rem; color: var(--text-dim); }
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; 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); }
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
.golden-info { font-size: 0.85rem; color: var(--text-dim); }
.golden-info strong { color: var(--text); }
.empty-msg { text-align: center; padding: 1rem; font-size: 0.85rem; color: var(--text-muted); }
details { margin-top: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
summary { padding: 0.5rem 0.75rem; font-size: 0.85rem; font-weight: 500; color: var(--text-dim); cursor: pointer; list-style: none; }
summary::-webkit-details-marker { display: none; }
summary::before { content: '▶'; display: inline-block; margin-right: 0.4rem; font-size: 0.6rem; transition: transform 0.2s; }
details[open] summary::before { transform: rotate(90deg); }
details .inner { padding: 0.75rem; padding-top: 0; font-size: 0.8rem; color: var(--text-dim); border-top: 1px solid var(--border); }
.log-pre { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-sm); padding: 0.5rem 0.75rem; max-height: 160px; overflow: auto; white-space: pre-wrap; word-break: break-all; color: var(--text-muted); }
.steps-list { list-style: none; }
.steps-list li { display: flex; gap: 0.5rem; padding: 0.3rem 0; }
.steps-list .num { flex-shrink: 0; width: 1.2rem; height: 1.2rem; background: var(--bg-secondary); color: var(--text-dim); border-radius: 50%; font-size: 0.7rem; 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.5rem 0 0.2rem 0; font-size: 0.85rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<h1>CM4 eMMC Provisioning</h1>
<a href="/admin">Admin</a>
</header>
<div class="grid">
<!-- Left: Status + Deploy -->
<div>
<div class="card" style="margin-bottom: 1rem;">
<h2 class="card-title">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="statusClearWrap" style="display:none; margin-top:0.5rem;"><button type="button" id="statusClearBtn" class="btn btn-outline btn-sm">Clear message</button></div>
<div id="progressWrap" class="progress-track" style="display:none;"><div id="progressFill" class="progress-fill"></div></div>
</div>
<div class="card">
<h2 class="card-title">Capture or deploy</h2>
<p id="shrinkOptionWrap" style="display:none; margin-bottom: 0.5rem; font-size: 0.8rem;"><label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label></p>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-msg" style="display:none;">No device connected. Use USB boot mode or register over network.</p>
</div>
</div>
<!-- Right: Golden + Log + Connect -->
<div>
<div class="card" style="margin-bottom: 1rem;">
<h2 class="card-title">Current deploy image</h2>
<p id="goldenInfo" class="golden-info">Loading…</p>
</div>
<details class="card" style="padding: 0;">
<summary>Recent log</summary>
<div class="inner"><pre id="log" class="log-pre"></pre></div>
</details>
<details class="card" style="padding: 0; margin-top: 1rem;">
<summary>How to connect</summary>
<div class="inner">
<p class="help-sub">USB boot</p>
<ol class="steps-list">
<li><span class="num">1</span> Set reTerminal to <strong>boot mode</strong> (eMMC disable jumper).</li>
<li><span class="num">2</span> Connect USB to host; choose <strong>Backup</strong> or <strong>Deploy</strong> above.</li>
<li><span class="num">3</span> Remove jumper and power cycle when done.</li>
</ol>
<p class="help-sub">Network</p>
<ol class="steps-list">
<li><span class="num">1</span> Enable network boot; device must reach this server.</li>
<li><span class="num">2</span> Boot with provisioning client; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div>
</details>
</div>
</div>
</div>
<script>
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';
document.getElementById('statusPill').className = 'status-pill ' + phase;
document.getElementById('statusPill').textContent = phaseLabels[phase] || phase;
document.getElementById('statusMsg').textContent = data.message || '';
const err = document.getElementById('statusErr');
if (data.error) { err.textContent = data.error; err.style.display = 'block'; } else { err.style.display = 'none'; }
var clearWrap = document.getElementById('statusClearWrap');
if (clearWrap) clearWrap.style.display = (phase === 'error' ? 'block' : 'none');
const progress = data.progress;
const inProgress = ['rpiboot', 'flashing', 'backup'].includes(phase);
const wrap = document.getElementById('progressWrap');
const fill = document.getElementById('progressFill');
if (inProgress || (phase === 'done' && progress != null)) {
wrap.style.display = 'block';
fill.classList.remove('indeterminate');
fill.style.width = (progress != null ? progress + '%' : '35%');
if (progress == null) fill.classList.add('indeterminate');
} else wrap.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;
shrinkWrap.style.display = 'block';
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">USB device — choose action</div><div><button type="button" class="btn btn-outline btn-sm" data-source="usb" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="usb" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
} else shrinkWrap.style.display = 'none';
(network || []).forEach(function(d) {
hasAny = true;
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-desc">' + escapeHtml(d.ip || '') + ' · ' + escapeHtml(d.mac || '') + '</div><div><button type="button" class="btn btn-outline btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button> <button type="button" class="btn btn-primary btn-sm" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
});
noPending.style.display = hasAny ? 'none' : 'block';
container.querySelectorAll('button[data-action]').forEach(function(btn) {
btn.onclick = function() {
const body = { source: btn.getAttribute('data-source'), action: btn.getAttribute('data-action') };
if (body.source === 'network') body.mac = btn.getAttribute('data-mac');
if (body.action === 'backup' && document.getElementById('shrinkAfterBackup').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 escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
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 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 fetchGolden() {
fetch('/api/golden-info').then(function(r){ return r.json(); }).then(function(d){
const el = document.getElementById('goldenInfo');
if (!d.present) { el.innerHTML = 'No image set. An admin must set a backup or cloud-init image as golden for Deploy.'; return; }
let t = 'Ready for deploy: ' + fmtSize(d.size) + ', updated ' + fmtDate(d.mtime);
if (d.name) t += ' <br><strong>' + (d.source === 'cloudinit' ? 'Cloud-init' : 'Backup') + ':</strong> ' + escapeHtml(d.name);
el.innerHTML = t;
}).catch(function(){ document.getElementById('goldenInfo').textContent = 'Could not load.'; });
}
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(); }); });
fetchStatus(); fetchLog(); fetchPending(); fetchGolden();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchGolden, 10000);
</script>
</body>
</html>

View File

@@ -0,0 +1,973 @@
<!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="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="statusClearBtn" 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="lite">Lite (no desktop)</option>
<option value="full">Full (desktop)</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><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>
</div>
<div id="buildCloudInitStatus" class="backups-mono" style="font-size:0.85rem; min-height:1.5em; margin-bottom:0.5rem;"></div>
<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="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=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';
}
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'].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 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);
} 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;
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 : 'lite';
}
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-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-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 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');
if (!el) return;
var busy = ['resolving','downloading','decompressing','injecting','finalizing'].indexOf(d.phase) >= 0;
if (btn) btn.disabled = busy;
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 {
el.textContent = (d.phase || '') + ': ' + (d.message || '');
}
if (busy) setTimeout(fetchBuildStatus, 5000);
}).catch(function() {});
}
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 body = {
variant: getBuildVariant(),
set_as_golden_after: setGolden && setGolden.checked,
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 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 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 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>

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin login · CM4 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=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root { --bg: #0a0e14; --bg-card: #151a24; --accent: #00d4aa; --text: #e6e8eb; --text-dim: #8b949e; --border: #2d333b; --danger: #f87171; --radius: 10px; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.box { width: 100%; max-width: 360px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem; }
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
p.sub { font-size: 0.9rem; color: var(--text-dim); margin-bottom: 1.5rem; }
.err { font-size: 0.85rem; color: var(--danger); margin-bottom: 1rem; }
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.35rem; color: var(--text-dim); }
input[type="text"], input[type="password"] { width: 100%; padding: 0.6rem 0.75rem; font: inherit; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); margin-bottom: 1rem; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; padding: 0.65rem; font: inherit; font-weight: 600; background: var(--accent); color: var(--bg); border: none; border-radius: 6px; cursor: pointer; }
button:hover { background: #00b894; }
a.back { display: inline-block; margin-top: 1rem; font-size: 0.9rem; color: var(--text-dim); text-decoration: none; }
a.back:hover { color: var(--accent); }
</style>
</head>
<body>
<div class="box">
<h1>Admin login</h1>
<p class="sub">CM4 eMMC provisioning dashboard</p>
{% if error %}<p class="err">{{ error }}</p>{% endif %}
<form method="post" action="{{ url_for('login') }}">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username" value="{{ request.form.username or '' }}">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Log in</button>
</form>
<a href="{{ url_for('index') }}" class="back">← Back to deploy</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portal files · Admin · CM4 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; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
--border: #2d333b; --danger: #f87171; --radius: 10px; --radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 1rem; }
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
.header h1 { font-size: 1.25rem; font-weight: 700; }
.header span { color: var(--text-dim); font-size: 0.9rem; }
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
.nav a:hover { color: var(--accent); }
.nav a.active { color: var(--accent); font-weight: 600; }
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
.header a:hover { color: var(--accent); }
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; 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); }
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
.btn-danger { background: rgba(248,113,113,0.2); color: var(--danger); }
.btn-danger:hover { background: rgba(248,113,113,0.3); }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
td { padding: 0.5rem 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
.empty-msg { text-align: center; padding: 1.5rem; font-size: 0.9rem; color: var(--text-muted); }
.breadcrumb { margin-bottom: 0.75rem; font-size: 0.85rem; }
.breadcrumb a { color: var(--text-dim); text-decoration: none; }
.breadcrumb a:hover { color: var(--accent); }
.breadcrumb span { color: var(--text-muted); margin: 0 0.25rem; }
.desc-input { width: 100%; max-width: 280px; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.3rem 0.5rem; border-radius: 4px; }
.folder-name { font-weight: 500; }
input[type="text"] { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
</style>
</head>
<body>
<div class="wrap">
<header class="header">
<div>
<h1>Portal files</h1>
<nav class="nav" style="margin-top:0.35rem;">
<a href="/admin">Admin</a>
<a href="/admin/portal-files" class="active">Portal files</a>
<a href="/admin/cloudinit-build">Cloud-init build</a>
</nav>
</div>
<span>Logged in as <strong>{{ username }}</strong></span>
<a href="/">Deploy</a>
<a href="/logout">Log out</a>
</header>
<div class="section">
<div class="section-title">
<span id="breadcrumb">Portal files</span>
<button type="button" class="btn btn-outline btn-sm" id="newFolderBtn">New folder</button>
<button type="button" class="btn btn-outline btn-sm" id="uploadBtn">Upload file</button>
<input type="file" id="uploadInput" style="display:none;" />
</div>
<p class="mono" style="font-size:0.8rem; margin-bottom:0.5rem;">Served at <strong id="baseUrl">/files/</strong> — use in cloud-init e.g. <code>curl -fsSL "http://SERVER/files/first-boot/splash.png" -o /tmp/splash.png</code></p>
<p class="mono" style="font-size:0.75rem; color:var(--text-muted); margin-bottom:0.75rem;">Directory on server: <strong id="portalDir"></strong></p>
<table>
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Description</th><th>Actions</th></tr></thead>
<tbody id="portalBody"></tbody>
</table>
<p id="portalEmpty" class="empty-msg" style="display:none;">No files or folders. Create a folder or upload a file.</p>
</div>
</div>
<script>
function authFetch(url, opts) {
opts = opts || {};
opts.credentials = opts.credentials || 'same-origin';
return fetch(url, opts).then(function(r) {
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
return r;
});
}
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function fmtSize(n) { if (!n) return '—'; 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 ts ? new Date(ts*1000).toLocaleString() : '—'; }
var currentPath = '';
var descriptions = {};
function buildBreadcrumb() {
var el = document.getElementById('breadcrumb');
var parts = currentPath ? currentPath.split('/') : [];
var html = '<a href="#" data-path="">Portal files</a>';
parts.forEach(function(p, i) {
var path = parts.slice(0, i+1).join('/');
html += ' <span>/</span> <a href="#" data-path="' + escapeHtml(path) + '">' + escapeHtml(p) + '</a>';
});
el.innerHTML = html;
el.querySelectorAll('a[data-path]').forEach(function(a) {
a.onclick = function(e) { e.preventDefault(); currentPath = a.getAttribute('data-path') || ''; fetchPortal(); };
});
}
function saveDescription(path, desc) {
descriptions[path] = desc;
authFetch('/api/portal-files/descriptions', { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ descriptions: descriptions }) }).then(function(r) { return r.json(); }).catch(function() {});
}
function renderPortal(data) {
var items = data.items || [];
var baseUrl = data.base_url || '';
descriptions = data.descriptions || {};
currentPath = data.current_path || '';
document.getElementById('baseUrl').textContent = baseUrl + (currentPath ? currentPath + '/' : '');
document.getElementById('portalDir').textContent = data.portal_files_dir || '—';
buildBreadcrumb();
var tbody = document.getElementById('portalBody');
var empty = document.getElementById('portalEmpty');
tbody.innerHTML = '';
if (items.length === 0) { empty.style.display = 'block'; return; }
empty.style.display = 'none';
items.forEach(function(it) {
var tr = document.createElement('tr');
var desc = descriptions[it.path] || '';
var typeLabel = it.type === 'folder' ? 'Folder' : 'File';
var sizeCell = it.type === 'file' ? '<td class="mono">' + fmtSize(it.size) + '</td>' : '<td class="mono">—</td>';
var descCell = '<td><input type="text" class="desc-input" data-path="' + escapeHtml(it.path) + '" value="' + escapeHtml(desc) + '" placeholder="Comment…" /></td>';
var actions = '';
if (it.type === 'folder') {
actions = '<button type="button" class="btn btn-outline btn-sm open-folder" data-path="' + escapeHtml(it.path) + '">Open</button> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="folder">Delete</button>';
} else {
actions = '<a href="' + escapeHtml(baseUrl + it.path) + '" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open</a> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="file">Delete</button>';
}
tr.innerHTML = '<td class="' + (it.type === 'folder' ? 'folder-name' : '') + '">' + escapeHtml(it.name) + (it.type === 'folder' ? ' /' : '') + '</td><td>' + typeLabel + '</td>' + sizeCell + descCell + '<td class="actions-cell">' + actions + '</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('.desc-input').forEach(function(inp) {
inp.onblur = function() { saveDescription(inp.getAttribute('data-path'), inp.value.trim()); };
});
tbody.querySelectorAll('.open-folder').forEach(function(btn) {
btn.onclick = function() { currentPath = btn.getAttribute('data-path'); fetchPortal(); };
});
tbody.querySelectorAll('.delete-item').forEach(function(btn) {
btn.onclick = function() {
var path = btn.getAttribute('data-path');
var type = btn.getAttribute('data-type');
if (!confirm('Delete ' + type + ' "' + path + '"?')) return;
authFetch('/api/portal-files/' + encodeURIComponent(path), { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) fetchPortal(); else alert(d.error || 'Delete failed');
});
};
});
}
function fetchPortal() {
var url = '/api/portal-files';
if (currentPath) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'path=' + encodeURIComponent(currentPath);
authFetch(url).then(function(r) { return r.json(); }).then(renderPortal).catch(function(err) {
document.getElementById('portalEmpty').style.display = 'block';
document.getElementById('portalEmpty').textContent = 'Could not load list (session may have expired). Trying read-only list…';
var fallbackUrl = '/api/portal-files?debug=1' + (currentPath ? '&path=' + encodeURIComponent(currentPath) : '');
fetch(fallbackUrl).then(function(r) { return r.json(); }).then(function(data) {
if (data.items && data.items.length) {
document.getElementById('portalEmpty').style.display = 'none';
renderPortal(data);
document.getElementById('portalDir').textContent = (data.portal_files_dir || '—') + ' (read-only; log in to edit)';
} else {
document.getElementById('portalEmpty').textContent = 'Server sees ' + (data.items ? data.items.length : 0) + ' item(s) at ' + (data.portal_files_dir || '?') + '. Log in to see and edit.';
}
}).catch(function() {
document.getElementById('portalEmpty').textContent = 'Could not load list. Log out and log in again, then refresh.';
});
});
}
document.getElementById('newFolderBtn').onclick = function() {
var name = prompt('Folder name (no slashes)');
if (!name || !name.trim()) return;
name = name.trim().replace(/[/\\]/g, '');
var path = currentPath ? currentPath + '/' + name : name;
authFetch('/api/portal-files/folder', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ path: path }) }).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) fetchPortal(); else alert(d.error || 'Failed');
});
};
document.getElementById('uploadBtn').onclick = function() { document.getElementById('uploadInput').click(); };
document.getElementById('uploadInput').onchange = function() {
var f = this.files && this.files[0];
if (!f) return;
var fd = new FormData();
fd.append('file', f);
if (currentPath) fd.append('path', currentPath);
authFetch('/api/portal-files/upload', { method: 'POST', body: fd }).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) { fetchPortal(); alert('Uploaded. URL: ' + d.url); } else alert(d.error || 'Upload failed');
});
this.value = '';
};
fetchPortal();
</script>
</body>
</html>