From 9a48f9e68eabedbeaf1a96aace50b8f898fb442a Mon Sep 17 00:00:00 2001 From: "tao.chen" Date: Fri, 10 Jul 2026 18:17:37 +0800 Subject: [PATCH] =?UTF-8?q?Phase=207=20=E8=B5=B7=E6=AD=A5:=20Admin=20OpenA?= =?UTF-8?q?PI=20+=20Scalar=20UI=20+=20Cluster=20CRUD=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户问题: 有 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 --- internal/admin/openapi.go | 52 +++++ internal/admin/openapi.yaml | 129 ++++++++++ internal/admin/router.go | 4 + internal/admin/web.go | 20 ++ internal/admin/web/cluster.html | 401 ++++++++++++++++++++++++++++++++ 5 files changed, 606 insertions(+) create mode 100644 internal/admin/openapi.go create mode 100644 internal/admin/openapi.yaml create mode 100644 internal/admin/web.go create mode 100644 internal/admin/web/cluster.html diff --git a/internal/admin/openapi.go b/internal/admin/openapi.go new file mode 100644 index 0000000..09d5fca --- /dev/null +++ b/internal/admin/openapi.go @@ -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 = ` + + + spark-mcp-go Admin API + + + + + + + + +` diff --git a/internal/admin/openapi.yaml b/internal/admin/openapi.yaml new file mode 100644 index 0000000..a88f2a6 --- /dev/null +++ b/internal/admin/openapi.yaml @@ -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 ` (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' } diff --git a/internal/admin/router.go b/internal/admin/router.go index 104bca5..43411e8 100644 --- a/internal/admin/router.go +++ b/internal/admin/router.go @@ -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 { diff --git a/internal/admin/web.go b/internal/admin/web.go new file mode 100644 index 0000000..515776f --- /dev/null +++ b/internal/admin/web.go @@ -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) +} diff --git a/internal/admin/web/cluster.html b/internal/admin/web/cluster.html new file mode 100644 index 0000000..b17c95c --- /dev/null +++ b/internal/admin/web/cluster.html @@ -0,0 +1,401 @@ + + + + + + spark-mcp-go Admin + + + +
+

spark-mcp-go Admin

+
+ + + + +
+
+ +
+ + +
+ + +
+ + + +
+ + + + + + + + + + + + + + +
IDNameRM URLActiveAuthActions
Loading...
+
+
+ + + +