feat(shared-sqs): queue CRUD + message peek/send/purge in UI (v0.1.6)
- admin.go: 5 new endpoints: create/delete queue, peek/send/purge messages - index.html: expandable message rows, send modal, msg detail modal, create queue modal - deployment.yaml: update image to naeel/shared-sqs:v0.1.6 - Docker image pushed: naeel/shared-sqs:v0.1.6
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
// app/admin/admin.go
|
// app/admin/admin.go
|
||||||
// Admin API handlers for shared-sqs management
|
// Admin API handlers for shared-sqs management
|
||||||
// Created: 2026-04-09
|
// Created: 2026-04-09
|
||||||
|
// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,10 +13,24 @@ import (
|
|||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
||||||
|
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
||||||
|
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
||||||
|
key := tenantAccessKey + ":" + queueName
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
q, ok := models.SyncQueues.Queues[key]
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return key, q
|
||||||
|
}
|
||||||
|
|
||||||
// Handler — admin API handler, holds TenantStore и admin token
|
// Handler — admin API handler, holds TenantStore и admin token
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *tenant.TenantStore
|
store *tenant.TenantStore
|
||||||
@@ -27,7 +42,7 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
|||||||
return &Handler{store: store, adminToken: adminToken}
|
return &Handler{store: store, adminToken: adminToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes — регистрирует admin маршруты на переданном router
|
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||||
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||||
adminRouter := r.PathPrefix("/admin").Subrouter()
|
adminRouter := r.PathPrefix("/admin").Subrouter()
|
||||||
adminRouter.Use(h.bearerAuthMiddleware)
|
adminRouter.Use(h.bearerAuthMiddleware)
|
||||||
@@ -36,11 +51,17 @@ func (h *Handler) RegisterRoutes(r *mux.Router) {
|
|||||||
adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
||||||
adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
||||||
adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE")
|
||||||
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
||||||
// Дублируют admin API, но доступны без bearer token для удобства демо
|
// Дублируют admin API, но доступны без bearer token для удобства демо
|
||||||
|
// TODO: убрать или заменить на session-auth перед production
|
||||||
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||||
ui := r.PathPrefix("/ui/api").Subrouter()
|
ui := r.PathPrefix("/ui/api").Subrouter()
|
||||||
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
@@ -49,6 +70,11 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
|||||||
ui.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
ui.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
||||||
ui.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
ui.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
||||||
ui.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
ui.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE")
|
||||||
}
|
}
|
||||||
|
|
||||||
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
||||||
@@ -245,6 +271,216 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(queues)
|
json.NewEncoder(w).Encode(queues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── QUEUE MANAGEMENT HANDLERS ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// createQueueRequest — тело запроса POST .../queues
|
||||||
|
type createQueueRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTenantQueue — POST /admin/tenants/{id}/queues
|
||||||
|
// Создаёт новую очередь для тенанта прямо в SyncQueues (без SQS-протокола).
|
||||||
|
// Проверяет лимит MaxQueues тенанта и уникальность имени.
|
||||||
|
func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid := vars["id"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createQueueRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := t.AccessKey + ":" + req.Name
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
if _, exists := models.SyncQueues.Queues[key]; exists {
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
jsonErr(w, http.StatusConflict, "queue already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Проверяем лимит очередей тенанта
|
||||||
|
count := 0
|
||||||
|
for k := range models.SyncQueues.Queues {
|
||||||
|
if strings.HasPrefix(k, t.AccessKey+":") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.MaxQueues > 0 && count >= t.MaxQueues {
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
jsonErr(w, http.StatusForbidden, "queue limit exceeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.SyncQueues.Queues[key] = &models.Queue{
|
||||||
|
Name: req.Name,
|
||||||
|
VisibilityTimeout: 30,
|
||||||
|
MaximumMessageSize: 262144,
|
||||||
|
MessageRetentionPeriod: 345600,
|
||||||
|
Messages: []models.SqsMessage{},
|
||||||
|
Duplicates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"name": req.Name, "status": "created"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTenantQueue — DELETE /admin/tenants/{id}/queues/{queue}
|
||||||
|
// Удаляет очередь тенанта из SyncQueues вместе со всеми её сообщениями.
|
||||||
|
func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid, queueName := vars["id"], vars["queue"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := t.AccessKey + ":" + queueName
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
if _, exists := models.SyncQueues.Queues[key]; !exists {
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(models.SyncQueues.Queues, key)
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// peekMessageItem — одно сообщение в ответе peekQueueMessages (без receipt handle)
|
||||||
|
type peekMessageItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
MD5 string `json:"md5"`
|
||||||
|
SentAt string `json:"sent_at"`
|
||||||
|
Receives int `json:"receives"`
|
||||||
|
InFlight bool `json:"in_flight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// peekQueueMessages — GET /admin/tenants/{id}/queues/{queue}/messages?limit=50
|
||||||
|
// Peek-просмотр сообщений: НЕ удаляет, НЕ выставляет ReceiptHandle — только чтение.
|
||||||
|
// Это принципиальное отличие от SQS ReceiveMessage (который скрывает сообщения).
|
||||||
|
func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid, queueName := vars["id"], vars["queue"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, q := findQueue(t.AccessKey, queueName)
|
||||||
|
if q == nil {
|
||||||
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Лимит по умолчанию 50, максимум 1000
|
||||||
|
limit := 50
|
||||||
|
if lv := r.URL.Query().Get("limit"); lv != "" {
|
||||||
|
if n := 0; len(lv) > 0 {
|
||||||
|
for _, c := range lv {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
n = -1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
if n > 0 && n <= 1000 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
result := make([]peekMessageItem, 0, len(q.Messages))
|
||||||
|
for i, msg := range q.Messages {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = append(result, peekMessageItem{
|
||||||
|
ID: msg.Uuid,
|
||||||
|
Body: msg.MessageBody,
|
||||||
|
MD5: msg.MD5OfMessageBody,
|
||||||
|
SentAt: msg.SentTime.Format(time.RFC3339),
|
||||||
|
Receives: msg.NumberOfReceives,
|
||||||
|
InFlight: msg.ReceiptHandle != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessageRequest — тело запроса POST .../messages
|
||||||
|
type sendMessageRequest struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessageToQueue — POST /admin/tenants/{id}/queues/{queue}/messages
|
||||||
|
// Отправляет сообщение напрямую в очередь минуя SQS-протокол.
|
||||||
|
// Используется только из UI console — для prod нужен нормальный SQS send.
|
||||||
|
func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid, queueName := vars["id"], vars["queue"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, q := findQueue(t.AccessKey, queueName)
|
||||||
|
if q == nil {
|
||||||
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req sendMessageRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Body == "" {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "body is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := models.SqsMessage{
|
||||||
|
MessageBody: req.Body,
|
||||||
|
Uuid: uuid.NewString(),
|
||||||
|
SentTime: time.Now(),
|
||||||
|
}
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeQueue — DELETE /admin/tenants/{id}/queues/{queue}/messages
|
||||||
|
// Удаляет все сообщения из очереди (purge). Сама очередь остаётся.
|
||||||
|
func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid, queueName := vars["id"], vars["queue"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, q := findQueue(t.AccessKey, queueName)
|
||||||
|
if q == nil {
|
||||||
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0]
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonErr — вспомогательная функция: ответ с ошибкой в JSON
|
||||||
|
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
// adminHealthDetail — ответ GET /admin/health
|
// adminHealthDetail — ответ GET /admin/health
|
||||||
type adminHealthDetail struct {
|
type adminHealthDetail struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
app/ui/index.html
|
app/ui/index.html
|
||||||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||||
Created: 2026-04-10
|
Created: 2026-04-10
|
||||||
|
Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge
|
||||||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||||
Режим: Admin (bearer token) — все тенанты, очереди, статистика.
|
Режим: Публичный UI API без авторизации (демо). Все данные in-memory.
|
||||||
-->
|
-->
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
@@ -313,6 +314,12 @@ tbody tr { cursor: pointer; }
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expandable message rows */
|
||||||
|
td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
||||||
|
.msg-expand-inner { padding: 12px 20px; background: var(--bg-page); }
|
||||||
|
.queue-name-link { cursor: pointer; color: var(--accent); }
|
||||||
|
.queue-name-link:hover { color: var(--accent-hover); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -377,11 +384,68 @@ tbody tr { cursor: pointer; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SEND MESSAGE MODAL ===== -->
|
||||||
|
<div id="modal-send" class="modal-overlay hidden" onclick="if(event.target===this)closeSendModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Отправить сообщение</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="send-body">Тело сообщения</label>
|
||||||
|
<textarea id="send-body" rows="6" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="send-error" class="login-error"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="sendMessage()">Отправить</button>
|
||||||
|
<button class="btn btn-logout" onclick="closeSendModal()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== MESSAGE DETAIL MODAL ===== -->
|
||||||
|
<div id="modal-msg-detail" class="modal-overlay hidden" onclick="if(event.target===this)closeMsgDetail()">
|
||||||
|
<div class="modal" style="width:600px;max-width:90vw">
|
||||||
|
<h2>Сообщение</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ID</label>
|
||||||
|
<input id="msg-detail-id" readonly onclick="copyField(this)" style="font-family:monospace;font-size:12px;cursor:pointer">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Body (нажмите, чтобы скопировать)</label>
|
||||||
|
<textarea id="msg-detail-body" readonly rows="8" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical;cursor:pointer" onclick="copyTextarea(this)"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Отправлено</label>
|
||||||
|
<input id="msg-detail-sent" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="closeMsgDetail()">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== CREATE QUEUE MODAL ===== -->
|
||||||
|
<div id="modal-queue-create" class="modal-overlay hidden" onclick="if(event.target===this)closeQueueModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Создать очередь</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cq-name">Имя очереди</label>
|
||||||
|
<input id="cq-name" placeholder="my-queue">
|
||||||
|
</div>
|
||||||
|
<div id="cq-error" class="login-error"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="createQueue()">Создать</button>
|
||||||
|
<button class="btn btn-logout" onclick="closeQueueModal()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ===== STATE =====
|
// ===== STATE =====
|
||||||
let BASE = '';
|
let BASE = '';
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let currentTenantId = null;
|
let currentTenantId = null;
|
||||||
|
let msgCache = {}; // id → объект сообщения для detail modal
|
||||||
|
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
|
||||||
|
let _createQueueState = { tenantId: null }; // контекст createQueue
|
||||||
|
|
||||||
// ===== INIT =====
|
// ===== INIT =====
|
||||||
// Запуск — сразу показываем dashboard без логина
|
// Запуск — сразу показываем dashboard без логина
|
||||||
@@ -543,7 +607,10 @@ function renderTenant(tenant, queues) {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<h2>Очереди</h2>
|
<h2>Очереди</h2>
|
||||||
<div class="auto-refresh"><span>⟳ 10с</span></div>
|
<div style="display:flex;gap:12px;align-items:center">
|
||||||
|
<div class="auto-refresh"><span>⟳ 10с</span></div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
@@ -555,20 +622,35 @@ function renderTenant(tenant, queues) {
|
|||||||
<th>Visibility Timeout</th>
|
<th>Visibility Timeout</th>
|
||||||
<th>Max Size</th>
|
<th>Max Size</th>
|
||||||
<th>Retention</th>
|
<th>Retention</th>
|
||||||
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${(queues || []).map(q => `
|
${(queues || []).map(q => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${esc(q.name)}</strong></td>
|
<td>
|
||||||
|
<span class="queue-name-link" onclick="toggleMessages('${esc(tenant.id)}','${esc(q.name)}')">
|
||||||
|
<span id="qicon-${esc(q.name)}">▶</span> <strong>${esc(q.name)}</strong>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td><span class="badge badge-count">${q.messages}</span></td>
|
<td><span class="badge badge-count">${q.messages}</span></td>
|
||||||
<td><span class="badge badge-count">${q.not_visible}</span></td>
|
<td><span class="badge badge-count">${q.not_visible}</span></td>
|
||||||
<td>${q.visibility_timeout}с</td>
|
<td>${q.visibility_timeout}с</td>
|
||||||
<td>${fmtBytes(q.max_message_size)}</td>
|
<td>${fmtBytes(q.max_message_size)}</td>
|
||||||
<td>${fmtDuration(q.retention_period)}</td>
|
<td>${fmtDuration(q.retention_period)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openSendModal('${esc(tenant.id)}','${esc(q.name)}')" title="Отправить сообщение">📨</button>
|
||||||
|
<button class="btn btn-logout btn-sm" onclick="purgeQueueConfirm('${esc(tenant.id)}','${esc(q.name)}')" style="margin-left:4px" title="Очистить очередь">🗑</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteQueueConfirm('${esc(tenant.id)}','${esc(q.name)}')" style="margin-left:4px" title="Удалить очередь">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="msgs-${esc(q.name)}" class="hidden">
|
||||||
|
<td colspan="7" class="msg-expand">
|
||||||
|
<div id="msgs-inner-${esc(q.name)}" class="msg-expand-inner"></div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
${(!queues || queues.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей — создайте через AWS CLI</td></tr>' : ''}
|
${(!queues || queues.length === 0) ? '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей</td></tr>' : ''}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,6 +750,183 @@ function fmtDuration(s) {
|
|||||||
if (s >= 60) return (s / 60).toFixed(0) + 'м';
|
if (s >= 60) return (s / 60).toFixed(0) + 'м';
|
||||||
return s + 'с';
|
return s + 'с';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyTextarea — копирует содержимое textarea в буфер обмена
|
||||||
|
function copyTextarea(el) {
|
||||||
|
navigator.clipboard.writeText(el.value).then(() => {
|
||||||
|
const ok = document.createElement('span');
|
||||||
|
ok.className = 'copy-ok';
|
||||||
|
ok.textContent = '✓ скопировано';
|
||||||
|
el.parentNode.appendChild(ok);
|
||||||
|
setTimeout(() => ok.remove(), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== QUEUE CRUD =====
|
||||||
|
// openCreateQueueModal — показывает модалку создания очереди для тенанта
|
||||||
|
function openCreateQueueModal(tenantId) {
|
||||||
|
_createQueueState.tenantId = tenantId;
|
||||||
|
document.getElementById('cq-name').value = '';
|
||||||
|
document.getElementById('cq-error').textContent = '';
|
||||||
|
document.getElementById('modal-queue-create').classList.remove('hidden');
|
||||||
|
document.getElementById('cq-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQueueModal() {
|
||||||
|
document.getElementById('modal-queue-create').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// createQueue — POST /tenants/{id}/queues — создаёт очередь
|
||||||
|
function createQueue() {
|
||||||
|
const name = document.getElementById('cq-name').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
document.getElementById('cq-error').textContent = 'Укажите имя очереди';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api('/tenants/' + _createQueueState.tenantId + '/queues', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
}).then(() => {
|
||||||
|
closeQueueModal();
|
||||||
|
if (currentTenantId) loadTenant(currentTenantId);
|
||||||
|
}).catch(err => {
|
||||||
|
document.getElementById('cq-error').textContent = 'Ошибка: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteQueueConfirm — DELETE /tenants/{id}/queues/{name} с подтверждением
|
||||||
|
function deleteQueueConfirm(tenantId, queueName) {
|
||||||
|
if (!confirm('Удалить очередь "' + queueName + '"?\nВсе сообщения будут потеряны.')) return;
|
||||||
|
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName), { method: 'DELETE' })
|
||||||
|
.then(() => { if (currentTenantId) loadTenant(currentTenantId); })
|
||||||
|
.catch(err => alert('Ошибка удаления: ' + err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MESSAGE PEEK / SEND / PURGE =====
|
||||||
|
// toggleMessages — разворачивает/сворачивает inline-таблицу сообщений очереди
|
||||||
|
function toggleMessages(tenantId, queueName) {
|
||||||
|
const row = document.getElementById('msgs-' + queueName);
|
||||||
|
const icon = document.getElementById('qicon-' + queueName);
|
||||||
|
if (!row) return;
|
||||||
|
if (row.classList.contains('hidden')) {
|
||||||
|
row.classList.remove('hidden');
|
||||||
|
if (icon) icon.textContent = '▼';
|
||||||
|
loadMessages(tenantId, queueName);
|
||||||
|
} else {
|
||||||
|
row.classList.add('hidden');
|
||||||
|
if (icon) icon.textContent = '▶';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadMessages — GET /tenants/{id}/queues/{q}/messages и рендерит таблицу
|
||||||
|
function loadMessages(tenantId, queueName) {
|
||||||
|
const inner = document.getElementById('msgs-inner-' + queueName);
|
||||||
|
if (!inner) return;
|
||||||
|
inner.innerHTML = '<span style="color:var(--text-secondary);font-size:13px">Загрузка...</span>';
|
||||||
|
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages')
|
||||||
|
.then(msgs => renderQueueMessages(queueName, msgs))
|
||||||
|
.catch(err => {
|
||||||
|
const el = document.getElementById('msgs-inner-' + queueName);
|
||||||
|
if (el) el.innerHTML = '<span style="color:var(--danger);font-size:13px">Ошибка: ' + esc(err.message) + '</span>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderQueueMessages — рендерит таблицу сообщений в expandable row
|
||||||
|
function renderQueueMessages(queueName, msgs) {
|
||||||
|
const inner = document.getElementById('msgs-inner-' + queueName);
|
||||||
|
if (!inner) return;
|
||||||
|
if (!msgs || msgs.length === 0) {
|
||||||
|
inner.innerHTML = '<span style="color:var(--text-secondary);font-size:13px">Очередь пуста</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Сохраняем в кеш для detail modal
|
||||||
|
msgs.forEach(m => { msgCache[m.id] = m; });
|
||||||
|
inner.innerHTML = `
|
||||||
|
<table style="width:100%;font-size:13px">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">ID</th>
|
||||||
|
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Body</th>
|
||||||
|
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Отправлено</th>
|
||||||
|
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Получений</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${msgs.map(m => `
|
||||||
|
<tr onclick="openMsgDetail('${esc(m.id)}')" style="cursor:pointer"
|
||||||
|
onmouseover="this.style.background='rgba(26,127,212,0.08)'" onmouseout="this.style.background=''">
|
||||||
|
<td style="padding:6px 12px;font-family:monospace;color:var(--text-secondary)">${esc(m.id).substring(0,8)}…</td>
|
||||||
|
<td style="padding:6px 12px;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.body || '').substring(0,120)}${(m.body||'').length>120?'…':''}</td>
|
||||||
|
<td style="padding:6px 12px;color:var(--text-secondary)">${fmtDate(m.sent)}</td>
|
||||||
|
<td style="padding:6px 12px">${m.receives}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// openMsgDetail — показывает модалку с полным телом сообщения
|
||||||
|
function openMsgDetail(id) {
|
||||||
|
const m = msgCache[id];
|
||||||
|
if (!m) return;
|
||||||
|
document.getElementById('msg-detail-id').value = m.id || '';
|
||||||
|
document.getElementById('msg-detail-body').value = m.body || '';
|
||||||
|
document.getElementById('msg-detail-sent').value = m.sent ? fmtDate(m.sent) : '';
|
||||||
|
document.getElementById('modal-msg-detail').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMsgDetail() {
|
||||||
|
document.getElementById('modal-msg-detail').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSendModal — открывает модалку отправки сообщения
|
||||||
|
function openSendModal(tenantId, queueName) {
|
||||||
|
_sendState.tenantId = tenantId;
|
||||||
|
_sendState.queueName = queueName;
|
||||||
|
document.getElementById('send-body').value = '';
|
||||||
|
document.getElementById('send-error').textContent = '';
|
||||||
|
document.getElementById('modal-send').classList.remove('hidden');
|
||||||
|
document.getElementById('send-body').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSendModal() {
|
||||||
|
document.getElementById('modal-send').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessage — POST /tenants/{id}/queues/{q}/messages — отправляет сообщение
|
||||||
|
function sendMessage() {
|
||||||
|
const body = document.getElementById('send-body').value.trim();
|
||||||
|
if (!body) {
|
||||||
|
document.getElementById('send-error').textContent = 'Введите тело сообщения';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { tenantId, queueName } = _sendState;
|
||||||
|
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ body: body })
|
||||||
|
}).then(() => {
|
||||||
|
closeSendModal();
|
||||||
|
// Если очередь раскрыта — перезагрузить сообщения
|
||||||
|
const row = document.getElementById('msgs-' + queueName);
|
||||||
|
if (row && !row.classList.contains('hidden')) loadMessages(tenantId, queueName);
|
||||||
|
if (currentTenantId) loadTenant(currentTenantId);
|
||||||
|
}).catch(err => {
|
||||||
|
document.getElementById('send-error').textContent = 'Ошибка: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeQueueConfirm — DELETE /tenants/{id}/queues/{q}/messages с подтверждением
|
||||||
|
function purgeQueueConfirm(tenantId, queueName) {
|
||||||
|
if (!confirm('Очистить очередь "' + queueName + '"?\nВсе сообщения будут удалены.')) return;
|
||||||
|
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages', { method: 'DELETE' })
|
||||||
|
.then(() => {
|
||||||
|
const row = document.getElementById('msgs-' + queueName);
|
||||||
|
if (row && !row.classList.contains('hidden')) loadMessages(tenantId, queueName);
|
||||||
|
if (currentTenantId) loadTenant(currentTenantId);
|
||||||
|
})
|
||||||
|
.catch(err => alert('Ошибка: ' + err.message));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: shared-sqs
|
- name: shared-sqs
|
||||||
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs:v0.1.0
|
image: naeel/shared-sqs:v0.1.6
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 4100
|
- containerPort: 4100
|
||||||
name: http
|
name: http
|
||||||
|
|||||||
Reference in New Issue
Block a user