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
Остался Этап 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
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
}