Files
reterminal-dm4/emmc-provisioning/dashboard/templates/portal_files.html
nearxos 55b8661a2e Update documentation and scripts for revision tracking and cloud-init enhancements</message>
<message>Introduce a revision tracking system across project files, allowing for easier identification of changes. Update the README files to include instructions for bumping revisions and auto-bumping on commits. Additionally, enhance cloud-init scripts with revision comments for better version control. Modify the dashboard API to improve build status management, including a forced clear option for stuck statuses, enhancing user experience and operational reliability.
2026-02-23 10:38:24 +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 · 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; }
/* 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>