Enhance first-boot script and documentation for eMMC provisioning: add structured logging, improve package installation process, and implement one-shot autostart for rotation and wallpaper setup. Update dashboard to manage portal file descriptions and enhance admin interface with new navigation links.

This commit is contained in:
nearxos
2026-02-20 08:42:53 +02:00
parent 9c533e95f9
commit 9098e820e6
22 changed files with 861 additions and 206 deletions

View File

@@ -21,6 +21,9 @@
.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; }
@@ -53,7 +56,14 @@
<body>
<div class="wrap">
<header class="header">
<h1>Admin</h1>
<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>
@@ -91,43 +101,6 @@
<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>
@@ -227,26 +200,6 @@
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 = '';
@@ -272,48 +225,6 @@
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(){
@@ -324,18 +235,6 @@
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'; };
@@ -347,9 +246,7 @@
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);
fetchBackups(); fetchCloudinit(); fetchUsers(); fetchLogs(); fetchGolden();
setInterval(fetchLogs, 30000);
</script>
</body>

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,197 @@
<!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.75rem;">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>
<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 || {};
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 + '/' : '');
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 += '?path=' + encodeURIComponent(currentPath);
authFetch(url).then(function(r) { return r.json(); }).then(renderPortal).catch(function() {});
}
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>