Files
spark-mcp/internal/admin/web/cluster.html
T
tao.chenandClaude df9b01272f admin: uploads list view with search, sort, and delete
Add /admin/uploads HTML page and JSON API backed by the upload_files

DB index. The page lists file_id, name, size (KB), uploaded_at, and

sha256 with client-side search by name, column sort, and per-row delete.

DELETE /admin/uploads/:id removes the DB index row and the on-disk

data file plus .meta.json sidecar. Admin deletes are written to the

audit log.

Wire the new dependencies through admin.Mount and main.go, and run

the backfill step on startup so pre-existing sidecars are indexed.

Tests cover auth, HTML rendering, JSON search, delete removing all

artifacts, and audit entry creation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 11:39:56 +08:00

501 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>spark-mcp-go Admin</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #c9d1d9;
--muted: #8b949e;
--accent: #58a6ff;
--danger: #f85149;
--ok: #3fb950;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-wrap: wrap;
}
header h1 { margin: 0; font-size: 1.1rem; color: var(--accent); }
nav { display: flex; gap: .75rem; }
nav a { color: var(--muted); text-decoration: none; }
nav a:hover { color: var(--text); }
nav a.active { color: var(--accent); }
.auth { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
input[type='text'], input[type='password'], input[type='number'], select, textarea {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: .4rem .5rem;
font: inherit;
}
input[type='text'], input[type='password'], select { min-width: 220px; }
button {
background: var(--accent);
color: #fff;
border: none;
border-radius: 4px;
padding: .4rem .8rem;
font: inherit;
cursor: pointer;
}
button:hover { opacity: .9; }
button.danger { background: var(--danger); }
button.secondary { background: var(--border); color: var(--text); }
main { padding: 1rem; max-width: 1200px; margin: 0 auto; }
.toolbar { display: flex; gap: .5rem; margin-bottom: 1rem; align-items: center; }
.error {
background: rgba(248, 81, 73, .15);
border: 1px solid var(--danger);
color: var(--danger);
padding: .75rem;
border-radius: 4px;
margin-bottom: 1rem;
white-space: pre-wrap;
}
.hidden { display: none; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: .5rem; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 600; }
td { vertical-align: middle; }
.actions { display: flex; gap: .4rem; }
.form-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.field { display: flex; flex-direction: column; gap: .25rem; }
.field label { color: var(--muted); font-size: .85rem; }
.field input, .field select { width: 100%; }
.form-actions { display: flex; gap: .5rem; margin-top: 1rem; }
.bool { flex-direction: row; align-items: center; gap: .5rem; }
.bool input { width: auto; }
dialog.modal {
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
border-radius: 6px;
padding: 1.5rem;
min-width: 360px;
max-width: 90vw;
}
dialog.modal::backdrop { background: rgba(0, 0, 0, .65); }
dialog.modal h2 { margin: 0 0 .5rem; font-size: 1.1rem; color: var(--accent); }
dialog.modal form { display: flex; flex-direction: column; gap: .75rem; }
dialog.modal label { color: var(--muted); font-size: .9rem; }
dialog.modal input { width: 100%; }
.modal-error { color: var(--danger); font-size: .9rem; margin: 0; }
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; }
</style>
</head>
<body>
<header>
<div style='display:flex;align-items:center;gap:1rem;'>
<h1>spark-mcp-go Admin</h1>
<nav>
<a href='/admin' class='active'>Clusters</a>
<a href='/admin/uploads'>Uploads</a>
</nav>
</div>
<div class='auth'>
<button id='set-token' class='secondary'>Set Token</button>
<button id='logout' class='danger'>Logout</button>
</div>
</header>
<main>
<div id='error' class='error hidden'></div>
<section class='toolbar'>
<button id='new-cluster'>+ New Cluster</button>
<button id='refresh' class='secondary'>Refresh</button>
</section>
<section id='form-panel' class='form-panel hidden'>
<h2 id='form-title'>New Cluster</h2>
<form id='cluster-form'>
<div class='form-grid'>
<div class='field'>
<label for='f_id'>ID</label>
<input id='f_id' name='id' type='text' required>
</div>
<div class='field'>
<label for='f_name'>Name</label>
<input id='f_name' name='name' type='text' required>
</div>
<div class='field'>
<label for='f_rm_url'>RM URL</label>
<input id='f_rm_url' name='rm_url' type='text' required>
</div>
<div class='field'>
<label for='f_shs_url'>SHS URL</label>
<input id='f_shs_url' name='shs_url' type='text' required>
</div>
<div class='field' style='grid-column: 1 / -1;'>
<label for='f_spark_submit_execute_bin'>Spark Submit Binary</label>
<input id='f_spark_submit_execute_bin' name='spark_submit_execute_bin' type='text' required>
</div>
<div class='field bool'>
<input id='f_is_active' name='is_active' type='checkbox' checked>
<label for='f_is_active'>Active</label>
</div>
<div class='field bool'>
<input id='f_ssl_verify' name='ssl_verify' type='checkbox' checked>
<label for='f_ssl_verify'>SSL verify</label>
</div>
<div class='field'>
<label for='f_auth_type'>Auth type</label>
<select id='f_auth_type' name='auth_type'>
<option value='none' selected>none</option>
<option value='simple'>simple</option>
<option value='basic'>basic</option>
</select>
</div>
<div class='field'>
<label for='f_auth_username'>Auth username</label>
<input id='f_auth_username' name='auth_username' type='text'>
</div>
<div class='field'>
<label for='f_ssl_ca_bundle'>SSL CA bundle</label>
<input id='f_ssl_ca_bundle' name='ssl_ca_bundle' type='text'>
</div>
<div class='field'>
<label for='f_url_allowlist'>URL allowlist (comma separated)</label>
<input id='f_url_allowlist' name='url_allowlist' type='text'>
</div>
<div class='field'>
<label for='f_default_submit_args'>Default submit args (comma separated)</label>
<input id='f_default_submit_args' name='default_submit_args' type='text'>
</div>
<div class='field'>
<label for='f_rate_limit_per_min'>Rate limit / min</label>
<input id='f_rate_limit_per_min' name='rate_limit_per_min' type='number' min='0' value='10'>
</div>
</div>
<div class='form-actions'>
<button type='submit' id='btn-save'>Save</button>
<button type='button' id='btn-cancel' class='secondary'>Cancel</button>
</div>
</form>
</section>
<section>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>RM URL</th>
<th>Active</th>
<th>Auth</th>
<th>Actions</th>
</tr>
</thead>
<tbody id='clusters-body'>
<tr><td colspan='6'>Loading...</td></tr>
</tbody>
</table>
</section>
</main>
<dialog id='token-modal' class='modal'>
<h2>Admin Token Required</h2>
<p id='modal-error' class='modal-error hidden'></p>
<form id='token-form'>
<label for='modal-token'>Bearer token</label>
<input id='modal-token' type='password' autocomplete='off' required>
<div class='modal-actions'>
<button type='submit' id='modal-save'>Save</button>
<button type='button' id='modal-cancel' class='secondary hidden'>Cancel</button>
</div>
</form>
</dialog>
<script>
const $ = (sel) => document.querySelector(sel);
const API = '/admin';
let pendingRetry = null;
let cancelBlocker = null;
function headers() {
return {
'Authorization': 'Bearer ' + (localStorage.getItem('adminToken') || ''),
'Content-Type': 'application/json'
};
}
function showError(msg) {
const el = $('#error');
el.textContent = msg || '';
el.classList.toggle('hidden', !msg);
}
function showModalError(msg) {
const el = $('#modal-error');
el.textContent = msg || '';
el.classList.toggle('hidden', !msg);
}
function openTokenModal(opts = {}) {
const dialog = $('#token-modal');
const cancelBtn = $('#modal-cancel');
showModalError(opts.error || '');
cancelBtn.classList.toggle('hidden', !opts.allowCancel);
if (cancelBlocker) {
dialog.removeEventListener('cancel', cancelBlocker);
cancelBlocker = null;
}
if (!opts.allowCancel) {
cancelBlocker = (e) => e.preventDefault();
dialog.addEventListener('cancel', cancelBlocker);
}
dialog.showModal();
$('#modal-token').focus();
}
function closeTokenModal() {
const dialog = $('#token-modal');
showModalError('');
if (cancelBlocker) {
dialog.removeEventListener('cancel', cancelBlocker);
cancelBlocker = null;
}
dialog.close();
$('#modal-token').value = '';
}
function saveTokenFromModal(e) {
e.preventDefault();
localStorage.setItem('adminToken', $('#modal-token').value.trim());
closeTokenModal();
if (pendingRetry) {
const fn = pendingRetry;
pendingRetry = null;
fn();
} else {
loadClusters();
}
}
async function api(method, path, body) {
const opts = { method, headers: headers() };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(API + path, opts);
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('adminToken');
pendingRetry = () => api(method, path, body);
openTokenModal({
error: 'Token rejected by server (401). Enter a valid token.',
allowCancel: false
});
throw new Error('401 Unauthorized');
}
let detail = res.statusText;
try {
const j = await res.json();
if (j.error) detail = j.error;
} catch (_) {}
throw new Error(`${res.status} ${detail}`);
}
if (res.status === 204) return null;
return res.json();
}
function toArray(value) {
if (!value) return [];
return value.split(',').map(s => s.trim()).filter(Boolean);
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderList(clusters) {
const tbody = $('#clusters-body');
tbody.innerHTML = '';
if (!clusters.length) {
tbody.innerHTML = '<tr><td colspan="6">No clusters.</td></tr>';
return;
}
for (const c of clusters) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(c.id)}</td>
<td>${escapeHtml(c.name)}</td>
<td>${escapeHtml(c.rm_url)}</td>
<td>${c.is_active ? 'yes' : 'no'}</td>
<td>${escapeHtml(c.auth_type)}</td>
<td class='actions'>
<button data-id='${escapeHtml(c.id)}' class='edit'>Edit</button>
<button data-id='${escapeHtml(c.id)}' class='danger delete'>Delete</button>
</td>
`;
tbody.appendChild(tr);
}
tbody.querySelectorAll('.edit').forEach(b => b.addEventListener('click', handleEdit));
tbody.querySelectorAll('.delete').forEach(b => b.addEventListener('click', handleDelete));
}
async function loadClusters() {
showError('');
try {
const clusters = await api('GET', '/clusters');
renderList(clusters);
} catch (e) {
showError('Failed to load clusters: ' + e.message);
}
}
let editingId = null;
function showForm(id) {
editingId = id || null;
$('#form-title').textContent = editingId ? `Edit Cluster: ${editingId}` : 'New Cluster';
$('#f_id').disabled = !!editingId;
$('#form-panel').classList.remove('hidden');
if (!editingId) resetForm();
}
function hideForm() {
editingId = null;
$('#form-panel').classList.add('hidden');
resetForm();
}
function resetForm() {
$('#cluster-form').reset();
$('#f_id').disabled = false;
$('#f_is_active').checked = true;
$('#f_ssl_verify').checked = true;
$('#f_auth_type').value = 'none';
$('#f_rate_limit_per_min').value = '10';
}
function formToCluster() {
return {
id: $('#f_id').value.trim(),
name: $('#f_name').value.trim(),
rm_url: $('#f_rm_url').value.trim(),
shs_url: $('#f_shs_url').value.trim(),
spark_submit_execute_bin: $('#f_spark_submit_execute_bin').value.trim(),
is_active: $('#f_is_active').checked,
auth_type: $('#f_auth_type').value,
auth_username: $('#f_auth_username').value.trim(),
ssl_verify: $('#f_ssl_verify').checked,
ssl_ca_bundle: $('#f_ssl_ca_bundle').value.trim(),
url_allowlist: toArray($('#f_url_allowlist').value),
default_submit_args: toArray($('#f_default_submit_args').value),
rate_limit_per_min: parseInt($('#f_rate_limit_per_min').value, 10) || 0
};
}
async function handleEdit(e) {
const id = e.target.dataset.id;
showError('');
try {
const c = await api('GET', `/clusters/${encodeURIComponent(id)}`);
showForm(id);
$('#f_id').value = c.id || '';
$('#f_name').value = c.name || '';
$('#f_rm_url').value = c.rm_url || '';
$('#f_shs_url').value = c.shs_url || '';
$('#f_spark_submit_execute_bin').value = c.spark_submit_execute_bin || '';
$('#f_is_active').checked = !!c.is_active;
$('#f_ssl_verify').checked = c.ssl_verify !== false;
$('#f_auth_type').value = c.auth_type || 'none';
$('#f_auth_username').value = c.auth_username || '';
$('#f_ssl_ca_bundle').value = c.ssl_ca_bundle || '';
$('#f_url_allowlist').value = (c.url_allowlist || []).join(', ');
$('#f_default_submit_args').value = (c.default_submit_args || []).join(', ');
$('#f_rate_limit_per_min').value = String(c.rate_limit_per_min ?? 10);
} catch (err) {
showError('Failed to load cluster: ' + err.message);
}
}
async function handleDelete(e) {
const id = e.target.dataset.id;
if (!confirm(`Delete cluster ${id}?`)) return;
showError('');
try {
await api('DELETE', `/clusters/${encodeURIComponent(id)}`);
await loadClusters();
} catch (err) {
showError('Failed to delete cluster: ' + err.message);
}
}
$('#new-cluster').addEventListener('click', () => showForm(null));
$('#refresh').addEventListener('click', loadClusters);
$('#btn-cancel').addEventListener('click', hideForm);
$('#set-token').addEventListener('click', () => openTokenModal({ allowCancel: true }));
$('#logout').addEventListener('click', () => {
localStorage.removeItem('adminToken');
openTokenModal({
error: 'Token cleared. Enter a new token to continue.',
allowCancel: false
});
});
$('#token-form').addEventListener('submit', saveTokenFromModal);
$('#modal-cancel').addEventListener('click', closeTokenModal);
$('#cluster-form').addEventListener('submit', async (e) => {
e.preventDefault();
showError('');
const payload = formToCluster();
try {
if (editingId) {
await api('PUT', `/clusters/${encodeURIComponent(editingId)}`, payload);
} else {
await api('POST', '/clusters', payload);
}
hideForm();
await loadClusters();
} catch (err) {
showError('Save failed: ' + err.message);
}
});
if (!localStorage.getItem('adminToken')) {
openTokenModal({ allowCancel: false });
} else {
loadClusters();
}
</script>
</body>
</html>