Skip to content

Commit 38384f5

Browse files
authored
feat(sqs): add dashboard management UI (#13)
1 parent 9ac9723 commit 38384f5

22 files changed

Lines changed: 1760 additions & 113 deletions

File tree

.agents/orbit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@
1616
| 2026-05-06 | Orbit | split DynamoDB dashboard advanced UI tasks into a Codex autoloop | `scripts/dynamodb-dashboard-advanced-autoloop/`, `.gitignore` | ready for pagination, saved/recent operations, table wizard, validation, delete confirmation, e2e, docs, and full-advanced-ui gates |
1717
| 2026-05-06 | Orbit | split GCS React dashboard integration into a Codex autoloop | `scripts/gcs-dashboard-react-autoloop/`, `.gitignore` | ready for React route, GCS inspection, guarded management, e2e, docs, and full-react-gcs gates |
1818
| 2026-05-06 | Orbit | split BigQuery dashboard management UI into a Codex autoloop | `scripts/bigquery-dashboard-management-autoloop/`, `.gitignore` | ready for query runner, dataset/table creation, row insert, job detail, validation, e2e, docs, and full-management-ui gates |
19+
| 2026-05-06 | Orbit | split SQS dashboard management UI into a Codex autoloop | `scripts/sqs-dashboard-management-autoloop/`, `.gitignore` | ready for queue creation, send/receive/delete, visibility, purge, DLQ, e2e, docs, and full-management-ui gates |

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ scripts/sqs-autoloop/progress.md
9999
scripts/sqs-autoloop/done.md
100100
scripts/sqs-autoloop/iteration-*.out
101101
scripts/sqs-autoloop/prompt-*.*
102+
scripts/sqs-dashboard-management-autoloop/.run-loop.lock
103+
scripts/sqs-dashboard-management-autoloop/.circuit-state
104+
scripts/sqs-dashboard-management-autoloop/runner.jsonl
105+
scripts/sqs-dashboard-management-autoloop/runner.log
106+
scripts/sqs-dashboard-management-autoloop/state.env
107+
scripts/sqs-dashboard-management-autoloop/state.env.sha256
108+
scripts/sqs-dashboard-management-autoloop/progress.md
109+
scripts/sqs-dashboard-management-autoloop/done.md
110+
scripts/sqs-dashboard-management-autoloop/iteration-*.out
111+
scripts/sqs-dashboard-management-autoloop/verify-*.out
112+
scripts/sqs-dashboard-management-autoloop/prompt-*.*
102113
scripts/pubsub-autoloop/.run-loop.lock
103114
scripts/pubsub-autoloop/.circuit-state
104115
scripts/pubsub-autoloop/runner.jsonl

internal/dashboard/assets/react/assets/index-BGtXVHIp.js

Lines changed: 0 additions & 96 deletions
This file was deleted.

internal/dashboard/assets/react/assets/index-BxS7z6Cv.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/dashboard/assets/react/assets/index-CFkAsiRY.css

Lines changed: 0 additions & 1 deletion
This file was deleted.

internal/dashboard/assets/react/assets/index-CQKKPOyV.js

Lines changed: 96 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/dashboard/assets/react/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>devcloud Dashboard</title>
7-
<script type="module" crossorigin src="/dashboard/assets/index-BGtXVHIp.js"></script>
8-
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-CFkAsiRY.css">
7+
<script type="module" crossorigin src="/dashboard/assets/index-CQKKPOyV.js"></script>
8+
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-BxS7z6Cv.css">
99
</head>
1010
<body>
1111
<div id="root"></div>

internal/dashboard/server.go

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -556,14 +556,18 @@ func (s *Server) handleSQSStatus(w http.ResponseWriter, r *http.Request) {
556556
}
557557

558558
func (s *Server) handleSQSQueues(w http.ResponseWriter, r *http.Request) {
559-
if r.Method != http.MethodGet {
560-
methodNotAllowed(w, "GET")
559+
if r.Method != http.MethodGet && r.Method != http.MethodPost {
560+
methodNotAllowed(w, "GET, POST")
561561
return
562562
}
563563
if s.sqs == nil {
564564
http.Error(w, "sqs service is disabled", http.StatusServiceUnavailable)
565565
return
566566
}
567+
if r.Method == http.MethodPost {
568+
s.forwardSQSDashboardOperation(w, r, "CreateQueue", "", "")
569+
return
570+
}
567571
snapshot := s.sqs.Snapshot()
568572
writeJSON(w, map[string]any{
569573
"queues": snapshot.Queues,
@@ -602,14 +606,24 @@ func (s *Server) handleSQSQueue(w http.ResponseWriter, r *http.Request) {
602606
}
603607
switch parts[1] {
604608
case "messages":
605-
if r.Method != http.MethodGet {
606-
methodNotAllowed(w, "GET")
609+
if r.Method != http.MethodGet && r.Method != http.MethodPost {
610+
methodNotAllowed(w, "GET, POST")
611+
return
612+
}
613+
if r.Method == http.MethodPost {
614+
s.forwardSQSDashboardOperation(w, r, "SendMessage", queueName, detail.Queue.URL)
607615
return
608616
}
609617
writeJSON(w, map[string]any{
610618
"queueName": queueName,
611619
"messages": detail.Messages,
612620
})
621+
case "receive":
622+
s.forwardSQSDashboardOperation(w, r, "ReceiveMessage", queueName, detail.Queue.URL)
623+
case "delete":
624+
s.forwardSQSDashboardOperation(w, r, "DeleteMessage", queueName, detail.Queue.URL)
625+
case "visibility":
626+
s.forwardSQSDashboardOperation(w, r, "ChangeMessageVisibility", queueName, detail.Queue.URL)
613627
case "leases":
614628
if r.Method != http.MethodGet {
615629
methodNotAllowed(w, "GET")
@@ -649,6 +663,71 @@ func (s *Server) handleSQSQueue(w http.ResponseWriter, r *http.Request) {
649663
}
650664
}
651665

666+
type dashboardSQSOperationRequest struct {
667+
Input json.RawMessage `json:"input"`
668+
}
669+
670+
func (s *Server) forwardSQSDashboardOperation(w http.ResponseWriter, r *http.Request, operation string, queueName string, queueURL string) {
671+
if r.Method != http.MethodPost {
672+
methodNotAllowed(w, "POST")
673+
return
674+
}
675+
var request dashboardSQSOperationRequest
676+
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
677+
http.Error(w, "invalid json request", http.StatusBadRequest)
678+
return
679+
}
680+
input, err := normalizeSQSDashboardInput(request.Input, queueName, queueURL)
681+
if err != nil {
682+
http.Error(w, err.Error(), http.StatusBadRequest)
683+
return
684+
}
685+
req := r.Clone(r.Context())
686+
req.Method = http.MethodPost
687+
req.URL = &url.URL{Path: "/"}
688+
req.RequestURI = ""
689+
req.Body = io.NopCloser(bytes.NewReader(input))
690+
req.ContentLength = int64(len(input))
691+
req.Header = make(http.Header)
692+
req.Header.Set("Content-Type", "application/x-amz-json-1.0")
693+
req.Header.Set("X-Amz-Target", "AmazonSQS."+operation)
694+
s.sqs.ServeHTTP(w, req)
695+
}
696+
697+
func normalizeSQSDashboardInput(raw json.RawMessage, queueName string, queueURL string) ([]byte, error) {
698+
if len(raw) == 0 {
699+
return nil, errors.New("input is required")
700+
}
701+
var input map[string]any
702+
if err := json.Unmarshal(raw, &input); err != nil {
703+
return nil, errors.New("input must be valid JSON")
704+
}
705+
if input == nil {
706+
return nil, errors.New("input must be a JSON object")
707+
}
708+
if queueName != "" {
709+
if existing, ok := input["QueueName"]; ok {
710+
if existingName, ok := existing.(string); !ok || existingName != queueName {
711+
return nil, errors.New("input QueueName must match the selected queue")
712+
}
713+
}
714+
}
715+
if queueURL != "" {
716+
if existing, ok := input["QueueUrl"]; ok {
717+
if existingURL, ok := existing.(string); !ok || existingURL != queueURL {
718+
return nil, errors.New("input QueueUrl must match the selected queue")
719+
}
720+
} else {
721+
input["QueueUrl"] = queueURL
722+
}
723+
}
724+
encoded, err := json.Marshal(input)
725+
if err != nil {
726+
return nil, errors.New("input could not be encoded")
727+
}
728+
return encoded, nil
729+
}
730+
652731
func (s *Server) handlePubSubStatus(w http.ResponseWriter, r *http.Request) {
653732
if r.Method != http.MethodGet {
654733
methodNotAllowed(w, "GET")

internal/dashboard/server_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,177 @@ func TestSQSQueueDetailAPIsExposeMessagesLeasesAndPurge(t *testing.T) {
331331
}
332332
}
333333

334+
func TestSQSDashboardManagementAPIsCreateQueueAndSendMessage(t *testing.T) {
335+
sqsServer := sqssvc.NewServer(sqssvc.Config{Addr: "127.0.0.1:9324"})
336+
server := NewServer(Config{}, newDashboardStore(nil, nil))
337+
server.SetSQS(sqsServer)
338+
routes := server.routes()
339+
340+
createRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues", `{
341+
"input":{
342+
"QueueName":"dashboard-managed.fifo",
343+
"Attributes":{"FifoQueue":"true","ContentBasedDeduplication":"true","VisibilityTimeout":"30"},
344+
"Tags":{"source":"dashboard"}
345+
}
346+
}`)
347+
if createRec.Code != http.StatusOK {
348+
t.Fatalf("create status = %d, body = %s", createRec.Code, createRec.Body.String())
349+
}
350+
if !strings.Contains(createRec.Body.String(), `"QueueUrl"`) || !strings.Contains(createRec.Body.String(), "dashboard-managed.fifo") {
351+
t.Fatalf("create body = %s", createRec.Body.String())
352+
}
353+
354+
sendRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-managed.fifo/messages", `{
355+
"input":{
356+
"MessageBody":"dashboard send",
357+
"MessageGroupId":"workers",
358+
"MessageAttributes":{"kind":{"DataType":"String","StringValue":"test"}}
359+
}
360+
}`)
361+
if sendRec.Code != http.StatusOK {
362+
t.Fatalf("send status = %d, body = %s", sendRec.Code, sendRec.Body.String())
363+
}
364+
if !strings.Contains(sendRec.Body.String(), `"MessageId"`) || !strings.Contains(sendRec.Body.String(), `"MD5OfMessageAttributes"`) {
365+
t.Fatalf("send body = %s", sendRec.Body.String())
366+
}
367+
368+
messagesRec := performRequest(routes, http.MethodGet, "/api/sqs/queues/dashboard-managed.fifo/messages")
369+
if messagesRec.Code != http.StatusOK {
370+
t.Fatalf("messages status = %d, body = %s", messagesRec.Code, messagesRec.Body.String())
371+
}
372+
if !strings.Contains(messagesRec.Body.String(), `"body":"dashboard send"`) || !strings.Contains(messagesRec.Body.String(), `"messageGroupId":"workers"`) {
373+
t.Fatalf("messages body = %s", messagesRec.Body.String())
374+
}
375+
}
376+
377+
func TestSQSDashboardSendMessageRejectsMismatchedQueueURL(t *testing.T) {
378+
sqsServer := sqssvc.NewServer(sqssvc.Config{Addr: "127.0.0.1:9324"})
379+
server := NewServer(Config{}, newDashboardStore(nil, nil))
380+
server.SetSQS(sqsServer)
381+
routes := server.routes()
382+
383+
createRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues", `{"input":{"QueueName":"dashboard-safe"}}`)
384+
if createRec.Code != http.StatusOK {
385+
t.Fatalf("create status = %d, body = %s", createRec.Code, createRec.Body.String())
386+
}
387+
388+
sendRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-safe/messages", `{
389+
"input":{"QueueUrl":"http://127.0.0.1:9324/000000000000/other","MessageBody":"wrong queue"}
390+
}`)
391+
if sendRec.Code != http.StatusBadRequest {
392+
t.Fatalf("send status = %d, want %d, body = %s", sendRec.Code, http.StatusBadRequest, sendRec.Body.String())
393+
}
394+
if !strings.Contains(sendRec.Body.String(), "QueueUrl must match") {
395+
t.Fatalf("send body = %s", sendRec.Body.String())
396+
}
397+
}
398+
399+
func TestSQSDashboardManagementAPIsReceiveAndDeleteMessage(t *testing.T) {
400+
sqsServer := sqssvc.NewServer(sqssvc.Config{Addr: "127.0.0.1:9324"})
401+
server := NewServer(Config{}, newDashboardStore(nil, nil))
402+
server.SetSQS(sqsServer)
403+
routes := server.routes()
404+
405+
createRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues", `{"input":{"QueueName":"dashboard-receive"}}`)
406+
if createRec.Code != http.StatusOK {
407+
t.Fatalf("create status = %d, body = %s", createRec.Code, createRec.Body.String())
408+
}
409+
sendRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-receive/messages", `{
410+
"input":{"MessageBody":"dashboard receive","MessageAttributes":{"kind":{"DataType":"String","StringValue":"test"}}}
411+
}`)
412+
if sendRec.Code != http.StatusOK {
413+
t.Fatalf("send status = %d, body = %s", sendRec.Code, sendRec.Body.String())
414+
}
415+
416+
receiveRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-receive/receive", `{
417+
"input":{"MaxNumberOfMessages":1,"VisibilityTimeout":30,"WaitTimeSeconds":0,"AttributeNames":["All"],"MessageAttributeNames":["All"]}
418+
}`)
419+
if receiveRec.Code != http.StatusOK {
420+
t.Fatalf("receive status = %d, body = %s", receiveRec.Code, receiveRec.Body.String())
421+
}
422+
var receiveBody struct {
423+
Messages []struct {
424+
MessageID string `json:"MessageId"`
425+
ReceiptHandle string `json:"ReceiptHandle"`
426+
Body string `json:"Body"`
427+
} `json:"Messages"`
428+
}
429+
if err := json.Unmarshal(receiveRec.Body.Bytes(), &receiveBody); err != nil {
430+
t.Fatalf("decode receive body: %v", err)
431+
}
432+
if len(receiveBody.Messages) != 1 || receiveBody.Messages[0].ReceiptHandle == "" || receiveBody.Messages[0].Body != "dashboard receive" {
433+
t.Fatalf("receive body = %s", receiveRec.Body.String())
434+
}
435+
436+
deleteRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-receive/delete", `{
437+
"input":{"ReceiptHandle":"`+receiveBody.Messages[0].ReceiptHandle+`"}
438+
}`)
439+
if deleteRec.Code != http.StatusOK {
440+
t.Fatalf("delete status = %d, body = %s", deleteRec.Code, deleteRec.Body.String())
441+
}
442+
443+
afterDeleteRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-receive/receive", `{
444+
"input":{"MaxNumberOfMessages":1,"WaitTimeSeconds":0}
445+
}`)
446+
if afterDeleteRec.Code != http.StatusOK {
447+
t.Fatalf("after delete status = %d, body = %s", afterDeleteRec.Code, afterDeleteRec.Body.String())
448+
}
449+
if strings.Contains(afterDeleteRec.Body.String(), "dashboard receive") {
450+
t.Fatalf("message was not deleted: %s", afterDeleteRec.Body.String())
451+
}
452+
}
453+
454+
func TestSQSDashboardManagementAPIsChangeMessageVisibility(t *testing.T) {
455+
sqsServer := sqssvc.NewServer(sqssvc.Config{Addr: "127.0.0.1:9324"})
456+
server := NewServer(Config{}, newDashboardStore(nil, nil))
457+
server.SetSQS(sqsServer)
458+
routes := server.routes()
459+
460+
createRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues", `{"input":{"QueueName":"dashboard-visibility"}}`)
461+
if createRec.Code != http.StatusOK {
462+
t.Fatalf("create status = %d, body = %s", createRec.Code, createRec.Body.String())
463+
}
464+
sendRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-visibility/messages", `{
465+
"input":{"MessageBody":"dashboard visibility"}
466+
}`)
467+
if sendRec.Code != http.StatusOK {
468+
t.Fatalf("send status = %d, body = %s", sendRec.Code, sendRec.Body.String())
469+
}
470+
receiveRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-visibility/receive", `{
471+
"input":{"MaxNumberOfMessages":1,"VisibilityTimeout":30,"WaitTimeSeconds":0}
472+
}`)
473+
if receiveRec.Code != http.StatusOK {
474+
t.Fatalf("receive status = %d, body = %s", receiveRec.Code, receiveRec.Body.String())
475+
}
476+
var receiveBody struct {
477+
Messages []struct {
478+
ReceiptHandle string `json:"ReceiptHandle"`
479+
} `json:"Messages"`
480+
}
481+
if err := json.Unmarshal(receiveRec.Body.Bytes(), &receiveBody); err != nil {
482+
t.Fatalf("decode receive body: %v", err)
483+
}
484+
if len(receiveBody.Messages) != 1 || receiveBody.Messages[0].ReceiptHandle == "" {
485+
t.Fatalf("receive body = %s", receiveRec.Body.String())
486+
}
487+
488+
changeRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-visibility/visibility", `{
489+
"input":{"ReceiptHandle":"`+receiveBody.Messages[0].ReceiptHandle+`","VisibilityTimeout":0}
490+
}`)
491+
if changeRec.Code != http.StatusOK {
492+
t.Fatalf("change visibility status = %d, body = %s", changeRec.Code, changeRec.Body.String())
493+
}
494+
againRec := performRequestWithBody(routes, http.MethodPost, "/api/sqs/queues/dashboard-visibility/receive", `{
495+
"input":{"MaxNumberOfMessages":1,"WaitTimeSeconds":0}
496+
}`)
497+
if againRec.Code != http.StatusOK {
498+
t.Fatalf("receive again status = %d, body = %s", againRec.Code, againRec.Body.String())
499+
}
500+
if !strings.Contains(againRec.Body.String(), "dashboard visibility") {
501+
t.Fatalf("message was not made visible: %s", againRec.Body.String())
502+
}
503+
}
504+
334505
func TestSQSQueuesAPIMarksDisabled(t *testing.T) {
335506
server := NewServer(Config{}, newDashboardStore(nil, nil))
336507

internal/services/sqs/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ type Server struct {
5353
}
5454

5555
func NewServer(cfg Config) *Server {
56+
if storagePath := strings.TrimSpace(cfg.StoragePath); storagePath != "" {
57+
if absolutePath, err := filepath.Abs(storagePath); err == nil {
58+
cfg.StoragePath = absolutePath
59+
}
60+
}
5661
server := &Server{
5762
config: cfg,
5863
queues: map[string]*queueState{},

0 commit comments

Comments
 (0)