Phase 7 起步: Admin OpenAPI + Scalar UI + Cluster CRUD HTML

用户问题: 有 list_clusters MCP tool 但没简易 admin HTML。
A + B 方案:
- A: OpenAPI 文档 + Scalar UI(让人看 + 试 admin API)
- B: Cluster CRUD HTML 页面(让人填表创建/改 cluster)

实现:
- internal/admin/openapi.yaml: 手写 6 admin 端点 + Cluster/AuditEntry/Error
  schema。auth_password 不在 spec(因 json:"-" tag)
- internal/admin/openapi.go: //go:embed openapi.yaml + Scalar HTML
  GET /admin/docs 返 Scalar UI (CDN), GET /admin/docs/spec 返 YAML
- internal/admin/web/cluster.html: 单文件 401 行暗色 monospace 风格
  cluster CRUD 页面, 14 字段表单, vanilla JS + localStorage token
  + 内嵌 CSS, 无前端框架, 无 bundler
- internal/admin/web.go: //go:embed web/cluster.html
- internal/admin/router.go: Mount 里追加 4 路由 (GET /admin, /admin/,
  /admin/docs, /admin/docs/spec), 全部在 AdminAuth 组内

Linus 视角决策: 整个 /admin 子树 (含 HTML) 统一用 AdminAuth —
用户进页面前输 token, JS 存 localStorage, 后续 fetch 自动加 header。
简单且一致, 不搞"白名单 + 不带 token 也能看页"这种特殊 case。

