snapshot of https://github.com/ThreeDotsLabs/watermill.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
master / ci (push) Failing after 0s
Some checks failed
master / ci (push) Failing after 0s
This commit is contained in:
32
tools/pq/cli/backend.go
Normal file
32
tools/pq/cli/backend.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type BackendConfig struct {
|
||||
Topic string
|
||||
RawTopic string
|
||||
}
|
||||
|
||||
func (c BackendConfig) Validate() error {
|
||||
if c.Topic == "" && c.RawTopic == "" {
|
||||
return errors.New("topic or raw topic must be provided")
|
||||
}
|
||||
|
||||
if c.Topic != "" && c.RawTopic != "" {
|
||||
return errors.New("only one of topic or raw topic must be provided")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type BackendConstructor func(ctx context.Context, cfg BackendConfig) (Backend, error)
|
||||
|
||||
type Backend interface {
|
||||
AllMessages(ctx context.Context) ([]Message, error)
|
||||
Requeue(ctx context.Context, msg Message) error
|
||||
Ack(ctx context.Context, msg Message) error
|
||||
}
|
||||
45
tools/pq/cli/message.go
Normal file
45
tools/pq/cli/message.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/components/delay"
|
||||
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
// ID is a unique message ID across the Pub/Sub's topic.
|
||||
ID string
|
||||
UUID string
|
||||
Payload string
|
||||
Metadata map[string]string
|
||||
|
||||
OriginalTopic string
|
||||
DelayedUntil string
|
||||
DelayedFor string
|
||||
RequeueIn time.Duration
|
||||
}
|
||||
|
||||
func NewMessage(id string, uuid string, payload string, metadata map[string]string) (Message, error) {
|
||||
originalTopic := metadata[middleware.PoisonedTopicKey]
|
||||
|
||||
// Calculate the time until the message should be requeued
|
||||
delayedUntil, err := time.Parse(time.RFC3339, metadata[delay.DelayedUntilKey])
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
delayedFor := metadata[delay.DelayedForKey]
|
||||
requeueIn := delayedUntil.Sub(time.Now().UTC()).Round(time.Second)
|
||||
|
||||
return Message{
|
||||
ID: id,
|
||||
UUID: uuid,
|
||||
Payload: payload,
|
||||
Metadata: metadata,
|
||||
OriginalTopic: originalTopic,
|
||||
DelayedUntil: delayedUntil.String(),
|
||||
DelayedFor: delayedFor,
|
||||
RequeueIn: requeueIn,
|
||||
}, nil
|
||||
}
|
||||
403
tools/pq/cli/model.go
Normal file
403
tools/pq/cli/model.go
Normal file
@@ -0,0 +1,403 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user