docs(thinking): update session log with UI deployment and shared-SQS repo creation
This commit is contained in:
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user