357 lines
29 KiB
HTML
357 lines
29 KiB
HTML
<!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); }
|
|
.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">
|
|
<h1>Admin</h1>
|
|
<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>
|
|
|
|
<!-- Build cloud-init (download latest + edit) -->
|
|
<div class="section">
|
|
<h2 class="section-title">Download & 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> above.</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 & build</button></div>
|
|
<div id="buildCloudInitStatus" class="mono" style="min-height:1.2em;"></div>
|
|
<details style="margin-top:0.75rem;">
|
|
<summary>Edit cloud-init (user-data, meta-data, network-config)</summary>
|
|
<div style="margin-top:0.75rem;">
|
|
<p>Templates: <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></p>
|
|
<ul id="buildTemplateList" style="list-style:none; padding:0; font-size:0.85rem; margin-bottom:0.5rem;"></ul>
|
|
<label>user-data (YAML)</label>
|
|
<textarea id="buildUserData" rows="6" placeholder="#cloud-config..."></textarea>
|
|
<label style="display:block; margin-top:0.5rem;">meta-data (optional)</label>
|
|
<textarea id="buildMetaData" rows="2"></textarea>
|
|
<label style="display:block; margin-top:0.5rem;">network-config (optional)</label>
|
|
<textarea id="buildNetworkConfig" rows="4"></textarea>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<!-- Portal files (wget in cloud-init) -->
|
|
<div class="section">
|
|
<h2 class="section-title">Portal files (for wget in cloud-init) <button type="button" class="btn btn-outline btn-sm" id="uploadPortalBtn">Upload file</button><input type="file" id="uploadPortalInput" style="display:none;" /></h2>
|
|
<p class="mono" style="font-size:0.8rem; margin-bottom:0.5rem;">Files here are served at <strong id="portalBaseUrl">/files/</strong> — use in user-data e.g. <code>curl -fsSL "http://THIS_SERVER/files/bootstrap.sh" -o /tmp/bootstrap.sh</code></p>
|
|
<table id="portalTable"><thead><tr><th>File</th><th>Size</th><th>Actions</th></tr></thead><tbody id="portalBody"></tbody></table>
|
|
<p id="portalEmpty" class="empty-msg" style="display:none;">No files. Upload scripts or configs for first-boot.</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 renderPortal(files, baseUrl) {
|
|
var tbody = document.getElementById('portalBody');
|
|
var empty = document.getElementById('portalEmpty');
|
|
document.getElementById('portalBaseUrl').textContent = baseUrl || '';
|
|
tbody.innerHTML = '';
|
|
if (!files || files.length === 0) { empty.style.display = 'block'; return; }
|
|
empty.style.display = 'none';
|
|
files.forEach(function(f){
|
|
var tr = document.createElement('tr');
|
|
tr.innerHTML = '<td><a href="'+escapeHtml(baseUrl+f.name)+'" target="_blank" rel="noopener">'+escapeHtml(f.name)+'</a></td><td class="mono">'+fmtSize(f.size)+'</td><td><button type="button" class="btn btn-outline btn-sm delete-portal" data-name="'+escapeHtml(f.name)+'">Delete</button></td>';
|
|
tbody.appendChild(tr);
|
|
});
|
|
tbody.querySelectorAll('.delete-portal').forEach(function(btn){
|
|
btn.onclick = function(){ var n = btn.getAttribute('data-name'); if (!confirm('Delete '+n+'?')) return; authFetch('/api/portal-files/'+encodeURIComponent(n), { method: 'DELETE' }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok) fetchPortal(); else alert(d.error); }); };
|
|
});
|
|
}
|
|
function fetchPortal() {
|
|
authFetch('/api/portal-files').then(function(r){ return r.json(); }).then(function(d){ renderPortal(d.files || [], d.base_url); }).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(){});
|
|
}
|
|
|
|
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 Cloud-init images above.'; fetchCloudinit(); fetchGolden(); }
|
|
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 };
|
|
var ud = document.getElementById('buildUserData').value.trim();
|
|
var md = document.getElementById('buildMetaData').value.trim();
|
|
var nc = document.getElementById('buildNetworkConfig').value.trim();
|
|
if(ud) body.user_data = ud; if(md) body.meta_data = md; if(nc) body.network_config = nc;
|
|
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('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('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;
|
|
document.getElementById('uploadPortalBtn').onclick = function(){ document.getElementById('uploadPortalInput').click(); };
|
|
document.getElementById('uploadPortalInput').onchange = function(){
|
|
var f = this.files && this.files[0];
|
|
if(!f) return;
|
|
var fd = new FormData(); fd.append('file', f);
|
|
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); }).catch(function(){});
|
|
this.value = '';
|
|
};
|
|
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(); fetchPortal(); fetchUsers(); fetchLogs(); fetchGolden(); 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);
|
|
setInterval(fetchLogs, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|