diff --git a/doc/thinking/2026-04-09.md b/doc/thinking/2026-04-09.md index e834ada..27fa75c 100644 --- a/doc/thinking/2026-04-09.md +++ b/doc/thinking/2026-04-09.md @@ -242,3 +242,48 @@ go build → OK (все этапы 1-8) - 2c9a2b2 — Этапы 7+8 Остался Этап 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` diff --git a/shared-sqs/app/conf/config.go b/shared-sqs/app/conf/config.go index 804a4b6..fa02bea 100644 --- a/shared-sqs/app/conf/config.go +++ b/shared-sqs/app/conf/config.go @@ -1,139 +1,139 @@ package conf import ( -"encoding/json" -"fmt" -"io/fs" -"os" -"path/filepath" -"strconv" -"strings" -"time" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "time" -"shared-sqs/app/models" -"shared-sqs/app/utils" + "shared-sqs/app/models" + "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 func LoadYamlConfig(filename string, env string) []string { -ports := []string{"4100"} + ports := []string{"4100"} -// Гарантируем что дефолты всегда применяются, даже если конфиг не найден -defer applyEnvironmentDefaults() + // Гарантируем что дефолты всегда применяются, даже если конфиг не найден + defer applyEnvironmentDefaults() -if filename == "" { -root, _ := filepath.Abs(".") -err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { -if "goaws.yaml" == d.Name() { -filename = path -} -return nil -}) -if err != nil || filename == "" { -log.Warn("Failure to find default config file") -return ports -} -} + if filename == "" { + root, _ := filepath.Abs(".") + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if "goaws.yaml" == d.Name() { + filename = path + } + return nil + }) + if err != nil || filename == "" { + log.Warn("Failure to find default config file") + return ports + } + } -filename, _ = filepath.Abs(filename) -if _, err := os.Stat(filename); err != nil { -log.Warnf("Failure to find config file: %s", filename) -return ports -} + filename, _ = filepath.Abs(filename) + if _, err := os.Stat(filename); err != nil { + log.Warnf("Failure to find config file: %s", filename) + return ports + } -log.Infof("Loading config file: %s", filename) -yamlFile, err := os.ReadFile(filename) -if err != nil { -return ports -} + log.Infof("Loading config file: %s", filename) + yamlFile, err := os.ReadFile(filename) + if err != nil { + return ports + } -err = yaml.Unmarshal(yamlFile, &envs) -if err != nil { -log.Errorf("err: %v\n", err) -return ports -} -if env == "" { -env = "Local" -} + err = yaml.Unmarshal(yamlFile, &envs) + if err != nil { + log.Errorf("err: %v\n", err) + return ports + } + if env == "" { + env = "Local" + } -if envs[env].Region == "" { -models.CurrentEnvironment.Region = "local" -} + if envs[env].Region == "" { + models.CurrentEnvironment.Region = "local" + } -models.CurrentEnvironment = envs[env] + models.CurrentEnvironment = envs[env] -if envs[env].Port != "" { -ports = []string{envs[env].Port} -} + if envs[env].Port != "" { + ports = []string{envs[env].Port} + } -models.LogMessages = false -models.LogFile = "./goaws_messages.log" -if envs[env].LogToFile == true { -models.LogMessages = true -if envs[env].LogFile != "" { -models.LogFile = envs[env].LogFile -} -} + models.LogMessages = false + models.LogFile = "./goaws_messages.log" + if envs[env].LogToFile == true { + models.LogMessages = true + if envs[env].LogFile != "" { + models.LogFile = envs[env].LogFile + } + } -// Дефолты применяются через defer applyEnvironmentDefaults() в начале функции + // Дефолты применяются через defer applyEnvironmentDefaults() в начале функции -models.SyncQueues.Lock() -for _, queue := range envs[env].Queues { -queueUrl := "http://" + models.CurrentEnvironment.Host + ":" + models.CurrentEnvironment.Port + -"/" + models.CurrentEnvironment.AccountID + "/" + queue.Name -if models.CurrentEnvironment.Region != "" { -queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" + -models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name -} -queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name + models.SyncQueues.Lock() + for _, queue := range envs[env].Queues { + queueUrl := "http://" + models.CurrentEnvironment.Host + ":" + models.CurrentEnvironment.Port + + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name + if models.CurrentEnvironment.Region != "" { + queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" + + models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name + } + queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name -if queue.ReceiveMessageWaitTimeSeconds == 0 { -queue.ReceiveMessageWaitTimeSeconds = models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds -} -if queue.MaximumMessageSize == 0 { -queue.MaximumMessageSize = models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize -} -if queue.VisibilityTimeout == 0 { -queue.VisibilityTimeout = models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout -} -if queue.MessageRetentionPeriod == 0 { -queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod -} + if queue.ReceiveMessageWaitTimeSeconds == 0 { + queue.ReceiveMessageWaitTimeSeconds = models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds + } + if queue.MaximumMessageSize == 0 { + queue.MaximumMessageSize = models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize + } + if queue.VisibilityTimeout == 0 { + queue.VisibilityTimeout = models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout + } + if queue.MessageRetentionPeriod == 0 { + queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod + } -models.SyncQueues.Queues[queue.Name] = &models.Queue{ -Name: queue.Name, -VisibilityTimeout: queue.VisibilityTimeout, -Arn: queueArn, -URL: queueUrl, -ReceiveMessageWaitTimeSeconds: queue.ReceiveMessageWaitTimeSeconds, -MaximumMessageSize: queue.MaximumMessageSize, -MessageRetentionPeriod: queue.MessageRetentionPeriod, -IsFIFO: utils.HasFIFOQueueName(queue.Name), -EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, -Duplicates: make(map[string]time.Time), -} -} + models.SyncQueues.Queues[queue.Name] = &models.Queue{ + Name: queue.Name, + VisibilityTimeout: queue.VisibilityTimeout, + Arn: queueArn, + URL: queueUrl, + ReceiveMessageWaitTimeSeconds: queue.ReceiveMessageWaitTimeSeconds, + MaximumMessageSize: queue.MaximumMessageSize, + MessageRetentionPeriod: queue.MessageRetentionPeriod, + IsFIFO: utils.HasFIFOQueueName(queue.Name), + EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, + Duplicates: make(map[string]time.Time), + } + } -// Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка -for _, queue := range envs[env].Queues { -q := models.SyncQueues.Queues[queue.Name] -if queue.RedrivePolicy != "" { -err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy) -if err != nil { -log.Errorf("err: %s", err) -return ports -} -} -} + // Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка + for _, queue := range envs[env].Queues { + q := models.SyncQueues.Queues[queue.Name] + if queue.RedrivePolicy != "" { + err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy) + if err != nil { + log.Errorf("err: %s", err) + return ports + } + } + } -models.SyncQueues.Unlock() + models.SyncQueues.Unlock() -return ports + return ports } // applyEnvironmentDefaults — применяет дефолтные значения для QueueAttributeDefaults, @@ -159,38 +159,38 @@ func applyEnvironmentDefaults() { } func setQueueRedrivePolicy(queues map[string]*models.Queue, q *models.Queue, strRedrivePolicy string) error { -// Поддерживаем maxReceiveCount как int и как string (AWS SDK использует string) -redrivePolicy1 := struct { -MaxReceiveCount int `json:"maxReceiveCount"` -DeadLetterTargetArn string `json:"deadLetterTargetArn"` -}{} -redrivePolicy2 := struct { -MaxReceiveCount string `json:"maxReceiveCount"` -DeadLetterTargetArn string `json:"deadLetterTargetArn"` -}{} -err1 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy1) -err2 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy2) -maxReceiveCount := redrivePolicy1.MaxReceiveCount -deadLetterQueueArn := redrivePolicy1.DeadLetterTargetArn -if err1 != nil && err2 != nil { -return fmt.Errorf("invalid json for queue redrive policy ") -} else if err1 != nil { -maxReceiveCount, _ = strconv.Atoi(redrivePolicy2.MaxReceiveCount) -deadLetterQueueArn = redrivePolicy2.DeadLetterTargetArn -} + // Поддерживаем maxReceiveCount как int и как string (AWS SDK использует string) + redrivePolicy1 := struct { + MaxReceiveCount int `json:"maxReceiveCount"` + DeadLetterTargetArn string `json:"deadLetterTargetArn"` + }{} + redrivePolicy2 := struct { + MaxReceiveCount string `json:"maxReceiveCount"` + DeadLetterTargetArn string `json:"deadLetterTargetArn"` + }{} + err1 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy1) + err2 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy2) + maxReceiveCount := redrivePolicy1.MaxReceiveCount + deadLetterQueueArn := redrivePolicy1.DeadLetterTargetArn + if err1 != nil && err2 != nil { + return fmt.Errorf("invalid json for queue redrive policy ") + } else if err1 != nil { + maxReceiveCount, _ = strconv.Atoi(redrivePolicy2.MaxReceiveCount) + deadLetterQueueArn = redrivePolicy2.DeadLetterTargetArn + } -if (deadLetterQueueArn != "" && maxReceiveCount == 0) || -(deadLetterQueueArn == "" && maxReceiveCount != 0) { -return fmt.Errorf("invalid redrive policy values") -} -dlt := strings.Split(deadLetterQueueArn, ":") -deadLetterQueueName := dlt[len(dlt)-1] -deadLetterQueue, ok := queues[deadLetterQueueName] -if !ok { -return fmt.Errorf("deadletter queue not found") -} -q.DeadLetterQueue = deadLetterQueue -q.MaxReceiveCount = maxReceiveCount + if (deadLetterQueueArn != "" && maxReceiveCount == 0) || + (deadLetterQueueArn == "" && maxReceiveCount != 0) { + return fmt.Errorf("invalid redrive policy values") + } + dlt := strings.Split(deadLetterQueueArn, ":") + deadLetterQueueName := dlt[len(dlt)-1] + deadLetterQueue, ok := queues[deadLetterQueueName] + if !ok { + return fmt.Errorf("deadletter queue not found") + } + q.DeadLetterQueue = deadLetterQueue + q.MaxReceiveCount = maxReceiveCount -return nil + return nil } diff --git a/shared-sqs/app/models/errors.go b/shared-sqs/app/models/errors.go index b3f9774..a0792c3 100644 --- a/shared-sqs/app/models/errors.go +++ b/shared-sqs/app/models/errors.go @@ -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."}, "InvalidAttributeValue": {HttpError: http.StatusBadRequest, Type: "InvalidAttributeValue", Code: "AWS.SimpleQueueService.InvalidAttributeValue", Message: "Invalid Value for the parameter RedrivePolicy."}, // 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": {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": {HttpError: http.StatusBadRequest, Type: "LimitExceeded", Code: "AWS.SimpleQueueService.LimitExceeded", Message: "You've reached the limit on the number of queues."}, }