From 12b3bb9bf30a61898c093c12833b98631544ca38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 9 Apr 2026 18:01:12 +0300 Subject: [PATCH] feat(shared-sqs): add SQS Console UI (v0.1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Embedded SPA with Nubes branding (go:embed) - Admin dashboard: health stats, tenant CRUD, queue listing - New API: GET /admin/tenants/{id}/queues — tenant queue list with counts - Route /ui/ serves built-in console - Auto-refresh every 10s, responsive design --- shared-sqs/app/admin/admin.go | 345 ++++++++------- shared-sqs/app/router/router.go | 4 + shared-sqs/app/ui/embed.go | 17 + shared-sqs/app/ui/index.html | 726 ++++++++++++++++++++++++++++++++ 4 files changed, 945 insertions(+), 147 deletions(-) create mode 100644 shared-sqs/app/ui/embed.go create mode 100644 shared-sqs/app/ui/index.html diff --git a/shared-sqs/app/admin/admin.go b/shared-sqs/app/admin/admin.go index 0457b1d..952bc64 100644 --- a/shared-sqs/app/admin/admin.go +++ b/shared-sqs/app/admin/admin.go @@ -4,208 +4,259 @@ package admin import ( -"encoding/json" -"net/http" -"strings" -"time" + "encoding/json" + "net/http" + "strings" + "time" -"shared-sqs/app/models" -"shared-sqs/app/tenant" + "shared-sqs/app/models" + "shared-sqs/app/tenant" -"github.com/gorilla/mux" -log "github.com/sirupsen/logrus" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" ) // Handler — admin API handler, holds TenantStore и admin token type Handler struct { -store *tenant.TenantStore -adminToken string + store *tenant.TenantStore + adminToken string } // NewHandler — создаёт admin handler func NewHandler(store *tenant.TenantStore, adminToken string) *Handler { -return &Handler{store: store, adminToken: adminToken} + return &Handler{store: store, adminToken: adminToken} } // RegisterRoutes — регистрирует admin маршруты на переданном router func (h *Handler) RegisterRoutes(r *mux.Router) { -adminRouter := r.PathPrefix("/admin").Subrouter() -adminRouter.Use(h.bearerAuthMiddleware) -adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST") -adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET") -adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET") -adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE") -adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET") + adminRouter := r.PathPrefix("/admin").Subrouter() + adminRouter.Use(h.bearerAuthMiddleware) + adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST") + adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET") + adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET") + adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE") + adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET") + adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET") } // bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12) func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler { -return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -authHeader := r.Header.Get("Authorization") -expected := "Bearer " + h.adminToken -if authHeader != expected { -w.Header().Set("Content-Type", "application/json") -w.WriteHeader(http.StatusUnauthorized) -json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) -return -} -next.ServeHTTP(w, r) -}) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + expected := "Bearer " + h.adminToken + if authHeader != expected { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + next.ServeHTTP(w, r) + }) } // createTenantRequest — тело запроса POST /admin/tenants type createTenantRequest struct { -Name string `json:"name"` -MaxQueues int `json:"max_queues"` + Name string `json:"name"` + MaxQueues int `json:"max_queues"` } // tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании) type tenantCreateResponse struct { -ID string `json:"id"` -Name string `json:"name"` -AccessKey string `json:"access_key"` -SecretKey string `json:"secret_key"` -MaxQueues int `json:"max_queues"` -CreatedAt time.Time `json:"created_at"` -Active bool `json:"active"` + ID string `json:"id"` + Name string `json:"name"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + MaxQueues int `json:"max_queues"` + CreatedAt time.Time `json:"created_at"` + Active bool `json:"active"` } // tenantListItem — данные тенанта без secret_key (для List/Get) type tenantListItem struct { -ID string `json:"id"` -Name string `json:"name"` -AccessKey string `json:"access_key"` -MaxQueues int `json:"max_queues"` -CreatedAt time.Time `json:"created_at"` -Active bool `json:"active"` + ID string `json:"id"` + Name string `json:"name"` + AccessKey string `json:"access_key"` + MaxQueues int `json:"max_queues"` + CreatedAt time.Time `json:"created_at"` + Active bool `json:"active"` } // createTenant — POST /admin/tenants func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) { -var req createTenantRequest -if err := json.NewDecoder(r.Body).Decode(&req); err != nil { -w.Header().Set("Content-Type", "application/json") -w.WriteHeader(http.StatusBadRequest) -json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) -return -} -if req.Name == "" { -w.Header().Set("Content-Type", "application/json") -w.WriteHeader(http.StatusBadRequest) -json.NewEncoder(w).Encode(map[string]string{"error": "name is required"}) -return -} -t, err := h.store.Create(req.Name, req.MaxQueues) -if err != nil { -log.Errorf("admin: failed to create tenant: %v", err) -w.Header().Set("Content-Type", "application/json") -w.WriteHeader(http.StatusInternalServerError) -json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"}) -return -} -w.Header().Set("Content-Type", "application/json") -w.WriteHeader(http.StatusCreated) -json.NewEncoder(w).Encode(tenantCreateResponse{ -ID: t.ID, -Name: t.Name, -AccessKey: t.AccessKey, -SecretKey: t.SecretKey, -MaxQueues: t.MaxQueues, -CreatedAt: t.CreatedAt, -Active: t.Active, -}) + var req createTenantRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) + return + } + if req.Name == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "name is required"}) + return + } + t, err := h.store.Create(req.Name, req.MaxQueues) + if err != nil { + log.Errorf("admin: failed to create tenant: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"}) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(tenantCreateResponse{ + ID: t.ID, + Name: t.Name, + AccessKey: t.AccessKey, + SecretKey: t.SecretKey, + MaxQueues: t.MaxQueues, + CreatedAt: t.CreatedAt, + Active: t.Active, + }) } // listTenants — GET /admin/tenants func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) { -tenants := h.store.List() -items := make([]tenantListItem, 0, len(tenants)) -for _, t := range tenants { -items = append(items, tenantListItem{ -ID: t.ID, -Name: t.Name, -AccessKey: t.AccessKey, -MaxQueues: t.MaxQueues, -CreatedAt: t.CreatedAt, -Active: t.Active, -}) -} -w.Header().Set("Content-Type", "application/json") -json.NewEncoder(w).Encode(items) + tenants := h.store.List() + items := make([]tenantListItem, 0, len(tenants)) + for _, t := range tenants { + items = append(items, tenantListItem{ + ID: t.ID, + Name: t.Name, + AccessKey: t.AccessKey, + MaxQueues: t.MaxQueues, + CreatedAt: t.CreatedAt, + Active: t.Active, + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) } // getTenant — GET /admin/tenants/{id} func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) { -vars := mux.Vars(r) -id := vars["id"] -t, ok := h.store.GetByID(id) -if !ok { -w.Header().Set("Content-Type", "application/json") -w.WriteHeader(http.StatusNotFound) -json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) -return -} -w.Header().Set("Content-Type", "application/json") -json.NewEncoder(w).Encode(tenantListItem{ -ID: t.ID, -Name: t.Name, -AccessKey: t.AccessKey, -MaxQueues: t.MaxQueues, -CreatedAt: t.CreatedAt, -Active: t.Active, -}) + vars := mux.Vars(r) + id := vars["id"] + t, ok := h.store.GetByID(id) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tenantListItem{ + ID: t.ID, + Name: t.Name, + AccessKey: t.AccessKey, + MaxQueues: t.MaxQueues, + CreatedAt: t.CreatedAt, + Active: t.Active, + }) } // deleteTenant — DELETE /admin/tenants/{id} // Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak) func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) { -vars := mux.Vars(r) -id := vars["id"] -t, ok := h.store.GetByID(id) -if !ok { -w.Header().Set("Content-Type", "application/json") -w.WriteHeader(http.StatusNotFound) -json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) -return -} -// Удаляем все очереди тенанта из SyncQueues -prefix := t.AccessKey + ":" -models.SyncQueues.Lock() -for key := range models.SyncQueues.Queues { -if strings.HasPrefix(key, prefix) { -delete(models.SyncQueues.Queues, key) -} -} -models.SyncQueues.Unlock() + vars := mux.Vars(r) + id := vars["id"] + t, ok := h.store.GetByID(id) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) + return + } + // Удаляем все очереди тенанта из SyncQueues + prefix := t.AccessKey + ":" + models.SyncQueues.Lock() + for key := range models.SyncQueues.Queues { + if strings.HasPrefix(key, prefix) { + delete(models.SyncQueues.Queues, key) + } + } + models.SyncQueues.Unlock() -h.store.Delete(id) -w.WriteHeader(http.StatusNoContent) + h.store.Delete(id) + w.WriteHeader(http.StatusNoContent) +} + +// listTenantQueues — GET /admin/tenants/{id}/queues +// Возвращает список очередей тенанта с количеством сообщений. +func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + t, ok := h.store.GetByID(id) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) + return + } + prefix := t.AccessKey + ":" + type queueInfo struct { + Name string `json:"name"` + Messages int `json:"messages"` + NotVisible int `json:"not_visible"` + VisibilityTimeout int `json:"visibility_timeout"` + MaxMessageSize int `json:"max_message_size"` + RetentionPeriod int `json:"retention_period"` + } + queues := make([]queueInfo, 0) + models.SyncQueues.RLock() + for key, q := range models.SyncQueues.Queues { + if strings.HasPrefix(key, prefix) { + name := strings.TrimPrefix(key, prefix) + visible := 0 + notVisible := 0 + for _, msg := range q.Messages { + if msg.ReceiptHandle != "" { + notVisible++ + } else { + visible++ + } + } + queues = append(queues, queueInfo{ + Name: name, + Messages: visible, + NotVisible: notVisible, + VisibilityTimeout: q.VisibilityTimeout, + MaxMessageSize: q.MaximumMessageSize, + RetentionPeriod: q.MessageRetentionPeriod, + }) + } + } + models.SyncQueues.RUnlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(queues) } // adminHealthDetail — ответ GET /admin/health type adminHealthDetail struct { -Status string `json:"status"` -TenantCount int `json:"tenant_count"` -QueueCount int `json:"queue_count"` -MessageCount int `json:"message_count"` + Status string `json:"status"` + TenantCount int `json:"tenant_count"` + QueueCount int `json:"queue_count"` + MessageCount int `json:"message_count"` } // detailedHealth — GET /admin/health func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) { -tenants := h.store.List() -models.SyncQueues.RLock() -queueCount := len(models.SyncQueues.Queues) -msgCount := 0 -for _, q := range models.SyncQueues.Queues { -msgCount += len(q.Messages) -} -models.SyncQueues.RUnlock() + tenants := h.store.List() + models.SyncQueues.RLock() + queueCount := len(models.SyncQueues.Queues) + msgCount := 0 + for _, q := range models.SyncQueues.Queues { + msgCount += len(q.Messages) + } + models.SyncQueues.RUnlock() -w.Header().Set("Content-Type", "application/json") -json.NewEncoder(w).Encode(adminHealthDetail{ -Status: "ok", -TenantCount: len(tenants), -QueueCount: queueCount, -MessageCount: msgCount, -}) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(adminHealthDetail{ + Status: "ok", + TenantCount: len(tenants), + QueueCount: queueCount, + MessageCount: msgCount, + }) } diff --git a/shared-sqs/app/router/router.go b/shared-sqs/app/router/router.go index aa84645..a4d4d9e 100644 --- a/shared-sqs/app/router/router.go +++ b/shared-sqs/app/router/router.go @@ -16,6 +16,7 @@ import ( "shared-sqs/app/interfaces" sqs "shared-sqs/app/gosqs" "shared-sqs/app/tenant" +"shared-sqs/app/ui" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" @@ -32,6 +33,9 @@ r.HandleFunc("/health", health).Methods("GET") adminHandler := admin.NewHandler(tenantStore, adminToken) adminHandler.RegisterRoutes(r) +// UI console — встроенный SPA, публичный доступ +r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler())) + // SQS API — tenant auth middleware sqsRouter := r.NewRoute().Subrouter() sqsRouter.Use(auth.AuthMiddleware(tenantStore)) diff --git a/shared-sqs/app/ui/embed.go b/shared-sqs/app/ui/embed.go new file mode 100644 index 0000000..ebd318d --- /dev/null +++ b/shared-sqs/app/ui/embed.go @@ -0,0 +1,17 @@ +// app/ui/embed.go +// Встраивание и раздача UI (SPA) для shared-sqs console +// Created: 2026-04-10 +package ui + +import ( + "embed" + "net/http" +) + +//go:embed index.html +var content embed.FS + +// Handler — возвращает http.Handler, раздающий встроенный index.html +func Handler() http.Handler { + return http.FileServer(http.FS(content)) +} diff --git a/shared-sqs/app/ui/index.html b/shared-sqs/app/ui/index.html new file mode 100644 index 0000000..1244cc7 --- /dev/null +++ b/shared-sqs/app/ui/index.html @@ -0,0 +1,726 @@ + + + + + + +SQS Console — Nubes + + + + + + +
+ +
+ + + + + + + + + + + + +