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,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>