Add demo UI showcase mode
This commit is contained in:
+142
-18
@@ -1,12 +1,14 @@
|
|||||||
// app/admin/admin.go
|
// app/admin/admin.go
|
||||||
// Admin API handlers for shared-sqs management
|
// Admin API handlers for shared-sqs management
|
||||||
// Created: 2026-04-09
|
// Created: 2026-04-09
|
||||||
// Updated: 2026-04-10 — JWT auth через nubes API, auto-provisioning тенантов
|
// Updated: 2026-04-12 09:28 MSK — demo UI token и изоляция UI API одним tenant-ом
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,6 +24,20 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type uiContextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
uiTenantContextKey uiContextKey = "ui-tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultUIDemoToken = "demo-ui-shared-sqs-ngcloud-2026"
|
||||||
|
defaultUIDemoTenantID = "t-demo-shared-sqs-ngcloud"
|
||||||
|
defaultUIDemoEmail = "demo@shared-sqs.ngcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errUIDemoTokenMismatch = errors.New("demo token mismatch")
|
||||||
|
|
||||||
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
||||||
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
||||||
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
||||||
@@ -51,6 +67,61 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
|||||||
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
|
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uiDemoToken — возвращает публичный demo token для UI, если он не переопределён через env.
|
||||||
|
func (h *Handler) uiDemoToken() string {
|
||||||
|
if token := os.Getenv("SHARED_SQS_UI_DEMO_TOKEN"); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
return defaultUIDemoToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiDemoTenantID — возвращает tenant ID, к которому привязан demo token.
|
||||||
|
func (h *Handler) uiDemoTenantID() string {
|
||||||
|
if tenantID := os.Getenv("SHARED_SQS_UI_DEMO_TENANT_ID"); tenantID != "" {
|
||||||
|
return tenantID
|
||||||
|
}
|
||||||
|
return defaultUIDemoTenantID
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiDemoEmail — возвращает отображаемый email для demo UI session.
|
||||||
|
func (h *Handler) uiDemoEmail() string {
|
||||||
|
if email := os.Getenv("SHARED_SQS_UI_DEMO_EMAIL"); email != "" {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
return defaultUIDemoEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticateUIDemoToken — маппит публичный demo token на заранее сидированный demo tenant.
|
||||||
|
func (h *Handler) authenticateUIDemoToken(token string) (*tenant.Tenant, string, error) {
|
||||||
|
demoToken := h.uiDemoToken()
|
||||||
|
if demoToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(demoToken)) != 1 {
|
||||||
|
return nil, "", errUIDemoTokenMismatch
|
||||||
|
}
|
||||||
|
demoTenant, ok := h.store.GetByID(h.uiDemoTenantID())
|
||||||
|
if !ok {
|
||||||
|
return nil, "", errors.New("demo tenant unavailable — enable SHARED_SQS_SEED_DEMO=true")
|
||||||
|
}
|
||||||
|
return demoTenant, h.uiDemoEmail(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentUITenant — возвращает tenant, авторизованный через UI middleware.
|
||||||
|
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
|
||||||
|
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantListItemFromTenant — строит публичный JSON-ответ без SecretKey.
|
||||||
|
func tenantListItemFromTenant(t *tenant.Tenant) tenantListItem {
|
||||||
|
return tenantListItem{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
AccessKey: t.AccessKey,
|
||||||
|
MaxQueues: t.MaxQueues,
|
||||||
|
CreatedAt: t.CreatedAt,
|
||||||
|
Active: t.Active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||||
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||||
adminRouter := r.PathPrefix("/admin").Subrouter()
|
adminRouter := r.PathPrefix("/admin").Subrouter()
|
||||||
@@ -118,6 +189,24 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if demoTenant, demoEmail, err := h.authenticateUIDemoToken(req.Token); err == nil {
|
||||||
|
log.Infof("ui auth: authenticated demo tenant=%s", demoTenant.ID)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"email": demoEmail,
|
||||||
|
"tenant_id": demoTenant.ID,
|
||||||
|
"access_key": demoTenant.AccessKey,
|
||||||
|
"secret_key": demoTenant.SecretKey,
|
||||||
|
"max_queues": demoTenant.MaxQueues,
|
||||||
|
"token": req.Token,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, errUIDemoTokenMismatch) {
|
||||||
|
log.Warnf("ui auth: demo login unavailable: %v", err)
|
||||||
|
jsonErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Парсим JWT claims
|
// Парсим JWT claims
|
||||||
claims, err := auth.ParseJWTClaims(req.Token)
|
claims, err := auth.ParseJWTClaims(req.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,6 +271,19 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
token := parts[1]
|
token := parts[1]
|
||||||
|
|
||||||
|
if demoTenant, _, err := h.authenticateUIDemoToken(token); err == nil {
|
||||||
|
if pathTenantID, exists := mux.Vars(r)["id"]; exists && pathTenantID != "" && pathTenantID != demoTenant.ID {
|
||||||
|
jsonErr(w, http.StatusForbidden, "forbidden tenant access")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), uiTenantContextKey, demoTenant)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, errUIDemoTokenMismatch) {
|
||||||
|
jsonErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
claims, err := auth.ParseJWTClaims(token)
|
claims, err := auth.ParseJWTClaims(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
|
||||||
@@ -210,7 +312,8 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
uiCtx := context.WithValue(r.Context(), uiTenantContextKey, jwtTenant)
|
||||||
|
next.ServeHTTP(w, r.WithContext(uiCtx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +346,10 @@ type tenantListItem struct {
|
|||||||
|
|
||||||
// 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) {
|
||||||
|
if _, ok := currentUITenant(r); ok {
|
||||||
|
jsonErr(w, http.StatusForbidden, "tenant creation via UI is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
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")
|
||||||
@@ -279,17 +386,15 @@ func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 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) {
|
||||||
|
if uiTenant, ok := currentUITenant(r); ok {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode([]tenantListItem{tenantListItemFromTenant(uiTenant)})
|
||||||
|
return
|
||||||
|
}
|
||||||
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, tenantListItemFromTenant(t))
|
||||||
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(items)
|
json.NewEncoder(w).Encode(items)
|
||||||
@@ -307,19 +412,16 @@ func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
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(tenantListItemFromTenant(t))
|
||||||
ID: t.ID,
|
|
||||||
Name: t.Name,
|
|
||||||
AccessKey: t.AccessKey,
|
|
||||||
MaxQueues: t.MaxQueues,
|
|
||||||
CreatedAt: t.CreatedAt,
|
|
||||||
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) {
|
||||||
|
if _, ok := currentUITenant(r); ok {
|
||||||
|
jsonErr(w, http.StatusForbidden, "tenant deletion via UI is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
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)
|
||||||
@@ -626,6 +728,28 @@ type adminHealthDetail struct {
|
|||||||
|
|
||||||
// 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) {
|
||||||
|
if uiTenant, ok := currentUITenant(r); ok {
|
||||||
|
prefix := uiTenant.AccessKey + ":"
|
||||||
|
queueCount := 0
|
||||||
|
msgCount := 0
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
for key, q := range models.SyncQueues.Queues {
|
||||||
|
if strings.HasPrefix(key, prefix) {
|
||||||
|
queueCount++
|
||||||
|
msgCount += len(q.Messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(adminHealthDetail{
|
||||||
|
Status: "ok",
|
||||||
|
TenantCount: 1,
|
||||||
|
QueueCount: queueCount,
|
||||||
|
MessageCount: msgCount,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
tenants := h.store.List()
|
tenants := h.store.List()
|
||||||
models.SyncQueues.RLock()
|
models.SyncQueues.RLock()
|
||||||
queueCount := len(models.SyncQueues.Queues)
|
queueCount := len(models.SyncQueues.Queues)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// app/admin/admin_test.go
|
||||||
|
// Focused tests for UI auth and scoping
|
||||||
|
// Created: 2026-04-12 09:28 MSK
|
||||||
|
// Updated: 2026-04-12 09:28 MSK — demo UI token and tenant-scoped UI responses
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"shared-sqs/app/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJWTAuth_AllowsDemoToken verifies that the public demo token authenticates into the seeded demo tenant.
|
||||||
|
func TestJWTAuth_AllowsDemoToken(t *testing.T) {
|
||||||
|
t.Setenv("SHARED_SQS_UI_DEMO_TOKEN", "demo-token-for-test")
|
||||||
|
t.Setenv("SHARED_SQS_UI_DEMO_TENANT_ID", "t-demo-test")
|
||||||
|
|
||||||
|
store := tenant.NewTenantStore()
|
||||||
|
demoTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFixed(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(store, "admin-token")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{"token":"demo-token-for-test"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.jwtAuth(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("jwtAuth() status = %d, body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got := resp["tenant_id"]; got != demoTenant.ID {
|
||||||
|
t.Fatalf("tenant_id = %v, want %s", got, demoTenant.ID)
|
||||||
|
}
|
||||||
|
if got := resp["access_key"]; got != demoTenant.AccessKey {
|
||||||
|
t.Fatalf("access_key = %v, want %s", got, demoTenant.AccessKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListTenants_UIContextReturnsOnlyOwnTenant verifies that UI API listing is scoped to the authenticated tenant.
|
||||||
|
func TestListTenants_UIContextReturnsOnlyOwnTenant(t *testing.T) {
|
||||||
|
store := tenant.NewTenantStore()
|
||||||
|
firstTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFixed(first): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.CreateFixed("other-service", 10, "t-other-test", "SSAK-other-test", "other-secret"); err != nil {
|
||||||
|
t.Fatalf("CreateFixed(second): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(store, "admin-token")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ui/api/tenants", nil)
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), uiTenantContextKey, firstTenant))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.listTenants(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("listTenants() status = %d, body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp []map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp) != 1 {
|
||||||
|
t.Fatalf("len(response) = %d, want 1", len(resp))
|
||||||
|
}
|
||||||
|
if got := resp[0]["id"]; got != firstTenant.ID {
|
||||||
|
t.Fatalf("response[0].id = %v, want %s", got, firstTenant.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-15
@@ -3,9 +3,9 @@
|
|||||||
app/ui/index.html
|
app/ui/index.html
|
||||||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||||
Created: 2026-04-10
|
Created: 2026-04-10
|
||||||
Updated: 2026-04-10 — JWT auth через nubes token, email в navbar, auto-provisioning
|
Updated: 2026-04-12 09:28 MSK — demo token login и UI только для собственного tenant-а
|
||||||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||||
Режим: JWT авторизация через nubes API. Пользователь вводит токен → валидация → сессия.
|
Режим: UI принимает либо nubes JWT, либо публичный demo token для seeded demo tenant.
|
||||||
-->
|
-->
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
@@ -330,13 +330,14 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
|||||||
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
|
||||||
<h1>SQS CONSOLE</h1>
|
<h1>SQS CONSOLE</h1>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="login-token">API Token (nubes JWT)</label>
|
<label for="login-token">API Token или Demo Token</label>
|
||||||
<input id="login-token" type="password" placeholder="eyJhbGciOiJSUzI1NiIs...">
|
<input id="login-token" type="password" placeholder="JWT или demo-ui-shared-sqs-ngcloud-2026">
|
||||||
</div>
|
</div>
|
||||||
<div id="login-error" class="login-error"></div>
|
<div id="login-error" class="login-error"></div>
|
||||||
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
|
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
|
||||||
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
|
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
|
||||||
Токен можно получить в панели управления облаком Nubes
|
Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br>
|
||||||
|
Для личного tenant-а используйте API token из панели Nubes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,10 +501,10 @@ function showLogin() {
|
|||||||
function doLogin() {
|
function doLogin() {
|
||||||
const token = document.getElementById('login-token').value.trim();
|
const token = document.getElementById('login-token').value.trim();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
document.getElementById('login-error').textContent = 'Введите токен';
|
document.getElementById('login-error').textContent = 'Введите API token или demo token';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById('login-error').textContent = 'Проверка токена...';
|
document.getElementById('login-error').textContent = 'Проверка доступа...';
|
||||||
fetch(BASE + '/ui/api/auth', {
|
fetch(BASE + '/ui/api/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -611,12 +612,11 @@ function renderDashboard(health, tenants) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<h2>Тенанты</h2>
|
<h2>Мой tenant</h2>
|
||||||
<div style="display:flex;gap:12px;align-items:center">
|
<div style="display:flex;gap:12px;align-items:center">
|
||||||
<div class="auto-refresh">
|
<div class="auto-refresh">
|
||||||
<span>⟳ 10с</span>
|
<span>⟳ 10с</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" onclick="openModal()">+ Создать</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -628,7 +628,6 @@ function renderDashboard(health, tenants) {
|
|||||||
<th>Макс. очередей</th>
|
<th>Макс. очередей</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Создан</th>
|
<th>Создан</th>
|
||||||
<th></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -641,12 +640,9 @@ function renderDashboard(health, tenants) {
|
|||||||
? '<span class="badge badge-active">active</span>'
|
? '<span class="badge badge-active">active</span>'
|
||||||
: '<span class="badge badge-inactive">inactive</span>'}</td>
|
: '<span class="badge badge-inactive">inactive</span>'}</td>
|
||||||
<td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</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>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
${(!tenants || tenants.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет тенантов</td></tr>' : ''}
|
${(!tenants || tenants.length === 0) ? '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:32px">Tenant не найден</td></tr>' : ''}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -681,7 +677,7 @@ function renderTenant(tenant, queues) {
|
|||||||
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
|
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a href="#" onclick="event.preventDefault();showDashboard()">Тенанты</a>
|
<a href="#" onclick="event.preventDefault();showDashboard()">Мой tenant</a>
|
||||||
<span>›</span>
|
<span>›</span>
|
||||||
${esc(tenant.name)}
|
${esc(tenant.name)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Решение: demo UI режим для showcase
|
||||||
|
|
||||||
|
Дата: 2026-04-12 09:57 MSK
|
||||||
|
Агент: GitHub Copilot (GPT-5.4)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Нужно показать заказчику два сценария на одном стенде:
|
||||||
|
|
||||||
|
1. Быстрый demo-вход без подготовки.
|
||||||
|
2. Реальный пользовательский вход по настоящему Nubes token.
|
||||||
|
|
||||||
|
До изменения UI принимал только реальный JWT и при этом использовал admin handlers слишком широко, из-за чего demo-сценарий был неудобным, а UI-поведение было ближе к admin console, чем к пользовательской витрине.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Принят временный showcase-режим:
|
||||||
|
|
||||||
|
- добавить публичный UI demo token `demo-ui-shared-sqs-ngcloud-2026`;
|
||||||
|
- привязать его к уже существующему seeded demo tenant `t-demo-shared-sqs-ngcloud`;
|
||||||
|
- сохранить реальный JWT flow без изменений;
|
||||||
|
- ограничить UI API текущим tenant-ом;
|
||||||
|
- запретить создание и удаление tenant-а через UI.
|
||||||
|
|
||||||
|
## Почему так
|
||||||
|
|
||||||
|
- Это позволяет быстро показать сервис без подготовки аккаунта.
|
||||||
|
- Это сохраняет реальный пользовательский сценарий: заказчик может ввести настоящий token и попасть в свой tenant.
|
||||||
|
- Это убирает из demo UI лишний обзор всей системы и снижает риск случайной демонстрации чужих данных.
|
||||||
|
- Это минимальное изменение, которое можно позже убрать без ломки основной JWT-модели.
|
||||||
|
|
||||||
|
## Границы решения
|
||||||
|
|
||||||
|
- Решение предназначено для demo/showcase, не для production security model.
|
||||||
|
- В production demo token должен быть удалён вместе с seeded demo tenant.
|
||||||
|
- Основным постоянным сценарием остаётся вход по реальному Nubes JWT.
|
||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
## Версия v0.1.x
|
## Версия v0.1.x
|
||||||
|
|
||||||
|
### v0.1.22-dev (2026-04-12) — demo UI login + UI tenant scoping
|
||||||
|
- ✅ Добавлен публичный UI demo token: `demo-ui-shared-sqs-ngcloud-2026`
|
||||||
|
- ✅ Demo token маппится на уже сидированный demo tenant `t-demo-shared-sqs-ngcloud`
|
||||||
|
- ✅ UI API больше не показывает чужие tenant-ы: `GET /ui/api/tenants` возвращает только текущий tenant
|
||||||
|
- ✅ UI health для авторизованного пользователя считает только его очереди и сообщения
|
||||||
|
- ✅ Создание и удаление tenant-а через UI отключены, чтобы demo/login-console не выглядела как admin panel
|
||||||
|
- ✅ Узкая валидация: `go test ./app/admin` PASS
|
||||||
|
- ✅ Публичный showcase README синхронизирован с новым demo UI token
|
||||||
|
|
||||||
### v0.1.21 (2026-04-11) — Redis schema v2 + per-message persistence
|
### v0.1.21 (2026-04-11) — Redis schema v2 + per-message persistence
|
||||||
- ✅ Redis schema v2: metadata в HASH `ssq:queues`, сообщения в отдельных HASH `ssq:msg:{queueKey}`
|
- ✅ Redis schema v2: metadata в HASH `ssq:queues`, сообщения в отдельных HASH `ssq:msg:{queueKey}`
|
||||||
- ✅ Per-message persistence: каждая операция (send/receive/delete/visibility) пишет только затронутое сообщение
|
- ✅ Per-message persistence: каждая операция (send/receive/delete/visibility) пишет только затронутое сообщение
|
||||||
|
|||||||
@@ -238,6 +238,49 @@
|
|||||||
|
|
||||||
Проблема 64KB+ payload у shared-sqs оказалась не багом Go-сервиса и не проблемой Redis/persistence, а ограничением ingress path в кластере штурвала: объект ingress у приложения настроен корректно, часть аннотаций применяется, но именно `proxy-request-buffering` platform controller в итоговый nginx.conf не прокидывает, при этом upstream ingress-nginx такую аннотацию поддерживает. Поэтому вводить hard-limit 32KB в коде я считаю неправильным; правильное практическое решение на текущий момент — оставить сервис без искусственного code-level ограничения, а 32KB считать безопасным эксплуатационным размером и документировать это как известную особенность инфраструктуры.
|
Проблема 64KB+ payload у shared-sqs оказалась не багом Go-сервиса и не проблемой Redis/persistence, а ограничением ingress path в кластере штурвала: объект ingress у приложения настроен корректно, часть аннотаций применяется, но именно `proxy-request-buffering` platform controller в итоговый nginx.conf не прокидывает, при этом upstream ingress-nginx такую аннотацию поддерживает. Поэтому вводить hard-limit 32KB в коде я считаю неправильным; правильное практическое решение на текущий момент — оставить сервис без искусственного code-level ограничения, а 32KB считать безопасным эксплуатационным размером и документировать это как известную особенность инфраструктуры.
|
||||||
|
|
||||||
|
---
|
||||||
|
# Agent: GitHub Copilot (GPT-5.4)
|
||||||
|
|
||||||
|
## Задача: дать обычному demo-пользователю понятный вход в UI без личного Nubes JWT
|
||||||
|
|
||||||
|
### Контекст
|
||||||
|
После переработки публичного showcase-репозитория осталась продуктовая дыра: AWS CLI уже имел публичные demo credentials, а UI по-прежнему требовал личный nubes JWT. Для обычного пользователя это ломало демо-сценарий: CLI можно попробовать сразу, а UI нет.
|
||||||
|
|
||||||
|
### Локальная гипотеза
|
||||||
|
Если в коде уже существует сидированный demo tenant с фиксированными AWS credentials, то самый дешёвый и чистый путь — не придумывать новую сущность, а добавить отдельный UI demo token, который логинит ровно в этот tenant. Тогда CLI и UI будут опираться на один и тот же демонстрационный контур.
|
||||||
|
|
||||||
|
### Что проверил перед правкой
|
||||||
|
1. В `app/admin/admin.go` единственный публичный UI login endpoint `POST /ui/api/auth` принимал только JWT, парсил claims и всегда вызывал `PingNubesAPI`.
|
||||||
|
2. В `app/ui/index.html` логин-форма явно требовала `API Token (nubes JWT)`.
|
||||||
|
3. В `app/cmd/seed.go` уже существует seeded demo tenant:
|
||||||
|
- tenant ID: `t-demo-shared-sqs-ngcloud`
|
||||||
|
- access key: `SSAK-demo-shared-sqs`
|
||||||
|
- secret key: `demo-secret-key-shared-sqs-ngcloud-2026`
|
||||||
|
4. Дополнительно нашёл соседний дефект: UI routes переиспользовали admin handlers и `GET /ui/api/tenants` возвращал весь список tenant-ов, а `/ui/api/health` показывал глобальные счётчики сервиса. Для demo-login это недопустимо.
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
Сделал один узкий срез:
|
||||||
|
1. Добавил публичный UI demo token `demo-ui-shared-sqs-ngcloud-2026` с возможностью переопределения через env.
|
||||||
|
2. Привязал его к уже существующему seeded demo tenant.
|
||||||
|
3. Оставил существующий JWT flow без изменения для реальных пользователей.
|
||||||
|
4. Начал класть авторизованный UI tenant в request context.
|
||||||
|
5. Ограничил UI API текущим tenant-ом:
|
||||||
|
- `GET /ui/api/tenants` возвращает только своего tenant-а;
|
||||||
|
- `GET /ui/api/health` считает только свои очереди и сообщения;
|
||||||
|
- создание и удаление tenant-а через UI запрещены.
|
||||||
|
6. Обновил встроенный UI: форма логина теперь прямо подсказывает demo token и больше не выглядит как админская панель для управления всеми tenant-ами.
|
||||||
|
|
||||||
|
### Почему именно так
|
||||||
|
- Это минимальное изменение с хорошим ROI: один новый demo token закрывает UX-проблему без нового storage, без нового auth-service и без изменения AWS credentials.
|
||||||
|
- Demo-пользователь теперь видит только demo tenant и не получает случайный обзор всей системы.
|
||||||
|
- CLI и UI сходятся на одной и той же demo-учётке, то есть продуктовая история становится понятной.
|
||||||
|
|
||||||
|
### Что сознательно НЕ делал
|
||||||
|
- Не убирал JWT flow.
|
||||||
|
- Не строил отдельную demo role model.
|
||||||
|
- Не менял SQS auth path для AWS CLI.
|
||||||
|
- Не пытался превращать UI в полноценную admin console и пользовательскую console одновременно: для UI выбрал явный user/demo режим с одним tenant-ом.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Внешние ссылки для возврата к теме 64KB+
|
## Внешние ссылки для возврата к теме 64KB+
|
||||||
|
|||||||
Reference in New Issue
Block a user