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>
This commit is contained in:
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,11 +17,12 @@ import (
|
||||
"spark-mcp-go/internal/cluster"
|
||||
"spark-mcp-go/internal/middleware"
|
||||
"spark-mcp-go/internal/storage"
|
||||
"spark-mcp-go/internal/uploads"
|
||||
)
|
||||
|
||||
// Mount attaches the /admin sub-router to r, protecting every route with
|
||||
// bearer-token admin authentication.
|
||||
func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, adminTokens []string) {
|
||||
func Mount(r *gin.Engine, repo *storage.ClusterRepo, uploadRepo *storage.UploadRepo, 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")
|
||||
@@ -33,6 +36,9 @@ func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, admi
|
||||
g.PUT("/clusters/:id", updateCluster(repo, auditRepo))
|
||||
g.DELETE("/clusters/:id", deleteCluster(repo, auditRepo))
|
||||
g.GET("/audit", listAudit(auditRepo))
|
||||
g.GET("/uploads", uploadsWebHandler)
|
||||
g.GET("/uploads/api", listUploads(uploadRepo))
|
||||
g.DELETE("/uploads/:id", deleteUpload(uploadRepo, auditRepo, uploadStore))
|
||||
g.GET("/docs", DocsHandler)
|
||||
g.GET("/docs/spec", OpenAPISpecHandler)
|
||||
}
|
||||
@@ -275,6 +281,66 @@ func deleteCluster(repo *storage.ClusterRepo, auditRepo *audit.Repo) gin.Handler
|
||||
}
|
||||
}
|
||||
|
||||
func listUploads(repo *storage.UploadRepo) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
search := c.Query("search")
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
|
||||
return
|
||||
}
|
||||
|
||||
uploads, err := repo.List(c.Request.Context(), search, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, uploads)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteUpload(repo *storage.UploadRepo, auditRepo *audit.Repo, 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 && !errors.Is(err, storage.ErrNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, id); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Always attempt to remove the on-disk files; the DB is only an index.
|
||||
if store != nil {
|
||||
dataPath := filepath.Join(store.Root, id)
|
||||
_ = os.Remove(dataPath)
|
||||
_ = os.Remove(dataPath + ".meta.json")
|
||||
}
|
||||
|
||||
if meta.FileID != "" {
|
||||
details, _ := audit.MarshalDetails(map[string]any{
|
||||
"file_id": id,
|
||||
"name": meta.Name,
|
||||
"size": meta.Size,
|
||||
"sha256": meta.SHA256,
|
||||
})
|
||||
_ = auditRepo.Insert(ctx, &audit.Entry{
|
||||
Actor: actor(c),
|
||||
Action: audit.ActionUploadDelete,
|
||||
ClusterID: id,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
func listAudit(auditRepo *audit.Repo) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"spark-mcp-go/internal/audit"
|
||||
"spark-mcp-go/internal/cluster"
|
||||
"spark-mcp-go/internal/storage"
|
||||
"spark-mcp-go/internal/uploads"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -29,7 +31,11 @@ func newTestAdmin(t *testing.T) (*gin.Engine, *storage.ClusterRepo, *audit.Repo)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
r := gin.New()
|
||||
Mount(r, db.Clusters(), audit.NewRepo(db), []string{"good-token"})
|
||||
uploadStore, err := uploads.New(filepath.Join(t.TempDir(), "uploads"))
|
||||
if err != nil {
|
||||
t.Fatalf("new upload store: %v", err)
|
||||
}
|
||||
Mount(r, db.Clusters(), db.Uploads(), audit.NewRepo(db), &uploadStore, []string{"good-token"})
|
||||
return r, db.Clusters(), audit.NewRepo(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"spark-mcp-go/internal/audit"
|
||||
"spark-mcp-go/internal/storage"
|
||||
"spark-mcp-go/internal/uploads"
|
||||
)
|
||||
|
||||
func newTestAdminUploads(t *testing.T) (*gin.Engine, *storage.UploadRepo, *audit.Repo, *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(), audit.NewRepo(db), &uploadStore, []string{"good-token"})
|
||||
return r, db.Uploads(), audit.NewRepo(db), &uploadStore
|
||||
}
|
||||
|
||||
func TestUploadsWeb_RequiresAuth(t *testing.T) {
|
||||
r, _, _, _ := newTestAdminUploads(t)
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/uploads", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET /admin/uploads without auth: got %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
w = doReq(t, r, "GET", "/admin/uploads", "good-token", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("GET /admin/uploads 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, "uploads-body") {
|
||||
t.Errorf("body missing uploads table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUploads(t *testing.T) {
|
||||
r, repo, _, _ := newTestAdminUploads(t)
|
||||
ctx := context.Background()
|
||||
|
||||
uploadedAt := time.Unix(0, time.Now().UnixNano())
|
||||
if err := repo.Create(ctx, "00000000000000000000000000000001", "alpha.txt", 10, "deadbeef", uploadedAt); err != nil {
|
||||
t.Fatalf("create upload: %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, "00000000000000000000000000000002", "beta.txt", 20, "cafebabe", uploadedAt.Add(time.Second)); err != nil {
|
||||
t.Fatalf("create upload: %v", err)
|
||||
}
|
||||
|
||||
w := doReq(t, r, "GET", "/admin/uploads/api", "good-token", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal list: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Errorf("got %d uploads, want 2", len(list))
|
||||
}
|
||||
|
||||
w = doReq(t, r, "GET", "/admin/uploads/api?search=alpha", "good-token", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search: got status %d", w.Code)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUpload(t *testing.T) {
|
||||
r, repo, auditRepo, store := newTestAdminUploads(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := "00000000000000000000000000000003"
|
||||
dataPath := filepath.Join(store.Root, id)
|
||||
metaPath := dataPath + ".meta.json"
|
||||
|
||||
if err := os.WriteFile(dataPath, []byte("payload"), 0o640); err != nil {
|
||||
t.Fatalf("create data file: %v", err)
|
||||
}
|
||||
sc := map[string]any{
|
||||
"name": "delete-me.txt",
|
||||
"size": 7,
|
||||
"sha256": "feedface",
|
||||
"uploaded_at": time.Now().Format(time.RFC3339Nano),
|
||||
}
|
||||
b, _ := json.Marshal(sc)
|
||||
if err := os.WriteFile(metaPath, b, 0o600); err != nil {
|
||||
t.Fatalf("create sidecar: %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, id, "delete-me.txt", 7, "feedface", time.Now()); err != nil {
|
||||
t.Fatalf("create db row: %v", err)
|
||||
}
|
||||
|
||||
w := doReq(t, r, "DELETE", "/admin/uploads/"+id, "good-token", nil)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: got status %d, want %d: %s", w.Code, http.StatusNoContent, w.Body.String())
|
||||
}
|
||||
|
||||
if _, err := repo.Get(ctx, id); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("db row still exists: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dataPath); !os.IsNotExist(err) {
|
||||
t.Errorf("data file still exists")
|
||||
}
|
||||
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
|
||||
t.Errorf("sidecar still exists")
|
||||
}
|
||||
|
||||
entries, err := auditRepo.List(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("list audit: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Action == audit.ActionUploadDelete && e.ClusterID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing upload.delete audit entry")
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed web/cluster.html
|
||||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
func webHandler(c *gin.Context) {
|
||||
@@ -18,3 +18,12 @@ func webHandler(c *gin.Context) {
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
func uploadsWebHandler(c *gin.Context) {
|
||||
data, err := webFS.ReadFile("web/uploads.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "uploads.html: %v", err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
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);
|
||||
@@ -111,7 +115,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>spark-mcp-go Admin</h1>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<title>Uploads - 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: 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; 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; }
|
||||
.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' class='active'>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'>
|
||||
<input id='search' type='text' placeholder='Search by name...'>
|
||||
<button id='refresh' class='secondary'>Refresh</button>
|
||||
<span id='count'></span>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class='sortable' data-key='file_id'>File ID</th>
|
||||
<th class='sortable' data-key='name'>Name</th>
|
||||
<th class='sortable' data-key='size'>Size (KB)</th>
|
||||
<th class='sortable' data-key='uploaded_at'>Uploaded At</th>
|
||||
<th>SHA256</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id='uploads-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/uploads';
|
||||
|
||||
let uploads = [];
|
||||
let sortKey = 'uploaded_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 {
|
||||
loadUploads();
|
||||
}
|
||||
}
|
||||
|
||||
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 formatKB(bytes) {
|
||||
return (bytes / 1024).toFixed(2);
|
||||
}
|
||||
|
||||
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, ''');
|
||||
}
|
||||
|
||||
function filteredUploads() {
|
||||
const q = $('#search').value.trim().toLowerCase();
|
||||
if (!q) return uploads.slice();
|
||||
return uploads.filter(u => (u.name || '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function compare(a, b) {
|
||||
let av = a[sortKey];
|
||||
let bv = b[sortKey];
|
||||
if (sortKey === 'size') { av = Number(av); bv = Number(bv); }
|
||||
else if (sortKey === 'uploaded_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 = $('#uploads-body');
|
||||
const list = filteredUploads().sort(compare);
|
||||
$('#count').textContent = list.length + ' file' + (list.length === 1 ? '' : 's');
|
||||
tbody.innerHTML = '';
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">No uploads.</td></tr>';
|
||||
return;
|
||||
}
|
||||
for (const u of list) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td class='mono' title='${escapeHtml(u.file_id)}'>${escapeHtml(truncate(u.file_id, 8))}</td>
|
||||
<td>${escapeHtml(u.name)}</td>
|
||||
<td>${escapeHtml(formatKB(u.size))}</td>
|
||||
<td>${escapeHtml(formatTime(u.uploaded_at))}</td>
|
||||
<td class='mono' title='${escapeHtml(u.sha256)}'>${escapeHtml(truncate(u.sha256, 8))}</td>
|
||||
<td class='actions'>
|
||||
<button data-id='${escapeHtml(u.file_id)}' class='danger delete'>Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
tbody.querySelectorAll('.delete').forEach(b => b.addEventListener('click', handleDelete));
|
||||
}
|
||||
|
||||
async function loadUploads() {
|
||||
showError('');
|
||||
try {
|
||||
uploads = await api('GET', '/api');
|
||||
renderList();
|
||||
} catch (e) {
|
||||
showError('Failed to load uploads: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(e) {
|
||||
const id = e.target.dataset.id;
|
||||
if (!confirm(`Delete upload ${id}?`)) return;
|
||||
showError('');
|
||||
try {
|
||||
await api('DELETE', `/${encodeURIComponent(id)}`);
|
||||
await loadUploads();
|
||||
} catch (err) {
|
||||
showError('Failed to delete upload: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSort(e) {
|
||||
const key = e.target.dataset.key;
|
||||
if (!key) return;
|
||||
if (sortKey === key) {
|
||||
sortDir = -sortDir;
|
||||
} else {
|
||||
sortKey = key;
|
||||
sortDir = key === 'uploaded_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', renderList);
|
||||
$('#refresh').addEventListener('click', loadUploads);
|
||||
$('#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 {
|
||||
loadUploads();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
// The .meta.json sidecar remains the source of truth; this struct is the
|
||||
// queryable mirror used by the admin UI.
|
||||
type UploadMeta struct {
|
||||
FileID string
|
||||
Name string
|
||||
Size int64
|
||||
SHA256 string
|
||||
UploadedAt time.Time
|
||||
FileID string `json:"file_id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
UploadedAt time.Time `json:"uploaded_at"`
|
||||
}
|
||||
|
||||
// UploadRepo provides CRUD operations for upload file index records.
|
||||
|
||||
@@ -84,6 +84,19 @@ func run() error {
|
||||
logger.Info("uploads.sweep", "deleted", n)
|
||||
}
|
||||
|
||||
backfillCtx, backfillCancel := context.WithTimeout(context.Background(), startupSweepTimeout)
|
||||
n, err = uploadStore.Backfill(backfillCtx, db.Uploads())
|
||||
backfillCancel()
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Warn("uploads.backfill.timeout", "inserted", n, "err", err)
|
||||
} else if err != nil {
|
||||
logger.Error("uploads.backfill", "err", err)
|
||||
} else {
|
||||
logger.Info("uploads.backfill", "inserted", n)
|
||||
}
|
||||
|
||||
uploadStore.SetRepo(db.Uploads())
|
||||
|
||||
gin.SetMode(cfg.GinMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
@@ -92,10 +105,11 @@ func run() error {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "version": "0.0.0"})
|
||||
})
|
||||
|
||||
admin.Mount(r, db.Clusters(), audit.NewRepo(db), cfg.AdminTokens)
|
||||
admin.Mount(r, db.Clusters(), db.Uploads(), audit.NewRepo(db), &uploadStore, cfg.AdminTokens)
|
||||
|
||||
mcpHandler, err := mcpsrv.Handler(&tools.Deps{
|
||||
Logger: logger.With("component", "mcp"),
|
||||
AuditRepo: audit.NewRepo(db),
|
||||
ClusterRepo: db.Clusters(),
|
||||
SparkSubmitTimeout: cfg.SparkSubmitTimeout,
|
||||
HTTPClient: httpclient.New(httpclient.Config{
|
||||
|
||||
Reference in New Issue
Block a user