sqs-browse: Added dynamo-browse
Added another tool for browsing DynamoDB tables
This commit is contained in:
parent
2c03f5160a
commit
1969504611
14 changed files with 477 additions and 4 deletions
10
internal/dynamo-browse/models/models.go
Normal file
10
internal/dynamo-browse/models/models.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package models
|
||||
|
||||
import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
|
||||
type ResultSet struct {
|
||||
Columns []string
|
||||
Items []Item
|
||||
}
|
||||
|
||||
type Item map[string]types.AttributeValue
|
||||
33
internal/dynamo-browse/providers/dynamo/provider.go
Normal file
33
internal/dynamo-browse/providers/dynamo/provider.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package dynamo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
client *dynamodb.Client
|
||||
}
|
||||
|
||||
func NewProvider(client *dynamodb.Client) *Provider {
|
||||
return &Provider{client: client}
|
||||
}
|
||||
|
||||
func (p *Provider) ScanItems(ctx context.Context, tableName string) ([]models.Item, error) {
|
||||
res, err := p.client.Scan(ctx, &dynamodb.ScanInput{
|
||||
TableName: aws.String(tableName),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName)
|
||||
}
|
||||
|
||||
items := make([]models.Item, len(res.Items))
|
||||
for i, itm := range res.Items {
|
||||
items[i] = itm
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
10
internal/dynamo-browse/services/tables/iface.go
Normal file
10
internal/dynamo-browse/services/tables/iface.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/models"
|
||||
)
|
||||
|
||||
type TableProvider interface {
|
||||
ScanItems(ctx context.Context, tableName string) ([]models.Item, error)
|
||||
}
|
||||
52
internal/dynamo-browse/services/tables/service.go
Normal file
52
internal/dynamo-browse/services/tables/service.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
provider TableProvider
|
||||
}
|
||||
|
||||
func NewService(provider TableProvider) *Service {
|
||||
return &Service{
|
||||
provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Scan(ctx context.Context, table string) (*models.ResultSet, error) {
|
||||
results, err := s.provider.ScanItems(ctx, table)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "unable to scan table %v", table)
|
||||
}
|
||||
|
||||
// Get the columns
|
||||
// TODO: need to get PKs and SKs from table
|
||||
seenColumns := make(map[string]int)
|
||||
seenColumns["pk"] = 0
|
||||
seenColumns["sk"] = 1
|
||||
|
||||
for _, result := range results {
|
||||
for k := range result {
|
||||
if _, isSeen := seenColumns[k]; !isSeen {
|
||||
seenColumns[k] = len(seenColumns)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
columns := make([]string, 0, len(seenColumns))
|
||||
for k := range seenColumns {
|
||||
columns = append(columns, k)
|
||||
}
|
||||
sort.Slice(columns, func(i, j int) bool {
|
||||
return seenColumns[columns[i]] < seenColumns[columns[j]]
|
||||
})
|
||||
|
||||
return &models.ResultSet{
|
||||
Columns: columns,
|
||||
Items: results,
|
||||
}, nil
|
||||
}
|
||||
10
internal/dynamo-browse/ui/events.go
Normal file
10
internal/dynamo-browse/ui/events.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package ui
|
||||
|
||||
import "github.com/lmika/awstools/internal/dynamo-browse/models"
|
||||
|
||||
type newResultSet struct {
|
||||
ResultSet *models.ResultSet
|
||||
}
|
||||
|
||||
type setStatusMessage string
|
||||
type errorRaised error
|
||||
7
internal/dynamo-browse/ui/iface.go
Normal file
7
internal/dynamo-browse/ui/iface.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package ui
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type MessagePublisher interface {
|
||||
Send(msg tea.Msg)
|
||||
}
|
||||
196
internal/dynamo-browse/ui/model.go
Normal file
196
internal/dynamo-browse/ui/model.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
table "github.com/calyptia/go-bubble-table"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/models"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
|
||||
"log"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
type uiModel struct {
|
||||
table table.Model
|
||||
viewport viewport.Model
|
||||
|
||||
msgPublisher MessagePublisher
|
||||
tableService *tables.Service
|
||||
tableName string
|
||||
|
||||
tableWidth, tableHeight int
|
||||
|
||||
ready bool
|
||||
resultSet *models.ResultSet
|
||||
message string
|
||||
}
|
||||
|
||||
func NewModel(tableService *tables.Service, msgPublisher MessagePublisher, tableName string) tea.Model {
|
||||
tbl := table.New([]string{"pk", "sk"}, 100, 20)
|
||||
rows := make([]table.Row, 0)
|
||||
tbl.SetRows(rows)
|
||||
|
||||
model := uiModel{
|
||||
table: tbl,
|
||||
tableService: tableService,
|
||||
tableName: tableName,
|
||||
msgPublisher: msgPublisher,
|
||||
message: "Press s to scan",
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
func (m uiModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *uiModel) updateTable() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
newTbl := table.New(m.resultSet.Columns, m.tableWidth, m.tableHeight)
|
||||
newRows := make([]table.Row, len(m.resultSet.Items))
|
||||
for i, r := range m.resultSet.Items {
|
||||
newRows[i] = itemTableRow{m.resultSet, r}
|
||||
}
|
||||
newTbl.SetRows(newRows)
|
||||
|
||||
m.table = newTbl
|
||||
}
|
||||
|
||||
func (m *uiModel) updateViewportToSelectedMessage() {
|
||||
if !m.ready {
|
||||
return
|
||||
}
|
||||
|
||||
if m.resultSet == nil || len(m.resultSet.Items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
selectedItem, ok := m.table.SelectedRow().(itemTableRow)
|
||||
if !ok {
|
||||
m.viewport.SetContent("(no row selected)")
|
||||
return
|
||||
}
|
||||
|
||||
viewportContent := &strings.Builder{}
|
||||
tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0)
|
||||
for _, colName := range selectedItem.resultSet.Columns {
|
||||
fmt.Fprintf(tabWriter, "%v\t", colName)
|
||||
|
||||
switch colVal := selectedItem.item[colName].(type) {
|
||||
case nil:
|
||||
fmt.Fprintln(tabWriter, "(nil)")
|
||||
case *types.AttributeValueMemberS:
|
||||
fmt.Fprintln(tabWriter, colVal.Value)
|
||||
case *types.AttributeValueMemberN:
|
||||
fmt.Fprintln(tabWriter, colVal.Value)
|
||||
default:
|
||||
fmt.Fprintln(tabWriter, "(other)")
|
||||
}
|
||||
}
|
||||
|
||||
tabWriter.Flush()
|
||||
m.viewport.SetContent(viewportContent.String())
|
||||
}
|
||||
|
||||
func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case setStatusMessage:
|
||||
m.message = ""
|
||||
case errorRaised:
|
||||
m.message = "Error: " + msg.Error()
|
||||
case newResultSet:
|
||||
m.resultSet = msg.ResultSet
|
||||
m.updateTable()
|
||||
m.updateViewportToSelectedMessage()
|
||||
case tea.WindowSizeMsg:
|
||||
footerHeight := lipgloss.Height(m.footerView())
|
||||
tableHeight := msg.Height / 2
|
||||
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-footerHeight)
|
||||
m.viewport.SetContent("(no message selected)")
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = msg.Height - tableHeight - footerHeight
|
||||
}
|
||||
|
||||
m.tableWidth, m.tableHeight = msg.Width, tableHeight
|
||||
m.table.SetSize(m.tableWidth, m.tableHeight)
|
||||
|
||||
case tea.KeyMsg:
|
||||
|
||||
switch msg.String() {
|
||||
case "s":
|
||||
m.startOperation("Scanning...", func(ctx context.Context) (tea.Msg, error) {
|
||||
resultSet, err := m.tableService.Scan(ctx, m.tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResultSet{resultSet}, nil
|
||||
})
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
case "up", "i":
|
||||
m.table.GoUp()
|
||||
m.updateViewportToSelectedMessage()
|
||||
case "down", "k":
|
||||
m.table.GoDown()
|
||||
m.updateViewportToSelectedMessage()
|
||||
}
|
||||
}
|
||||
|
||||
updatedTable, tableMsgs := m.table.Update(msg)
|
||||
updatedViewport, viewportMsgs := m.viewport.Update(msg)
|
||||
|
||||
m.table = updatedTable
|
||||
m.viewport = updatedViewport
|
||||
|
||||
return m, tea.Batch(tableMsgs, viewportMsgs)
|
||||
}
|
||||
|
||||
// TODO: this should probably be a separate service
|
||||
func (m *uiModel) startOperation(msg string, op func(ctx context.Context) (tea.Msg, error)) {
|
||||
m.message = msg
|
||||
go func() {
|
||||
resMsg, err := op(context.Background())
|
||||
if err != nil {
|
||||
m.msgPublisher.Send(errorRaised(err))
|
||||
} else if resMsg != nil {
|
||||
m.msgPublisher.Send(resMsg)
|
||||
}
|
||||
m.msgPublisher.Send(setStatusMessage(""))
|
||||
}()
|
||||
}
|
||||
|
||||
func (m uiModel) View() string {
|
||||
if !m.ready {
|
||||
return "Initializing"
|
||||
}
|
||||
|
||||
log.Println("Returning full view")
|
||||
return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.viewport.View(), m.footerView())
|
||||
//return lipgloss.JoinVertical(lipgloss.Top, m.table.View(), m.footerView())
|
||||
}
|
||||
|
||||
func (m uiModel) footerView() string {
|
||||
title := m.message
|
||||
line := strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
40
internal/dynamo-browse/ui/tblmodel.go
Normal file
40
internal/dynamo-browse/ui/tblmodel.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
table "github.com/calyptia/go-bubble-table"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/models"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type itemTableRow struct {
|
||||
resultSet *models.ResultSet
|
||||
item models.Item
|
||||
}
|
||||
|
||||
func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) {
|
||||
sb := strings.Builder{}
|
||||
for i, colName := range mtr.resultSet.Columns {
|
||||
if i > 0 {
|
||||
sb.WriteString("\t")
|
||||
}
|
||||
|
||||
switch colVal := mtr.item[colName].(type) {
|
||||
case nil:
|
||||
sb.WriteString("(nil)")
|
||||
case *types.AttributeValueMemberS:
|
||||
sb.WriteString(colVal.Value)
|
||||
case *types.AttributeValueMemberN:
|
||||
sb.WriteString(colVal.Value)
|
||||
default:
|
||||
sb.WriteString("(other)")
|
||||
}
|
||||
}
|
||||
if index == model.Cursor() {
|
||||
fmt.Fprintln(w, model.Styles.SelectedRow.Render(sb.String()))
|
||||
} else {
|
||||
fmt.Fprintln(w, sb.String())
|
||||
}
|
||||
}
|
||||
10
internal/sqs-browse/services/messages/iface.go
Normal file
10
internal/sqs-browse/services/messages/iface.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package messages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lmika/awstools/internal/sqs-browse/models"
|
||||
)
|
||||
|
||||
type MessageSender interface {
|
||||
SendMessage(ctx context.Context, msg models.Message, queue string) error
|
||||
}
|
||||
19
internal/sqs-browse/services/messages/service.go
Normal file
19
internal/sqs-browse/services/messages/service.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package messages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lmika/awstools/internal/sqs-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
messageSender MessageSender
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) SendTo(ctx context.Context, msg models.Message, destQueue string) error {
|
||||
return errors.Wrapf(s.messageSender.SendMessage(ctx, msg, destQueue), "cannot send message to %v", destQueue)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue