mirror of
https://github.com/hibiken/asynq.git
synced 2024-11-10 03:21:55 +08:00
(cli): Add dash command
This commit is contained in:
parent
e0e5d1ac24
commit
86fe31990b
29
.github/workflows/build.yml
vendored
29
.github/workflows/build.yml
vendored
@ -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 ..
|
||||
|
||||
|
44
tools/asynq/cmd/dash.go
Normal file
44
tools/asynq/cmd/dash.go
Normal file
@ -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,
|
||||
})
|
||||
},
|
||||
}
|
219
tools/asynq/cmd/dash/dash.go
Normal file
219
tools/asynq/cmd/dash/dash.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
724
tools/asynq/cmd/dash/draw.go
Normal file
724
tools/asynq/cmd/dash/draw.go
Normal file
@ -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) // <qname> |<graph> <size>
|
||||
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("<Esc>: GoBack", style)
|
||||
default:
|
||||
d.Print("<?>: Help <Ctrl+C>: 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 "<nil>"
|
||||
}
|
||||
if !isPrintable(data) {
|
||||
return "<non-printable>"
|
||||
}
|
||||
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("<Enter>", keyStyle)
|
||||
d.Print(" to select", baseStyle)
|
||||
},
|
||||
func(d *modalRowDrawer) {
|
||||
d.Print("<Esc>", keyStyle)
|
||||
d.Print(" or ", baseStyle)
|
||||
d.Print("<q>", keyStyle)
|
||||
d.Print(" to go back", baseStyle)
|
||||
},
|
||||
func(d *modalRowDrawer) {
|
||||
d.Print("<UpArrow>", keyStyle)
|
||||
d.Print(" or ", baseStyle)
|
||||
d.Print("<k>", keyStyle)
|
||||
d.Print(" to move up", baseStyle)
|
||||
},
|
||||
func(d *modalRowDrawer) {
|
||||
d.Print("<DownArrow>", keyStyle)
|
||||
d.Print(" or ", baseStyle)
|
||||
d.Print("<j>", keyStyle)
|
||||
d.Print(" to move down", baseStyle)
|
||||
},
|
||||
func(d *modalRowDrawer) {
|
||||
d.Print("<LeftArrow>", keyStyle)
|
||||
d.Print(" or ", baseStyle)
|
||||
d.Print("<h>", keyStyle)
|
||||
d.Print(" to move left", baseStyle)
|
||||
},
|
||||
func(d *modalRowDrawer) {
|
||||
d.Print("<RightArrow>", keyStyle)
|
||||
d.Print(" or ", baseStyle)
|
||||
d.Print("<l>", keyStyle)
|
||||
d.Print(" to move right", baseStyle)
|
||||
},
|
||||
func(d *modalRowDrawer) {
|
||||
d.Print("<Ctrl+C>", keyStyle)
|
||||
d.Print(" to quit", baseStyle)
|
||||
},
|
||||
})
|
||||
}
|
33
tools/asynq/cmd/dash/draw_test.go
Normal file
33
tools/asynq/cmd/dash/draw_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
185
tools/asynq/cmd/dash/fetch.go
Normal file
185
tools/asynq/cmd/dash/fetch.go
Normal file
@ -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
|
||||
}
|
317
tools/asynq/cmd/dash/key_event.go
Normal file
317
tools/asynq/cmd/dash/key_event.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
234
tools/asynq/cmd/dash/key_event_test.go
Normal file
234
tools/asynq/cmd/dash/key_event_test.go
Normal file
@ -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) {}
|
100
tools/asynq/cmd/dash/screen_drawer.go
Normal file
100
tools/asynq/cmd/dash/screen_drawer.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
70
tools/asynq/cmd/dash/table.go
Normal file
70
tools/asynq/cmd/dash/table.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
40
tools/go.mod
40
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
|
||||
)
|
||||
|
19
tools/go.sum
19
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=
|
||||
|
Loading…
Reference in New Issue
Block a user