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
|
||||
// Admin API handlers for shared-sqs management
|
||||
// Created: 2026-04-09
|
||||
// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений
|
||||
package admin
|
||||
|
||||
import (
|
||||
@@ -12,10 +13,24 @@ import (
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/tenant"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
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
|
||||
type Handler struct {
|
||||
store *tenant.TenantStore
|
||||
@@ -27,7 +42,7 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
||||
return &Handler{store: store, adminToken: adminToken}
|
||||
}
|
||||
|
||||
// RegisterRoutes — регистрирует admin маршруты на переданном router
|
||||
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||
adminRouter := r.PathPrefix("/admin").Subrouter()
|
||||
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.deleteTenant).Methods("DELETE")
|
||||
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")
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
||||
// Дублируют admin API, но доступны без bearer token для удобства демо
|
||||
// TODO: убрать или заменить на session-auth перед production
|
||||
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||
ui := r.PathPrefix("/ui/api").Subrouter()
|
||||
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.deleteTenant).Methods("DELETE")
|
||||
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)
|
||||
@@ -245,6 +271,216 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
type adminHealthDetail struct {
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
app/ui/index.html
|
||||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||
Created: 2026-04-10
|
||||
Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge
|
||||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||
Режим: Admin (bearer token) — все тенанты, очереди, статистика.
|
||||
Режим: Публичный UI API без авторизации (демо). Все данные in-memory.
|
||||
-->
|
||||
<html lang="ru">
|
||||
<head>
|
||||
@@ -313,6 +314,12 @@ tbody tr { cursor: pointer; }
|
||||
font-size: 13px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -377,11 +384,68 @@ tbody tr { cursor: pointer; }
|
||||
</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>
|
||||
// ===== STATE =====
|
||||
let BASE = '';
|
||||
let refreshTimer = null;
|
||||
let currentTenantId = null;
|
||||
let msgCache = {}; // id → объект сообщения для detail modal
|
||||
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
|
||||
let _createQueueState = { tenantId: null }; // контекст createQueue
|
||||
|
||||
// ===== INIT =====
|
||||
// Запуск — сразу показываем dashboard без логина
|
||||
@@ -543,7 +607,10 @@ function renderTenant(tenant, queues) {
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<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 class="table-wrap">
|
||||
<table>
|
||||
@@ -555,20 +622,35 @@ function renderTenant(tenant, queues) {
|
||||
<th>Visibility Timeout</th>
|
||||
<th>Max Size</th>
|
||||
<th>Retention</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${(queues || []).map(q => `
|
||||
<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.not_visible}</span></td>
|
||||
<td>${q.visibility_timeout}с</td>
|
||||
<td>${fmtBytes(q.max_message_size)}</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>
|
||||
`).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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -668,6 +750,183 @@ function fmtDuration(s) {
|
||||
if (s >= 60) return (s / 60).toFixed(0) + 'м';
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: shared-sqs
|
||||
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs:v0.1.0
|
||||
image: naeel/shared-sqs:v0.1.6
|
||||
ports:
|
||||
- containerPort: 4100
|
||||
name: http
|
||||
|
||||
Reference in New Issue
Block a user