Files
upstream-watermill/tools/pq/cli/model.go
2026-09-15 21:27:46 +05:00

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
}