admin: submissions page, download endpoint, uploads search by app_id
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -22,7 +23,7 @@ import (
|
||||
|
||||
// Mount attaches the /admin sub-router to r, protecting every route with
|
||||
// bearer-token admin authentication.
|
||||
func Mount(r *gin.Engine, repo *storage.ClusterRepo, uploadRepo *storage.UploadRepo, auditRepo *audit.Repo, uploadStore *uploads.Store, adminTokens []string) {
|
||||
func Mount(r *gin.Engine, repo *storage.ClusterRepo, uploadRepo *storage.UploadRepo, submissionRepo *storage.SubmissionRepo, auditRepo *audit.Repo, uploadStore *uploads.Store, adminTokens []string) {
|
||||
// HTML page is public — its own modal prompts for the token.
|
||||
// All other /admin/* endpoints (API + OpenAPI docs) still require auth.
|
||||
gPublic := r.Group("/admin")
|
||||
@@ -38,7 +39,10 @@ func Mount(r *gin.Engine, repo *storage.ClusterRepo, uploadRepo *storage.UploadR
|
||||
g.GET("/audit", listAudit(auditRepo))
|
||||
g.GET("/uploads", uploadsWebHandler)
|
||||
g.GET("/uploads/api", listUploads(uploadRepo))
|
||||
g.GET("/uploads/:id/download", downloadUpload(uploadRepo, uploadStore))
|
||||
g.DELETE("/uploads/:id", deleteUpload(uploadRepo, auditRepo, uploadStore))
|
||||
g.GET("/submissions", submissionsWebHandler)
|
||||
g.GET("/submissions/api", listSubmissions(submissionRepo))
|
||||
g.GET("/docs", DocsHandler)
|
||||
g.GET("/docs/spec", OpenAPISpecHandler)
|
||||
}
|
||||
@@ -300,6 +304,81 @@ func listUploads(repo *storage.UploadRepo) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func listSubmissions(repo *storage.SubmissionRepo) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
|
||||
return
|
||||
}
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
offset, err := strconv.Atoi(offsetStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
|
||||
return
|
||||
}
|
||||
|
||||
subs, err := repo.List(c.Request.Context(), storage.SubmissionFilter{
|
||||
Search: c.Query("search"),
|
||||
FileID: c.Query("file_id"),
|
||||
ClusterID: c.Query("cluster_id"),
|
||||
AppID: c.Query("app_id"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadUpload(repo *storage.UploadRepo, store *uploads.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
meta, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "upload not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dataPath, err := store.AbsPath(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(dataPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "upload file missing"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", "attachment; filename=\""+escapeDispositionFilename(meta.Name)+"\"")
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
}
|
||||
|
||||
// escapeDispositionFilename escapes quotes and backslashes for the
|
||||
// Content-Disposition filename parameter. The upload validator already rejects
|
||||
// names containing quotes or newlines; this is defense in depth.
|
||||
func escapeDispositionFilename(name string) string {
|
||||
name = strings.ReplaceAll(name, "\\", "\\\\")
|
||||
name = strings.ReplaceAll(name, "\"", "\\\"")
|
||||
return name
|
||||
}
|
||||
|
||||
func deleteUpload(repo *storage.UploadRepo, auditRepo *audit.Repo, store *uploads.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -35,7 +35,7 @@ func newTestAdmin(t *testing.T) (*gin.Engine, *storage.ClusterRepo, *audit.Repo)
|
||||
if err != nil {
|
||||
t.Fatalf("new upload store: %v", err)
|
||||
}
|
||||
Mount(r, db.Clusters(), db.Uploads(), audit.NewRepo(db), &uploadStore, []string{"good-token"})
|
||||
Mount(r, db.Clusters(), db.Uploads(), db.Submissions(), audit.NewRepo(db), &uploadStore, []string{"good-token"})
|
||||
return r, db.Clusters(), audit.NewRepo(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"spark-mcp-go/internal/storage"
|
||||
"spark-mcp-go/internal/uploads"
|
||||
)
|
||||
|
||||
func newTestAdminSubmissions(t *testing.T) (*gin.Engine, *storage.SubmissionRepo, *storage.UploadRepo, *uploads.Store) {
|
||||
t.Helper()
|
||||
db, err := storage.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
uploadStore, err := uploads.New(filepath.Join(t.TempDir(), "uploads"))
|
||||
if err != nil {
|
||||
t.Fatalf("new upload store: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
Mount(r, db.Clusters(), db.Uploads(), db.Submissions(), nil, &uploadStore, []string{"good-token"})
|
||||
return r, db.Submissions(), db.Uploads(), &uploadStore
|
||||
}
|
||||
|
||||
func TestSubmissionsWeb_RequiresAuth(t *testing.T) {
|
||||
r, _, _, _ := newTestAdminSubmissions(t)
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/submissions", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET /admin/submissions without auth: got %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionsWeb_WithAuth(t *testing.T) {
|
||||
r, _, _, _ := newTestAdminSubmissions(t)
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/submissions", "good-token", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/submissions with auth: got %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "href=\"/admin/uploads\"") && !strings.Contains(body, "href='/admin/uploads'") {
|
||||
t.Errorf("body missing uploads nav link")
|
||||
}
|
||||
if !strings.Contains(body, "href=\"/admin/submissions\"") && !strings.Contains(body, "href='/admin/submissions'") {
|
||||
t.Errorf("body missing submissions nav link")
|
||||
}
|
||||
if !strings.Contains(body, "submissions-body") {
|
||||
t.Errorf("body missing submissions table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSubmissions_SearchByAppID(t *testing.T) {
|
||||
r, subRepo, uploadRepo, _ := newTestAdminSubmissions(t)
|
||||
ctx := context.Background()
|
||||
|
||||
uploadedAt := time.Unix(0, time.Now().UnixNano())
|
||||
if err := uploadRepo.Create(ctx, "00000000000000000000000000000001", "alpha.py", 10, "deadbeef", uploadedAt); err != nil {
|
||||
t.Fatalf("create upload: %v", err)
|
||||
}
|
||||
if err := uploadRepo.Create(ctx, "00000000000000000000000000000002", "beta.py", 20, "cafebabe", uploadedAt.Add(time.Second)); err != nil {
|
||||
t.Fatalf("create upload: %v", err)
|
||||
}
|
||||
|
||||
submissions := []storage.Submission{
|
||||
{FileID: "00000000000000000000000000000001", AppID: "application_111_0001", TrackingURL: "http://a", ClusterID: "c1", ExitCode: 0, DurationMS: 100, SubmittedAt: time.Now()},
|
||||
{FileID: "00000000000000000000000000000002", AppID: "application_222_0001", TrackingURL: "http://b", ClusterID: "c1", ExitCode: 0, DurationMS: 100, SubmittedAt: time.Now().Add(time.Second)},
|
||||
}
|
||||
for _, s := range submissions {
|
||||
if err := subRepo.Create(ctx, s); err != nil {
|
||||
t.Fatalf("create submission: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/submissions/api?search=application_111", "good-token", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search: got status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal search: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("search result: got %d, want 1", len(list))
|
||||
}
|
||||
if len(list) > 0 && list[0]["app_id"] != "application_111_0001" {
|
||||
t.Errorf("app_id=%v, want application_111_0001", list[0]["app_id"])
|
||||
}
|
||||
|
||||
w = doReq(t, r, "GET", "/admin/submissions/api?search=beta.py", "good-token", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search by name: got status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal search: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("search by name result: got %d, want 1", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadUpload(t *testing.T) {
|
||||
r, _, uploadRepo, store := newTestAdminSubmissions(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := "00000000000000000000000000000003"
|
||||
payload := []byte("hello payload")
|
||||
if err := uploadRepo.Create(ctx, id, "report.csv", int64(len(payload)), "feedface", time.Now()); err != nil {
|
||||
t.Fatalf("create upload: %v", err)
|
||||
}
|
||||
dataPath, err := store.AbsPath(id)
|
||||
if err != nil {
|
||||
t.Fatalf("abspath: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dataPath, payload, 0o640); err != nil {
|
||||
t.Fatalf("write data: %v", err)
|
||||
}
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/uploads/"+id+"/download", "good-token", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("download: got status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if string(w.Body.Bytes()) != string(payload) {
|
||||
t.Errorf("body = %q, want %q", w.Body.Bytes(), payload)
|
||||
}
|
||||
cd := w.Header().Get("Content-Disposition")
|
||||
if !strings.Contains(cd, `filename="report.csv"`) {
|
||||
t.Errorf("Content-Disposition = %q, want filename=\"report.csv\"", cd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadUpload_NotFound(t *testing.T) {
|
||||
r, _, _, _ := newTestAdminSubmissions(t)
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/uploads/00000000000000000000000000000099/download", "good-token", nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("download unknown: got %d, want %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"spark-mcp-go/internal/uploads"
|
||||
)
|
||||
|
||||
func newTestAdminUploads(t *testing.T) (*gin.Engine, *storage.UploadRepo, *audit.Repo, *uploads.Store) {
|
||||
func newTestAdminUploads(t *testing.T) (*gin.Engine, *storage.UploadRepo, *storage.SubmissionRepo, *audit.Repo, *uploads.Store) {
|
||||
t.Helper()
|
||||
db, err := storage.Open(":memory:")
|
||||
if err != nil {
|
||||
@@ -32,12 +32,12 @@ func newTestAdminUploads(t *testing.T) (*gin.Engine, *storage.UploadRepo, *audit
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
Mount(r, db.Clusters(), db.Uploads(), audit.NewRepo(db), &uploadStore, []string{"good-token"})
|
||||
return r, db.Uploads(), audit.NewRepo(db), &uploadStore
|
||||
Mount(r, db.Clusters(), db.Uploads(), db.Submissions(), audit.NewRepo(db), &uploadStore, []string{"good-token"})
|
||||
return r, db.Uploads(), db.Submissions(), audit.NewRepo(db), &uploadStore
|
||||
}
|
||||
|
||||
func TestUploadsWeb_RequiresAuth(t *testing.T) {
|
||||
r, _, _, _ := newTestAdminUploads(t)
|
||||
r, _, _, _, _ := newTestAdminUploads(t)
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/uploads", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
@@ -62,7 +62,7 @@ func TestUploadsWeb_RequiresAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListUploads(t *testing.T) {
|
||||
r, repo, _, _ := newTestAdminUploads(t)
|
||||
r, repo, _, _, _ := newTestAdminUploads(t)
|
||||
ctx := context.Background()
|
||||
|
||||
uploadedAt := time.Unix(0, time.Now().UnixNano())
|
||||
@@ -98,7 +98,7 @@ func TestListUploads(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteUpload(t *testing.T) {
|
||||
r, repo, auditRepo, store := newTestAdminUploads(t)
|
||||
r, repo, _, auditRepo, store := newTestAdminUploads(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := "00000000000000000000000000000003"
|
||||
@@ -152,3 +152,45 @@ func TestDeleteUpload(t *testing.T) {
|
||||
t.Errorf("missing upload.delete audit entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUploads_SearchByAppID(t *testing.T) {
|
||||
r, uploadRepo, subRepo, _, _ := newTestAdminUploads(t)
|
||||
ctx := context.Background()
|
||||
|
||||
uploadedAt := time.Unix(0, time.Now().UnixNano())
|
||||
if err := uploadRepo.Create(ctx, "00000000000000000000000000000010", "gamma.py", 10, "deadbeef", uploadedAt); err != nil {
|
||||
t.Fatalf("create upload: %v", err)
|
||||
}
|
||||
if err := uploadRepo.Create(ctx, "00000000000000000000000000000011", "delta.py", 20, "cafebabe", uploadedAt.Add(time.Second)); err != nil {
|
||||
t.Fatalf("create upload: %v", err)
|
||||
}
|
||||
|
||||
// Only gamma.py has an associated submission; searching by app_id should
|
||||
// still return it even though the filename doesn't match the query.
|
||||
if err := subRepo.Create(ctx, storage.Submission{
|
||||
FileID: "00000000000000000000000000000010",
|
||||
AppID: "application_gamma_0001",
|
||||
TrackingURL: "http://gamma",
|
||||
ClusterID: "c1",
|
||||
ExitCode: 0,
|
||||
DurationMS: 100,
|
||||
SubmittedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("create submission: %v", err)
|
||||
}
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/uploads/api?search=application_gamma", "good-token", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search by app_id: got status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal search: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("search by app_id result: got %d, want 1", len(list))
|
||||
}
|
||||
if len(list) > 0 && list[0]["file_id"] != "00000000000000000000000000000010" {
|
||||
t.Errorf("file_id=%v, want upload 10", list[0]["file_id"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,12 @@ func uploadsWebHandler(c *gin.Context) {
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
func submissionsWebHandler(c *gin.Context) {
|
||||
data, err := webFS.ReadFile("web/submissions.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "submissions.html: %v", err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
@@ -117,10 +117,11 @@
|
||||
<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>
|
||||
<nav>
|
||||
<a href='/admin' class='active'>Clusters</a>
|
||||
<a href='/admin/uploads'>Uploads</a>
|
||||
<a href='/admin/submissions'>Submissions</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class='auth'>
|
||||
<button id='set-token' class='secondary'>Set Token</button>
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<title>Submissions - 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'] {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: .4rem .5rem;
|
||||
font: inherit;
|
||||
}
|
||||
input[type='text'] { min-width: 260px; }
|
||||
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: 1400px; margin: 0 auto; }
|
||||
.toolbar { display: flex; gap: .5rem; margin-bottom: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
.toolbar span { color: var(--muted); }
|
||||
.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; user-select: none; }
|
||||
th.sortable { cursor: pointer; }
|
||||
th.sortable:hover { color: var(--text); }
|
||||
td { vertical-align: middle; font-size: .9rem; }
|
||||
td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||||
td.wrap { word-break: break-all; }
|
||||
.actions { display: flex; gap: .4rem; }
|
||||
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'>Clusters</a>
|
||||
<a href='/admin/uploads'>Uploads</a>
|
||||
<a href='/admin/submissions' class='active'>Submissions</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'>
|
||||
<input id='search' type='text' placeholder='Search by file name or app id...'>
|
||||
<button id='refresh' class='secondary'>Refresh</button>
|
||||
<span id='count'></span>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class='sortable' data-key='id'>ID</th>
|
||||
<th class='sortable' data-key='file_id'>File ID</th>
|
||||
<th class='sortable' data-key='file_name'>File Name</th>
|
||||
<th class='sortable' data-key='app_id'>App ID</th>
|
||||
<th>Tracking URL</th>
|
||||
<th class='sortable' data-key='cluster_id'>Cluster</th>
|
||||
<th class='sortable' data-key='exit_code'>Exit</th>
|
||||
<th class='sortable' data-key='duration_ms'>Duration (ms)</th>
|
||||
<th class='sortable' data-key='submitted_at'>Submitted At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id='submissions-body'>
|
||||
<tr><td colspan='9'>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/submissions';
|
||||
|
||||
let submissions = [];
|
||||
let sortKey = 'submitted_at';
|
||||
let sortDir = -1;
|
||||
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 {
|
||||
loadSubmissions();
|
||||
}
|
||||
}
|
||||
|
||||
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 formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return isNaN(d) ? ts : d.toISOString();
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (!s || s.length <= n) return s || '';
|
||||
return s.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function loadSubmissions() {
|
||||
showError('');
|
||||
try {
|
||||
const search = $('#search').value.trim();
|
||||
const path = search ? `/api?search=${encodeURIComponent(search)}` : '/api';
|
||||
submissions = await api('GET', path);
|
||||
renderList();
|
||||
} catch (e) {
|
||||
showError('Failed to load submissions: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function compare(a, b) {
|
||||
let av = a[sortKey];
|
||||
let bv = b[sortKey];
|
||||
if (sortKey === 'id' || sortKey === 'exit_code' || sortKey === 'duration_ms') {
|
||||
av = Number(av); bv = Number(bv);
|
||||
} else if (sortKey === 'submitted_at') {
|
||||
av = new Date(av).getTime(); bv = new Date(bv).getTime();
|
||||
} else {
|
||||
av = String(av).toLowerCase(); bv = String(bv).toLowerCase();
|
||||
}
|
||||
if (av < bv) return -sortDir;
|
||||
if (av > bv) return sortDir;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const tbody = $('#submissions-body');
|
||||
const list = submissions.slice().sort(compare);
|
||||
$('#count').textContent = list.length + ' submission' + (list.length === 1 ? '' : 's');
|
||||
tbody.innerHTML = '';
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9">No submissions.</td></tr>';
|
||||
return;
|
||||
}
|
||||
for (const s of list) {
|
||||
const tr = document.createElement('tr');
|
||||
const fileLink = s.file_id ? `<a href='/admin/uploads/${encodeURIComponent(s.file_id)}'>${escapeHtml(truncate(s.file_id, 8))}</a>` : escapeHtml(truncate(s.file_id, 8));
|
||||
tr.innerHTML = `
|
||||
<td class='mono'>${escapeHtml(s.id)}</td>
|
||||
<td class='mono' title='${escapeHtml(s.file_id)}'>${fileLink}</td>
|
||||
<td>${escapeHtml(s.file_name || '')}</td>
|
||||
<td class='mono'>${escapeHtml(s.app_id)}</td>
|
||||
<td class='wrap'>${escapeHtml(s.tracking_url || '')}</td>
|
||||
<td>${escapeHtml(s.cluster_id)}</td>
|
||||
<td>${escapeHtml(s.exit_code)}</td>
|
||||
<td>${escapeHtml(s.duration_ms)}</td>
|
||||
<td>${escapeHtml(formatTime(s.submitted_at))}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSort(e) {
|
||||
const key = e.target.dataset.key;
|
||||
if (!key) return;
|
||||
if (sortKey === key) {
|
||||
sortDir = -sortDir;
|
||||
} else {
|
||||
sortKey = key;
|
||||
sortDir = key === 'submitted_at' ? -1 : 1;
|
||||
}
|
||||
document.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.textContent = th.textContent.replace(/ [▲▼]$/, '');
|
||||
});
|
||||
const marker = sortDir > 0 ? ' ▲' : ' ▼';
|
||||
e.target.textContent = e.target.textContent.replace(/ [▲▼]$/, '') + marker;
|
||||
renderList();
|
||||
}
|
||||
|
||||
$('#search').addEventListener('input', loadSubmissions);
|
||||
$('#refresh').addEventListener('click', loadSubmissions);
|
||||
$('#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);
|
||||
document.querySelectorAll('th.sortable').forEach(th => th.addEventListener('click', handleSort));
|
||||
|
||||
if (!localStorage.getItem('adminToken')) {
|
||||
openTokenModal({ allowCancel: false });
|
||||
} else {
|
||||
loadSubmissions();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -103,10 +103,11 @@
|
||||
<header>
|
||||
<div style='display:flex;align-items:center;gap:1rem;'>
|
||||
<h1>spark-mcp-go Admin</h1>
|
||||
<nav>
|
||||
<a href='/admin'>Clusters</a>
|
||||
<a href='/admin/uploads' class='active'>Uploads</a>
|
||||
</nav>
|
||||
<nav>
|
||||
<a href='/admin'>Clusters</a>
|
||||
<a href='/admin/uploads' class='active'>Uploads</a>
|
||||
<a href='/admin/submissions'>Submissions</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class='auth'>
|
||||
<button id='set-token' class='secondary'>Set Token</button>
|
||||
|
||||
@@ -68,7 +68,8 @@ func (r *UploadRepo) Get(ctx context.Context, fileID string) (UploadMeta, error)
|
||||
|
||||
// List returns upload index records ordered by uploaded_at descending.
|
||||
//
|
||||
// If search is non-empty, name is filtered with a case-insensitive LIKE.
|
||||
// If search is non-empty, file_name or any associated spark_submit app_id is
|
||||
// filtered with a case-insensitive LIKE.
|
||||
// A limit of zero or less falls back to 100; values above 500 are capped.
|
||||
func (r *UploadRepo) List(ctx context.Context, search string, limit int) ([]UploadMeta, error) {
|
||||
if limit <= 0 {
|
||||
@@ -82,18 +83,19 @@ func (r *UploadRepo) List(ctx context.Context, search string, limit int) ([]Uplo
|
||||
var err error
|
||||
if search != "" {
|
||||
rows, err = r.db.sqlDB.QueryContext(ctx, `
|
||||
SELECT file_id, name, size, sha256, uploaded_at
|
||||
FROM upload_files
|
||||
WHERE name LIKE ?
|
||||
ORDER BY uploaded_at DESC
|
||||
SELECT DISTINCT u.file_id, u.name, u.size, u.sha256, u.uploaded_at
|
||||
FROM upload_files u
|
||||
LEFT JOIN spark_submissions s ON u.file_id = s.file_id
|
||||
WHERE u.name LIKE ? OR s.app_id LIKE ?
|
||||
ORDER BY u.uploaded_at DESC
|
||||
LIMIT ?`,
|
||||
"%"+search+"%", limit,
|
||||
"%"+search+"%", "%"+search+"%", limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = r.db.sqlDB.QueryContext(ctx, `
|
||||
SELECT file_id, name, size, sha256, uploaded_at
|
||||
FROM upload_files
|
||||
ORDER BY uploaded_at DESC
|
||||
SELECT u.file_id, u.name, u.size, u.sha256, u.uploaded_at
|
||||
FROM upload_files u
|
||||
ORDER BY u.uploaded_at DESC
|
||||
LIMIT ?`, limit)
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -105,7 +105,7 @@ func run() error {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "version": "0.0.0"})
|
||||
})
|
||||
|
||||
admin.Mount(r, db.Clusters(), db.Uploads(), audit.NewRepo(db), &uploadStore, cfg.AdminTokens)
|
||||
admin.Mount(r, db.Clusters(), db.Uploads(), db.Submissions(), audit.NewRepo(db), &uploadStore, cfg.AdminTokens)
|
||||
|
||||
mcpHandler, err := mcpsrv.Handler(&tools.Deps{
|
||||
Logger: logger.With("component", "mcp"),
|
||||
@@ -119,6 +119,7 @@ func run() error {
|
||||
MaxResponseBytes: cfg.MaxResponseBytes,
|
||||
DataDir: cfg.DataDir,
|
||||
UploadStore: uploadStore,
|
||||
SubmissionRepo: db.Submissions(),
|
||||
AnalyzerThresholds: analyzer.Thresholds{
|
||||
DataSkewRatio: cfg.AnalyzerDataSkewRatio,
|
||||
GCPressureRatio: cfg.AnalyzerGCPressureRatio,
|
||||
|
||||
Reference in New Issue
Block a user