diff --git a/tools/asynq/cmd/dash/dash.go b/tools/asynq/cmd/dash/dash.go index 8d6ea76..c6913e8 100644 --- a/tools/asynq/cmd/dash/dash.go +++ b/tools/asynq/cmd/dash/dash.go @@ -72,13 +72,19 @@ func Run(opts Options) { fmt.Printf("failed to initialize screen: %v\n", err) os.Exit(1) } - // Set default text style - s.SetStyle(baseStyle) + s.SetStyle(baseStyle) // set default text style + opts.PollInterval = 2 * time.Second - inspector := asynq.NewInspector(asynq.RedisClientOpt{Addr: ":6379"}) - - // channels to send/receive data fetched asynchronously 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) queuesCh = make(chan []*asynq.QueueInfo) @@ -86,17 +92,6 @@ func Run(opts Options) { tasksCh = make(chan []*asynq.TaskInfo) redisInfoCh = make(chan *redisInfo) ) - go fetchQueues(inspector, queuesCh, errorCh, opts) - - var state State // contained in this goroutine only; do not share - - // draw initial screen - drawDash(s, &state, opts) - - eventCh := make(chan tcell.Event) - done := make(chan struct{}) - opts.PollInterval = 2 * time.Second - ticker := time.NewTicker(opts.PollInterval) defer ticker.Stop() f := dataFetcher{ @@ -111,16 +106,22 @@ func Run(opts Options) { redisInfoCh, } + d := dashDrawer{ + s, + opts, + } + h := keyEventHandler{ s: s, fetcher: &f, + drawer: &d, state: &state, - opts: opts, done: done, } - // TODO: Double check that we are not leaking goroutine with this one. - go s.ChannelEvents(eventCh, done) + 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 @@ -158,31 +159,31 @@ func Run(opts Options) { case queues := <-queuesCh: state.queues = queues state.err = nil - drawDash(s, &state, opts) + d.draw(&state) case q := <-queueCh: state.selectedQueue = q state.err = nil - drawDash(s, &state, opts) + d.draw(&state) case groups := <-groupsCh: state.groups = groups state.err = nil - drawDash(s, &state, opts) + d.draw(&state) case tasks := <-tasksCh: state.tasks = tasks state.err = nil - drawDash(s, &state, opts) + d.draw(&state) case redisInfo := <-redisInfoCh: state.redisInfo = *redisInfo state.err = nil - drawDash(s, &state, opts) + d.draw(&state) case err := <-errorCh: state.err = err - drawDash(s, &state, opts) + d.draw(&state) } } diff --git a/tools/asynq/cmd/dash/draw.go b/tools/asynq/cmd/dash/draw.go index ec52f2f..ded6c5d 100644 --- a/tools/asynq/cmd/dash/draw.go +++ b/tools/asynq/cmd/dash/draw.go @@ -16,7 +16,18 @@ import ( "github.com/mattn/go-runewidth" ) -func drawDash(s tcell.Screen, state *State, opts Options) { +// 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) diff --git a/tools/asynq/cmd/dash/fetch.go b/tools/asynq/cmd/dash/fetch.go index ddb9df9..f148ed9 100644 --- a/tools/asynq/cmd/dash/fetch.go +++ b/tools/asynq/cmd/dash/fetch.go @@ -11,6 +11,15 @@ import ( "github.com/hibiken/asynq" ) +type fetcher interface { + fetchQueues() + fetchQueueInfo(qname string) + fetchRedisInfo() + fetchTasks(qname string, taskState asynq.TaskState, pageSize, pageNum int) + fetchAggregatingTasks(qname, group string, pageSize, pageNum int) + fetchGroups(qname string) +} + type dataFetcher struct { ticker *time.Ticker inspector *asynq.Inspector diff --git a/tools/asynq/cmd/dash/key_event.go b/tools/asynq/cmd/dash/key_event.go index 7e44f3a..90ea37e 100644 --- a/tools/asynq/cmd/dash/key_event.go +++ b/tools/asynq/cmd/dash/key_event.go @@ -11,13 +11,15 @@ import ( "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 - opts Options done chan struct{} - fetcher *dataFetcher + fetcher fetcher + drawer drawer } func (h *keyEventHandler) quit() { @@ -62,16 +64,15 @@ func (h *keyEventHandler) HandleKeyEvent(ev *tcell.EventKey) { func (h *keyEventHandler) goBack() { var ( - s = h.s state = h.state - opts = h.opts + d = h.drawer ) if state.view == viewTypeHelp { state.view = state.prevView // exit help - drawDash(s, state, opts) + d.draw(state) } else if state.view == viewTypeQueueDetails { state.view = viewTypeQueues - drawDash(s, state, opts) + d.draw(state) } else { h.quit() } @@ -92,11 +93,11 @@ func (h *keyEventHandler) downKeyQueues() { } else { h.state.queueTableRowIdx = 0 // loop back } - drawDash(h.s, h.state, h.opts) + h.drawer.draw(h.state) } func (h *keyEventHandler) downKeyQueueDetails() { - s, state, opts := h.s, h.state, h.opts + s, state := h.s, h.state if shouldShowGroupTable(state) { if state.groupTableRowIdx < groupPageSize(s) { state.groupTableRowIdx++ @@ -110,7 +111,7 @@ func (h *keyEventHandler) downKeyQueueDetails() { state.taskTableRowIdx = 0 // loop back } } - drawDash(s, state, opts) + h.drawer.draw(state) } func (h *keyEventHandler) handleUpKey() { @@ -123,17 +124,17 @@ func (h *keyEventHandler) handleUpKey() { } func (h *keyEventHandler) upKeyQueues() { - s, state, opts := h.s, h.state, h.opts + state := h.state if state.queueTableRowIdx == 0 { state.queueTableRowIdx = len(state.queues) } else { state.queueTableRowIdx-- } - drawDash(s, state, opts) + h.drawer.draw(state) } func (h *keyEventHandler) upKeyQueueDetails() { - s, state, opts := h.s, h.state, h.opts + s, state := h.s, h.state if shouldShowGroupTable(state) { if state.groupTableRowIdx == 0 { state.groupTableRowIdx = groupPageSize(s) @@ -147,7 +148,7 @@ func (h *keyEventHandler) upKeyQueueDetails() { state.taskTableRowIdx-- } } - drawDash(s, state, opts) + h.drawer.draw(state) } func (h *keyEventHandler) handleEnterKey() { @@ -163,8 +164,8 @@ func (h *keyEventHandler) enterKeyQueues() { var ( s = h.s state = h.state - opts = h.opts f = h.fetcher + d = h.drawer ) if state.queueTableRowIdx != 0 { state.selectedQueue = state.queues[state.queueTableRowIdx-1] @@ -173,7 +174,7 @@ func (h *keyEventHandler) enterKeyQueues() { state.tasks = nil state.pageNum = 1 f.fetchTasks(state.selectedQueue.Queue, state.taskState, taskPageSize(s), state.pageNum) - drawDash(s, state, opts) + d.draw(state) } } @@ -181,15 +182,15 @@ func (h *keyEventHandler) enterKeyQueueDetails() { var ( s = h.s state = h.state - opts = h.opts 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.fetchAggregatingTasks(state.selectedQueue.Queue, state.selectedGroup.Group, taskPageSize(s), state.pageNum) - drawDash(s, state, opts) + d.draw(state) } } @@ -197,8 +198,8 @@ func (h *keyEventHandler) handleLeftKey() { var ( s = h.s state = h.state - opts = h.opts f = h.fetcher + d = h.drawer ) if state.view == viewTypeQueueDetails { state.taskState = prevTaskState(state.taskState) @@ -211,7 +212,7 @@ func (h *keyEventHandler) handleLeftKey() { } else { f.fetchTasks(state.selectedQueue.Queue, state.taskState, taskPageSize(s), state.pageNum) } - drawDash(s, state, opts) + d.draw(state) } } @@ -219,8 +220,8 @@ func (h *keyEventHandler) handleRightKey() { var ( s = h.s state = h.state - opts = h.opts f = h.fetcher + d = h.drawer ) if state.view == viewTypeQueueDetails { state.taskState = nextTaskState(state.taskState) @@ -233,7 +234,7 @@ func (h *keyEventHandler) handleRightKey() { } else { f.fetchTasks(state.selectedQueue.Queue, state.taskState, taskPageSize(s), state.pageNum) } - drawDash(s, state, opts) + d.draw(state) } } @@ -241,8 +242,8 @@ func (h *keyEventHandler) nextPage() { var ( s = h.s state = h.state - opts = h.opts f = h.fetcher + d = h.drawer ) if state.view == viewTypeQueueDetails { if shouldShowGroupTable(state) { @@ -252,7 +253,7 @@ func (h *keyEventHandler) nextPage() { end := start + pageSize if end <= total { state.pageNum++ - drawDash(s, state, opts) + d.draw(state) } } else { pageSize := taskPageSize(s) @@ -269,8 +270,8 @@ func (h *keyEventHandler) prevPage() { var ( s = h.s state = h.state - opts = h.opts f = h.fetcher + d = h.drawer ) if state.view == viewTypeQueueDetails { if shouldShowGroupTable(state) { @@ -278,7 +279,7 @@ func (h *keyEventHandler) prevPage() { start := (state.pageNum - 1) * pageSize if start > 0 { state.pageNum-- - drawDash(s, state, opts) + d.draw(state) } } else { if state.pageNum > 1 { @@ -291,67 +292,62 @@ func (h *keyEventHandler) prevPage() { func (h *keyEventHandler) showQueues() { var ( - s = h.s state = h.state - opts = h.opts f = h.fetcher + d = h.drawer ) if state.view != viewTypeQueues { f.fetchQueues() state.view = viewTypeQueues - drawDash(s, state, opts) + d.draw(state) } } func (h *keyEventHandler) showServers() { var ( - s = h.s state = h.state - opts = h.opts + d = h.drawer ) if state.view != viewTypeServers { //TODO Start data fetch and reset ticker state.view = viewTypeServers - drawDash(s, state, opts) + d.draw(state) } } func (h *keyEventHandler) showSchedulers() { var ( - s = h.s state = h.state - opts = h.opts + d = h.drawer ) if state.view != viewTypeSchedulers { //TODO Start data fetch and reset ticker state.view = viewTypeSchedulers - drawDash(s, state, opts) + d.draw(state) } } func (h *keyEventHandler) showRedisInfo() { var ( - s = h.s state = h.state - opts = h.opts f = h.fetcher + d = h.drawer ) if state.view != viewTypeRedis { f.fetchRedisInfo() state.view = viewTypeRedis - drawDash(s, state, opts) + d.draw(state) } } func (h *keyEventHandler) showHelp() { var ( - s = h.s state = h.state - opts = h.opts + d = h.drawer ) if state.view != viewTypeHelp { state.prevView = state.view state.view = viewTypeHelp - drawDash(s, state, opts) + 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..2b2da17 --- /dev/null +++ b/tools/asynq/cmd/dash/key_event_test.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 ( + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/google/go-cmp/cmp" + "github.com/hibiken/asynq" +) + +func makeKeyEventHandler(state *State) *keyEventHandler { + return &keyEventHandler{ + s: tcell.NewSimulationScreen("UTF-8"), + state: state, + done: make(chan struct{}), + fetcher: &fakeFetcher{}, + drawer: &fakeDrawer{}, + } +} + +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 page", + state: &State{view: viewTypeQueues}, + events: []*tcell.EventKey{tcell.NewEventKey(tcell.KeyRune, '?', tcell.ModNone)}, + wantState: State{view: viewTypeHelp}, + }, + // TODO: Add more tests + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + h := makeKeyEventHandler(tc.state) + for _, e := range tc.events { + h.HandleKeyEvent(e) + } + if diff := cmp.Diff(tc.wantState, *tc.state, cmp.AllowUnexported(State{}, redisInfo{})); 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) fetchQueues() {} +func (f *fakeFetcher) fetchQueueInfo(qname string) {} +func (f *fakeFetcher) fetchRedisInfo() {} +func (f *fakeFetcher) fetchTasks(qname string, taskState asynq.TaskState, pageSize, pageNum int) {} +func (f *fakeFetcher) fetchAggregatingTasks(qname, group string, pageSize, pageNum int) {} +func (f *fakeFetcher) fetchGroups(qname string) {} + +type fakeDrawer struct{} + +func (d *fakeDrawer) draw(s *State) {} diff --git a/tools/go.mod b/tools/go.mod index e77ca8b..562f819 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -7,6 +7,7 @@ require ( 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