404 lines
8.6 KiB
Go
404 lines
8.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/table"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"golang.org/x/exp/maps"
|
|
)
|
|
|
|
var warningStyle = lipgloss.NewStyle().
|
|
Background(lipgloss.Color("196")).
|
|
Align(lipgloss.Center).
|
|
Padding(1, 10)
|
|
|
|
var dialogStyle = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("241")).
|
|
Padding(1, 4)
|
|
|
|
var buttonStyle = lipgloss.NewStyle()
|
|
|
|
var buttonSelectedStyle = lipgloss.NewStyle().
|
|
Background(lipgloss.Color("57"))
|
|
|
|
var readOnlyMessageActions = []string{"<- Back", "Show payload"}
|
|
var writeMessageActions = []string{"Requeue", "Ack (drop)"}
|
|
|
|
var dialogActions = []string{"Cancel", "Confirm"}
|
|
|
|
type MessagesUpdated struct {
|
|
Messages []Message
|
|
}
|
|
|
|
type DialogResult struct {
|
|
Err error
|
|
}
|
|
|
|
func (m Model) FetchMessages() tea.Cmd {
|
|
return func() tea.Msg {
|
|
for {
|
|
msgs, err := m.backend.AllMessages(context.Background())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
m.sub <- MessagesUpdated{
|
|
Messages: msgs,
|
|
}
|
|
|
|
time.Sleep(time.Second)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m Model) WaitForMessages() tea.Cmd {
|
|
return func() tea.Msg {
|
|
return <-m.sub
|
|
}
|
|
}
|
|
|
|
var baseStyle = lipgloss.NewStyle().
|
|
BorderStyle(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color("240"))
|
|
|
|
type Model struct {
|
|
backend Backend
|
|
sub chan MessagesUpdated
|
|
|
|
chosenMessage *Message
|
|
chosenMessageGone bool
|
|
|
|
table table.Model
|
|
messages []Message
|
|
|
|
chosenAction int
|
|
currentDialog *Dialog
|
|
|
|
showingPayload bool
|
|
payloadViewport viewport.Model
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.FetchMessages(),
|
|
m.WaitForMessages(),
|
|
)
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case MessagesUpdated:
|
|
rows := make([]table.Row, len(msg.Messages))
|
|
for i, message := range msg.Messages {
|
|
rows[i] = table.Row{
|
|
message.ID,
|
|
message.UUID,
|
|
message.OriginalTopic,
|
|
message.DelayedFor,
|
|
message.RequeueIn.String(),
|
|
}
|
|
}
|
|
m.table.SetRows(rows)
|
|
m.messages = msg.Messages
|
|
|
|
// If the chosen message is no longer in the list, go back to the table.
|
|
// This is to avoid accidentally making an action on a message that has been requeued or deleted.
|
|
if m.chosenMessage != nil {
|
|
found := false
|
|
for _, message := range m.messages {
|
|
if message.ID == m.chosenMessage.ID {
|
|
foundMessage := message
|
|
m.chosenMessage = &foundMessage
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if found {
|
|
m.chosenMessageGone = false
|
|
} else {
|
|
if !m.chosenMessageGone {
|
|
m.chosenAction = 0
|
|
}
|
|
|
|
m.chosenMessageGone = true
|
|
}
|
|
}
|
|
|
|
return m, m.WaitForMessages()
|
|
}
|
|
|
|
if m.chosenMessage == nil {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
case " ", "enter":
|
|
c := m.table.Cursor()
|
|
m.chosenAction = 0
|
|
chosenMessage := m.messages[c]
|
|
m.chosenMessage = &chosenMessage
|
|
m.chosenMessageGone = false
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.table, cmd = m.table.Update(msg)
|
|
return m, cmd
|
|
} else if m.showingPayload {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
case "esc", "backspace":
|
|
m.showingPayload = false
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.payloadViewport, cmd = m.payloadViewport.Update(msg)
|
|
return m, cmd
|
|
} else if m.currentDialog != nil {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
case "esc", "backspace":
|
|
m.currentDialog = nil
|
|
case "h", "left":
|
|
m.currentDialog.Choice--
|
|
if m.currentDialog.Choice < 0 {
|
|
m.currentDialog.Choice = 0
|
|
}
|
|
case "l", "right":
|
|
m.currentDialog.Choice++
|
|
if m.currentDialog.Choice >= len(dialogActions) {
|
|
m.currentDialog.Choice = len(dialogActions) - 1
|
|
}
|
|
case " ", "enter":
|
|
switch m.currentDialog.Choice {
|
|
case 0:
|
|
m.currentDialog = nil
|
|
case 1:
|
|
m.currentDialog.Running = true
|
|
return m, m.currentDialog.Action
|
|
}
|
|
}
|
|
case DialogResult:
|
|
if msg.Err != nil {
|
|
// TODO Could be handled better
|
|
panic(msg.Err)
|
|
}
|
|
|
|
m.currentDialog = nil
|
|
}
|
|
|
|
return m, nil
|
|
} else {
|
|
messageActions := len(readOnlyMessageActions)
|
|
if !m.chosenMessageGone {
|
|
messageActions += len(writeMessageActions)
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
case "esc", "backspace":
|
|
m.chosenMessage = nil
|
|
m.chosenMessageGone = false
|
|
case "j", "down":
|
|
m.chosenAction++
|
|
if m.chosenAction >= messageActions {
|
|
m.chosenAction = messageActions - 1
|
|
}
|
|
case "k", "up":
|
|
m.chosenAction--
|
|
if m.chosenAction < 0 {
|
|
m.chosenAction = 0
|
|
}
|
|
case " ", "enter":
|
|
switch m.chosenAction {
|
|
case 0:
|
|
m.chosenMessage = nil
|
|
m.chosenMessageGone = false
|
|
case 1:
|
|
// Show payload
|
|
m.showingPayload = true
|
|
m.payloadViewport = viewport.New(80, 20)
|
|
b := lipgloss.RoundedBorder()
|
|
m.payloadViewport.Style = lipgloss.NewStyle().BorderStyle(b).Padding(0, 1)
|
|
|
|
payload := m.chosenMessage.Payload
|
|
|
|
var jsonPayload any
|
|
err := json.Unmarshal([]byte(payload), &jsonPayload)
|
|
if err == nil {
|
|
pretty, err := json.MarshalIndent(jsonPayload, "", " ")
|
|
if err == nil {
|
|
payload = string(pretty)
|
|
}
|
|
}
|
|
|
|
m.payloadViewport.SetContent(payload)
|
|
case 2:
|
|
chosenMessage := *m.chosenMessage
|
|
m.currentDialog = &Dialog{
|
|
Prompt: "Requeue message? It will go back to the original topic.",
|
|
Action: func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
return DialogResult{
|
|
Err: m.backend.Requeue(ctx, chosenMessage),
|
|
}
|
|
},
|
|
}
|
|
case 3:
|
|
chosenMessage := *m.chosenMessage
|
|
m.currentDialog = &Dialog{
|
|
Prompt: "Acknowledge message? It will be dropped from the topic.",
|
|
Action: func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
return DialogResult{
|
|
Err: m.backend.Ack(ctx, chosenMessage),
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
if m.chosenMessage == nil {
|
|
return baseStyle.Render(m.table.View()) + "\n " + m.table.HelpView() + "\n"
|
|
}
|
|
|
|
msg := m.chosenMessage
|
|
|
|
var out string
|
|
|
|
if m.chosenMessageGone {
|
|
out += warningStyle.Render("Read only — the message is gone.")
|
|
out += "\n"
|
|
}
|
|
|
|
out += fmt.Sprintf(
|
|
"ID: %v\nUUID: %v\nOriginal Topic: %v\nDelayed For: %v\nDelayed Until: %v\nRequeue In: %v\n\n",
|
|
msg.ID,
|
|
msg.UUID,
|
|
msg.OriginalTopic,
|
|
msg.DelayedFor,
|
|
msg.DelayedUntil,
|
|
msg.RequeueIn,
|
|
)
|
|
|
|
if m.showingPayload {
|
|
out += m.payloadViewport.View()
|
|
return out
|
|
}
|
|
|
|
out += "Metadata:\n"
|
|
|
|
keys := maps.Keys(msg.Metadata)
|
|
slices.Sort(keys)
|
|
for _, k := range keys {
|
|
v := msg.Metadata[k]
|
|
out += fmt.Sprintf(" %v: %v\n", k, v)
|
|
}
|
|
|
|
if m.currentDialog != nil {
|
|
prompt := m.currentDialog.Prompt + "\n\n"
|
|
|
|
if m.currentDialog.Running {
|
|
prompt += "Running..."
|
|
} else {
|
|
for i, action := range dialogActions {
|
|
style := buttonStyle
|
|
if i == m.currentDialog.Choice {
|
|
style = buttonSelectedStyle
|
|
}
|
|
|
|
prompt += fmt.Sprintf("%v", style.MarginLeft(13).Render(action))
|
|
}
|
|
}
|
|
|
|
out += dialogStyle.Render(prompt)
|
|
} else {
|
|
out += "\nActions:\n"
|
|
|
|
messageActions := readOnlyMessageActions
|
|
if !m.chosenMessageGone {
|
|
messageActions = append(messageActions, writeMessageActions...)
|
|
}
|
|
|
|
for i, action := range messageActions {
|
|
style := buttonStyle
|
|
if i == m.chosenAction {
|
|
style = buttonSelectedStyle
|
|
}
|
|
|
|
out += fmt.Sprintf("%v\n", style.MarginLeft(2).Render(action))
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func NewModel(backend Backend) Model {
|
|
columns := []table.Column{
|
|
{Title: "ID", Width: 8},
|
|
{Title: "UUID", Width: 40},
|
|
{Title: "Original Topic", Width: 20},
|
|
{Title: "Delayed For", Width: 14},
|
|
{Title: "Requeue In", Width: 14},
|
|
}
|
|
|
|
t := table.New(
|
|
table.WithColumns(columns),
|
|
table.WithFocused(true),
|
|
table.WithHeight(20),
|
|
)
|
|
|
|
s := table.DefaultStyles()
|
|
s.Header = s.Header.
|
|
BorderStyle(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color("240")).
|
|
BorderBottom(true).
|
|
Bold(false)
|
|
s.Selected = s.Selected.
|
|
Foreground(lipgloss.Color("229")).
|
|
Background(lipgloss.Color("57")).
|
|
Bold(false)
|
|
t.SetStyles(s)
|
|
|
|
return Model{
|
|
backend: backend,
|
|
sub: make(chan MessagesUpdated),
|
|
table: t,
|
|
}
|
|
}
|
|
|
|
type Dialog struct {
|
|
Prompt string
|
|
Action func() tea.Msg
|
|
Choice int
|
|
Running bool
|
|
}
|