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:
“Naeel”
2026-04-09 18:01:12 +03:00
parent 2c1b7d042e
commit 12b3bb9bf3
4 changed files with 945 additions and 147 deletions
+198 -147
View File
@@ -4,208 +4,259 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
"encoding/json"
"net/http"
"strings"
"time"
"shared-sqs/app/models"
"shared-sqs/app/tenant"
"shared-sqs/app/models"
"shared-sqs/app/tenant"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
)
// Handler — admin API handler, holds TenantStore и admin token
type Handler struct {
store *tenant.TenantStore
adminToken string
store *tenant.TenantStore
adminToken string
}
// NewHandler — создаёт admin handler
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
return &Handler{store: store, adminToken: adminToken}
return &Handler{store: store, adminToken: adminToken}
}
// RegisterRoutes — регистрирует admin маршруты на переданном router
func (h *Handler) RegisterRoutes(r *mux.Router) {
adminRouter := r.PathPrefix("/admin").Subrouter()
adminRouter.Use(h.bearerAuthMiddleware)
adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST")
adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET")
adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
adminRouter := r.PathPrefix("/admin").Subrouter()
adminRouter.Use(h.bearerAuthMiddleware)
adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST")
adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET")
adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
}
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
expected := "Bearer " + h.adminToken
if authHeader != expected {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
next.ServeHTTP(w, r)
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
expected := "Bearer " + h.adminToken
if authHeader != expected {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
next.ServeHTTP(w, r)
})
}
// createTenantRequest — тело запроса POST /admin/tenants
type createTenantRequest struct {
Name string `json:"name"`
MaxQueues int `json:"max_queues"`
Name string `json:"name"`
MaxQueues int `json:"max_queues"`
}
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
type tenantCreateResponse struct {
ID string `json:"id"`
Name string `json:"name"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
MaxQueues int `json:"max_queues"`
CreatedAt time.Time `json:"created_at"`
Active bool `json:"active"`
ID string `json:"id"`
Name string `json:"name"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
MaxQueues int `json:"max_queues"`
CreatedAt time.Time `json:"created_at"`
Active bool `json:"active"`
}
// tenantListItem — данные тенанта без secret_key (для List/Get)
type tenantListItem struct {
ID string `json:"id"`
Name string `json:"name"`
AccessKey string `json:"access_key"`
MaxQueues int `json:"max_queues"`
CreatedAt time.Time `json:"created_at"`
Active bool `json:"active"`
ID string `json:"id"`
Name string `json:"name"`
AccessKey string `json:"access_key"`
MaxQueues int `json:"max_queues"`
CreatedAt time.Time `json:"created_at"`
Active bool `json:"active"`
}
// createTenant — POST /admin/tenants
func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
var req createTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
return
}
if req.Name == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
return
}
t, err := h.store.Create(req.Name, req.MaxQueues)
if err != nil {
log.Errorf("admin: failed to create tenant: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(tenantCreateResponse{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
SecretKey: t.SecretKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
})
var req createTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
return
}
if req.Name == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
return
}
t, err := h.store.Create(req.Name, req.MaxQueues)
if err != nil {
log.Errorf("admin: failed to create tenant: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(tenantCreateResponse{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
SecretKey: t.SecretKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
})
}
// listTenants — GET /admin/tenants
func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) {
tenants := h.store.List()
items := make([]tenantListItem, 0, len(tenants))
for _, t := range tenants {
items = append(items, tenantListItem{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
tenants := h.store.List()
items := make([]tenantListItem, 0, len(tenants))
for _, t := range tenants {
items = append(items, tenantListItem{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}
// getTenant — GET /admin/tenants/{id}
func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
t, ok := h.store.GetByID(id)
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tenantListItem{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
})
vars := mux.Vars(r)
id := vars["id"]
t, ok := h.store.GetByID(id)
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tenantListItem{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
})
}
// deleteTenant — DELETE /admin/tenants/{id}
// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak)
func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
t, ok := h.store.GetByID(id)
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
return
}
// Удаляем все очереди тенанта из SyncQueues
prefix := t.AccessKey + ":"
models.SyncQueues.Lock()
for key := range models.SyncQueues.Queues {
if strings.HasPrefix(key, prefix) {
delete(models.SyncQueues.Queues, key)
}
}
models.SyncQueues.Unlock()
vars := mux.Vars(r)
id := vars["id"]
t, ok := h.store.GetByID(id)
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
return
}
// Удаляем все очереди тенанта из SyncQueues
prefix := t.AccessKey + ":"
models.SyncQueues.Lock()
for key := range models.SyncQueues.Queues {
if strings.HasPrefix(key, prefix) {
delete(models.SyncQueues.Queues, key)
}
}
models.SyncQueues.Unlock()
h.store.Delete(id)
w.WriteHeader(http.StatusNoContent)
h.store.Delete(id)
w.WriteHeader(http.StatusNoContent)
}
// listTenantQueues — GET /admin/tenants/{id}/queues
// Возвращает список очередей тенанта с количеством сообщений.
func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
t, ok := h.store.GetByID(id)
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
return
}
prefix := t.AccessKey + ":"
type queueInfo struct {
Name string `json:"name"`
Messages int `json:"messages"`
NotVisible int `json:"not_visible"`
VisibilityTimeout int `json:"visibility_timeout"`
MaxMessageSize int `json:"max_message_size"`
RetentionPeriod int `json:"retention_period"`
}
queues := make([]queueInfo, 0)
models.SyncQueues.RLock()
for key, q := range models.SyncQueues.Queues {
if strings.HasPrefix(key, prefix) {
name := strings.TrimPrefix(key, prefix)
visible := 0
notVisible := 0
for _, msg := range q.Messages {
if msg.ReceiptHandle != "" {
notVisible++
} else {
visible++
}
}
queues = append(queues, queueInfo{
Name: name,
Messages: visible,
NotVisible: notVisible,
VisibilityTimeout: q.VisibilityTimeout,
MaxMessageSize: q.MaximumMessageSize,
RetentionPeriod: q.MessageRetentionPeriod,
})
}
}
models.SyncQueues.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(queues)
}
// adminHealthDetail — ответ GET /admin/health
type adminHealthDetail struct {
Status string `json:"status"`
TenantCount int `json:"tenant_count"`
QueueCount int `json:"queue_count"`
MessageCount int `json:"message_count"`
Status string `json:"status"`
TenantCount int `json:"tenant_count"`
QueueCount int `json:"queue_count"`
MessageCount int `json:"message_count"`
}
// detailedHealth — GET /admin/health
func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
tenants := h.store.List()
models.SyncQueues.RLock()
queueCount := len(models.SyncQueues.Queues)
msgCount := 0
for _, q := range models.SyncQueues.Queues {
msgCount += len(q.Messages)
}
models.SyncQueues.RUnlock()
tenants := h.store.List()
models.SyncQueues.RLock()
queueCount := len(models.SyncQueues.Queues)
msgCount := 0
for _, q := range models.SyncQueues.Queues {
msgCount += len(q.Messages)
}
models.SyncQueues.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok",
TenantCount: len(tenants),
QueueCount: queueCount,
MessageCount: msgCount,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok",
TenantCount: len(tenants),
QueueCount: queueCount,
MessageCount: msgCount,
})
}
+4
View File
@@ -16,6 +16,7 @@ import (
"shared-sqs/app/interfaces"
sqs "shared-sqs/app/gosqs"
"shared-sqs/app/tenant"
"shared-sqs/app/ui"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
@@ -32,6 +33,9 @@ r.HandleFunc("/health", health).Methods("GET")
adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r)
// UI console — встроенный SPA, публичный доступ
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
// SQS API — tenant auth middleware
sqsRouter := r.NewRoute().Subrouter()
sqsRouter.Use(auth.AuthMiddleware(tenantStore))
+17
View File
@@ -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))
}
+726
View File
@@ -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>