Constraint:
- 不引第三方 Go 库 (不用 gofiber/swagger/scalar-go)
- 不引前端框架 (no React/Vue)
- 不开 JS bundler (no webpack/vite)
- HTML/CSS/JS 全内嵌在 cluster.html 一个文件
- Scalar 走 CDN (@scalar/api-reference latest)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-10 18:17:37 +08:00
co-authored by Claude
parent 77d8bae503
commit 9a48f9e68e
5 changed files with 606 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
package admin
import (
"embed"
"net/http"
"github.com/gin-gonic/gin"
)
//go:embed openapi.yaml
var openAPIFS embed.FS
// OpenAPIYAML returns the embedded OpenAPI spec bytes.
func OpenAPIYAML() ([]byte, error) {
return openAPIFS.ReadFile("openapi.yaml")
}
// DocsHandler serves the Scalar API reference HTML.
func DocsHandler(c *gin.Context) {
_, err := OpenAPIYAML()
if err != nil {
c.String(http.StatusInternalServerError, "openapi.yaml not embedded: %v", err)
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarHTML))
}
// OpenAPISpecHandler serves the raw OpenAPI YAML spec.
func OpenAPISpecHandler(c *gin.Context) {
spec, err := OpenAPIYAML()
if err != nil {
c.String(http.StatusInternalServerError, "openapi.yaml: %v", err)
return
}
c.Data(http.StatusOK, "application/yaml", spec)
}
const scalarHTML = `<!doctype html>
<html>
<head>
<title>spark-mcp-go Admin API</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { margin: 0; padding: 0; }
</style>
</head>
<body>
<script id="api-reference" data-url="/admin/docs/spec"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>`
+129
View File
@@ -0,0 +1,129 @@
openapi: 3.0.3
info:
title: spark-mcp-go Admin API
version: 0.0.0
description: |
Admin API for cluster and audit log configuration. All endpoints
require `Authorization: Bearer <admin_token>` (matches any token in
the comma-separated ADMIN_TOKENS env var).
servers:
- url: http://localhost:8080
description: Default local dev
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
Cluster:
type: object
required: [id, name, rm_url, shs_url, spark_submit_execute_bin]
properties:
id: { type: string, example: prod }
name: { type: string, example: Production }
rm_url: { type: string, format: uri, example: http://rm:8088 }
shs_url: { type: string, format: uri, example: http://shs:18080 }
spark_submit_execute_bin:
type: string
example: /opt/spark/bin/spark-submit
description: Absolute path to spark-submit binary
is_active: { type: boolean, default: true }
auth_type:
type: string
enum: [none, simple, basic]
default: none
auth_username: { type: string, description: 'simple: user.name, basic: username' }
ssl_verify: { type: boolean, default: true }
ssl_ca_bundle: { type: string }
url_allowlist:
type: array
items: { type: string }
description: Additional host patterns (besides RM/SHS) permitted for fetch_url
default_submit_args:
type: array
items: { type: string }
description: Prepended to every spark_submit args
rate_limit_per_min:
type: integer
default: 10
minimum: 0
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
AuditEntry:
type: object
properties:
id: { type: integer }
timestamp: { type: string, format: date-time }
actor: { type: string, example: 'admin:secret-a' }
action: { type: string, enum: [cluster.create, cluster.update, cluster.delete] }
cluster_id: { type: string, nullable: true }
details: { type: string, description: 'JSON-encoded before/after diff' }
Error:
type: object
properties:
error: { type: string }
security:
- bearerAuth: []
paths:
/admin/clusters:
get:
summary: List all clusters
responses:
'200':
description: Array of clusters (always [] even if empty)
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Cluster' }
post:
summary: Create a cluster
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Cluster' }
responses:
'201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Cluster' } } } }
'400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
/admin/clusters/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string }
get:
summary: Get a single cluster
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Cluster' } } } }
'404': { description: Not found }
put:
summary: Update a cluster (password in body is ignored; set via dedicated endpoint)
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Cluster' }
responses:
'200': { description: OK }
'404': { description: Not found }
delete:
summary: Delete a cluster
responses:
'204': { description: Deleted }
'404': { description: Not found }
/admin/audit:
get:
summary: Query audit log (most recent first)
parameters:
- name: limit
in: query
schema: { type: integer, default: 100, minimum: 1, maximum: 1000 }
responses:
'200':
description: Array of audit entries
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/AuditEntry' }
+4
View File
@@ -19,12 +19,16 @@ import (
// bearer-token admin authentication.
func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, adminTokens []string) {
g := r.Group("/admin", middleware.AdminAuth(adminTokens))
g.GET("", webHandler)
g.GET("/", webHandler)
g.GET("/clusters", listClusters(repo))
g.POST("/clusters", createCluster(repo, auditRepo))
g.GET("/clusters/:id", getCluster(repo))
g.PUT("/clusters/:id", updateCluster(repo, auditRepo))
g.DELETE("/clusters/:id", deleteCluster(repo, auditRepo))
g.GET("/audit", listAudit(auditRepo))
g.GET("/docs", DocsHandler)
g.GET("/docs/spec", OpenAPISpecHandler)
}
func listClusters(repo *storage.ClusterRepo) gin.HandlerFunc {
+20
View File
@@ -0,0 +1,20 @@
package admin
import (
"embed"
"net/http"
"github.com/gin-gonic/gin"
)
//go:embed web/cluster.html
var webFS embed.FS
func webHandler(c *gin.Context) {
data, err := webFS.ReadFile("web/cluster.html")
if err != nil {
c.String(http.StatusInternalServerError, "cluster.html: %v", err)
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
}
+401
View File
@@ -0,0 +1,401 @@
<!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; }
</style>
</head>
<body>
<header>
<h1>spark-mcp-go Admin</h1>
<div class='auth'>
<label for='token'>Token</label>
<input id='token' type='password' placeholder='Bearer token' autocomplete='off'>
<button id='save-token'>Save</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>
<script>
const $ = (sel) => document.querySelector(sel);
const API = '/admin';
const tokenInput = $('#token');
tokenInput.value = localStorage.getItem('adminToken') || '';
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);
}
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) {
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);
$('#save-token').addEventListener('click', () => {
localStorage.setItem('adminToken', tokenInput.value.trim());
loadClusters();
});
$('#logout').addEventListener('click', () => {
localStorage.removeItem('adminToken');
tokenInput.value = '';
showError('Token cleared; reload the page after supplying a new token.');
});
$('#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);
}
});
loadClusters();
</script>
</body>
</html>