docs(thinking): update session log with UI deployment and shared-SQS repo creation

This commit is contained in:
“Naeel”
2026-04-09 18:25:49 +03:00
parent 70c276b4e7
commit a4fe253768
3 changed files with 188 additions and 143 deletions
+45
View File
@@ -242,3 +242,48 @@ go build → OK (все этапы 1-8)
- 2c9a2b2 — Этапы 7+8 - 2c9a2b2 — Этапы 7+8
Остался Этап 9 — bash тесты. Ждём указания пользователя. Остался Этап 9 — bash тесты. Ждём указания пользователя.
---
## Агент: GitHub Copilot (Claude Opus 4.6) — SQS Console UI
### Задача
Создание веб-интерфейса для shared-sqs по образцу IoT Console (Nubes branding).
### Анализ
- Изучил HTML/CSS IoT Console (`iot.kube5s.ru/console`) — 1330 строк, vanilla SPA
- Извлёк палитру Nubes: `#001C34` navy, `#001120` bg, `#1a7fd4` accent, `#e2ecf6` text
- Изучил admin API: GET /admin/health, GET/POST /admin/tenants, GET/DELETE /admin/tenants/{id}
- Изучил структуры: `TenantStore`, `SyncQueues.Queues`, `Queue`, `SqsMessage`
### Реализация
1. **API endpoint** — `GET /admin/tenants/{id}/queues` в `app/admin/admin.go`:
- Ищет очереди по префиксу `AccessKey:` в `SyncQueues.Queues`
- Возвращает JSON: name, messages, not_visible, visibility_timeout, max_message_size, retention_period
- Thread-safe (RLock/RUnlock)
2. **Embedded UI** — `app/ui/embed.go` + `app/ui/index.html`:
- `go:embed index.html` → `http.FileServer(http.FS(content))`
- SPA ~400 строк HTML + CSS + JS, vanilla, без фреймворков
- Логин по admin bearer token (проверка через `/admin/health`)
- Dashboard: stats grid (tenants, queues, messages, status) + таблица тенантов
- Tenant detail: breadcrumb, stats, таблица очередей с live-данными
- CRUD тенантов: создание (модалка), удаление (confirm), показ credentials
- Auto-refresh каждые 10с, sessionStorage для авто-логина
- XSS-защита через `esc()` (textContent → innerHTML)
- Responsive: mobile-friendly grid
3. **Route** — `/ui` в `app/router/router.go`:
- `r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))`
- До SQS subrouter (не перехватывается auth middleware)
### Деплой
- Docker образ `naeel/shared-sqs:v0.1.4` — собран, запушен
- Deployment обновлён, rollout successful
- UI доступен: `https://qu.kube5s.ru/ui/`
- API проверен: health (1 tenant, 5 queues), tenant queues endpoint работает
### Коммит
- `12b3bb9` — feat(shared-sqs): add SQS Console UI (v0.1.4)
- Pushed to `sqs-operator`
+141 -141
View File
@@ -1,139 +1,139 @@
package conf package conf
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/utils" "shared-sqs/app/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/ghodss/yaml" "github.com/ghodss/yaml"
) )
var envs map[string]models.Environment var envs map[string]models.Environment
func LoadYamlConfig(filename string, env string) []string { func LoadYamlConfig(filename string, env string) []string {
ports := []string{"4100"} ports := []string{"4100"}
// Гарантируем что дефолты всегда применяются, даже если конфиг не найден // Гарантируем что дефолты всегда применяются, даже если конфиг не найден
defer applyEnvironmentDefaults() defer applyEnvironmentDefaults()
if filename == "" { if filename == "" {
root, _ := filepath.Abs(".") root, _ := filepath.Abs(".")
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if "goaws.yaml" == d.Name() { if "goaws.yaml" == d.Name() {
filename = path filename = path
} }
return nil return nil
}) })
if err != nil || filename == "" { if err != nil || filename == "" {
log.Warn("Failure to find default config file") log.Warn("Failure to find default config file")
return ports return ports
} }
} }
filename, _ = filepath.Abs(filename) filename, _ = filepath.Abs(filename)
if _, err := os.Stat(filename); err != nil { if _, err := os.Stat(filename); err != nil {
log.Warnf("Failure to find config file: %s", filename) log.Warnf("Failure to find config file: %s", filename)
return ports return ports
} }
log.Infof("Loading config file: %s", filename) log.Infof("Loading config file: %s", filename)
yamlFile, err := os.ReadFile(filename) yamlFile, err := os.ReadFile(filename)
if err != nil { if err != nil {
return ports return ports
} }
err = yaml.Unmarshal(yamlFile, &envs) err = yaml.Unmarshal(yamlFile, &envs)
if err != nil { if err != nil {
log.Errorf("err: %v\n", err) log.Errorf("err: %v\n", err)
return ports return ports
} }
if env == "" { if env == "" {
env = "Local" env = "Local"
} }
if envs[env].Region == "" { if envs[env].Region == "" {
models.CurrentEnvironment.Region = "local" models.CurrentEnvironment.Region = "local"
} }
models.CurrentEnvironment = envs[env] models.CurrentEnvironment = envs[env]
if envs[env].Port != "" { if envs[env].Port != "" {
ports = []string{envs[env].Port} ports = []string{envs[env].Port}
} }
models.LogMessages = false models.LogMessages = false
models.LogFile = "./goaws_messages.log" models.LogFile = "./goaws_messages.log"
if envs[env].LogToFile == true { if envs[env].LogToFile == true {
models.LogMessages = true models.LogMessages = true
if envs[env].LogFile != "" { if envs[env].LogFile != "" {
models.LogFile = envs[env].LogFile models.LogFile = envs[env].LogFile
} }
} }
// Дефолты применяются через defer applyEnvironmentDefaults() в начале функции // Дефолты применяются через defer applyEnvironmentDefaults() в начале функции
models.SyncQueues.Lock() models.SyncQueues.Lock()
for _, queue := range envs[env].Queues { for _, queue := range envs[env].Queues {
queueUrl := "http://" + models.CurrentEnvironment.Host + ":" + models.CurrentEnvironment.Port + queueUrl := "http://" + models.CurrentEnvironment.Host + ":" + models.CurrentEnvironment.Port +
"/" + models.CurrentEnvironment.AccountID + "/" + queue.Name "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
if models.CurrentEnvironment.Region != "" { if models.CurrentEnvironment.Region != "" {
queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" + queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" +
models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
} }
queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name
if queue.ReceiveMessageWaitTimeSeconds == 0 { if queue.ReceiveMessageWaitTimeSeconds == 0 {
queue.ReceiveMessageWaitTimeSeconds = models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds queue.ReceiveMessageWaitTimeSeconds = models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds
} }
if queue.MaximumMessageSize == 0 { if queue.MaximumMessageSize == 0 {
queue.MaximumMessageSize = models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize queue.MaximumMessageSize = models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize
} }
if queue.VisibilityTimeout == 0 { if queue.VisibilityTimeout == 0 {
queue.VisibilityTimeout = models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout queue.VisibilityTimeout = models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout
} }
if queue.MessageRetentionPeriod == 0 { if queue.MessageRetentionPeriod == 0 {
queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod
} }
models.SyncQueues.Queues[queue.Name] = &models.Queue{ models.SyncQueues.Queues[queue.Name] = &models.Queue{
Name: queue.Name, Name: queue.Name,
VisibilityTimeout: queue.VisibilityTimeout, VisibilityTimeout: queue.VisibilityTimeout,
Arn: queueArn, Arn: queueArn,
URL: queueUrl, URL: queueUrl,
ReceiveMessageWaitTimeSeconds: queue.ReceiveMessageWaitTimeSeconds, ReceiveMessageWaitTimeSeconds: queue.ReceiveMessageWaitTimeSeconds,
MaximumMessageSize: queue.MaximumMessageSize, MaximumMessageSize: queue.MaximumMessageSize,
MessageRetentionPeriod: queue.MessageRetentionPeriod, MessageRetentionPeriod: queue.MessageRetentionPeriod,
IsFIFO: utils.HasFIFOQueueName(queue.Name), IsFIFO: utils.HasFIFOQueueName(queue.Name),
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Duplicates: make(map[string]time.Time), Duplicates: make(map[string]time.Time),
} }
} }
// Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка // Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка
for _, queue := range envs[env].Queues { for _, queue := range envs[env].Queues {
q := models.SyncQueues.Queues[queue.Name] q := models.SyncQueues.Queues[queue.Name]
if queue.RedrivePolicy != "" { if queue.RedrivePolicy != "" {
err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy) err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy)
if err != nil { if err != nil {
log.Errorf("err: %s", err) log.Errorf("err: %s", err)
return ports return ports
} }
} }
} }
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
return ports return ports
} }
// applyEnvironmentDefaults — применяет дефолтные значения для QueueAttributeDefaults, // applyEnvironmentDefaults — применяет дефолтные значения для QueueAttributeDefaults,
@@ -159,38 +159,38 @@ func applyEnvironmentDefaults() {
} }
func setQueueRedrivePolicy(queues map[string]*models.Queue, q *models.Queue, strRedrivePolicy string) error { func setQueueRedrivePolicy(queues map[string]*models.Queue, q *models.Queue, strRedrivePolicy string) error {
// Поддерживаем maxReceiveCount как int и как string (AWS SDK использует string) // Поддерживаем maxReceiveCount как int и как string (AWS SDK использует string)
redrivePolicy1 := struct { redrivePolicy1 := struct {
MaxReceiveCount int `json:"maxReceiveCount"` MaxReceiveCount int `json:"maxReceiveCount"`
DeadLetterTargetArn string `json:"deadLetterTargetArn"` DeadLetterTargetArn string `json:"deadLetterTargetArn"`
}{} }{}
redrivePolicy2 := struct { redrivePolicy2 := struct {
MaxReceiveCount string `json:"maxReceiveCount"` MaxReceiveCount string `json:"maxReceiveCount"`
DeadLetterTargetArn string `json:"deadLetterTargetArn"` DeadLetterTargetArn string `json:"deadLetterTargetArn"`
}{} }{}
err1 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy1) err1 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy1)
err2 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy2) err2 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy2)
maxReceiveCount := redrivePolicy1.MaxReceiveCount maxReceiveCount := redrivePolicy1.MaxReceiveCount
deadLetterQueueArn := redrivePolicy1.DeadLetterTargetArn deadLetterQueueArn := redrivePolicy1.DeadLetterTargetArn
if err1 != nil && err2 != nil { if err1 != nil && err2 != nil {
return fmt.Errorf("invalid json for queue redrive policy ") return fmt.Errorf("invalid json for queue redrive policy ")
} else if err1 != nil { } else if err1 != nil {
maxReceiveCount, _ = strconv.Atoi(redrivePolicy2.MaxReceiveCount) maxReceiveCount, _ = strconv.Atoi(redrivePolicy2.MaxReceiveCount)
deadLetterQueueArn = redrivePolicy2.DeadLetterTargetArn deadLetterQueueArn = redrivePolicy2.DeadLetterTargetArn
} }
if (deadLetterQueueArn != "" && maxReceiveCount == 0) || if (deadLetterQueueArn != "" && maxReceiveCount == 0) ||
(deadLetterQueueArn == "" && maxReceiveCount != 0) { (deadLetterQueueArn == "" && maxReceiveCount != 0) {
return fmt.Errorf("invalid redrive policy values") return fmt.Errorf("invalid redrive policy values")
} }
dlt := strings.Split(deadLetterQueueArn, ":") dlt := strings.Split(deadLetterQueueArn, ":")
deadLetterQueueName := dlt[len(dlt)-1] deadLetterQueueName := dlt[len(dlt)-1]
deadLetterQueue, ok := queues[deadLetterQueueName] deadLetterQueue, ok := queues[deadLetterQueueName]
if !ok { if !ok {
return fmt.Errorf("deadletter queue not found") return fmt.Errorf("deadletter queue not found")
} }
q.DeadLetterQueue = deadLetterQueue q.DeadLetterQueue = deadLetterQueue
q.MaxReceiveCount = maxReceiveCount q.MaxReceiveCount = maxReceiveCount
return nil return nil
} }
+2 -2
View File
@@ -17,9 +17,9 @@ func init() {
"InvalidParameterValue": {HttpError: http.StatusBadRequest, Type: "InvalidParameterValue", Code: "AWS.SimpleQueueService.InvalidParameterValue", Message: "An invalid or out-of-range value was supplied for the input parameter."}, "InvalidParameterValue": {HttpError: http.StatusBadRequest, Type: "InvalidParameterValue", Code: "AWS.SimpleQueueService.InvalidParameterValue", Message: "An invalid or out-of-range value was supplied for the input parameter."},
"InvalidAttributeValue": {HttpError: http.StatusBadRequest, Type: "InvalidAttributeValue", Code: "AWS.SimpleQueueService.InvalidAttributeValue", Message: "Invalid Value for the parameter RedrivePolicy."}, "InvalidAttributeValue": {HttpError: http.StatusBadRequest, Type: "InvalidAttributeValue", Code: "AWS.SimpleQueueService.InvalidAttributeValue", Message: "Invalid Value for the parameter RedrivePolicy."},
// InvalidClientTokenId — невалидные credentials тенанта // InvalidClientTokenId — невалидные credentials тенанта
"InvalidClientTokenId": {HttpError: http.StatusForbidden, Type: "InvalidClientTokenId", Code: "AWS.SimpleQueueService.InvalidClientTokenId", Message: "The security token included in the request is invalid."}, "InvalidClientTokenId": {HttpError: http.StatusForbidden, Type: "InvalidClientTokenId", Code: "AWS.SimpleQueueService.InvalidClientTokenId", Message: "The security token included in the request is invalid."},
// ValidationError — ошибка валидации параметров (например, VisibilityTimeout вне диапазона) // ValidationError — ошибка валидации параметров (например, VisibilityTimeout вне диапазона)
"ValidationError": {HttpError: http.StatusBadRequest, Type: "ValidationError", Code: "AWS.SimpleQueueService.ValidationError", Message: "The input fails to satisfy the constraints specified by an AWS service."}, "ValidationError": {HttpError: http.StatusBadRequest, Type: "ValidationError", Code: "AWS.SimpleQueueService.ValidationError", Message: "The input fails to satisfy the constraints specified by an AWS service."},
// LimitExceeded — превышен лимит очередей тенанта (max_queues) // LimitExceeded — превышен лимит очередей тенанта (max_queues)
"LimitExceeded": {HttpError: http.StatusBadRequest, Type: "LimitExceeded", Code: "AWS.SimpleQueueService.LimitExceeded", Message: "You've reached the limit on the number of queues."}, "LimitExceeded": {HttpError: http.StatusBadRequest, Type: "LimitExceeded", Code: "AWS.SimpleQueueService.LimitExceeded", Message: "You've reached the limit on the number of queues."},
} }