把 header 内嵌的 token 输入框 + Save 按钮拆掉,改用原生 <dialog> 弹窗。三种触发场景: 首访无 token 自动弹、API 401 自动弹 (清旧 token 后弹带错误信息)、用户主动点 Set Token 按钮弹。无有效 token 时阻止 Escape 关闭 (cancel 事件 preventDefault), 防止误操作丢失输入。 localStorage 键名 adminToken 不变, 零迁移成本。401 路径在 api() 内部清 token + 弹窗 + 记录 pendingRetry 闭包, 用户保存新 token 后 自动重试失败请求。 设计取舍: - 选原生 <dialog> 而非自造 div + JS, 自带 modal/背板/焦点陷阱/a11y - 选 <form> + preventDefault 而非 method='dialog', 避免自动关闭 - CSS 全部复用现有 dark theme 变量, 0 视觉割裂 - 0 新依赖 (纯 HTML5 + 原生 JS) 验证: - go build / go vet / go test 全部通过 - 端到端 curl 确认新元素嵌入、旧元素删除、401/200 路径正常 - router_test.go 无回归 (HTML 改动不涉及 Go) Co-Authored-By: Claude <noreply@anthropic.com>
491 lines
16 KiB
HTML
491 lines
16 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); }
|
|
.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>
|
|
<h1>spark-mcp-go Admin</h1>
|
|
<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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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>
|