Files
nearxos 16bfc1e0e1 Enhance cloud-init scripts and dashboard for improved USB boot functionality</message>
<message>Update the bootstrap script to ensure hostname resolution by adding entries to /etc/hosts, preventing "sudo: unable to resolve host" errors. Modify user-data.bootstrap to include the same hostname resolution logic. Revise dashboard templates to reflect the new project name "GNSS Guard Provisioning" and improve user interface elements related to USB boot operations, including clearer instructions and status messages. These changes enhance the overall user experience and streamline the provisioning process.
2026-02-24 08:50:32 +02:00

289 lines
17 KiB
HTML

<!DOCTYPE html>
<!-- Revision: 2 -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portal files · Admin · GNSS Guard 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; }
/* Editor modal */
.editor-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 1rem; }
.editor-overlay.visible { display: flex; }
.editor-modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); width: 100%; max-width: 900px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
.editor-header { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.editor-title { font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; color: var(--text); }
.editor-actions { display: flex; gap: 0.5rem; }
.editor-body { flex: 1; min-height: 400px; overflow: hidden; }
#editorHost { width: 100%; height: 100%; min-height: 420px; }
</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 id="editorOverlay" class="editor-overlay">
<div class="editor-modal">
<div class="editor-header">
<span id="editorTitle" class="editor-title"></span>
<div class="editor-actions">
<button type="button" class="btn btn-primary btn-sm" id="editorSaveBtn">Save</button>
<button type="button" class="btn btn-outline btn-sm" id="editorCancelBtn">Cancel</button>
</div>
</div>
<div class="editor-body">
<div id="editorHost"></div>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.35.0/ace.min.js" crossorigin="anonymous"></script>
<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 = '<button type="button" class="btn btn-outline btn-sm edit-file" data-path="' + escapeHtml(it.path) + '">Edit</button> <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('.edit-file').forEach(function(btn) {
btn.onclick = function() { openEditor(btn.getAttribute('data-path')); };
});
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 = '';
};
var editorInstance = null;
var editorCurrentPath = null;
var aceModeMap = { sh: 'sh', json: 'json', python: 'python', yaml: 'yaml', markdown: 'markdown', ini: 'ini', plain_text: 'plain_text' };
function initAce() {
if (editorInstance) return editorInstance;
var host = document.getElementById('editorHost');
editorInstance = ace.edit(host);
editorInstance.setTheme('ace/theme/tomorrow_night_eighties');
editorInstance.setOptions({ fontSize: '13px', showPrintMargin: false, wrap: true, useSoftTabs: true, tabSize: 2 });
editorInstance.session.setUseWorker(false);
return editorInstance;
}
function openEditor(path) {
editorCurrentPath = path;
document.getElementById('editorTitle').textContent = path;
document.getElementById('editorOverlay').classList.add('visible');
authFetch('/api/portal-files/content?path=' + encodeURIComponent(path)).then(function(r) { return r.json(); }).then(function(d) {
if (!d.ok) { alert(d.error || 'Could not load file'); closeEditor(); return; }
var ace = initAce();
ace.session.setValue(d.content || '');
var mode = aceModeMap[d.language] || 'plain_text';
ace.session.setMode('ace/mode/' + mode);
ace.focus();
}).catch(function() { alert('Could not load file'); closeEditor(); });
}
function closeEditor() {
editorCurrentPath = null;
document.getElementById('editorOverlay').classList.remove('visible');
}
document.getElementById('editorSaveBtn').onclick = function() {
if (!editorCurrentPath) return;
var content = editorInstance.getSession().getValue();
authFetch('/api/portal-files/content', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: editorCurrentPath, content: content }) })
.then(function(r) { return r.json(); })
.then(function(d) { if (d.ok) { closeEditor(); fetchPortal(); } else alert(d.error || 'Save failed'); })
.catch(function() { alert('Save failed'); });
};
document.getElementById('editorCancelBtn').onclick = closeEditor;
document.getElementById('editorOverlay').onclick = function(e) { if (e.target === document.getElementById('editorOverlay')) closeEditor(); };
fetchPortal();
</script>
</body>
</html>