diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be1e5ce..77067d1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,9 +28,6 @@ jobs: - name: Build x module run: cd x && go build -v ./... && cd .. - - name: Build tools module - run: cd tools && go build -v ./... && cd .. - - name: Test core module run: go test -race -v -coverprofile=coverage.txt -covermode=atomic ./... @@ -42,3 +39,29 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 + + build-tool: + strategy: + matrix: + os: [ubuntu-latest] + go-version: [1.18.x] + runs-on: ${{ matrix.os }} + services: + redis: + image: redis + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + + - name: Build tools module + run: cd tools && go build -v ./... && cd .. + + - name: Test tools module + run: cd tools && go test -race -v ./... && cd .. + diff --git a/tools/asynq/cmd/dash.go b/tools/asynq/cmd/dash.go new file mode 100644 index 0000000..5042d72 --- /dev/null +++ b/tools/asynq/cmd/dash.go @@ -0,0 +1,44 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/hibiken/asynq/tools/asynq/cmd/dash" + "github.com/spf13/cobra" +) + +var ( + flagPollInterval = 8 * time.Second +) + +func init() { + rootCmd.AddCommand(dashCmd) + dashCmd.Flags().DurationVar(&flagPollInterval, "refresh", 8*time.Second, "Interval between data refresh (default: 8s, min allowed: 1s)") +} + +var dashCmd = &cobra.Command{ + Use: "dash", + Short: "View dashboard", + Long: heredoc.Doc(` + Display interactive dashboard.`), + Args: cobra.NoArgs, + Example: heredoc.Doc(` + $ asynq dash + $ asynq dash --refresh=3s`), + Run: func(cmd *cobra.Command, args []string) { + if flagPollInterval < 1*time.Second { + fmt.Println("error: --refresh cannot be less than 1s") + os.Exit(1) + } + dash.Run(dash.Options{ + PollInterval: flagPollInterval, + }) + }, +} diff --git a/tools/asynq/cmd/dash/dash.go b/tools/asynq/cmd/dash/dash.go new file mode 100644 index 0000000..2f9f505 --- /dev/null +++ b/tools/asynq/cmd/dash/dash.go @@ -0,0 +1,219 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package dash + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/hibiken/asynq" +) + +// viewType is an enum for dashboard views. +type viewType int + +const ( + viewTypeQueues viewType = iota + viewTypeQueueDetails + viewTypeHelp +) + +// State holds dashboard state. +type State struct { + queues []*asynq.QueueInfo + tasks []*asynq.TaskInfo + groups []*asynq.GroupInfo + err error + + // Note: index zero corresponds to the table header; index=1 correctponds to the first element + queueTableRowIdx int // highlighted row in queue table + taskTableRowIdx int // highlighted row in task table + groupTableRowIdx int // highlighted row in group table + taskState asynq.TaskState // highlighted task state in queue details view + taskID string // selected task ID + + selectedQueue *asynq.QueueInfo // queue shown on queue details view + selectedGroup *asynq.GroupInfo + selectedTask *asynq.TaskInfo + + pageNum int // pagination page number + + view viewType // current view type + prevView viewType // to support "go back" +} + +func (s *State) DebugString() string { + var b strings.Builder + b.WriteString(fmt.Sprintf("len(queues)=%d ", len(s.queues))) + b.WriteString(fmt.Sprintf("len(tasks)=%d ", len(s.tasks))) + b.WriteString(fmt.Sprintf("len(groups)=%d ", len(s.groups))) + b.WriteString(fmt.Sprintf("err=%v ", s.err)) + + if s.taskState != 0 { + b.WriteString(fmt.Sprintf("taskState=%s ", s.taskState.String())) + } else { + b.WriteString(fmt.Sprintf("taskState=0")) + } + b.WriteString(fmt.Sprintf("taskID=%s ", s.taskID)) + + b.WriteString(fmt.Sprintf("queueTableRowIdx=%d ", s.queueTableRowIdx)) + b.WriteString(fmt.Sprintf("taskTableRowIdx=%d ", s.taskTableRowIdx)) + b.WriteString(fmt.Sprintf("groupTableRowIdx=%d ", s.groupTableRowIdx)) + + if s.selectedQueue != nil { + b.WriteString(fmt.Sprintf("selectedQueue={Queue:%s} ", s.selectedQueue.Queue)) + } else { + b.WriteString("selectedQueue=nil ") + } + + if s.selectedGroup != nil { + b.WriteString(fmt.Sprintf("selectedGroup={Group:%s} ", s.selectedGroup.Group)) + } else { + b.WriteString("selectedGroup=nil ") + } + + if s.selectedTask != nil { + b.WriteString(fmt.Sprintf("selectedTask={ID:%s} ", s.selectedTask.ID)) + } else { + b.WriteString("selectedTask=nil ") + } + + b.WriteString(fmt.Sprintf("pageNum=%d", s.pageNum)) + return b.String() +} + +type Options struct { + DebugMode bool + PollInterval time.Duration +} + +func Run(opts Options) { + s, err := tcell.NewScreen() + if err != nil { + fmt.Printf("failed to create a screen: %v\n", err) + os.Exit(1) + } + if err := s.Init(); err != nil { + fmt.Printf("failed to initialize screen: %v\n", err) + os.Exit(1) + } + s.SetStyle(baseStyle) // set default text style + + var ( + state = State{} // confined in this goroutine only; DO NOT SHARE + + inspector = asynq.NewInspector(asynq.RedisClientOpt{Addr: ":6379"}) + ticker = time.NewTicker(opts.PollInterval) + + eventCh = make(chan tcell.Event) + done = make(chan struct{}) + + // channels to send/receive data fetched asynchronously + errorCh = make(chan error) + queueCh = make(chan *asynq.QueueInfo) + taskCh = make(chan *asynq.TaskInfo) + queuesCh = make(chan []*asynq.QueueInfo) + groupsCh = make(chan []*asynq.GroupInfo) + tasksCh = make(chan []*asynq.TaskInfo) + ) + defer ticker.Stop() + + f := dataFetcher{ + inspector, + opts, + s, + errorCh, + queueCh, + taskCh, + queuesCh, + groupsCh, + tasksCh, + } + + d := dashDrawer{ + s, + opts, + } + + h := keyEventHandler{ + s: s, + fetcher: &f, + drawer: &d, + state: &state, + done: done, + ticker: ticker, + pollInterval: opts.PollInterval, + } + + go fetchQueues(inspector, queuesCh, errorCh, opts) + go s.ChannelEvents(eventCh, done) // TODO: Double check that we are not leaking goroutine with this one. + d.Draw(&state) // draw initial screen + + for { + // Update screen + s.Show() + + select { + case ev := <-eventCh: + // Process event + switch ev := ev.(type) { + case *tcell.EventResize: + s.Sync() + case *tcell.EventKey: + h.HandleKeyEvent(ev) + } + + case <-ticker.C: + f.Fetch(&state) + + case queues := <-queuesCh: + state.queues = queues + state.err = nil + if len(queues) < state.queueTableRowIdx { + state.queueTableRowIdx = len(queues) + } + d.Draw(&state) + + case q := <-queueCh: + state.selectedQueue = q + state.err = nil + d.Draw(&state) + + case groups := <-groupsCh: + state.groups = groups + state.err = nil + if len(groups) < state.groupTableRowIdx { + state.groupTableRowIdx = len(groups) + } + d.Draw(&state) + + case tasks := <-tasksCh: + state.tasks = tasks + state.err = nil + if len(tasks) < state.taskTableRowIdx { + state.taskTableRowIdx = len(tasks) + } + d.Draw(&state) + + case t := <-taskCh: + state.selectedTask = t + state.err = nil + d.Draw(&state) + + case err := <-errorCh: + if errors.Is(err, asynq.ErrTaskNotFound) { + state.selectedTask = nil + } else { + state.err = err + } + d.Draw(&state) + } + } + +} diff --git a/tools/asynq/cmd/dash/draw.go b/tools/asynq/cmd/dash/draw.go new file mode 100644 index 0000000..b53349e --- /dev/null +++ b/tools/asynq/cmd/dash/draw.go @@ -0,0 +1,724 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package dash + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/gdamore/tcell/v2" + "github.com/hibiken/asynq" + "github.com/mattn/go-runewidth" +) + +var ( + baseStyle = tcell.StyleDefault.Background(tcell.ColorReset).Foreground(tcell.ColorReset) + labelStyle = baseStyle.Foreground(tcell.ColorLightGray) + + // styles for bar graph + activeStyle = baseStyle.Foreground(tcell.ColorBlue) + pendingStyle = baseStyle.Foreground(tcell.ColorGreen) + aggregatingStyle = baseStyle.Foreground(tcell.ColorLightGreen) + scheduledStyle = baseStyle.Foreground(tcell.ColorYellow) + retryStyle = baseStyle.Foreground(tcell.ColorPink) + archivedStyle = baseStyle.Foreground(tcell.ColorPurple) + completedStyle = baseStyle.Foreground(tcell.ColorDarkGreen) +) + +// drawer draws UI with the given state. +type drawer interface { + Draw(state *State) +} + +type dashDrawer struct { + s tcell.Screen + opts Options +} + +func (dd *dashDrawer) Draw(state *State) { + s, opts := dd.s, dd.opts + s.Clear() + // Simulate data update on every render + d := NewScreenDrawer(s) + switch state.view { + case viewTypeQueues: + d.Println("=== Queues ===", baseStyle.Bold(true)) + d.NL() + drawQueueSizeGraphs(d, state) + d.NL() + drawQueueTable(d, baseStyle, state) + case viewTypeQueueDetails: + d.Println("=== Queue Summary ===", baseStyle.Bold(true)) + d.NL() + drawQueueSummary(d, state) + d.NL() + d.NL() + d.Println("=== Tasks ===", baseStyle.Bold(true)) + d.NL() + drawTaskStateBreakdown(d, baseStyle, state) + d.NL() + drawTaskTable(d, state) + drawTaskModal(d, state) + case viewTypeHelp: + drawHelp(d) + } + d.GoToBottom() + if opts.DebugMode { + drawDebugInfo(d, state) + } else { + drawFooter(d, state) + } +} + +func drawQueueSizeGraphs(d *ScreenDrawer, state *State) { + var qnames []string + var qsizes []string // queue size in strings + maxSize := 1 // not zero to avoid division by zero + for _, q := range state.queues { + qnames = append(qnames, q.Queue) + qsizes = append(qsizes, strconv.Itoa(q.Size)) + if q.Size > maxSize { + maxSize = q.Size + } + } + qnameWidth := maxwidth(qnames) + qsizeWidth := maxwidth(qsizes) + + // Calculate the multipler to scale the graph + screenWidth, _ := d.Screen().Size() + graphMaxWidth := screenWidth - (qnameWidth + qsizeWidth + 3) // | + multipiler := 1.0 + if graphMaxWidth < maxSize { + multipiler = float64(graphMaxWidth) / float64(maxSize) + } + + const tick = '▇' + for _, q := range state.queues { + d.Print(q.Queue, baseStyle) + d.Print(strings.Repeat(" ", qnameWidth-runewidth.StringWidth(q.Queue)+1), baseStyle) // padding between qname and graph + d.Print("|", baseStyle) + d.Print(strings.Repeat(string(tick), int(math.Floor(float64(q.Active)*multipiler))), activeStyle) + d.Print(strings.Repeat(string(tick), int(math.Floor(float64(q.Pending)*multipiler))), pendingStyle) + d.Print(strings.Repeat(string(tick), int(math.Floor(float64(q.Aggregating)*multipiler))), aggregatingStyle) + d.Print(strings.Repeat(string(tick), int(math.Floor(float64(q.Scheduled)*multipiler))), scheduledStyle) + d.Print(strings.Repeat(string(tick), int(math.Floor(float64(q.Retry)*multipiler))), retryStyle) + d.Print(strings.Repeat(string(tick), int(math.Floor(float64(q.Archived)*multipiler))), archivedStyle) + d.Print(strings.Repeat(string(tick), int(math.Floor(float64(q.Completed)*multipiler))), completedStyle) + d.Print(fmt.Sprintf(" %d", q.Size), baseStyle) + d.NL() + } + d.NL() + d.Print("active=", baseStyle) + d.Print(string(tick), activeStyle) + d.Print(" pending=", baseStyle) + d.Print(string(tick), pendingStyle) + d.Print(" aggregating=", baseStyle) + d.Print(string(tick), aggregatingStyle) + d.Print(" scheduled=", baseStyle) + d.Print(string(tick), scheduledStyle) + d.Print(" retry=", baseStyle) + d.Print(string(tick), retryStyle) + d.Print(" archived=", baseStyle) + d.Print(string(tick), archivedStyle) + d.Print(" completed=", baseStyle) + d.Print(string(tick), completedStyle) + d.NL() +} + +func drawFooter(d *ScreenDrawer, state *State) { + if state.err != nil { + style := baseStyle.Background(tcell.ColorDarkRed) + d.Print(state.err.Error(), style) + d.FillLine(' ', style) + return + } + style := baseStyle.Background(tcell.ColorDarkSlateGray).Foreground(tcell.ColorWhite) + switch state.view { + case viewTypeHelp: + d.Print(": GoBack", style) + default: + d.Print(": Help : Exit ", style) + } + d.FillLine(' ', style) +} + +// returns the maximum width from the given list of names +func maxwidth(names []string) int { + max := 0 + for _, s := range names { + if w := runewidth.StringWidth(s); w > max { + max = w + } + } + return max +} + +// rpad adds padding to the right of a string. +func rpad(s string, padding int) string { + tmpl := fmt.Sprintf("%%-%ds ", padding) + return fmt.Sprintf(tmpl, s) + +} + +// lpad adds padding to the left of a string. +func lpad(s string, padding int) string { + tmpl := fmt.Sprintf("%%%ds ", padding) + return fmt.Sprintf(tmpl, s) +} + +// byteCount converts the given bytes into human readable string +func byteCount(b int64) string { + const unit = 1000 + if b < unit { + return fmt.Sprintf("%d B", b) + + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp]) +} + +var queueColumnConfigs = []*columnConfig[*asynq.QueueInfo]{ + {"Queue", alignLeft, func(q *asynq.QueueInfo) string { return q.Queue }}, + {"State", alignLeft, func(q *asynq.QueueInfo) string { return formatQueueState(q) }}, + {"Size", alignRight, func(q *asynq.QueueInfo) string { return strconv.Itoa(q.Size) }}, + {"Latency", alignRight, func(q *asynq.QueueInfo) string { return q.Latency.Round(time.Second).String() }}, + {"MemoryUsage", alignRight, func(q *asynq.QueueInfo) string { return byteCount(q.MemoryUsage) }}, + {"Processed", alignRight, func(q *asynq.QueueInfo) string { return strconv.Itoa(q.Processed) }}, + {"Failed", alignRight, func(q *asynq.QueueInfo) string { return strconv.Itoa(q.Failed) }}, + {"ErrorRate", alignRight, func(q *asynq.QueueInfo) string { return formatErrorRate(q.Processed, q.Failed) }}, +} + +func formatQueueState(q *asynq.QueueInfo) string { + if q.Paused { + return "PAUSED" + } + return "RUN" +} + +func formatErrorRate(processed, failed int) string { + if processed == 0 { + return "-" + } + return fmt.Sprintf("%.2f", float64(failed)/float64(processed)) +} + +func formatNextProcessTime(t time.Time) string { + now := time.Now() + if t.Before(now) { + return "now" + } + return fmt.Sprintf("in %v", (t.Sub(now).Round(time.Second))) +} + +func formatPastTime(t time.Time) string { + now := time.Now() + if t.After(now) || t.Equal(now) { + return "just now" + } + return fmt.Sprintf("%v ago", time.Since(t).Round(time.Second)) +} + +func drawQueueTable(d *ScreenDrawer, style tcell.Style, state *State) { + drawTable(d, style, queueColumnConfigs, state.queues, state.queueTableRowIdx-1) +} + +func drawQueueSummary(d *ScreenDrawer, state *State) { + q := state.selectedQueue + if q == nil { + d.Println("ERROR: Press q to go back", baseStyle) + return + } + d.Print("Name ", labelStyle) + d.Println(q.Queue, baseStyle) + d.Print("Size ", labelStyle) + d.Println(strconv.Itoa(q.Size), baseStyle) + d.Print("Latency ", labelStyle) + d.Println(q.Latency.Round(time.Second).String(), baseStyle) + d.Print("MemUsage ", labelStyle) + d.Println(byteCount(q.MemoryUsage), baseStyle) +} + +// Returns the max number of groups that can be displayed. +func groupPageSize(s tcell.Screen) int { + _, h := s.Size() + return h - 16 // height - (# of rows used) +} + +// Returns the number of tasks to fetch. +func taskPageSize(s tcell.Screen) int { + _, h := s.Size() + return h - 15 // height - (# of rows used) +} + +func shouldShowGroupTable(state *State) bool { + return state.taskState == asynq.TaskStateAggregating && state.selectedGroup == nil +} + +func getTaskTableColumnConfig(taskState asynq.TaskState) []*columnConfig[*asynq.TaskInfo] { + switch taskState { + case asynq.TaskStateActive: + return activeTaskTableColumns + case asynq.TaskStatePending: + return pendingTaskTableColumns + case asynq.TaskStateAggregating: + return aggregatingTaskTableColumns + case asynq.TaskStateScheduled: + return scheduledTaskTableColumns + case asynq.TaskStateRetry: + return retryTaskTableColumns + case asynq.TaskStateArchived: + return archivedTaskTableColumns + case asynq.TaskStateCompleted: + return completedTaskTableColumns + } + panic("unknown task state") +} + +var activeTaskTableColumns = []*columnConfig[*asynq.TaskInfo]{ + {"ID", alignLeft, func(t *asynq.TaskInfo) string { return t.ID }}, + {"Type", alignLeft, func(t *asynq.TaskInfo) string { return t.Type }}, + {"Retried", alignRight, func(t *asynq.TaskInfo) string { return strconv.Itoa(t.Retried) }}, + {"Max Retry", alignRight, func(t *asynq.TaskInfo) string { return strconv.Itoa(t.MaxRetry) }}, + {"Payload", alignLeft, func(t *asynq.TaskInfo) string { return formatByteSlice(t.Payload) }}, +} + +var pendingTaskTableColumns = []*columnConfig[*asynq.TaskInfo]{ + {"ID", alignLeft, func(t *asynq.TaskInfo) string { return t.ID }}, + {"Type", alignLeft, func(t *asynq.TaskInfo) string { return t.Type }}, + {"Retried", alignRight, func(t *asynq.TaskInfo) string { return strconv.Itoa(t.Retried) }}, + {"Max Retry", alignRight, func(t *asynq.TaskInfo) string { return strconv.Itoa(t.MaxRetry) }}, + {"Payload", alignLeft, func(t *asynq.TaskInfo) string { return formatByteSlice(t.Payload) }}, +} + +var aggregatingTaskTableColumns = []*columnConfig[*asynq.TaskInfo]{ + {"ID", alignLeft, func(t *asynq.TaskInfo) string { return t.ID }}, + {"Type", alignLeft, func(t *asynq.TaskInfo) string { return t.Type }}, + {"Payload", alignLeft, func(t *asynq.TaskInfo) string { return formatByteSlice(t.Payload) }}, + {"Group", alignLeft, func(t *asynq.TaskInfo) string { return t.Group }}, +} + +var scheduledTaskTableColumns = []*columnConfig[*asynq.TaskInfo]{ + {"ID", alignLeft, func(t *asynq.TaskInfo) string { return t.ID }}, + {"Type", alignLeft, func(t *asynq.TaskInfo) string { return t.Type }}, + {"Next Process Time", alignLeft, func(t *asynq.TaskInfo) string { + return formatNextProcessTime(t.NextProcessAt) + }}, + {"Payload", alignLeft, func(t *asynq.TaskInfo) string { return formatByteSlice(t.Payload) }}, +} + +var retryTaskTableColumns = []*columnConfig[*asynq.TaskInfo]{ + {"ID", alignLeft, func(t *asynq.TaskInfo) string { return t.ID }}, + {"Type", alignLeft, func(t *asynq.TaskInfo) string { return t.Type }}, + {"Retry", alignRight, func(t *asynq.TaskInfo) string { return fmt.Sprintf("%d/%d", t.Retried, t.MaxRetry) }}, + {"Last Failure", alignLeft, func(t *asynq.TaskInfo) string { return t.LastErr }}, + {"Last Failure Time", alignLeft, func(t *asynq.TaskInfo) string { return formatPastTime(t.LastFailedAt) }}, + {"Next Process Time", alignLeft, func(t *asynq.TaskInfo) string { + return formatNextProcessTime(t.NextProcessAt) + }}, + {"Payload", alignLeft, func(t *asynq.TaskInfo) string { return formatByteSlice(t.Payload) }}, +} + +var archivedTaskTableColumns = []*columnConfig[*asynq.TaskInfo]{ + {"ID", alignLeft, func(t *asynq.TaskInfo) string { return t.ID }}, + {"Type", alignLeft, func(t *asynq.TaskInfo) string { return t.Type }}, + {"Retry", alignRight, func(t *asynq.TaskInfo) string { return fmt.Sprintf("%d/%d", t.Retried, t.MaxRetry) }}, + {"Last Failure", alignLeft, func(t *asynq.TaskInfo) string { return t.LastErr }}, + {"Last Failure Time", alignLeft, func(t *asynq.TaskInfo) string { return formatPastTime(t.LastFailedAt) }}, + {"Payload", alignLeft, func(t *asynq.TaskInfo) string { return formatByteSlice(t.Payload) }}, +} + +var completedTaskTableColumns = []*columnConfig[*asynq.TaskInfo]{ + {"ID", alignLeft, func(t *asynq.TaskInfo) string { return t.ID }}, + {"Type", alignLeft, func(t *asynq.TaskInfo) string { return t.Type }}, + {"Completion Time", alignLeft, func(t *asynq.TaskInfo) string { return formatPastTime(t.CompletedAt) }}, + {"Payload", alignLeft, func(t *asynq.TaskInfo) string { return formatByteSlice(t.Payload) }}, + {"Result", alignLeft, func(t *asynq.TaskInfo) string { return formatByteSlice(t.Result) }}, +} + +func drawTaskTable(d *ScreenDrawer, state *State) { + if shouldShowGroupTable(state) { + drawGroupTable(d, state) + return + } + if len(state.tasks) == 0 { + return // print nothing + } + drawTable(d, baseStyle, getTaskTableColumnConfig(state.taskState), state.tasks, state.taskTableRowIdx-1) + + // Pagination + pageSize := taskPageSize(d.Screen()) + totalCount := getTaskCount(state.selectedQueue, state.taskState) + if state.taskState == asynq.TaskStateAggregating { + // aggregating tasks are scoped to each group when shown in the table. + totalCount = state.selectedGroup.Size + } + if pageSize < totalCount { + start := (state.pageNum-1)*pageSize + 1 + end := start + len(state.tasks) - 1 + paginationStyle := baseStyle.Foreground(tcell.ColorLightGray) + d.Print(fmt.Sprintf("Showing %d-%d out of %d", start, end, totalCount), paginationStyle) + if isNextTaskPageAvailable(d.Screen(), state) { + d.Print(" n=NextPage", paginationStyle) + } + if state.pageNum > 1 { + d.Print(" p=PrevPage", paginationStyle) + } + d.FillLine(' ', paginationStyle) + } +} + +func isNextTaskPageAvailable(s tcell.Screen, state *State) bool { + totalCount := getTaskCount(state.selectedQueue, state.taskState) + end := (state.pageNum-1)*taskPageSize(s) + len(state.tasks) + return end < totalCount +} + +func drawGroupTable(d *ScreenDrawer, state *State) { + if len(state.groups) == 0 { + return // print nothing + } + d.Println("<<< Select group >>>", baseStyle) + colConfigs := []*columnConfig[*asynq.GroupInfo]{ + {"Name", alignLeft, func(g *asynq.GroupInfo) string { return g.Group }}, + {"Size", alignRight, func(g *asynq.GroupInfo) string { return strconv.Itoa(g.Size) }}, + } + // pagination + pageSize := groupPageSize(d.Screen()) + total := len(state.groups) + start := (state.pageNum - 1) * pageSize + end := min(start+pageSize, total) + drawTable(d, baseStyle, colConfigs, state.groups[start:end], state.groupTableRowIdx-1) + + if pageSize < total { + d.Print(fmt.Sprintf("Showing %d-%d out of %d", start+1, end, total), labelStyle) + if end < total { + d.Print(" n=NextPage", labelStyle) + } + if start > 0 { + d.Print(" p=PrevPage", labelStyle) + } + } + d.FillLine(' ', labelStyle) +} + +type number interface { + int | int64 | float64 +} + +// min returns the smaller of x and y. if x==y, returns x +func min[V number](x, y V) V { + if x > y { + return y + } + return x +} + +// Define the order of states to show +var taskStates = []asynq.TaskState{ + asynq.TaskStateActive, + asynq.TaskStatePending, + asynq.TaskStateAggregating, + asynq.TaskStateScheduled, + asynq.TaskStateRetry, + asynq.TaskStateArchived, + asynq.TaskStateCompleted, +} + +func nextTaskState(current asynq.TaskState) asynq.TaskState { + for i, ts := range taskStates { + if current == ts { + if i == len(taskStates)-1 { + return taskStates[0] + } else { + return taskStates[i+1] + } + } + } + panic("unkown task state") +} + +func prevTaskState(current asynq.TaskState) asynq.TaskState { + for i, ts := range taskStates { + if current == ts { + if i == 0 { + return taskStates[len(taskStates)-1] + } else { + return taskStates[i-1] + } + } + } + panic("unkown task state") +} + +func getTaskCount(queue *asynq.QueueInfo, taskState asynq.TaskState) int { + switch taskState { + case asynq.TaskStateActive: + return queue.Active + case asynq.TaskStatePending: + return queue.Pending + case asynq.TaskStateAggregating: + return queue.Aggregating + case asynq.TaskStateScheduled: + return queue.Scheduled + case asynq.TaskStateRetry: + return queue.Retry + case asynq.TaskStateArchived: + return queue.Archived + case asynq.TaskStateCompleted: + return queue.Completed + } + panic("unkonwn task state") +} + +func drawTaskStateBreakdown(d *ScreenDrawer, style tcell.Style, state *State) { + const pad = " " // padding between states + for _, ts := range taskStates { + s := style + if state.taskState == ts { + s = s.Bold(true).Underline(true) + } + d.Print(fmt.Sprintf("%s:%d", strings.Title(ts.String()), getTaskCount(state.selectedQueue, ts)), s) + d.Print(pad, style) + } + d.NL() +} + +func drawTaskModal(d *ScreenDrawer, state *State) { + if state.taskID == "" { + return + } + task := state.selectedTask + if task == nil { + // task no longer found + fns := []func(d *modalRowDrawer){ + func(d *modalRowDrawer) { d.Print("=== Task Info ===", baseStyle.Bold(true)) }, + func(d *modalRowDrawer) { d.Print("", baseStyle) }, + func(d *modalRowDrawer) { + d.Print(fmt.Sprintf("Task %q no longer exists", state.taskID), baseStyle) + }, + } + withModal(d, fns) + return + } + fns := []func(d *modalRowDrawer){ + func(d *modalRowDrawer) { d.Print("=== Task Info ===", baseStyle.Bold(true)) }, + func(d *modalRowDrawer) { d.Print("", baseStyle) }, + func(d *modalRowDrawer) { + d.Print("ID: ", labelStyle) + d.Print(task.ID, baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("Type: ", labelStyle) + d.Print(task.Type, baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("State: ", labelStyle) + d.Print(task.State.String(), baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("Queue: ", labelStyle) + d.Print(task.Queue, baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("Retry: ", labelStyle) + d.Print(fmt.Sprintf("%d/%d", task.Retried, task.MaxRetry), baseStyle) + }, + } + if task.LastErr != "" { + fns = append(fns, func(d *modalRowDrawer) { + d.Print("Last Failure: ", labelStyle) + d.Print(task.LastErr, baseStyle) + }) + fns = append(fns, func(d *modalRowDrawer) { + d.Print("Last Failure Time: ", labelStyle) + d.Print(fmt.Sprintf("%v (%s)", task.LastFailedAt, formatPastTime(task.LastFailedAt)), baseStyle) + }) + } + if !task.NextProcessAt.IsZero() { + fns = append(fns, func(d *modalRowDrawer) { + d.Print("Next Process Time: ", labelStyle) + d.Print(fmt.Sprintf("%v (%s)", task.NextProcessAt, formatNextProcessTime(task.NextProcessAt)), baseStyle) + }) + } + if !task.CompletedAt.IsZero() { + fns = append(fns, func(d *modalRowDrawer) { + d.Print("Completion Time: ", labelStyle) + d.Print(fmt.Sprintf("%v (%s)", task.CompletedAt, formatPastTime(task.CompletedAt)), baseStyle) + }) + } + fns = append(fns, func(d *modalRowDrawer) { + d.Print("Payload: ", labelStyle) + d.Print(formatByteSlice(task.Payload), baseStyle) + }) + if task.Result != nil { + fns = append(fns, func(d *modalRowDrawer) { + d.Print("Result: ", labelStyle) + d.Print(formatByteSlice(task.Result), baseStyle) + }) + } + withModal(d, fns) +} + +// Reports whether the given byte slice is printable (i.e. human readable) +func isPrintable(data []byte) bool { + if !utf8.Valid(data) { + return false + } + isAllSpace := true + for _, r := range string(data) { + if !unicode.IsGraphic(r) { + return false + } + if !unicode.IsSpace(r) { + isAllSpace = false + } + } + return !isAllSpace +} + +func formatByteSlice(data []byte) string { + if data == nil { + return "" + } + if !isPrintable(data) { + return "" + } + return strings.ReplaceAll(string(data), "\n", " ") +} + +type modalRowDrawer struct { + d *ScreenDrawer + width int // current width occupied by content + maxWidth int +} + +// Note: s should not include newline +func (d *modalRowDrawer) Print(s string, style tcell.Style) { + if d.width >= d.maxWidth { + return // no longer write to this row + } + if d.width+runewidth.StringWidth(s) > d.maxWidth { + s = truncate(s, d.maxWidth-d.width) + } + d.d.Print(s, style) +} + +// withModal draws a modal with the given functions row by row. +func withModal(d *ScreenDrawer, rowPrintFns []func(d *modalRowDrawer)) { + w, h := d.Screen().Size() + var ( + modalWidth = int(math.Floor(float64(w) * 0.6)) + modalHeight = int(math.Floor(float64(h) * 0.6)) + rowOffset = int(math.Floor(float64(h) * 0.2)) // 20% from the top + colOffset = int(math.Floor(float64(w) * 0.2)) // 20% from the left + ) + if modalHeight < 3 { + return // no content can be shown + } + d.Goto(colOffset, rowOffset) + d.Print(string(tcell.RuneULCorner), baseStyle) + d.Print(strings.Repeat(string(tcell.RuneHLine), modalWidth-2), baseStyle) + d.Print(string(tcell.RuneURCorner), baseStyle) + d.NL() + rowDrawer := modalRowDrawer{ + d: d, + width: 0, + maxWidth: modalWidth - 4, /* borders + paddings */ + } + for i := 1; i < modalHeight-1; i++ { + d.Goto(colOffset, rowOffset+i) + d.Print(fmt.Sprintf("%c ", tcell.RuneVLine), baseStyle) + if i <= len(rowPrintFns) { + rowPrintFns[i-1](&rowDrawer) + } + d.FillUntil(' ', baseStyle, colOffset+modalWidth-2) + d.Print(fmt.Sprintf(" %c", tcell.RuneVLine), baseStyle) + d.NL() + } + d.Goto(colOffset, rowOffset+modalHeight-1) + d.Print(string(tcell.RuneLLCorner), baseStyle) + d.Print(strings.Repeat(string(tcell.RuneHLine), modalWidth-2), baseStyle) + d.Print(string(tcell.RuneLRCorner), baseStyle) + d.NL() +} + +func adjustWidth(s string, width int) string { + sw := runewidth.StringWidth(s) + if sw > width { + return truncate(s, width) + } + var b strings.Builder + b.WriteString(s) + b.WriteString(strings.Repeat(" ", width-sw)) + return b.String() +} + +// truncates s if s exceeds max length. +func truncate(s string, max int) string { + if runewidth.StringWidth(s) <= max { + return s + } + return string([]rune(s)[:max-1]) + "…" +} + +func drawDebugInfo(d *ScreenDrawer, state *State) { + d.Println(state.DebugString(), baseStyle) +} + +func drawHelp(d *ScreenDrawer) { + keyStyle := labelStyle.Bold(true) + withModal(d, []func(*modalRowDrawer){ + func(d *modalRowDrawer) { d.Print("=== Help ===", baseStyle.Bold(true)) }, + func(d *modalRowDrawer) { d.Print("", baseStyle) }, + func(d *modalRowDrawer) { + d.Print("", keyStyle) + d.Print(" to select", baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("", keyStyle) + d.Print(" or ", baseStyle) + d.Print("", keyStyle) + d.Print(" to go back", baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("", keyStyle) + d.Print(" or ", baseStyle) + d.Print("", keyStyle) + d.Print(" to move up", baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("", keyStyle) + d.Print(" or ", baseStyle) + d.Print("", keyStyle) + d.Print(" to move down", baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("", keyStyle) + d.Print(" or ", baseStyle) + d.Print("", keyStyle) + d.Print(" to move left", baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("", keyStyle) + d.Print(" or ", baseStyle) + d.Print("", keyStyle) + d.Print(" to move right", baseStyle) + }, + func(d *modalRowDrawer) { + d.Print("", keyStyle) + d.Print(" to quit", baseStyle) + }, + }) +} diff --git a/tools/asynq/cmd/dash/draw_test.go b/tools/asynq/cmd/dash/draw_test.go new file mode 100644 index 0000000..a7a42eb --- /dev/null +++ b/tools/asynq/cmd/dash/draw_test.go @@ -0,0 +1,33 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package dash + +import "testing" + +func TestTruncate(t *testing.T) { + tests := []struct { + s string + max int + want string + }{ + { + s: "hello world!", + max: 15, + want: "hello world!", + }, + { + s: "hello world!", + max: 6, + want: "hello…", + }, + } + + for _, tc := range tests { + got := truncate(tc.s, tc.max) + if tc.want != got { + t.Errorf("truncate(%q, %d) = %q, want %q", tc.s, tc.max, got, tc.want) + } + } +} diff --git a/tools/asynq/cmd/dash/fetch.go b/tools/asynq/cmd/dash/fetch.go new file mode 100644 index 0000000..6b2e576 --- /dev/null +++ b/tools/asynq/cmd/dash/fetch.go @@ -0,0 +1,185 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package dash + +import ( + "sort" + + "github.com/gdamore/tcell/v2" + "github.com/hibiken/asynq" +) + +type fetcher interface { + // Fetch retries data required by the given state of the dashboard. + Fetch(state *State) +} + +type dataFetcher struct { + inspector *asynq.Inspector + opts Options + s tcell.Screen + + errorCh chan<- error + queueCh chan<- *asynq.QueueInfo + taskCh chan<- *asynq.TaskInfo + queuesCh chan<- []*asynq.QueueInfo + groupsCh chan<- []*asynq.GroupInfo + tasksCh chan<- []*asynq.TaskInfo +} + +func (f *dataFetcher) Fetch(state *State) { + switch state.view { + case viewTypeQueues: + f.fetchQueues() + case viewTypeQueueDetails: + if shouldShowGroupTable(state) { + f.fetchGroups(state.selectedQueue.Queue) + } else if state.taskState == asynq.TaskStateAggregating { + f.fetchAggregatingTasks(state.selectedQueue.Queue, state.selectedGroup.Group, taskPageSize(f.s), state.pageNum) + } else { + f.fetchTasks(state.selectedQueue.Queue, state.taskState, taskPageSize(f.s), state.pageNum) + } + // if the task modal is open, additionally fetch the selected task's info + if state.taskID != "" { + f.fetchTaskInfo(state.selectedQueue.Queue, state.taskID) + } + } +} + +func (f *dataFetcher) fetchQueues() { + var ( + inspector = f.inspector + queuesCh = f.queuesCh + errorCh = f.errorCh + opts = f.opts + ) + go fetchQueues(inspector, queuesCh, errorCh, opts) +} + +func fetchQueues(i *asynq.Inspector, queuesCh chan<- []*asynq.QueueInfo, errorCh chan<- error, opts Options) { + queues, err := i.Queues() + if err != nil { + errorCh <- err + return + } + sort.Strings(queues) + var res []*asynq.QueueInfo + for _, q := range queues { + info, err := i.GetQueueInfo(q) + if err != nil { + errorCh <- err + return + } + res = append(res, info) + } + queuesCh <- res +} + +func fetchQueueInfo(i *asynq.Inspector, qname string, queueCh chan<- *asynq.QueueInfo, errorCh chan<- error) { + q, err := i.GetQueueInfo(qname) + if err != nil { + errorCh <- err + return + } + queueCh <- q +} + +func (f *dataFetcher) fetchGroups(qname string) { + var ( + i = f.inspector + groupsCh = f.groupsCh + errorCh = f.errorCh + queueCh = f.queueCh + ) + go fetchGroups(i, qname, groupsCh, errorCh) + go fetchQueueInfo(i, qname, queueCh, errorCh) +} + +func fetchGroups(i *asynq.Inspector, qname string, groupsCh chan<- []*asynq.GroupInfo, errorCh chan<- error) { + groups, err := i.Groups(qname) + if err != nil { + errorCh <- err + return + } + groupsCh <- groups +} + +func (f *dataFetcher) fetchAggregatingTasks(qname, group string, pageSize, pageNum int) { + var ( + i = f.inspector + tasksCh = f.tasksCh + errorCh = f.errorCh + queueCh = f.queueCh + ) + go fetchAggregatingTasks(i, qname, group, pageSize, pageNum, tasksCh, errorCh) + go fetchQueueInfo(i, qname, queueCh, errorCh) +} + +func fetchAggregatingTasks(i *asynq.Inspector, qname, group string, pageSize, pageNum int, + tasksCh chan<- []*asynq.TaskInfo, errorCh chan<- error) { + tasks, err := i.ListAggregatingTasks(qname, group, asynq.PageSize(pageSize), asynq.Page(pageNum)) + if err != nil { + errorCh <- err + return + } + tasksCh <- tasks +} + +func (f *dataFetcher) fetchTasks(qname string, taskState asynq.TaskState, pageSize, pageNum int) { + var ( + i = f.inspector + tasksCh = f.tasksCh + errorCh = f.errorCh + queueCh = f.queueCh + ) + go fetchTasks(i, qname, taskState, pageSize, pageNum, tasksCh, errorCh) + go fetchQueueInfo(i, qname, queueCh, errorCh) +} + +func fetchTasks(i *asynq.Inspector, qname string, taskState asynq.TaskState, pageSize, pageNum int, + tasksCh chan<- []*asynq.TaskInfo, errorCh chan<- error) { + var ( + tasks []*asynq.TaskInfo + err error + ) + opts := []asynq.ListOption{asynq.PageSize(pageSize), asynq.Page(pageNum)} + switch taskState { + case asynq.TaskStateActive: + tasks, err = i.ListActiveTasks(qname, opts...) + case asynq.TaskStatePending: + tasks, err = i.ListPendingTasks(qname, opts...) + case asynq.TaskStateScheduled: + tasks, err = i.ListScheduledTasks(qname, opts...) + case asynq.TaskStateRetry: + tasks, err = i.ListRetryTasks(qname, opts...) + case asynq.TaskStateArchived: + tasks, err = i.ListArchivedTasks(qname, opts...) + case asynq.TaskStateCompleted: + tasks, err = i.ListCompletedTasks(qname, opts...) + } + if err != nil { + errorCh <- err + return + } + tasksCh <- tasks +} + +func (f *dataFetcher) fetchTaskInfo(qname, taskID string) { + var ( + i = f.inspector + taskCh = f.taskCh + errorCh = f.errorCh + ) + go fetchTaskInfo(i, qname, taskID, taskCh, errorCh) +} + +func fetchTaskInfo(i *asynq.Inspector, qname, taskID string, taskCh chan<- *asynq.TaskInfo, errorCh chan<- error) { + info, err := i.GetTaskInfo(qname, taskID) + if err != nil { + errorCh <- err + return + } + taskCh <- info +} diff --git a/tools/asynq/cmd/dash/key_event.go b/tools/asynq/cmd/dash/key_event.go new file mode 100644 index 0000000..83a21dc --- /dev/null +++ b/tools/asynq/cmd/dash/key_event.go @@ -0,0 +1,317 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package dash + +import ( + "os" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/hibiken/asynq" +) + +// keyEventHandler handles keyboard events and updates the state. +// It delegates data fetching to fetcher and UI rendering to drawer. +type keyEventHandler struct { + s tcell.Screen + state *State + done chan struct{} + + fetcher fetcher + drawer drawer + + ticker *time.Ticker + pollInterval time.Duration +} + +func (h *keyEventHandler) quit() { + h.s.Fini() + close(h.done) + os.Exit(0) +} + +func (h *keyEventHandler) HandleKeyEvent(ev *tcell.EventKey) { + if ev.Key() == tcell.KeyEscape || ev.Rune() == 'q' { + h.goBack() // Esc and 'q' key have "go back" semantics + } else if ev.Key() == tcell.KeyCtrlC { + h.quit() + } else if ev.Key() == tcell.KeyCtrlL { + h.s.Sync() + } else if ev.Key() == tcell.KeyDown || ev.Rune() == 'j' { + h.handleDownKey() + } else if ev.Key() == tcell.KeyUp || ev.Rune() == 'k' { + h.handleUpKey() + } else if ev.Key() == tcell.KeyRight || ev.Rune() == 'l' { + h.handleRightKey() + } else if ev.Key() == tcell.KeyLeft || ev.Rune() == 'h' { + h.handleLeftKey() + } else if ev.Key() == tcell.KeyEnter { + h.handleEnterKey() + } else if ev.Rune() == '?' { + h.showHelp() + } else if ev.Rune() == 'n' { + h.nextPage() + } else if ev.Rune() == 'p' { + h.prevPage() + } +} + +func (h *keyEventHandler) goBack() { + var ( + state = h.state + d = h.drawer + f = h.fetcher + ) + if state.view == viewTypeHelp { + state.view = state.prevView // exit help + f.Fetch(state) + h.resetTicker() + d.Draw(state) + } else if state.view == viewTypeQueueDetails { + // if task modal is open close it; otherwise go back to the previous view + if state.taskID != "" { + state.taskID = "" + state.selectedTask = nil + d.Draw(state) + } else { + state.view = viewTypeQueues + f.Fetch(state) + h.resetTicker() + d.Draw(state) + } + } else { + h.quit() + } +} + +func (h *keyEventHandler) handleDownKey() { + switch h.state.view { + case viewTypeQueues: + h.downKeyQueues() + case viewTypeQueueDetails: + h.downKeyQueueDetails() + } +} + +func (h *keyEventHandler) downKeyQueues() { + if h.state.queueTableRowIdx < len(h.state.queues) { + h.state.queueTableRowIdx++ + } else { + h.state.queueTableRowIdx = 0 // loop back + } + h.drawer.Draw(h.state) +} + +func (h *keyEventHandler) downKeyQueueDetails() { + s, state := h.s, h.state + if shouldShowGroupTable(state) { + if state.groupTableRowIdx < groupPageSize(s) { + state.groupTableRowIdx++ + } else { + state.groupTableRowIdx = 0 // loop back + } + } else if state.taskID == "" { + if state.taskTableRowIdx < len(state.tasks) { + state.taskTableRowIdx++ + } else { + state.taskTableRowIdx = 0 // loop back + } + } + h.drawer.Draw(state) +} + +func (h *keyEventHandler) handleUpKey() { + switch h.state.view { + case viewTypeQueues: + h.upKeyQueues() + case viewTypeQueueDetails: + h.upKeyQueueDetails() + } +} + +func (h *keyEventHandler) upKeyQueues() { + state := h.state + if state.queueTableRowIdx == 0 { + state.queueTableRowIdx = len(state.queues) + } else { + state.queueTableRowIdx-- + } + h.drawer.Draw(state) +} + +func (h *keyEventHandler) upKeyQueueDetails() { + s, state := h.s, h.state + if shouldShowGroupTable(state) { + if state.groupTableRowIdx == 0 { + state.groupTableRowIdx = groupPageSize(s) + } else { + state.groupTableRowIdx-- + } + } else if state.taskID == "" { + if state.taskTableRowIdx == 0 { + state.taskTableRowIdx = len(state.tasks) + } else { + state.taskTableRowIdx-- + } + } + h.drawer.Draw(state) +} + +func (h *keyEventHandler) handleEnterKey() { + switch h.state.view { + case viewTypeQueues: + h.enterKeyQueues() + case viewTypeQueueDetails: + h.enterKeyQueueDetails() + } +} + +func (h *keyEventHandler) resetTicker() { + h.ticker.Reset(h.pollInterval) +} + +func (h *keyEventHandler) enterKeyQueues() { + var ( + state = h.state + f = h.fetcher + d = h.drawer + ) + if state.queueTableRowIdx != 0 { + state.selectedQueue = state.queues[state.queueTableRowIdx-1] + state.view = viewTypeQueueDetails + state.taskState = asynq.TaskStateActive + state.tasks = nil + state.pageNum = 1 + f.Fetch(state) + h.resetTicker() + d.Draw(state) + } +} + +func (h *keyEventHandler) enterKeyQueueDetails() { + var ( + state = h.state + f = h.fetcher + d = h.drawer + ) + if shouldShowGroupTable(state) && state.groupTableRowIdx != 0 { + state.selectedGroup = state.groups[state.groupTableRowIdx-1] + state.tasks = nil + state.pageNum = 1 + f.Fetch(state) + h.resetTicker() + d.Draw(state) + } else if !shouldShowGroupTable(state) && state.taskTableRowIdx != 0 { + task := state.tasks[state.taskTableRowIdx-1] + state.selectedTask = task + state.taskID = task.ID + f.Fetch(state) + h.resetTicker() + d.Draw(state) + } + +} + +func (h *keyEventHandler) handleLeftKey() { + var ( + state = h.state + f = h.fetcher + d = h.drawer + ) + if state.view == viewTypeQueueDetails && state.taskID == "" { + state.taskState = prevTaskState(state.taskState) + state.pageNum = 1 + state.taskTableRowIdx = 0 + state.tasks = nil + state.selectedGroup = nil + f.Fetch(state) + h.resetTicker() + d.Draw(state) + } +} + +func (h *keyEventHandler) handleRightKey() { + var ( + state = h.state + f = h.fetcher + d = h.drawer + ) + if state.view == viewTypeQueueDetails && state.taskID == "" { + state.taskState = nextTaskState(state.taskState) + state.pageNum = 1 + state.taskTableRowIdx = 0 + state.tasks = nil + state.selectedGroup = nil + f.Fetch(state) + h.resetTicker() + d.Draw(state) + } +} + +func (h *keyEventHandler) nextPage() { + var ( + s = h.s + state = h.state + f = h.fetcher + d = h.drawer + ) + if state.view == viewTypeQueueDetails { + if shouldShowGroupTable(state) { + pageSize := groupPageSize(s) + total := len(state.groups) + start := (state.pageNum - 1) * pageSize + end := start + pageSize + if end <= total { + state.pageNum++ + d.Draw(state) + } + } else { + pageSize := taskPageSize(s) + totalCount := getTaskCount(state.selectedQueue, state.taskState) + if (state.pageNum-1)*pageSize+len(state.tasks) < totalCount { + state.pageNum++ + f.Fetch(state) + h.resetTicker() + } + } + } +} + +func (h *keyEventHandler) prevPage() { + var ( + s = h.s + state = h.state + f = h.fetcher + d = h.drawer + ) + if state.view == viewTypeQueueDetails { + if shouldShowGroupTable(state) { + pageSize := groupPageSize(s) + start := (state.pageNum - 1) * pageSize + if start > 0 { + state.pageNum-- + d.Draw(state) + } + } else { + if state.pageNum > 1 { + state.pageNum-- + f.Fetch(state) + h.resetTicker() + } + } + } +} + +func (h *keyEventHandler) showHelp() { + var ( + state = h.state + d = h.drawer + ) + if state.view != viewTypeHelp { + state.prevView = state.view + state.view = viewTypeHelp + d.Draw(state) + } +} diff --git a/tools/asynq/cmd/dash/key_event_test.go b/tools/asynq/cmd/dash/key_event_test.go new file mode 100644 index 0000000..cb60ab9 --- /dev/null +++ b/tools/asynq/cmd/dash/key_event_test.go @@ -0,0 +1,234 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package dash + +import ( + "testing" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/google/go-cmp/cmp" + "github.com/hibiken/asynq" +) + +func makeKeyEventHandler(t *testing.T, state *State) *keyEventHandler { + ticker := time.NewTicker(time.Second) + t.Cleanup(func() { ticker.Stop() }) + return &keyEventHandler{ + s: tcell.NewSimulationScreen("UTF-8"), + state: state, + done: make(chan struct{}), + fetcher: &fakeFetcher{}, + drawer: &fakeDrawer{}, + ticker: ticker, + pollInterval: time.Second, + } +} + +type keyEventHandlerTest struct { + desc string // test description + state *State // initial state, to be mutated by the handler + events []*tcell.EventKey // keyboard events + wantState State // expected state after the events +} + +func TestKeyEventHandler(t *testing.T) { + tests := []*keyEventHandlerTest{ + { + desc: "navigates to help view", + state: &State{view: viewTypeQueues}, + events: []*tcell.EventKey{tcell.NewEventKey(tcell.KeyRune, '?', tcell.ModNone)}, + wantState: State{view: viewTypeHelp}, + }, + { + desc: "navigates to queue details view", + state: &State{ + view: viewTypeQueues, + queues: []*asynq.QueueInfo{ + {Queue: "default", Size: 100, Active: 10, Pending: 40, Scheduled: 40, Completed: 10}, + }, + queueTableRowIdx: 0, + }, + events: []*tcell.EventKey{ + tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone), // down + tcell.NewEventKey(tcell.KeyEnter, '\n', tcell.ModNone), // Enter + }, + wantState: State{ + view: viewTypeQueueDetails, + queues: []*asynq.QueueInfo{ + {Queue: "default", Size: 100, Active: 10, Pending: 40, Scheduled: 40, Completed: 10}, + }, + selectedQueue: &asynq.QueueInfo{Queue: "default", Size: 100, Active: 10, Pending: 40, Scheduled: 40, Completed: 10}, + queueTableRowIdx: 1, + taskState: asynq.TaskStateActive, + pageNum: 1, + }, + }, + { + desc: "does nothing if no queues are present", + state: &State{ + view: viewTypeQueues, + queues: []*asynq.QueueInfo{}, // empty + queueTableRowIdx: 0, + }, + events: []*tcell.EventKey{ + tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone), // down + tcell.NewEventKey(tcell.KeyEnter, '\n', tcell.ModNone), // Enter + }, + wantState: State{ + view: viewTypeQueues, + queues: []*asynq.QueueInfo{}, + queueTableRowIdx: 0, + }, + }, + { + desc: "opens task info modal", + state: &State{ + view: viewTypeQueueDetails, + queues: []*asynq.QueueInfo{ + {Queue: "default", Size: 500, Active: 10, Pending: 40}, + }, + queueTableRowIdx: 1, + selectedQueue: &asynq.QueueInfo{Queue: "default", Size: 50, Active: 10, Pending: 40}, + taskState: asynq.TaskStatePending, + pageNum: 1, + tasks: []*asynq.TaskInfo{ + {ID: "xxxx", Type: "foo"}, + {ID: "yyyy", Type: "bar"}, + {ID: "zzzz", Type: "baz"}, + }, + taskTableRowIdx: 2, + }, + events: []*tcell.EventKey{ + tcell.NewEventKey(tcell.KeyEnter, '\n', tcell.ModNone), // Enter + }, + wantState: State{ + view: viewTypeQueueDetails, + queues: []*asynq.QueueInfo{ + {Queue: "default", Size: 500, Active: 10, Pending: 40}, + }, + queueTableRowIdx: 1, + selectedQueue: &asynq.QueueInfo{Queue: "default", Size: 50, Active: 10, Pending: 40}, + taskState: asynq.TaskStatePending, + pageNum: 1, + tasks: []*asynq.TaskInfo{ + {ID: "xxxx", Type: "foo"}, + {ID: "yyyy", Type: "bar"}, + {ID: "zzzz", Type: "baz"}, + }, + taskTableRowIdx: 2, + // new states + taskID: "yyyy", + selectedTask: &asynq.TaskInfo{ID: "yyyy", Type: "bar"}, + }, + }, + { + desc: "Esc closes task info modal", + state: &State{ + view: viewTypeQueueDetails, + queues: []*asynq.QueueInfo{ + {Queue: "default", Size: 500, Active: 10, Pending: 40}, + }, + queueTableRowIdx: 1, + selectedQueue: &asynq.QueueInfo{Queue: "default", Size: 50, Active: 10, Pending: 40}, + taskState: asynq.TaskStatePending, + pageNum: 1, + tasks: []*asynq.TaskInfo{ + {ID: "xxxx", Type: "foo"}, + {ID: "yyyy", Type: "bar"}, + {ID: "zzzz", Type: "baz"}, + }, + taskTableRowIdx: 2, + taskID: "yyyy", // presence of this field opens the modal + }, + events: []*tcell.EventKey{ + tcell.NewEventKey(tcell.KeyEscape, ' ', tcell.ModNone), // Esc + }, + wantState: State{ + view: viewTypeQueueDetails, + queues: []*asynq.QueueInfo{ + {Queue: "default", Size: 500, Active: 10, Pending: 40}, + }, + queueTableRowIdx: 1, + selectedQueue: &asynq.QueueInfo{Queue: "default", Size: 50, Active: 10, Pending: 40}, + taskState: asynq.TaskStatePending, + pageNum: 1, + tasks: []*asynq.TaskInfo{ + {ID: "xxxx", Type: "foo"}, + {ID: "yyyy", Type: "bar"}, + {ID: "zzzz", Type: "baz"}, + }, + taskTableRowIdx: 2, + taskID: "", // this field should be unset + }, + }, + { + desc: "Arrow keys are disabled while task info modal is open", + state: &State{ + view: viewTypeQueueDetails, + queues: []*asynq.QueueInfo{ + {Queue: "default", Size: 500, Active: 10, Pending: 40}, + }, + queueTableRowIdx: 1, + selectedQueue: &asynq.QueueInfo{Queue: "default", Size: 50, Active: 10, Pending: 40}, + taskState: asynq.TaskStatePending, + pageNum: 1, + tasks: []*asynq.TaskInfo{ + {ID: "xxxx", Type: "foo"}, + {ID: "yyyy", Type: "bar"}, + {ID: "zzzz", Type: "baz"}, + }, + taskTableRowIdx: 2, + taskID: "yyyy", // presence of this field opens the modal + }, + events: []*tcell.EventKey{ + tcell.NewEventKey(tcell.KeyLeft, ' ', tcell.ModNone), + }, + + // no change + wantState: State{ + view: viewTypeQueueDetails, + queues: []*asynq.QueueInfo{ + {Queue: "default", Size: 500, Active: 10, Pending: 40}, + }, + queueTableRowIdx: 1, + selectedQueue: &asynq.QueueInfo{Queue: "default", Size: 50, Active: 10, Pending: 40}, + taskState: asynq.TaskStatePending, + pageNum: 1, + tasks: []*asynq.TaskInfo{ + {ID: "xxxx", Type: "foo"}, + {ID: "yyyy", Type: "bar"}, + {ID: "zzzz", Type: "baz"}, + }, + taskTableRowIdx: 2, + taskID: "yyyy", // presence of this field opens the modal + }, + }, + // TODO: Add more tests + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + h := makeKeyEventHandler(t, tc.state) + for _, e := range tc.events { + h.HandleKeyEvent(e) + } + if diff := cmp.Diff(tc.wantState, *tc.state, cmp.AllowUnexported(State{})); diff != "" { + t.Errorf("after state was %+v, want %+v: (-want,+got)\n%s", *tc.state, tc.wantState, diff) + } + }) + } + +} + +/*** fake implementation for tests ***/ + +type fakeFetcher struct{} + +func (f *fakeFetcher) Fetch(s *State) {} + +type fakeDrawer struct{} + +func (d *fakeDrawer) Draw(s *State) {} diff --git a/tools/asynq/cmd/dash/screen_drawer.go b/tools/asynq/cmd/dash/screen_drawer.go new file mode 100644 index 0000000..3c07813 --- /dev/null +++ b/tools/asynq/cmd/dash/screen_drawer.go @@ -0,0 +1,100 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package dash + +import ( + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" +) + +/*** Screen Drawer ***/ + +// ScreenDrawer is used to draw contents on screen. +// +// Usage example: +// d := NewScreenDrawer(s) +// d.Println("Hello world", mystyle) +// d.NL() // adds newline +// d.Print("foo", mystyle.Bold(true)) +// d.Print("bar", mystyle.Italic(true)) +type ScreenDrawer struct { + l *LineDrawer +} + +func NewScreenDrawer(s tcell.Screen) *ScreenDrawer { + return &ScreenDrawer{l: NewLineDrawer(0, s)} +} + +func (d *ScreenDrawer) Print(s string, style tcell.Style) { + d.l.Draw(s, style) +} + +func (d *ScreenDrawer) Println(s string, style tcell.Style) { + d.Print(s, style) + d.NL() +} + +// FillLine prints the given rune until the end of the current line +// and adds a newline. +func (d *ScreenDrawer) FillLine(r rune, style tcell.Style) { + w, _ := d.Screen().Size() + if w-d.l.col < 0 { + d.NL() + return + } + s := strings.Repeat(string(r), w-d.l.col) + d.Print(s, style) + d.NL() +} + +func (d *ScreenDrawer) FillUntil(r rune, style tcell.Style, limit int) { + if d.l.col > limit { + return // already passed the limit + } + s := strings.Repeat(string(r), limit-d.l.col) + d.Print(s, style) +} + +// NL adds a newline (i.e., moves to the next line). +func (d *ScreenDrawer) NL() { + d.l.row++ + d.l.col = 0 +} + +func (d *ScreenDrawer) Screen() tcell.Screen { + return d.l.s +} + +// Goto moves the screendrawer to the specified cell. +func (d *ScreenDrawer) Goto(x, y int) { + d.l.row = y + d.l.col = x +} + +// Go to the bottom of the screen. +func (d *ScreenDrawer) GoToBottom() { + _, h := d.Screen().Size() + d.l.row = h - 1 + d.l.col = 0 +} + +type LineDrawer struct { + s tcell.Screen + row int + col int +} + +func NewLineDrawer(row int, s tcell.Screen) *LineDrawer { + return &LineDrawer{row: row, col: 0, s: s} +} + +func (d *LineDrawer) Draw(s string, style tcell.Style) { + for _, r := range s { + d.s.SetContent(d.col, d.row, r, nil, style) + d.col += runewidth.RuneWidth(r) + } +} diff --git a/tools/asynq/cmd/dash/table.go b/tools/asynq/cmd/dash/table.go new file mode 100644 index 0000000..b55a28a --- /dev/null +++ b/tools/asynq/cmd/dash/table.go @@ -0,0 +1,70 @@ +// Copyright 2022 Kentaro Hibino. All rights reserved. +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package dash + +import ( + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" +) + +type columnAlignment int + +const ( + alignRight columnAlignment = iota + alignLeft +) + +type columnConfig[V any] struct { + name string + alignment columnAlignment + displayFn func(v V) string +} + +type column[V any] struct { + *columnConfig[V] + width int +} + +// Helper to draw a table. +func drawTable[V any](d *ScreenDrawer, style tcell.Style, configs []*columnConfig[V], data []V, highlightRowIdx int) { + const colBuffer = " " // extra buffer between columns + cols := make([]*column[V], len(configs)) + for i, cfg := range configs { + cols[i] = &column[V]{cfg, runewidth.StringWidth(cfg.name)} + } + // adjust the column width to accommodate the widest value. + for _, v := range data { + for _, col := range cols { + if w := runewidth.StringWidth(col.displayFn(v)); col.width < w { + col.width = w + } + } + } + // print header + headerStyle := style.Background(tcell.ColorDimGray).Foreground(tcell.ColorWhite) + for _, col := range cols { + if col.alignment == alignLeft { + d.Print(rpad(col.name, col.width)+colBuffer, headerStyle) + } else { + d.Print(lpad(col.name, col.width)+colBuffer, headerStyle) + } + } + d.FillLine(' ', headerStyle) + // print body + for i, v := range data { + rowStyle := style + if highlightRowIdx == i { + rowStyle = style.Background(tcell.ColorDarkOliveGreen) + } + for _, col := range cols { + if col.alignment == alignLeft { + d.Print(rpad(col.displayFn(v), col.width)+colBuffer, rowStyle) + } else { + d.Print(lpad(col.displayFn(v), col.width)+colBuffer, rowStyle) + } + } + d.FillLine(' ', rowStyle) + } +} diff --git a/tools/asynq/cmd/root.go b/tools/asynq/cmd/root.go index bc1795a..377c784 100644 --- a/tools/asynq/cmd/root.go +++ b/tools/asynq/cmd/root.go @@ -259,6 +259,12 @@ func rpad(s string, padding int) string { } +// lpad adds padding to the left of a string. +func lpad(s string, padding int) string { + tmpl := fmt.Sprintf("%%%ds ", padding) + return fmt.Sprintf(tmpl, s) +} + // indent indents the given text by given spaces. func indent(text string, space int) string { if len(text) == 0 { diff --git a/tools/go.mod b/tools/go.mod index 0e4c6ef..562f819 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -1,13 +1,16 @@ module github.com/hibiken/asynq/tools -go 1.13 +go 1.18 require ( github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/fatih/color v1.9.0 + github.com/gdamore/tcell/v2 v2.5.1 github.com/go-redis/redis/v8 v8.11.4 + github.com/google/go-cmp v0.5.6 github.com/hibiken/asynq v0.23.0 github.com/hibiken/asynq/x v0.0.0-20220131170841-349f4c50fb1d + github.com/mattn/go-runewidth v0.0.13 github.com/mitchellh/go-homedir v1.1.0 github.com/prometheus/client_golang v1.11.0 github.com/spf13/cobra v1.1.1 @@ -15,3 +18,38 @@ require ( github.com/spf13/viper v1.7.0 golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136 ) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/magiconair/properties v1.8.1 // indirect + github.com/mattn/go-colorable v0.1.4 // indirect + github.com/mattn/go-isatty v0.0.11 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.1.2 // indirect + github.com/pelletier/go-toml v1.2.0 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.26.0 // indirect + github.com/prometheus/procfs v0.6.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/spf13/afero v1.1.2 // indirect + github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/jwalterweatherman v1.0.0 // indirect + github.com/subosito/gotenv v1.2.0 // indirect + golang.org/x/sys v0.0.0-20220318055525-2edf467146b5 // indirect + golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect + golang.org/x/text v0.3.7 // indirect + golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect + google.golang.org/protobuf v1.26.0 // indirect + gopkg.in/ini.v1 v1.51.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/tools/go.sum b/tools/go.sum index c2e3f56..9acd7ba 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -32,7 +32,6 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= @@ -59,6 +58,10 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.5.1 h1:zc3LPdpK184lBW7syF2a5C6MV827KmErk9jGVnmsl/I= +github.com/gdamore/tcell/v2 v2.5.1/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -169,6 +172,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -178,6 +183,8 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= @@ -244,6 +251,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -385,15 +394,19 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 h1:JWgyZ1qgdTaF3N3oxC+MdTV7qvEEgHo3otj+HB5CM7Q= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220318055525-2edf467146b5 h1:saXMvIOKvRFwbOMicHXr0B1uwoxq9dGmLe5ExMES6c4= +golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=