feat(shared-sqs): add SQS Console UI (v0.1.4)
- 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
This commit is contained in:
+198
-147
@@ -4,208 +4,259 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
||||||
adminToken string
|
adminToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler — создаёт admin handler
|
// NewHandler — создаёт admin handler
|
||||||
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
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
|
||||||
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)
|
||||||
adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST")
|
adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST")
|
||||||
adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET")
|
adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET")
|
||||||
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("/health", h.detailedHealth).Methods("GET")
|
adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
||||||
|
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
||||||
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
expected := "Bearer " + h.adminToken
|
expected := "Bearer " + h.adminToken
|
||||||
if authHeader != expected {
|
if authHeader != expected {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTenantRequest — тело запроса POST /admin/tenants
|
// createTenantRequest — тело запроса POST /admin/tenants
|
||||||
type createTenantRequest struct {
|
type createTenantRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MaxQueues int `json:"max_queues"`
|
MaxQueues int `json:"max_queues"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
|
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
|
||||||
type tenantCreateResponse struct {
|
type tenantCreateResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AccessKey string `json:"access_key"`
|
AccessKey string `json:"access_key"`
|
||||||
SecretKey string `json:"secret_key"`
|
SecretKey string `json:"secret_key"`
|
||||||
MaxQueues int `json:"max_queues"`
|
MaxQueues int `json:"max_queues"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// tenantListItem — данные тенанта без secret_key (для List/Get)
|
// tenantListItem — данные тенанта без secret_key (для List/Get)
|
||||||
type tenantListItem struct {
|
type tenantListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AccessKey string `json:"access_key"`
|
AccessKey string `json:"access_key"`
|
||||||
MaxQueues int `json:"max_queues"`
|
MaxQueues int `json:"max_queues"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTenant — POST /admin/tenants
|
// createTenant — POST /admin/tenants
|
||||||
func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
var req createTenantRequest
|
var req createTenantRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Name == "" {
|
if req.Name == "" {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t, err := h.store.Create(req.Name, req.MaxQueues)
|
t, err := h.store.Create(req.Name, req.MaxQueues)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("admin: failed to create tenant: %v", err)
|
log.Errorf("admin: failed to create tenant: %v", err)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(tenantCreateResponse{
|
json.NewEncoder(w).Encode(tenantCreateResponse{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
AccessKey: t.AccessKey,
|
AccessKey: t.AccessKey,
|
||||||
SecretKey: t.SecretKey,
|
SecretKey: t.SecretKey,
|
||||||
MaxQueues: t.MaxQueues,
|
MaxQueues: t.MaxQueues,
|
||||||
CreatedAt: t.CreatedAt,
|
CreatedAt: t.CreatedAt,
|
||||||
Active: t.Active,
|
Active: t.Active,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// listTenants — GET /admin/tenants
|
// listTenants — GET /admin/tenants
|
||||||
func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) {
|
||||||
tenants := h.store.List()
|
tenants := h.store.List()
|
||||||
items := make([]tenantListItem, 0, len(tenants))
|
items := make([]tenantListItem, 0, len(tenants))
|
||||||
for _, t := range tenants {
|
for _, t := range tenants {
|
||||||
items = append(items, tenantListItem{
|
items = append(items, tenantListItem{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
AccessKey: t.AccessKey,
|
AccessKey: t.AccessKey,
|
||||||
MaxQueues: t.MaxQueues,
|
MaxQueues: t.MaxQueues,
|
||||||
CreatedAt: t.CreatedAt,
|
CreatedAt: t.CreatedAt,
|
||||||
Active: t.Active,
|
Active: t.Active,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(items)
|
json.NewEncoder(w).Encode(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getTenant — GET /admin/tenants/{id}
|
// getTenant — GET /admin/tenants/{id}
|
||||||
func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
id := vars["id"]
|
id := vars["id"]
|
||||||
t, ok := h.store.GetByID(id)
|
t, ok := h.store.GetByID(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(tenantListItem{
|
json.NewEncoder(w).Encode(tenantListItem{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
AccessKey: t.AccessKey,
|
AccessKey: t.AccessKey,
|
||||||
MaxQueues: t.MaxQueues,
|
MaxQueues: t.MaxQueues,
|
||||||
CreatedAt: t.CreatedAt,
|
CreatedAt: t.CreatedAt,
|
||||||
Active: t.Active,
|
Active: t.Active,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteTenant — DELETE /admin/tenants/{id}
|
// deleteTenant — DELETE /admin/tenants/{id}
|
||||||
// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak)
|
// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak)
|
||||||
func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
id := vars["id"]
|
id := vars["id"]
|
||||||
t, ok := h.store.GetByID(id)
|
t, ok := h.store.GetByID(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Удаляем все очереди тенанта из SyncQueues
|
// Удаляем все очереди тенанта из SyncQueues
|
||||||
prefix := t.AccessKey + ":"
|
prefix := t.AccessKey + ":"
|
||||||
models.SyncQueues.Lock()
|
models.SyncQueues.Lock()
|
||||||
for key := range models.SyncQueues.Queues {
|
for key := range models.SyncQueues.Queues {
|
||||||
if strings.HasPrefix(key, prefix) {
|
if strings.HasPrefix(key, prefix) {
|
||||||
delete(models.SyncQueues.Queues, key)
|
delete(models.SyncQueues.Queues, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
|
|
||||||
h.store.Delete(id)
|
h.store.Delete(id)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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
|
// adminHealthDetail — ответ GET /admin/health
|
||||||
type adminHealthDetail struct {
|
type adminHealthDetail struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
TenantCount int `json:"tenant_count"`
|
TenantCount int `json:"tenant_count"`
|
||||||
QueueCount int `json:"queue_count"`
|
QueueCount int `json:"queue_count"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailedHealth — GET /admin/health
|
// detailedHealth — GET /admin/health
|
||||||
func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
tenants := h.store.List()
|
tenants := h.store.List()
|
||||||
models.SyncQueues.RLock()
|
models.SyncQueues.RLock()
|
||||||
queueCount := len(models.SyncQueues.Queues)
|
queueCount := len(models.SyncQueues.Queues)
|
||||||
msgCount := 0
|
msgCount := 0
|
||||||
for _, q := range models.SyncQueues.Queues {
|
for _, q := range models.SyncQueues.Queues {
|
||||||
msgCount += len(q.Messages)
|
msgCount += len(q.Messages)
|
||||||
}
|
}
|
||||||
models.SyncQueues.RUnlock()
|
models.SyncQueues.RUnlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(adminHealthDetail{
|
json.NewEncoder(w).Encode(adminHealthDetail{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
TenantCount: len(tenants),
|
TenantCount: len(tenants),
|
||||||
QueueCount: queueCount,
|
QueueCount: queueCount,
|
||||||
MessageCount: msgCount,
|
MessageCount: msgCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"shared-sqs/app/interfaces"
|
"shared-sqs/app/interfaces"
|
||||||
sqs "shared-sqs/app/gosqs"
|
sqs "shared-sqs/app/gosqs"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
|
"shared-sqs/app/ui"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -32,6 +33,9 @@ r.HandleFunc("/health", health).Methods("GET")
|
|||||||
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
adminHandler := admin.NewHandler(tenantStore, adminToken)
|
||||||
adminHandler.RegisterRoutes(r)
|
adminHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
|
// UI console — встроенный SPA, публичный доступ
|
||||||
|
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
||||||
|
|
||||||
// SQS API — tenant auth middleware
|
// SQS API — tenant auth middleware
|
||||||
sqsRouter := r.NewRoute().Subrouter()
|
sqsRouter := r.NewRoute().Subrouter()
|
||||||
sqsRouter.Use(auth.AuthMiddleware(tenantStore))
|
sqsRouter.Use(auth.AuthMiddleware(tenantStore))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
app/ui/index.html
|
||||||
|
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||||
|
Created: 2026-04-10
|
||||||
|
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||||
|
Режим: Admin (bearer token) — все тенанты, очереди, статистика.
|
||||||
|
-->
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SQS Console — Nubes</title>
|
||||||
|
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
|
||||||
|
<style>
|
||||||
|
/* Nubes palette — идентично iot.kube5s.ru/console */
|
||||||
|
:root {
|
||||||
|
--bg-page: #001120;
|
||||||
|
--bg-surface: #001929;
|
||||||
|
--bg-navbar: #001C34;
|
||||||
|
--border: #0b2d50;
|
||||||
|
--accent: #1a7fd4;
|
||||||
|
--accent-hover: #2196f3;
|
||||||
|
--text-primary: #e2ecf6;
|
||||||
|
--text-secondary: #6b8eaa;
|
||||||
|
--danger: #e74c3c;
|
||||||
|
--success: #27ae60;
|
||||||
|
--warning: #f39c12;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar {
|
||||||
|
background: var(--bg-navbar);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.navbar-brand img {
|
||||||
|
height: 28px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
.navbar-brand span {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.navbar-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.btn-logout {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-logout:hover { border-color: var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
tbody td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
tbody tr:hover { background: rgba(26, 127, 212, 0.05); }
|
||||||
|
tbody tr { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-danger { background: var(--danger); color: #fff; }
|
||||||
|
.btn-danger:hover { background: #c0392b; }
|
||||||
|
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login page */
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.login-box {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-box img { height: 40px; filter: brightness(0) invert(1); margin-bottom: 12px; }
|
||||||
|
.login-box h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.login-error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-active { background: rgba(39,174,96,0.15); color: var(--success); }
|
||||||
|
.badge-inactive { background: rgba(231,76,60,0.15); color: var(--danger); }
|
||||||
|
.badge-count {
|
||||||
|
background: rgba(26,127,212,0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb */
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.breadcrumb a { color: var(--accent); }
|
||||||
|
.breadcrumb span { margin: 0 8px; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden helper */
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container { padding: 12px; }
|
||||||
|
.login-box { width: 95vw; padding: 24px; }
|
||||||
|
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copying animation */
|
||||||
|
.copy-ok {
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 8px;
|
||||||
|
animation: fadeout 1.5s forwards;
|
||||||
|
}
|
||||||
|
@keyframes fadeout { 0%{opacity:1} 70%{opacity:1} 100%{opacity:0} }
|
||||||
|
|
||||||
|
/* Refresh */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.toolbar h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.auto-refresh {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ===== LOGIN ===== -->
|
||||||
|
<div id="login-page" class="login-page">
|
||||||
|
<div class="login-box">
|
||||||
|
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
||||||
|
<h1>SQS CONSOLE</h1>
|
||||||
|
<div id="login-error" class="login-error"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="token-input">Admin Token</label>
|
||||||
|
<input type="password" id="token-input" placeholder="sqs-admin-..." autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== APP SHELL ===== -->
|
||||||
|
<div id="app" class="hidden">
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
||||||
|
<span>SQS CONSOLE</span>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-user">
|
||||||
|
<span>admin</span>
|
||||||
|
<button class="btn-logout" onclick="doLogout()">Выход</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Dashboard view -->
|
||||||
|
<div id="view-dashboard"></div>
|
||||||
|
<!-- Tenant detail view -->
|
||||||
|
<div id="view-tenant" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== CREATE TENANT MODAL ===== -->
|
||||||
|
<div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Создать тенанта</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ct-name">Имя</label>
|
||||||
|
<input id="ct-name" placeholder="my-service">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ct-queues">Макс. очередей</label>
|
||||||
|
<input id="ct-queues" type="number" value="10" min="1" max="1000">
|
||||||
|
</div>
|
||||||
|
<div id="ct-error" class="login-error"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="createTenant()">Создать</button>
|
||||||
|
<button class="btn btn-logout" onclick="closeModal()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== CREDENTIALS MODAL ===== -->
|
||||||
|
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Тенант создан</h2>
|
||||||
|
<p style="color:var(--warning);font-size:13px;margin-bottom:16px">
|
||||||
|
⚠ Сохраните credentials — Secret Key показывается только один раз.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Access Key</label>
|
||||||
|
<input id="creds-ak" readonly onclick="copyField(this)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Secret Key</label>
|
||||||
|
<input id="creds-sk" readonly onclick="copyField(this)">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="closeCredsModal()">Готово</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===== STATE =====
|
||||||
|
let TOKEN = '';
|
||||||
|
let BASE = '';
|
||||||
|
let refreshTimer = null;
|
||||||
|
let currentTenantId = null;
|
||||||
|
|
||||||
|
// ===== AUTH =====
|
||||||
|
// doLogin — проверяет токен через /admin/health и сохраняет в sessionStorage
|
||||||
|
function doLogin() {
|
||||||
|
const token = document.getElementById('token-input').value.trim();
|
||||||
|
if (!token) return;
|
||||||
|
// Определяем base URL — тот же origin что и UI
|
||||||
|
BASE = window.location.origin;
|
||||||
|
TOKEN = token;
|
||||||
|
api('/admin/health').then(data => {
|
||||||
|
sessionStorage.setItem('sqs_token', token);
|
||||||
|
document.getElementById('login-page').classList.add('hidden');
|
||||||
|
document.getElementById('app').classList.remove('hidden');
|
||||||
|
showDashboard();
|
||||||
|
}).catch(err => {
|
||||||
|
document.getElementById('login-error').textContent = 'Неверный токен';
|
||||||
|
TOKEN = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// doLogout — очищает сессию и возвращает на логин
|
||||||
|
function doLogout() {
|
||||||
|
TOKEN = '';
|
||||||
|
sessionStorage.removeItem('sqs_token');
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
document.getElementById('app').classList.add('hidden');
|
||||||
|
document.getElementById('login-page').classList.remove('hidden');
|
||||||
|
document.getElementById('token-input').value = '';
|
||||||
|
document.getElementById('login-error').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Авто-логин из sessionStorage
|
||||||
|
(function autoLogin() {
|
||||||
|
const saved = sessionStorage.getItem('sqs_token');
|
||||||
|
if (saved) {
|
||||||
|
document.getElementById('token-input').value = saved;
|
||||||
|
doLogin();
|
||||||
|
}
|
||||||
|
// Enter на поле токена
|
||||||
|
document.getElementById('token-input').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') doLogin();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ===== API HELPER =====
|
||||||
|
// api — выполняет запрос к admin API с bearer token
|
||||||
|
function api(path, opts = {}) {
|
||||||
|
return fetch(BASE + path, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + TOKEN,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(opts.headers || {})
|
||||||
|
}
|
||||||
|
}).then(r => {
|
||||||
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
||||||
|
if (r.status === 204) return null;
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DASHBOARD =====
|
||||||
|
// showDashboard — загружает health + tenants и рендерит главную страницу
|
||||||
|
function showDashboard() {
|
||||||
|
currentTenantId = null;
|
||||||
|
document.getElementById('view-tenant').classList.add('hidden');
|
||||||
|
document.getElementById('view-dashboard').classList.remove('hidden');
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
loadDashboard();
|
||||||
|
refreshTimer = setInterval(loadDashboard, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDashboard — загружает данные и обновляет DOM
|
||||||
|
function loadDashboard() {
|
||||||
|
Promise.all([api('/admin/health'), api('/admin/tenants')])
|
||||||
|
.then(([health, tenants]) => renderDashboard(health, tenants))
|
||||||
|
.catch(err => console.error('Dashboard load error:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDashboard — рендерит статистику и таблицу тенантов
|
||||||
|
function renderDashboard(health, tenants) {
|
||||||
|
const el = document.getElementById('view-dashboard');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${health.tenant_count || 0}</div>
|
||||||
|
<div class="stat-label">Тенанты</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${health.queue_count || 0}</div>
|
||||||
|
<div class="stat-label">Очереди</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${health.message_count || 0}</div>
|
||||||
|
<div class="stat-label">Сообщения</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" style="color:var(--success)">●</div>
|
||||||
|
<div class="stat-label">${health.status || 'ok'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>Тенанты</h2>
|
||||||
|
<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="openModal()">+ Создать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Access Key</th>
|
||||||
|
<th>Макс. очередей</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${(tenants || []).map(t => `
|
||||||
|
<tr onclick="showTenant('${esc(t.id)}')">
|
||||||
|
<td><strong>${esc(t.name)}</strong></td>
|
||||||
|
<td style="font-family:monospace;font-size:12px">${esc(t.access_key)}</td>
|
||||||
|
<td>${t.max_queues}</td>
|
||||||
|
<td>${t.active
|
||||||
|
? '<span class="badge badge-active">active</span>'
|
||||||
|
: '<span class="badge badge-inactive">inactive</span>'}</td>
|
||||||
|
<td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteTenant('${esc(t.id)}','${esc(t.name)}')">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
${(!tenants || tenants.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет тенантов</td></tr>' : ''}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TENANT DETAIL =====
|
||||||
|
// showTenant — переключает вид на детали тенанта и его очереди
|
||||||
|
function showTenant(id) {
|
||||||
|
currentTenantId = id;
|
||||||
|
document.getElementById('view-dashboard').classList.add('hidden');
|
||||||
|
document.getElementById('view-tenant').classList.remove('hidden');
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
loadTenant(id);
|
||||||
|
refreshTimer = setInterval(() => loadTenant(id), 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTenant — загружает тенанта и его очереди
|
||||||
|
function loadTenant(id) {
|
||||||
|
Promise.all([api('/admin/tenants/' + id), api('/admin/tenants/' + id + '/queues')])
|
||||||
|
.then(([tenant, queues]) => renderTenant(tenant, queues))
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Tenant load error:', err);
|
||||||
|
showDashboard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTenant — рендерит детали тенанта: credentials, список очередей
|
||||||
|
function renderTenant(tenant, queues) {
|
||||||
|
const el = document.getElementById('view-tenant');
|
||||||
|
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="#" onclick="event.preventDefault();showDashboard()">Тенанты</a>
|
||||||
|
<span>›</span>
|
||||||
|
${esc(tenant.name)}
|
||||||
|
</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${(queues || []).length}</div>
|
||||||
|
<div class="stat-label">Очереди</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${totalMsgs}</div>
|
||||||
|
<div class="stat-label">Сообщения</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${tenant.max_queues}</div>
|
||||||
|
<div class="stat-label">Лимит очередей</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" style="font-size:20px;font-family:monospace">${esc(tenant.access_key)}</div>
|
||||||
|
<div class="stat-label">Access Key</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>Очереди</h2>
|
||||||
|
<div class="auto-refresh"><span>⟳ 10с</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя очереди</th>
|
||||||
|
<th>Сообщения</th>
|
||||||
|
<th>In-flight</th>
|
||||||
|
<th>Visibility Timeout</th>
|
||||||
|
<th>Max Size</th>
|
||||||
|
<th>Retention</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${(queues || []).map(q => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${esc(q.name)}</strong></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>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
${(!queues || queues.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей — создайте через AWS CLI</td></tr>' : ''}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TENANT CRUD =====
|
||||||
|
// openModal — показывает модалку создания тенанта
|
||||||
|
function openModal() {
|
||||||
|
document.getElementById('ct-name').value = '';
|
||||||
|
document.getElementById('ct-queues').value = '10';
|
||||||
|
document.getElementById('ct-error').textContent = '';
|
||||||
|
document.getElementById('modal-create').classList.remove('hidden');
|
||||||
|
document.getElementById('ct-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal-create').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTenant — POST /admin/tenants, показывает credentials
|
||||||
|
function createTenant() {
|
||||||
|
const name = document.getElementById('ct-name').value.trim();
|
||||||
|
const maxQ = parseInt(document.getElementById('ct-queues').value) || 10;
|
||||||
|
if (!name) {
|
||||||
|
document.getElementById('ct-error').textContent = 'Укажите имя';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api('/admin/tenants', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: name, max_queues: maxQ })
|
||||||
|
}).then(data => {
|
||||||
|
closeModal();
|
||||||
|
// Показываем credentials
|
||||||
|
document.getElementById('creds-ak').value = data.access_key || '';
|
||||||
|
document.getElementById('creds-sk').value = data.secret_key || '';
|
||||||
|
document.getElementById('modal-creds').classList.remove('hidden');
|
||||||
|
loadDashboard();
|
||||||
|
}).catch(err => {
|
||||||
|
document.getElementById('ct-error').textContent = 'Ошибка: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCredsModal() {
|
||||||
|
document.getElementById('modal-creds').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTenant — DELETE /admin/tenants/{id} с подтверждением
|
||||||
|
function deleteTenant(id, name) {
|
||||||
|
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;
|
||||||
|
api('/admin/tenants/' + id, { method: 'DELETE' })
|
||||||
|
.then(() => loadDashboard())
|
||||||
|
.catch(err => alert('Ошибка удаления: ' + err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HELPERS =====
|
||||||
|
// esc — экранирование HTML для защиты от XSS
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyField — копирует значение input в буфер обмена
|
||||||
|
function copyField(input) {
|
||||||
|
navigator.clipboard.writeText(input.value).then(() => {
|
||||||
|
const ok = document.createElement('span');
|
||||||
|
ok.className = 'copy-ok';
|
||||||
|
ok.textContent = '✓ скопировано';
|
||||||
|
input.parentNode.appendChild(ok);
|
||||||
|
setTimeout(() => ok.remove(), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtDate — форматирует дату для таблицы
|
||||||
|
function fmtDate(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
const d = new Date(s);
|
||||||
|
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtBytes — форматирует байты (262144 → 256 KB)
|
||||||
|
function fmtBytes(b) {
|
||||||
|
if (!b) return '—';
|
||||||
|
if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB';
|
||||||
|
if (b >= 1024) return (b / 1024).toFixed(0) + ' KB';
|
||||||
|
return b + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtDuration — форматирует секунды (345600 → 4д)
|
||||||
|
function fmtDuration(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
if (s >= 86400) return (s / 86400).toFixed(0) + 'д';
|
||||||
|
if (s >= 3600) return (s / 3600).toFixed(0) + 'ч';
|
||||||
|
if (s >= 60) return (s / 60).toFixed(0) + 'м';
|
||||||
|
return s + 'с';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user