Initial commit

This commit is contained in:
Ken Hibino 2020-11-24 06:54:00 -08:00
commit 7bd35a88e5
51 changed files with 16522 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# main binary
asynqmon

208
conversion_helpers.go Normal file
View File

@ -0,0 +1,208 @@
package main
import (
"time"
"github.com/hibiken/asynq"
)
type QueueStateSnapshot struct {
// Name of the queue.
Queue string `json:"queue"`
// Total number of tasks in the queue.
Size int `json:"size"`
// Number of tasks in each state.
Active int `json:"active"`
Pending int `json:"pending"`
Scheduled int `json:"scheduled"`
Retry int `json:"retry"`
Dead int `json:"dead"`
// Total number of tasks processed during the given date.
// The number includes both succeeded and failed tasks.
Processed int `json:"processed"`
// Breakdown of processed tasks.
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
// Paused indicates whether the queue is paused.
// If true, tasks in the queue will not be processed.
Paused bool `json:"paused"`
// Time when this snapshot was taken.
Timestamp time.Time `json:"timestamp"`
}
func toQueueStateSnapshot(s *asynq.QueueStats) *QueueStateSnapshot {
return &QueueStateSnapshot{
Queue: s.Queue,
Size: s.Size,
Active: s.Active,
Pending: s.Pending,
Scheduled: s.Scheduled,
Retry: s.Retry,
Dead: s.Dead,
Processed: s.Processed,
Succeeded: s.Processed - s.Failed,
Failed: s.Failed,
Paused: s.Paused,
Timestamp: s.Timestamp,
}
}
type DailyStats struct {
Queue string `json:"queue"`
Processed int `json:"processed"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Date time.Time `json:"date"`
}
func toDailyStats(s *asynq.DailyStats) *DailyStats {
return &DailyStats{
Queue: s.Queue,
Processed: s.Processed,
Succeeded: s.Processed - s.Failed,
Failed: s.Failed,
Date: s.Date,
}
}
type BaseTask struct {
ID string `json:"id"`
Type string `json:"type"`
Payload asynq.Payload `json:"payload"`
Queue string `json:"queue"`
}
type ActiveTask struct {
*BaseTask
}
func toActiveTask(t *asynq.ActiveTask) *ActiveTask {
base := &BaseTask{
ID: t.ID,
Type: t.Type,
Payload: t.Payload,
Queue: t.Queue,
}
return &ActiveTask{base}
}
func toActiveTasks(in []*asynq.ActiveTask) []*ActiveTask {
out := make([]*ActiveTask, len(in))
for i, t := range in {
out[i] = toActiveTask(t)
}
return out
}
type PendingTask struct {
*BaseTask
}
func toPendingTask(t *asynq.PendingTask) *PendingTask {
base := &BaseTask{
ID: t.ID,
Type: t.Type,
Payload: t.Payload,
Queue: t.Queue,
}
return &PendingTask{base}
}
func toPendingTasks(in []*asynq.PendingTask) []*PendingTask {
out := make([]*PendingTask, len(in))
for i, t := range in {
out[i] = toPendingTask(t)
}
return out
}
type ScheduledTask struct {
*BaseTask
NextProcessAt time.Time `json:"next_process_at"`
}
func toScheduledTask(t *asynq.ScheduledTask) *ScheduledTask {
base := &BaseTask{
ID: t.ID,
Type: t.Type,
Payload: t.Payload,
Queue: t.Queue,
}
return &ScheduledTask{
BaseTask: base,
NextProcessAt: t.NextProcessAt,
}
}
func toScheduledTasks(in []*asynq.ScheduledTask) []*ScheduledTask {
out := make([]*ScheduledTask, len(in))
for i, t := range in {
out[i] = toScheduledTask(t)
}
return out
}
type RetryTask struct {
*BaseTask
NextProcessAt time.Time `json:"next_process_at"`
MaxRetry int `json:"max_retry"`
Retried int `json:"retried"`
ErrorMsg string `json:"error_message"`
}
func toRetryTask(t *asynq.RetryTask) *RetryTask {
base := &BaseTask{
ID: t.ID,
Type: t.Type,
Payload: t.Payload,
Queue: t.Queue,
}
return &RetryTask{
BaseTask: base,
NextProcessAt: t.NextProcessAt,
MaxRetry: t.MaxRetry,
Retried: t.Retried,
ErrorMsg: t.ErrorMsg,
}
}
func toRetryTasks(in []*asynq.RetryTask) []*RetryTask {
out := make([]*RetryTask, len(in))
for i, t := range in {
out[i] = toRetryTask(t)
}
return out
}
type DeadTask struct {
*BaseTask
MaxRetry int `json:"max_retry"`
Retried int `json:"retried"`
ErrorMsg string `json:"error_message"`
LastFailedAt time.Time `json:"last_failed_at"`
}
func toDeadTask(t *asynq.DeadTask) *DeadTask {
base := &BaseTask{
ID: t.ID,
Type: t.Type,
Payload: t.Payload,
Queue: t.Queue,
}
return &DeadTask{
BaseTask: base,
MaxRetry: t.MaxRetry,
Retried: t.Retried,
ErrorMsg: t.ErrorMsg,
LastFailedAt: t.LastFailedAt,
}
}
func toDeadTasks(in []*asynq.DeadTask) []*DeadTask {
out := make([]*DeadTask, len(in))
for i, t := range in {
out[i] = toDeadTask(t)
}
return out
}

9
go.mod Normal file
View File

@ -0,0 +1,9 @@
module asynqmon
go 1.14
require (
github.com/gorilla/mux v1.8.0
github.com/hibiken/asynq v0.13.1
github.com/rs/cors v1.7.0
)

51
go.sum Normal file
View File

@ -0,0 +1,51 @@
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-redis/redis/v7 v7.4.0 h1:7obg6wUoj05T0EpY0o8B59S9w5yeMWql7sw2kwNW1x4=
github.com/go-redis/redis/v7 v7.4.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hibiken/asynq v0.13.1 h1:OxHreBx1BvKYQUzMtdxEgUcOqeS16YziCL0jauLqN4Y=
github.com/hibiken/asynq v0.13.1/go.mod h1:yfQUmjFqSBSUIVxTK0WyW4LPj4gpr283UpWb6hKYaqE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e h1:9vRrk9YW2BTzLP0VCB9ZDjU4cPqkg+IDWL7XgxA1yxQ=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

103
main.go Normal file
View File

@ -0,0 +1,103 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gorilla/mux"
"github.com/hibiken/asynq"
"github.com/rs/cors"
)
// staticFileServer implements the http.Handler interface, so we can use it
// to respond to HTTP requests. The path to the static directory and
// path to the index file within that static directory are used to
// serve the SPA in the given static directory.
type staticFileServer struct {
staticPath string
indexPath string
}
// ServeHTTP inspects the URL path to locate a file within the static dir
// on the SPA handler. If a file is found, it will be served. If not, the
// file located at the index path on the SPA handler will be served. This
// is suitable behavior for serving an SPA (single page application).
func (srv *staticFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// get the absolute path to prevent directory traversal
path, err := filepath.Abs(r.URL.Path)
if err != nil {
// if we failed to get the absolute path respond with a 400 bad request
// and stop
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// prepend the path with the path to the static directory
path = filepath.Join(srv.staticPath, path)
// check whether a file exists at the given path
_, err = os.Stat(path)
if os.IsNotExist(err) {
// file does not exist, serve index.html
http.ServeFile(w, r, filepath.Join(srv.staticPath, srv.indexPath))
return
} else if err != nil {
// if we got an error (that wasn't that the file doesn't exist) stating the
// file, return a 500 internal server error and stop
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// otherwise, use http.FileServer to serve the static dir
http.FileServer(http.Dir(srv.staticPath)).ServeHTTP(w, r)
}
const addr = "127.0.0.1:8080"
func main() {
inspector := asynq.NewInspector(asynq.RedisClientOpt{
Addr: "localhost:6379",
})
defer inspector.Close()
router := mux.NewRouter()
api := router.PathPrefix("/api").Subrouter()
api.HandleFunc("/queues",
newListQueuesHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}",
newGetQueueHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/pause",
newPauseQueueHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/resume",
newResumeQueueHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/active_tasks",
newListActiveTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/pending_tasks",
newListPendingTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/scheduled_tasks",
newListScheduledTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/retry_tasks",
newListRetryTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/dead_tasks",
newListDeadTasksHandlerFunc(inspector)).Methods("GET")
fs := &staticFileServer{staticPath: "ui/build", indexPath: "index.html"}
router.PathPrefix("/").Handler(fs)
handler := cors.Default().Handler(router)
srv := &http.Server{
Handler: handler,
Addr: addr,
WriteTimeout: 10 * time.Second,
ReadTimeout: 10 * time.Second,
}
fmt.Printf("Asynq Monitoring WebUI server is running on %s\n", addr)
log.Fatal(srv.ListenAndServe())
}

83
queue_handlers.go Normal file
View File

@ -0,0 +1,83 @@
package main
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/hibiken/asynq"
)
func newListQueuesHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
qnames, err := inspector.Queues()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var snapshots []*QueueStateSnapshot
for _, qname := range qnames {
s, err := inspector.CurrentStats(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
snapshots = append(snapshots, toQueueStateSnapshot(s))
}
payload := map[string]interface{}{"queues": snapshots}
json.NewEncoder(w).Encode(payload)
}
}
func newGetQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
payload := make(map[string]interface{})
stats, err := inspector.CurrentStats(qname)
if err != nil {
// TODO: Check for queue not found error.
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload["current"] = toQueueStateSnapshot(stats)
// TODO: make this n a variable
data, err := inspector.History(qname, 10)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var dailyStats []*DailyStats
for _, s := range data {
dailyStats = append(dailyStats, toDailyStats(s))
}
payload["history"] = dailyStats
json.NewEncoder(w).Encode(payload)
}
}
func newPauseQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
if err := inspector.PauseQueue(qname); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func newResumeQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
if err := inspector.UnpauseQueue(qname); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

169
task_handlers.go Normal file
View File

@ -0,0 +1,169 @@
package main
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/hibiken/asynq"
)
func newListActiveTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListActiveTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
stats, err := inspector.CurrentStats(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload := make(map[string]interface{})
if len(tasks) == 0 {
// avoid nil for the tasks field in json output.
payload["tasks"] = make([]*asynq.ActiveTask, 0)
} else {
payload["tasks"] = toActiveTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
json.NewEncoder(w).Encode(payload)
}
}
func newListPendingTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListPendingTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
stats, err := inspector.CurrentStats(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload := make(map[string]interface{})
if len(tasks) == 0 {
// avoid nil for the tasks field in json output.
payload["tasks"] = make([]*asynq.PendingTask, 0)
} else {
payload["tasks"] = toPendingTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
json.NewEncoder(w).Encode(payload)
}
}
func newListScheduledTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListScheduledTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
stats, err := inspector.CurrentStats(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload := make(map[string]interface{})
if len(tasks) == 0 {
// avoid nil for the tasks field in json output.
payload["tasks"] = make([]*asynq.ScheduledTask, 0)
} else {
payload["tasks"] = toScheduledTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
json.NewEncoder(w).Encode(payload)
}
}
func newListRetryTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListRetryTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
stats, err := inspector.CurrentStats(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload := make(map[string]interface{})
if len(tasks) == 0 {
// avoid nil for the tasks field in json output.
payload["tasks"] = make([]*asynq.RetryTask, 0)
} else {
payload["tasks"] = toRetryTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
json.NewEncoder(w).Encode(payload)
}
}
func newListDeadTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListDeadTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
stats, err := inspector.CurrentStats(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
payload := make(map[string]interface{})
if len(tasks) == 0 {
// avoid nil for the tasks field in json output.
payload["tasks"] = make([]*asynq.DeadTask, 0)
} else {
payload["tasks"] = toDeadTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
json.NewEncoder(w).Encode(payload)
}
}
// getPageOptions read page size and number from the request url if set,
// otherwise it returns the default value.
func getPageOptions(r *http.Request) (pageSize, pageNum int) {
pageSize = 20 // default page size
pageNum = 1 // default page num
q := r.URL.Query()
if s := q.Get("size"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
pageSize = n
}
}
if s := q.Get("page"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
pageNum = n
}
}
return pageSize, pageNum
}

23
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

44
ui/README.md Normal file
View File

@ -0,0 +1,44 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

59
ui/package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "4.11.0",
"@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.56",
"@reduxjs/toolkit": "1.4.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/node": "^12.0.0",
"@types/react": "^16.9.0",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "7.1.9",
"@types/react-router-dom": "5.1.6",
"@types/react-syntax-highlighter": "13.5.0",
"@types/recharts": "1.8.16",
"@types/styled-components": "5.1.4",
"axios": "0.20.0",
"clsx": "1.1.1",
"query-string": "6.13.7",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "7.2.2",
"react-router-dom": "5.2.0",
"react-scripts": "3.4.3",
"react-syntax-highlighter": "15.3.0",
"recharts": "1.8.5",
"styled-components": "5.2.0",
"typescript": "~3.7.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"redux-devtools": "3.7.0"
}
}

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

51
ui/public/index.html Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
ui/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
ui/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
ui/public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
ui/public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

9
ui/src/App.test.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
test("renders learn react link", () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

194
ui/src/App.tsx Normal file
View File

@ -0,0 +1,194 @@
import React, { useState } from "react";
import clsx from "clsx";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import { makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Drawer from "@material-ui/core/Drawer";
import Toolbar from "@material-ui/core/Toolbar";
import List from "@material-ui/core/List";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import MenuIcon from "@material-ui/icons/Menu";
import BarChartIcon from "@material-ui/icons/BarChart";
import LayersIcon from "@material-ui/icons/Layers";
import SettingsIcon from "@material-ui/icons/Settings";
import { paths } from "./paths";
import ListItemLink from "./components/ListItemLink";
import CronView from "./views/CronView";
import DashboardView from "./views/DashboardView";
import TasksView from "./views/TasksView";
import SettingsView from "./views/SettingsView";
const drawerWidth = 220;
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
},
toolbar: {
paddingRight: 24, // keep right padding when drawer closed
},
toolbarIcon: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: "0 8px",
...theme.mixins.toolbar,
},
appBar: {
backgroundColor: theme.palette.background.paper,
zIndex: theme.zIndex.drawer + 1,
},
menuButton: {
marginRight: theme.spacing(2),
color: theme.palette.grey[700],
},
menuButtonHidden: {
display: "none",
},
title: {
flexGrow: 1,
color: theme.palette.grey[800],
},
drawerPaper: {
position: "relative",
whiteSpace: "nowrap",
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
border: "none",
},
drawerPaperClose: {
overflowX: "hidden",
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
width: theme.spacing(7),
[theme.breakpoints.up("sm")]: {
width: theme.spacing(9),
},
},
appBarSpacer: theme.mixins.toolbar,
mainContainer: {
display: "flex",
width: "100vw",
},
content: {
flex: 1,
height: "100vh",
overflow: "hidden",
background: "#ffffff",
},
contentWrapper: {
height: "100%",
display: "flex",
paddingTop: "64px", // app-bar height
overflow: "scroll",
},
sidebarContainer: {
display: "flex",
justifyContent: "space-between",
height: "100%",
flexDirection: "column",
},
}));
function App() {
const classes = useStyles();
const [open, setOpen] = useState(true);
const toggleDrawer = () => {
setOpen(!open);
};
return (
<Router>
<div className={classes.root}>
<AppBar
position="absolute"
className={classes.appBar}
variant="outlined"
>
<Toolbar className={classes.toolbar}>
<IconButton
edge="start"
color="inherit"
aria-label="open drawer"
onClick={toggleDrawer}
className={classes.menuButton}
>
<MenuIcon />
</IconButton>
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
className={classes.title}
>
Asynq Monitoring
</Typography>
</Toolbar>
</AppBar>
<div className={classes.mainContainer}>
<Drawer
variant="permanent"
classes={{
paper: clsx(
classes.drawerPaper,
!open && classes.drawerPaperClose
),
}}
open={open}
>
<div className={classes.appBarSpacer} />
<div className={classes.sidebarContainer}>
<List>
<div>
<ListItemLink
to="/"
primary="Queues"
icon={<BarChartIcon />}
/>
<ListItemLink
to="/cron"
primary="Cron"
icon={<LayersIcon />}
/>
</div>
</List>
<List>
<ListItemLink
to="/settings"
primary="Settings"
icon={<SettingsIcon />}
/>
</List>
</div>
</Drawer>
<main className={classes.content}>
<div className={classes.contentWrapper}>
<Switch>
<Route exact path={paths.QUEUE_DETAILS}>
<TasksView />
</Route>
<Route exact path={paths.CRON}>
<CronView />
</Route>
<Route exact path={paths.SETTINGS}>
<SettingsView />
</Route>
<Route path={paths.HOME}>
<DashboardView />
</Route>
</Switch>
</div>
</main>
</div>
</div>
</Router>
);
}
export default App;

View File

@ -0,0 +1,158 @@
import {
getQueue,
GetQueueResponse,
listQueues,
ListQueuesResponse,
pauseQueue,
resumeQueue,
} from "../api";
import { Dispatch } from "redux";
// List of queue related action types.
export const LIST_QUEUES_BEGIN = "LIST_QUEUES_BEGIN";
export const LIST_QUEUES_SUCCESS = "LIST_QUEUES_SUCCESS";
export const GET_QUEUE_BEGIN = "GET_QUEUE_BEGIN";
export const GET_QUEUE_SUCCESS = "GET_QUEUE_SUCCESS";
export const GET_QUEUE_ERROR = "GET_QUEUE_ERROR";
export const PAUSE_QUEUE_BEGIN = "PAUSE_QUEUE_BEGIN";
export const PAUSE_QUEUE_SUCCESS = "PAUSE_QUEUE_SUCCESS";
export const PAUSE_QUEUE_ERROR = "PAUSE_QUEUE_ERROR";
export const RESUME_QUEUE_BEGIN = "RESUME_QUEUE_BEGIN";
export const RESUME_QUEUE_SUCCESS = "RESUME_QUEUE_SUCCESS";
export const RESUME_QUEUE_ERROR = "RESUME_QUEUE_ERROR";
interface ListQueuesBeginAction {
type: typeof LIST_QUEUES_BEGIN;
}
interface ListQueuesSuccessAction {
type: typeof LIST_QUEUES_SUCCESS;
payload: ListQueuesResponse;
}
interface GetQueueBeginAction {
type: typeof GET_QUEUE_BEGIN;
queue: string; // name of the queue
}
interface GetQueueSuccessAction {
type: typeof GET_QUEUE_SUCCESS;
queue: string; // name of the queue
payload: GetQueueResponse;
}
interface GetQueueErrorAction {
type: typeof GET_QUEUE_ERROR;
queue: string; // name of the queue
error: string; // error description
}
interface PauseQueueBeginAction {
type: typeof PAUSE_QUEUE_BEGIN;
queue: string; // name of the queue
}
interface PauseQueueSuccessAction {
type: typeof PAUSE_QUEUE_SUCCESS;
queue: string; // name of the queue
}
interface PauseQueueErrorAction {
type: typeof PAUSE_QUEUE_ERROR;
queue: string; // name of the queue
error: string; // error description
}
interface ResumeQueueBeginAction {
type: typeof RESUME_QUEUE_BEGIN;
queue: string; // name of the queue
}
interface ResumeQueueSuccessAction {
type: typeof RESUME_QUEUE_SUCCESS;
queue: string; // name of the queue
}
interface ResumeQueueErrorAction {
type: typeof RESUME_QUEUE_ERROR;
queue: string; // name of the queue
error: string; // error description
}
// Union of all queues related action types.
export type QueuesActionTypes =
| ListQueuesBeginAction
| ListQueuesSuccessAction
| GetQueueBeginAction
| GetQueueSuccessAction
| GetQueueErrorAction
| PauseQueueBeginAction
| PauseQueueSuccessAction
| PauseQueueErrorAction
| ResumeQueueBeginAction
| ResumeQueueSuccessAction
| ResumeQueueErrorAction;
export function listQueuesAsync() {
return async (dispatch: Dispatch<QueuesActionTypes>) => {
dispatch({ type: LIST_QUEUES_BEGIN });
// TODO: try/catch and dispatch error action on failure
const response = await listQueues();
dispatch({
type: LIST_QUEUES_SUCCESS,
payload: response,
});
};
}
export function getQueueAsync(qname: string) {
return async (dispatch: Dispatch<QueuesActionTypes>) => {
dispatch({ type: GET_QUEUE_BEGIN, queue: qname });
try {
const response = await getQueue(qname);
dispatch({
type: GET_QUEUE_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: GET_QUEUE_ERROR,
queue: qname,
error: `Could not retrieve queue data for queue: ${qname}`,
});
}
};
}
export function pauseQueueAsync(qname: string) {
return async (dispatch: Dispatch<QueuesActionTypes>) => {
dispatch({ type: PAUSE_QUEUE_BEGIN, queue: qname });
try {
await pauseQueue(qname);
dispatch({ type: PAUSE_QUEUE_SUCCESS, queue: qname });
} catch {
dispatch({
type: PAUSE_QUEUE_ERROR,
queue: qname,
error: `Could not pause queue: ${qname}`,
});
}
};
}
export function resumeQueueAsync(qname: string) {
return async (dispatch: Dispatch<QueuesActionTypes>) => {
dispatch({ type: RESUME_QUEUE_BEGIN, queue: qname });
try {
await resumeQueue(qname);
dispatch({ type: RESUME_QUEUE_SUCCESS, queue: qname });
} catch {
dispatch({
type: RESUME_QUEUE_ERROR,
queue: qname,
error: `Could not resume queue: ${qname}`,
});
}
};
}

View File

@ -0,0 +1,17 @@
// List of settings related action types.
export const POLL_INTERVAL_CHANGE = "POLL_INTERVAL_CHANGE";
interface PollIntervalChangeAction {
type: typeof POLL_INTERVAL_CHANGE;
value: number; // new poll interval value in seconds
}
// Union of all settings related action types.
export type SettingsActionTypes = PollIntervalChangeAction;
export function pollIntervalChange(value: number) {
return {
type: POLL_INTERVAL_CHANGE,
value,
};
}

View File

@ -0,0 +1,246 @@
import {
listActiveTasks,
ListActiveTasksResponse,
listDeadTasks,
ListDeadTasksResponse,
listPendingTasks,
ListPendingTasksResponse,
listRetryTasks,
ListRetryTasksResponse,
listScheduledTasks,
ListScheduledTasksResponse,
PaginationOptions,
} from "../api";
import { Dispatch } from "redux";
// List of tasks related action types.
export const LIST_ACTIVE_TASKS_BEGIN = "LIST_ACTIVE_TASKS_BEGIN";
export const LIST_ACTIVE_TASKS_SUCCESS = "LIST_ACTIVE_TASKS_SUCCESS";
export const LIST_ACTIVE_TASKS_ERROR = "LIST_ACTIVE_TASKS_ERROR";
export const LIST_PENDING_TASKS_BEGIN = "LIST_PENDING_TASKS_BEGIN";
export const LIST_PENDING_TASKS_SUCCESS = "LIST_PENDING_TASKS_SUCCESS";
export const LIST_PENDING_TASKS_ERROR = "LIST_PENDING_TASKS_ERROR";
export const LIST_SCHEDULED_TASKS_BEGIN = "LIST_SCHEDULED_TASKS_BEGIN";
export const LIST_SCHEDULED_TASKS_SUCCESS = "LIST_SCHEDULED_TASKS_SUCCESS";
export const LIST_SCHEDULED_TASKS_ERROR = "LIST_SCHEDULED_TASKS_ERROR";
export const LIST_RETRY_TASKS_BEGIN = "LIST_RETRY_TASKS_BEGIN";
export const LIST_RETRY_TASKS_SUCCESS = "LIST_RETRY_TASKS_SUCCESS";
export const LIST_RETRY_TASKS_ERROR = "LIST_RETRY_TASKS_ERROR";
export const LIST_DEAD_TASKS_BEGIN = "LIST_DEAD_TASKS_BEGIN";
export const LIST_DEAD_TASKS_SUCCESS = "LIST_DEAD_TASKS_SUCCESS";
export const LIST_DEAD_TASKS_ERROR = "LIST_DEAD_TASKS_ERROR";
interface ListActiveTasksBeginAction {
type: typeof LIST_ACTIVE_TASKS_BEGIN;
queue: string;
}
interface ListActiveTasksSuccessAction {
type: typeof LIST_ACTIVE_TASKS_SUCCESS;
queue: string;
payload: ListActiveTasksResponse;
}
interface ListActiveTasksErrorAction {
type: typeof LIST_ACTIVE_TASKS_ERROR;
queue: string;
error: string; // error description
}
interface ListPendingTasksBeginAction {
type: typeof LIST_PENDING_TASKS_BEGIN;
queue: string;
}
interface ListPendingTasksSuccessAction {
type: typeof LIST_PENDING_TASKS_SUCCESS;
queue: string;
payload: ListPendingTasksResponse;
}
interface ListPendingTasksErrorAction {
type: typeof LIST_PENDING_TASKS_ERROR;
queue: string;
error: string; // error description
}
interface ListScheduledTasksBeginAction {
type: typeof LIST_SCHEDULED_TASKS_BEGIN;
queue: string;
}
interface ListScheduledTasksSuccessAction {
type: typeof LIST_SCHEDULED_TASKS_SUCCESS;
queue: string;
payload: ListScheduledTasksResponse;
}
interface ListScheduledTasksErrorAction {
type: typeof LIST_SCHEDULED_TASKS_ERROR;
queue: string;
error: string; // error description
}
interface ListRetryTasksBeginAction {
type: typeof LIST_RETRY_TASKS_BEGIN;
queue: string;
}
interface ListRetryTasksSuccessAction {
type: typeof LIST_RETRY_TASKS_SUCCESS;
queue: string;
payload: ListRetryTasksResponse;
}
interface ListRetryTasksErrorAction {
type: typeof LIST_RETRY_TASKS_ERROR;
queue: string;
error: string; // error description
}
interface ListDeadTasksBeginAction {
type: typeof LIST_DEAD_TASKS_BEGIN;
queue: string;
}
interface ListDeadTasksSuccessAction {
type: typeof LIST_DEAD_TASKS_SUCCESS;
queue: string;
payload: ListDeadTasksResponse;
}
interface ListDeadTasksErrorAction {
type: typeof LIST_DEAD_TASKS_ERROR;
queue: string;
error: string; // error description
}
// Union of all tasks related action types.
export type TasksActionTypes =
| ListActiveTasksBeginAction
| ListActiveTasksSuccessAction
| ListActiveTasksErrorAction
| ListPendingTasksBeginAction
| ListPendingTasksSuccessAction
| ListPendingTasksErrorAction
| ListScheduledTasksBeginAction
| ListScheduledTasksSuccessAction
| ListScheduledTasksErrorAction
| ListRetryTasksBeginAction
| ListRetryTasksSuccessAction
| ListRetryTasksErrorAction
| ListDeadTasksBeginAction
| ListDeadTasksSuccessAction
| ListDeadTasksErrorAction;
export function listActiveTasksAsync(qname: string) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_ACTIVE_TASKS_BEGIN, queue: qname });
try {
const response = await listActiveTasks(qname);
dispatch({
type: LIST_ACTIVE_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_ACTIVE_TASKS_ERROR,
queue: qname,
error: `Could not retreive active tasks data for queue: ${qname}`,
});
}
};
}
export function listPendingTasksAsync(
qname: string,
pageOpts?: PaginationOptions
) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_PENDING_TASKS_BEGIN, queue: qname });
try {
const response = await listPendingTasks(qname, pageOpts);
dispatch({
type: LIST_PENDING_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_PENDING_TASKS_ERROR,
queue: qname,
error: `Could not retreive pending tasks data for queue: ${qname}`,
});
}
};
}
export function listScheduledTasksAsync(
qname: string,
pageOpts?: PaginationOptions
) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_SCHEDULED_TASKS_BEGIN, queue: qname });
try {
const response = await listScheduledTasks(qname, pageOpts);
dispatch({
type: LIST_SCHEDULED_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_SCHEDULED_TASKS_ERROR,
queue: qname,
error: `Could not retreive scheduled tasks data for queue: ${qname}`,
});
}
};
}
export function listRetryTasksAsync(
qname: string,
pageOpts?: PaginationOptions
) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_RETRY_TASKS_BEGIN, queue: qname });
try {
const response = await listRetryTasks(qname, pageOpts);
dispatch({
type: LIST_RETRY_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_RETRY_TASKS_ERROR,
queue: qname,
error: `Could not retreive retry tasks data for queue: ${qname}`,
});
}
};
}
export function listDeadTasksAsync(
qname: string,
pageOpts?: PaginationOptions
) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_DEAD_TASKS_BEGIN, queue: qname });
try {
const response = await listDeadTasks(qname, pageOpts);
dispatch({
type: LIST_DEAD_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_DEAD_TASKS_ERROR,
queue: qname,
error: `Could not retreive dead tasks data for queue: ${qname}`,
});
}
};
}

209
ui/src/api.ts Normal file
View File

@ -0,0 +1,209 @@
import axios from "axios";
import queryString from "query-string";
const BASE_URL = "http://localhost:8080/api";
export interface ListQueuesResponse {
queues: Queue[];
}
export interface GetQueueResponse {
current: Queue;
history: DailyStat[];
}
export interface ListActiveTasksResponse {
tasks: ActiveTask[];
stats: Queue;
}
export interface ListPendingTasksResponse {
tasks: PendingTask[];
stats: Queue;
}
export interface ListScheduledTasksResponse {
tasks: ScheduledTask[];
stats: Queue;
}
export interface ListRetryTasksResponse {
tasks: RetryTask[];
stats: Queue;
}
export interface ListDeadTasksResponse {
tasks: DeadTask[];
stats: Queue;
}
export interface Queue {
queue: string;
paused: boolean;
size: number;
active: number;
pending: number;
scheduled: number;
retry: number;
dead: number;
processed: number;
failed: number;
timestamp: string;
}
export interface DailyStat {
date: string;
processed: number;
failed: number;
}
// BaseTask corresponds to asynq.Task type.
interface BaseTask {
type: string;
payload: { [key: string]: any };
}
export interface ActiveTask extends BaseTask {
id: string;
queue: string;
}
export interface PendingTask extends BaseTask {
id: string;
queue: string;
}
export interface ScheduledTask extends BaseTask {
id: string;
queue: string;
next_process_at: string;
}
export interface RetryTask extends BaseTask {
id: string;
queue: string;
next_process_at: string;
max_retry: number;
retried: number;
error_message: string;
}
export interface DeadTask extends BaseTask {
id: string;
queue: string;
max_retry: number;
retried: number;
last_failed_at: string;
error_message: string;
}
export interface PaginationOptions extends Record<string, number | undefined> {
size?: number; // size of the page
page?: number; // page number (1 being the first page)
}
export async function listQueues(): Promise<ListQueuesResponse> {
const resp = await axios({
method: "get",
url: `${BASE_URL}/queues`,
});
console.log("debug: listQueues response", resp.data);
return resp.data;
}
export async function getQueue(qname: string): Promise<GetQueueResponse> {
const resp = await axios({
method: "get",
url: `${BASE_URL}/queues/${qname}`,
});
return resp.data;
}
export async function pauseQueue(qname: string): Promise<void> {
await axios({
method: "post",
url: `${BASE_URL}/queues/${qname}/pause`,
});
}
export async function resumeQueue(qname: string): Promise<void> {
await axios({
method: "post",
url: `${BASE_URL}/queues/${qname}/resume`,
});
}
export async function listActiveTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListActiveTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/active_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listPendingTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListPendingTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/pending_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listScheduledTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListScheduledTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/scheduled_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listRetryTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListRetryTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/retry_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listDeadTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListDeadTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/dead_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}

View File

@ -0,0 +1,195 @@
import React, { useEffect, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import { listActiveTasksAsync } from "../actions/tasksActions";
import { AppState } from "../store";
import { ActiveTask } from "../api";
import TablePaginationActions, {
rowsPerPageOptions,
defaultPageSize,
} from "./TablePaginationActions";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.activeTasks.loading,
tasks: state.tasks.activeTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listActiveTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue
}
function ActiveTasksTable(props: Props & ReduxProps) {
const { pollInterval, listActiveTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
listActiveTasksAsync(queue);
const interval = setInterval(
() => listActiveTasksAsync(queue),
pollInterval * 1000
);
return () => clearInterval(interval);
}, [pollInterval, listActiveTasksAsync, queue]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No active tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="active tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{/* TODO: loading and empty state */}
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.tasks.length}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function Row(props: { task: ActiveTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(ActiveTasksTable);

View File

@ -0,0 +1,200 @@
import React, { useState, useEffect } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import { AppState } from "../store";
import { listDeadTasksAsync } from "../actions/tasksActions";
import { DeadTask } from "../api";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import { timeAgo } from "../timeutil";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.deadTasks.loading,
tasks: state.tasks.deadTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listDeadTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of dead tasks.
}
function DeadTasksTable(props: Props & ReduxProps) {
const { pollInterval, listDeadTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
const pageOpts = { page: page + 1, size: pageSize };
listDeadTasksAsync(queue, pageOpts);
const interval = setInterval(() => {
listDeadTasksAsync(queue, pageOpts);
}, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listDeadTasksAsync, queue, page, pageSize]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No dead tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Last Failed" },
{ label: "Last Error" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="dead tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
function Row(props: { task: DeadTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>{timeAgo(task.last_failed_at)}</TableCell>
<TableCell>{task.error_message}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(DeadTasksTable);

View File

@ -0,0 +1,68 @@
import React, { ReactElement } from "react";
import clsx from "clsx";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import { makeStyles } from "@material-ui/core/styles";
import {
useRouteMatch,
Link as RouterLink,
LinkProps as RouterLinkProps,
} from "react-router-dom";
const useStyles = makeStyles({
listItem: {
borderTopRightRadius: "24px",
borderBottomRightRadius: "24px",
},
selected: {
backgroundColor: "rgba(0, 0, 0, 0.07)",
},
boldText: {
fontWeight: 600,
},
});
interface Props {
to: string;
primary: string;
icon?: ReactElement;
}
// Note: See https://material-ui.com/guides/composition/ for details.
function ListItemLink(props: Props): ReactElement {
const classes = useStyles();
const { icon, primary, to } = props;
const isMatch = useRouteMatch({
path: to,
strict: true,
sensitive: true,
exact: true,
});
const renderLink = React.useMemo(
() =>
React.forwardRef<any, Omit<RouterLinkProps, "to">>((itemProps, ref) => (
<RouterLink to={to} ref={ref} {...itemProps} />
)),
[to]
);
return (
<li>
<ListItem
button
component={renderLink}
className={clsx(classes.listItem, isMatch && classes.selected)}
>
{icon ? <ListItemIcon>{icon}</ListItemIcon> : null}
<ListItemText
primary={primary}
classes={{
primary: isMatch ? classes.boldText : undefined,
}}
/>
</ListItem>
</li>
);
}
export default ListItemLink;

View File

@ -0,0 +1,195 @@
import React, { useEffect, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import { listPendingTasksAsync } from "../actions/tasksActions";
import { AppState } from "../store";
import { PendingTask } from "../api";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.pendingTasks.loading,
tasks: state.tasks.pendingTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listPendingTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string;
totalTaskCount: number; // total number of pending tasks
}
function PendingTasksTable(props: Props & ReduxProps) {
const { pollInterval, listPendingTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
const pageOpts = { page: page + 1, size: pageSize };
listPendingTasksAsync(queue, pageOpts);
const interval = setInterval(() => {
listPendingTasksAsync(queue, pageOpts);
}, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listPendingTasksAsync, queue, page, pageSize]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No pending tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="pending tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function Row(props: { task: PendingTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(PendingTasksTable);

View File

@ -0,0 +1,45 @@
import React from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { useTheme, Theme } from "@material-ui/core/styles";
interface Props {
data: ProcessedStats[];
}
interface ProcessedStats {
queue: string; // name of the queue.
succeeded: number; // number of tasks succeeded.
failed: number; // number of tasks failed.
}
function ProcessedTasksChart(props: Props) {
const theme = useTheme<Theme>();
return (
<ResponsiveContainer>
<BarChart data={props.data} maxBarSize={100}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="queue" />
<YAxis />
<Tooltip />
<Legend />
<Bar
dataKey="succeeded"
stackId="a"
fill={theme.palette.success.light}
/>
<Bar dataKey="failed" stackId="a" fill={theme.palette.error.light} />
</BarChart>
</ResponsiveContainer>
);
}
export default ProcessedTasksChart;

View File

@ -0,0 +1,45 @@
import React from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
interface Props {
data: TaskBreakdown[];
}
interface TaskBreakdown {
queue: string; // name of the queue.
active: number; // number of active tasks in the queue.
pending: number; // number of pending tasks in the queue.
scheduled: number; // number of scheduled tasks in the queue.
retry: number; // number of retry tasks in the queue.
dead: number; // number of dead tasks in the queue.
}
function QueueSizeChart(props: Props) {
return (
<ResponsiveContainer>
<BarChart data={props.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="queue" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="active" stackId="a" fill="#7bb3ff" />
<Bar dataKey="pending" stackId="a" fill="#e86af0" />
<Bar dataKey="scheduled" stackId="a" fill="#9e379f" />
<Bar dataKey="retry" stackId="a" fill="#493267" />
<Bar dataKey="dead" stackId="a" fill="#373854" />
</BarChart>
</ResponsiveContainer>
);
}
export default QueueSizeChart;

View File

@ -0,0 +1,348 @@
import React, { useState } from "react";
import clsx from "clsx";
import { Link } from "react-router-dom";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableFooter from "@material-ui/core/TableFooter";
import TableSortLabel from "@material-ui/core/TableSortLabel";
import IconButton from "@material-ui/core/IconButton";
import PauseCircleFilledIcon from "@material-ui/icons/PauseCircleFilled";
import PlayCircleFilledIcon from "@material-ui/icons/PlayCircleFilled";
import { Queue } from "../api";
import { queueDetailsPath } from "../paths";
const useStyles = makeStyles((theme) => ({
table: {
minWidth: 650,
},
linkCell: {
textDecoration: "none",
},
footerCell: {
fontWeight: 600,
fontSize: "0.875rem",
borderBottom: "none",
},
boldCell: {
fontWeight: 600,
},
fixedCell: {
position: "sticky",
zIndex: 1,
left: 0,
background: theme.palette.common.white,
},
}));
interface QueueWithMetadata extends Queue {
pauseRequestPending: boolean; // indicates pause/resume request is pending for the queue.
}
interface Props {
queues: QueueWithMetadata[];
onPauseClick: (qname: string) => Promise<void>;
onResumeClick: (qname: string) => Promise<void>;
}
enum SortBy {
Queue,
Size,
Active,
Pending,
Scheduled,
Retry,
Dead,
Processed,
Succeeded,
Failed,
}
enum SortDirection {
Asc = "asc",
Desc = "desc",
}
const columnConfig = [
{ label: "Queue", key: "queue", sortBy: SortBy.Queue },
{ label: "Size", key: "size", sortBy: SortBy.Size },
{ label: "Active", key: "active", sortBy: SortBy.Active },
{ label: "Pending", key: "pending", sortBy: SortBy.Pending },
{ label: "Scheduled", key: "scheduled", sortBy: SortBy.Scheduled },
{ label: "Retry", key: "retry", sortBy: SortBy.Retry },
{ label: "Dead", key: "dead", sortBy: SortBy.Dead },
{ label: "Processed", key: "processed", sortBy: SortBy.Processed },
{ label: "Succeeded", key: "Succeeded", sortBy: SortBy.Succeeded },
{ label: "Failed", key: "failed", sortBy: SortBy.Failed },
];
// sortQueues takes a array of queues and return a sorted array.
// It returns a new array and leave the original array untouched.
function sortQueues(
queues: QueueWithMetadata[],
cmpFn: (first: QueueWithMetadata, second: QueueWithMetadata) => number
): QueueWithMetadata[] {
let copy = [...queues];
copy.sort(cmpFn);
return copy;
}
export default function QueuesOverviewTable(props: Props) {
const classes = useStyles();
const [sortBy, setSortBy] = useState<SortBy>(SortBy.Queue);
const [sortDir, setSortDir] = useState<SortDirection>(SortDirection.Asc);
const total = getAggregateCounts(props.queues);
const createSortClickHandler = (sortKey: SortBy) => (e: React.MouseEvent) => {
if (sortKey === sortBy) {
// Toggle sort direction.
const nextSortDir =
sortDir === SortDirection.Asc ? SortDirection.Desc : SortDirection.Asc;
setSortDir(nextSortDir);
} else {
// Change the sort key.
setSortBy(sortKey);
}
};
const cmpFunc = (q1: QueueWithMetadata, q2: QueueWithMetadata): number => {
let isQ1Smaller: boolean;
switch (sortBy) {
case SortBy.Queue:
if (q1.queue === q2.queue) return 0;
isQ1Smaller = q1.queue < q2.queue;
break;
case SortBy.Size:
if (q1.size === q2.size) return 0;
isQ1Smaller = q1.size < q2.size;
break;
case SortBy.Active:
if (q1.active === q2.active) return 0;
isQ1Smaller = q1.active < q2.active;
break;
case SortBy.Pending:
if (q1.pending === q2.pending) return 0;
isQ1Smaller = q1.pending < q2.pending;
break;
case SortBy.Scheduled:
if (q1.scheduled === q2.scheduled) return 0;
isQ1Smaller = q1.scheduled < q2.scheduled;
break;
case SortBy.Retry:
if (q1.retry === q2.retry) return 0;
isQ1Smaller = q1.retry < q2.retry;
break;
case SortBy.Dead:
if (q1.dead === q2.dead) return 0;
isQ1Smaller = q1.dead < q2.dead;
break;
case SortBy.Processed:
if (q1.processed === q2.processed) return 0;
isQ1Smaller = q1.processed < q2.processed;
break;
case SortBy.Succeeded:
const q1Succeeded = q1.processed - q1.failed;
const q2Succeeded = q2.processed - q2.failed;
if (q1Succeeded === q2Succeeded) return 0;
isQ1Smaller = q1Succeeded < q2Succeeded;
break;
case SortBy.Failed:
if (q1.failed === q2.failed) return 0;
isQ1Smaller = q1.failed < q2.failed;
break;
default:
// eslint-disable-next-line no-throw-literal
throw `Unexpected order by value: ${sortBy}`;
}
if (sortDir === SortDirection.Asc) {
return isQ1Smaller ? -1 : 1;
} else {
return isQ1Smaller ? 1 : -1;
}
};
return (
<TableContainer>
<Table className={classes.table} aria-label="queues overview table">
<TableHead>
<TableRow>
{columnConfig.map((cfg, i) => (
<TableCell
key={cfg.key}
align={i === 0 ? "left" : "right"}
className={clsx(i === 0 && classes.fixedCell)}
>
<TableSortLabel
active={sortBy === cfg.sortBy}
direction={sortDir}
onClick={createSortClickHandler(cfg.sortBy)}
>
{cfg.label}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{sortQueues(props.queues, cmpFunc).map((q) => (
<TableRow key={q.queue}>
<TableCell
component="th"
scope="row"
className={clsx(classes.boldCell, classes.fixedCell)}
>
<Link to={queueDetailsPath(q.queue)}>
{q.queue}
{q.paused ? " (paused)" : ""}
</Link>
</TableCell>
<TableCell align="right" className={classes.boldCell}>
{q.size}
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "active")}
className={classes.linkCell}
>
{q.active}
</Link>
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "pending")}
className={classes.linkCell}
>
{q.pending}
</Link>
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "scheduled")}
className={classes.linkCell}
>
{q.scheduled}
</Link>
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "retry")}
className={classes.linkCell}
>
{q.retry}
</Link>
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "dead")}
className={classes.linkCell}
>
{q.dead}
</Link>
</TableCell>
<TableCell align="right" className={classes.boldCell}>
{q.processed}
</TableCell>
<TableCell align="right">{q.processed - q.failed}</TableCell>
<TableCell align="right">{q.failed}</TableCell>
{/* <TableCell align="right">
{q.paused ? (
<IconButton
color="secondary"
onClick={() => props.onResumeClick(q.queue)}
disabled={q.pauseRequestPending}
>
<PlayCircleFilledIcon />
</IconButton>
) : (
<IconButton
color="primary"
onClick={() => props.onPauseClick(q.queue)}
disabled={q.pauseRequestPending}
>
<PauseCircleFilledIcon />
</IconButton>
)}
</TableCell> */}
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className={clsx(classes.fixedCell, classes.footerCell)}>
Total
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.size}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.active}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.pending}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.scheduled}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.retry}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.dead}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.processed}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.succeeded}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.failed}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
interface AggregateCounts {
size: number;
active: number;
pending: number;
scheduled: number;
retry: number;
dead: number;
processed: number;
succeeded: number;
failed: number;
}
function getAggregateCounts(queues: Queue[]): AggregateCounts {
let total = {
size: 0,
active: 0,
pending: 0,
scheduled: 0,
retry: 0,
dead: 0,
processed: 0,
succeeded: 0,
failed: 0,
};
queues.forEach((q) => {
total.size += q.size;
total.active += q.active;
total.pending += q.pending;
total.scheduled += q.scheduled;
total.retry += q.retry;
total.dead += q.dead;
total.processed += q.processed;
total.succeeded += q.processed - q.failed;
total.failed += q.failed;
});
return total;
}

View File

@ -0,0 +1,204 @@
import React, { useEffect, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import { listRetryTasksAsync } from "../actions/tasksActions";
import { AppState } from "../store";
import { RetryTask } from "../api";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import { durationBefore } from "../timeutil";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.retryTasks.loading,
tasks: state.tasks.retryTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listRetryTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of scheduled tasks.
}
function RetryTasksTable(props: Props & ReduxProps) {
const { pollInterval, listRetryTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
const pageOpts = { page: page + 1, size: pageSize };
listRetryTasksAsync(queue, pageOpts);
const interval = setInterval(() => {
listRetryTasksAsync(queue, pageOpts);
}, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listRetryTasksAsync, queue, page, pageSize]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No retry tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Retry In" },
{ label: "Last Error" },
{ label: "Retried" },
{ label: "Max Retry" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="retry tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function Row(props: { task: RetryTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>{durationBefore(task.next_process_at)}</TableCell>
<TableCell>{task.error_message}</TableCell>
<TableCell>{task.retried}</TableCell>
<TableCell>{task.max_retry}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={9}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(RetryTasksTable);

View File

@ -0,0 +1,197 @@
import React, { useEffect, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import { listScheduledTasksAsync } from "../actions/tasksActions";
import { AppState } from "../store";
import { ScheduledTask } from "../api";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import { durationBefore } from "../timeutil";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.scheduledTasks.loading,
tasks: state.tasks.scheduledTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listScheduledTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of scheduled tasks.
}
function ScheduledTasksTable(props: Props & ReduxProps) {
const { pollInterval, listScheduledTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
const pageOpts = { page: page + 1, size: pageSize };
listScheduledTasksAsync(queue, pageOpts);
const interval = setInterval(() => {
listScheduledTasksAsync(queue, pageOpts);
}, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listScheduledTasksAsync, queue, page, pageSize]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No scheduled tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Process In" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="scheduled tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function Row(props: { task: ScheduledTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>{durationBefore(task.next_process_at)}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={5}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(ScheduledTasksTable);

View File

@ -0,0 +1,107 @@
import React from "react";
import {
useTheme,
makeStyles,
Theme,
createStyles,
} from "@material-ui/core/styles";
import IconButton from "@material-ui/core/IconButton";
import FirstPageIcon from "@material-ui/icons/FirstPage";
import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft";
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight";
import LastPageIcon from "@material-ui/icons/LastPage";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexShrink: 0,
marginLeft: theme.spacing(2.5),
},
})
);
interface TablePaginationActionsProps {
count: number;
page: number;
rowsPerPage: number;
onChangePage: (
event: React.MouseEvent<HTMLButtonElement>,
newPage: number
) => void;
}
function TablePaginationActions(props: TablePaginationActionsProps) {
const classes = useStyles();
const theme = useTheme();
const { count, page, rowsPerPage, onChangePage } = props;
const handleFirstPageButtonClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
onChangePage(event, 0);
};
const handleBackButtonClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
onChangePage(event, page - 1);
};
const handleNextButtonClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
onChangePage(event, page + 1);
};
const handleLastPageButtonClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<div className={classes.root}>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? <LastPageIcon /> : <FirstPageIcon />}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowRight />
) : (
<KeyboardArrowLeft />
)}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowLeft />
) : (
<KeyboardArrowRight />
)}
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
{theme.direction === "rtl" ? <FirstPageIcon /> : <LastPageIcon />}
</IconButton>
</div>
);
}
export default TablePaginationActions;
export const rowsPerPageOptions = [10, 20, 30, 60, 100];
export const defaultPageSize = 20;

View File

@ -0,0 +1,332 @@
import React from "react";
import { connect, ConnectedProps } from "react-redux";
import styled from "styled-components";
import { makeStyles } from "@material-ui/core/styles";
import Tabs from "@material-ui/core/Tabs";
import Tab from "@material-ui/core/Tab";
import ActiveTasksTable from "./ActiveTasksTable";
import PendingTasksTable from "./PendingTasksTable";
import ScheduledTasksTable from "./ScheduledTasksTable";
import RetryTasksTable from "./RetryTasksTable";
import DeadTasksTable from "./DeadTasksTable";
import { useHistory } from "react-router-dom";
import { queueDetailsPath } from "../paths";
import { Typography } from "@material-ui/core";
import Paper from "@material-ui/core/Paper/Paper";
import { QueueInfo } from "../reducers/queuesReducer";
import { AppState } from "../store";
interface TabPanelProps {
children?: React.ReactNode;
selected: string; // currently selected value
value: string; // tab panel will be shown if selected value equals to the value
}
const TabPanelRoot = styled.div`
flex: 1;
overflow-y: scroll;
`;
function TabPanel(props: TabPanelProps) {
const { children, value, selected, ...other } = props;
return (
<TabPanelRoot
role="tabpanel"
hidden={value !== selected}
id={`scrollable-auto-tabpanel-${selected}`}
aria-labelledby={`scrollable-auto-tab-${selected}`}
{...other}
>
{value === selected && children}
</TabPanelRoot>
);
}
function a11yProps(value: string) {
return {
id: `scrollable-auto-tab-${value}`,
"aria-controls": `scrollable-auto-tabpanel-${value}`,
};
}
const Container = styled.div`
display: flex;
width: 100%;
height: 100%;
`;
const TaskCount = styled.div`
font-size: 2rem;
font-weight: 600;
margin: 0;
`;
const Heading = styled.div`
opacity: 0.7;
font-size: 1.7rem;
font-weight: 500;
background: #f5f7f9;
padding-left: 28px;
padding-top: 28px;
padding-bottom: 28px;
`;
const PanelContainer = styled.div`
padding: 24px;
background: #ffffff;
`;
const TabsContainer = styled.div`
background: #f5f7f9;
`;
const useStyles = makeStyles((theme) => ({
paper: {
padding: theme.spacing(2),
marginBottom: theme.spacing(2),
display: "flex",
justifyContent: "space-between",
},
heading: {
padingLeft: theme.spacing(2),
},
tabsRoot: {
paddingLeft: theme.spacing(2),
background: theme.palette.background.default,
},
tabsIndicator: {
right: "auto",
left: "0",
},
tabroot: {
width: "204px",
textAlign: "left",
padding: theme.spacing(2),
},
tabwrapper: {
alignItems: "flex-start",
},
tabSelected: {
background: theme.palette.common.white,
boxShadow: theme.shadows[1],
},
}));
function PanelHeading(props: {
queue: string;
processed: number;
failed: number;
paused: boolean;
}) {
const classes = useStyles();
return (
<Paper className={classes.paper}>
<div>
<Typography variant="overline" display="block">
Queue Name
</Typography>
<Typography variant="h5">{props.queue}</Typography>
</div>
<div>
<Typography variant="overline" display="block">
Processed Today (UTC)
</Typography>
<Typography variant="h5">{props.processed}</Typography>
</div>
<div>
<Typography variant="overline" display="block">
Failed Today (UTC)
</Typography>
<Typography variant="h5">{props.failed}</Typography>
</div>
<div>
<Typography variant="overline" display="block">
Paused
</Typography>
<Typography variant="h5">{props.paused ? "YES" : "No"}</Typography>
</div>
</Paper>
);
}
function mapStatetoProps(state: AppState, ownProps: Props) {
// TODO: Add loading state for each queue.
const queueInfo = state.queues.data.find(
(q: QueueInfo) => q.name === ownProps.queue
);
const currentStats = queueInfo
? queueInfo.currentStats
: {
queue: ownProps.queue,
paused: false,
size: 0,
active: 0,
pending: 0,
scheduled: 0,
retry: 0,
dead: 0,
processed: 0,
failed: 0,
timestamp: "n/a",
};
return { currentStats };
}
const connector = connect(mapStatetoProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string;
selected: string;
}
function TasksTable(props: Props & ReduxProps) {
const { currentStats } = props;
const classes = useStyles();
const history = useHistory();
return (
<Container>
<TabsContainer>
<Heading>Tasks</Heading>
<Tabs
value={props.selected}
onChange={(_, value: string) =>
history.push(queueDetailsPath(props.queue, value))
}
aria-label="tasks table"
orientation="vertical"
classes={{ root: classes.tabsRoot, indicator: classes.tabsIndicator }}
>
<Tab
value="active"
label="Active"
icon={<TaskCount>{currentStats.active}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("active")}
/>
<Tab
value="pending"
label="Pending"
icon={<TaskCount>{currentStats.pending}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("pending")}
/>
<Tab
value="scheduled"
label="Scheduled"
icon={<TaskCount>{currentStats.scheduled}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("scheduled")}
/>
<Tab
value="retry"
label="Retry"
icon={<TaskCount>{currentStats.retry}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("retry")}
/>
<Tab
value="dead"
label="Dead"
icon={<TaskCount>{currentStats.dead}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("dead")}
/>
</Tabs>
</TabsContainer>
<TabPanel value="active" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<ActiveTasksTable queue={props.queue} />
</PanelContainer>
</TabPanel>
<TabPanel value="pending" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<PendingTasksTable
queue={props.queue}
totalTaskCount={currentStats.pending}
/>
</PanelContainer>
</TabPanel>
<TabPanel value="scheduled" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<ScheduledTasksTable
queue={props.queue}
totalTaskCount={currentStats.scheduled}
/>
</PanelContainer>
</TabPanel>
<TabPanel value="retry" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<RetryTasksTable
queue={props.queue}
totalTaskCount={currentStats.retry}
/>
</PanelContainer>
</TabPanel>
<TabPanel value="dead" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<DeadTasksTable
queue={props.queue}
totalTaskCount={currentStats.dead}
/>
</PanelContainer>
</TabPanel>
</Container>
);
}
export default connector(TasksTable);

View File

@ -0,0 +1,13 @@
import { Theme, withStyles } from "@material-ui/core/styles";
import Tooltip from "@material-ui/core/Tooltip";
// Export custom style tooltip.
export default withStyles((theme: Theme) => ({
tooltip: {
backgroundColor: "#f5f5f9",
color: "rgba(0, 0, 0, 0.87)",
maxWidth: 400,
fontSize: theme.typography.pxToRem(12),
border: "1px solid #dadde9",
},
}))(Tooltip);

27
ui/src/index.tsx Normal file
View File

@ -0,0 +1,27 @@
import React from "react";
import ReactDOM from "react-dom";
import CssBaseline from "@material-ui/core/CssBaseline";
import { Provider } from "react-redux";
import { ThemeProvider } from "@material-ui/core/styles";
import App from "./App";
import store from "./store";
import theme from "./theme";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(
<React.StrictMode>
<CssBaseline />
<Provider store={store}>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
// TODO(hibiken): Look into this.
serviceWorker.unregister();

14
ui/src/paths.ts Normal file
View File

@ -0,0 +1,14 @@
export const paths = {
HOME: "/",
SETTINGS: "/settings",
CRON: "/cron",
QUEUE_DETAILS: "/queues/:qname",
};
export function queueDetailsPath(qname: string, taskStatus?: string): string {
const path = paths.QUEUE_DETAILS.replace(":qname", qname);
if (taskStatus) {
return `${path}?status=${taskStatus}`;
}
return path;
}

1
ui/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,143 @@
import {
LIST_QUEUES_SUCCESS,
LIST_QUEUES_BEGIN,
QueuesActionTypes,
PAUSE_QUEUE_BEGIN,
PAUSE_QUEUE_SUCCESS,
PAUSE_QUEUE_ERROR,
RESUME_QUEUE_BEGIN,
RESUME_QUEUE_ERROR,
RESUME_QUEUE_SUCCESS,
GET_QUEUE_SUCCESS,
} from "../actions/queuesActions";
import {
LIST_ACTIVE_TASKS_SUCCESS,
LIST_DEAD_TASKS_SUCCESS,
LIST_PENDING_TASKS_SUCCESS,
LIST_RETRY_TASKS_SUCCESS,
LIST_SCHEDULED_TASKS_SUCCESS,
TasksActionTypes,
} from "../actions/tasksActions";
import { DailyStat, Queue } from "../api";
interface QueuesState {
loading: boolean;
data: QueueInfo[];
}
export interface QueueInfo {
name: string; // name of the queue.
currentStats: Queue;
history: DailyStat[];
pauseRequestPending: boolean; // indicates pause/resume action is pending on this queue
}
const initialState: QueuesState = { data: [], loading: false };
function queuesReducer(
state = initialState,
action: QueuesActionTypes | TasksActionTypes
): QueuesState {
switch (action.type) {
case LIST_QUEUES_BEGIN:
return { ...state, loading: true };
case LIST_QUEUES_SUCCESS:
const { queues } = action.payload;
return {
...state,
loading: false,
data: queues.map((q: Queue) => ({
name: q.queue,
currentStats: q,
history: [],
pauseRequestPending: false,
})),
};
case GET_QUEUE_SUCCESS:
const newData = state.data
.filter((queueInfo) => queueInfo.name !== action.queue)
.concat({
name: action.queue,
currentStats: action.payload.current,
history: action.payload.history,
pauseRequestPending: false,
});
return { ...state, data: newData };
case PAUSE_QUEUE_BEGIN:
case RESUME_QUEUE_BEGIN: {
const newData = state.data.map((queueInfo) => {
if (queueInfo.name !== action.queue) {
return queueInfo;
}
return { ...queueInfo, pauseRequestPending: true };
});
return { ...state, data: newData };
}
case PAUSE_QUEUE_SUCCESS: {
const newData = state.data.map((queueInfo) => {
if (queueInfo.name !== action.queue) {
return queueInfo;
}
return {
...queueInfo,
pauseRequestPending: false,
currentStats: { ...queueInfo.currentStats, paused: true },
};
});
return { ...state, data: newData };
}
case RESUME_QUEUE_SUCCESS: {
const newData = state.data.map((queueInfo) => {
if (queueInfo.name !== action.queue) {
return queueInfo;
}
return {
...queueInfo,
pauseRequestPending: false,
currentStats: { ...queueInfo.currentStats, paused: false },
};
});
return { ...state, data: newData };
}
case PAUSE_QUEUE_ERROR:
case RESUME_QUEUE_ERROR: {
const newData = state.data.map((queueInfo) => {
if (queueInfo.name !== action.queue) {
return queueInfo;
}
return {
...queueInfo,
pauseRequestPending: false,
};
});
return { ...state, data: newData };
}
case LIST_ACTIVE_TASKS_SUCCESS:
case LIST_PENDING_TASKS_SUCCESS:
case LIST_SCHEDULED_TASKS_SUCCESS:
case LIST_RETRY_TASKS_SUCCESS:
case LIST_DEAD_TASKS_SUCCESS: {
const newData = state.data
.filter((queueInfo) => queueInfo.name !== action.queue)
.concat({
name: action.queue,
currentStats: action.payload.stats,
history: [],
pauseRequestPending: false,
});
return { ...state, data: newData };
}
default:
return state;
}
}
export default queuesReducer;

View File

@ -0,0 +1,26 @@
import {
POLL_INTERVAL_CHANGE,
SettingsActionTypes,
} from "../actions/settingsActions";
interface SettingsState {
pollInterval: number;
}
const initialState: SettingsState = {
pollInterval: 8,
};
function settingsReducer(
state = initialState,
action: SettingsActionTypes
): SettingsState {
switch (action.type) {
case POLL_INTERVAL_CHANGE:
return { ...state, pollInterval: action.value };
default:
return state;
}
}
export default settingsReducer;

View File

@ -0,0 +1,243 @@
import {
LIST_ACTIVE_TASKS_BEGIN,
LIST_ACTIVE_TASKS_SUCCESS,
LIST_ACTIVE_TASKS_ERROR,
TasksActionTypes,
LIST_PENDING_TASKS_BEGIN,
LIST_PENDING_TASKS_SUCCESS,
LIST_PENDING_TASKS_ERROR,
LIST_SCHEDULED_TASKS_BEGIN,
LIST_SCHEDULED_TASKS_SUCCESS,
LIST_SCHEDULED_TASKS_ERROR,
LIST_RETRY_TASKS_BEGIN,
LIST_RETRY_TASKS_SUCCESS,
LIST_RETRY_TASKS_ERROR,
LIST_DEAD_TASKS_BEGIN,
LIST_DEAD_TASKS_SUCCESS,
LIST_DEAD_TASKS_ERROR,
} from "../actions/tasksActions";
import {
ActiveTask,
DeadTask,
PendingTask,
RetryTask,
ScheduledTask,
} from "../api";
interface TasksState {
activeTasks: {
loading: boolean;
error: string;
data: ActiveTask[];
};
pendingTasks: {
loading: boolean;
error: string;
data: PendingTask[];
};
scheduledTasks: {
loading: boolean;
error: string;
data: ScheduledTask[];
};
retryTasks: {
loading: boolean;
error: string;
data: RetryTask[];
};
deadTasks: {
loading: boolean;
error: string;
data: DeadTask[];
};
}
const initialState: TasksState = {
activeTasks: {
loading: false,
error: "",
data: [],
},
pendingTasks: {
loading: false,
error: "",
data: [],
},
scheduledTasks: {
loading: false,
error: "",
data: [],
},
retryTasks: {
loading: false,
error: "",
data: [],
},
deadTasks: {
loading: false,
error: "",
data: [],
},
};
function tasksReducer(
state = initialState,
action: TasksActionTypes
): TasksState {
switch (action.type) {
case LIST_ACTIVE_TASKS_BEGIN:
return {
...state,
activeTasks: {
...state.activeTasks,
error: "",
loading: true,
},
};
case LIST_ACTIVE_TASKS_SUCCESS:
return {
...state,
activeTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_ACTIVE_TASKS_ERROR:
return {
...state,
activeTasks: {
...state.activeTasks,
loading: false,
error: action.error,
},
};
case LIST_PENDING_TASKS_BEGIN:
return {
...state,
pendingTasks: {
...state.pendingTasks,
error: "",
loading: true,
},
};
case LIST_PENDING_TASKS_SUCCESS:
return {
...state,
pendingTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_PENDING_TASKS_ERROR:
return {
...state,
pendingTasks: {
...state.pendingTasks,
loading: false,
error: action.error,
},
};
case LIST_SCHEDULED_TASKS_BEGIN:
return {
...state,
scheduledTasks: {
...state.scheduledTasks,
error: "",
loading: true,
},
};
case LIST_SCHEDULED_TASKS_SUCCESS:
return {
...state,
scheduledTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_SCHEDULED_TASKS_ERROR:
return {
...state,
scheduledTasks: {
...state.scheduledTasks,
loading: false,
error: action.error,
},
};
case LIST_RETRY_TASKS_BEGIN:
return {
...state,
retryTasks: {
...state.retryTasks,
error: "",
loading: true,
},
};
case LIST_RETRY_TASKS_SUCCESS:
return {
...state,
retryTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_RETRY_TASKS_ERROR:
return {
...state,
retryTasks: {
...state.retryTasks,
loading: false,
error: action.error,
},
};
case LIST_DEAD_TASKS_BEGIN:
return {
...state,
deadTasks: {
...state.deadTasks,
error: "",
loading: true,
},
};
case LIST_DEAD_TASKS_SUCCESS:
return {
...state,
deadTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_DEAD_TASKS_ERROR:
return {
...state,
deadTasks: {
...state.deadTasks,
loading: false,
error: action.error,
},
};
default:
return state;
}
}
export default tasksReducer;

149
ui/src/serviceWorker.ts Normal file
View File

@ -0,0 +1,149 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(
process.env.PUBLIC_URL,
window.location.href
);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
}

5
ui/src/setupTests.ts Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

17
ui/src/store.tsx Normal file
View File

@ -0,0 +1,17 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import settingsReducer from "./reducers/settingsReducer";
import queuesReducer from "./reducers/queuesReducer";
import tasksReducer from "./reducers/tasksReducer";
const rootReducer = combineReducers({
settings: settingsReducer,
queues: queuesReducer,
tasks: tasksReducer,
});
// AppState is the top-level application state maintained by redux store.
export type AppState = ReturnType<typeof rootReducer>;
export default configureStore({
reducer: rootReducer,
});

18
ui/src/theme.tsx Normal file
View File

@ -0,0 +1,18 @@
import { createMuiTheme } from "@material-ui/core/styles";
// Got color palette from https://htmlcolors.com/palette/31/stripe
const theme = createMuiTheme({
palette: {
primary: {
main: "#4379FF",
},
secondary: {
main: "#97FBD1",
},
background: {
default: "#f5f7f9",
},
},
});
export default theme;

53
ui/src/timeutil.ts Normal file
View File

@ -0,0 +1,53 @@
interface Duration {
hour: number;
minute: number;
second: number;
totalSeconds: number;
}
// start and end are in milliseconds.
function durationBetween(start: number, end: number): Duration {
const durationInMillisec = start - end;
const totalSeconds = Math.floor(durationInMillisec / 1000);
const hour = Math.floor(totalSeconds / 3600);
const minute = Math.floor((totalSeconds - 3600 * hour) / 60);
const second = totalSeconds - 3600 * hour - 60 * minute;
return { hour, minute, second, totalSeconds };
}
function stringifyDuration(d: Duration): string {
return (
(d.hour !== 0 ? `${d.hour}h` : "") +
(d.minute !== 0 ? `${d.minute}m` : "") +
`${d.second}s`
);
}
export function durationBefore(timestamp: string): string {
try {
const duration = durationBetween(Date.parse(timestamp), Date.now());
if (duration.totalSeconds < 1) {
return "now";
}
return stringifyDuration(duration);
} catch {
return "-";
}
}
export function timeAgo(timestamp: string): string {
try {
const duration = durationBetween(Date.now(), Date.parse(timestamp));
return stringifyDuration(duration) + " ago";
} catch {
return "-";
}
}
export function getCurrentUTCDate(): string {
const today = new Date();
const dd = today.getUTCDate().toString().padStart(2, "0");
const mm = (today.getMonth() + 1).toString().padStart(2, "0");
const yyyy = today.getFullYear();
return `${yyyy}-${mm}-${dd}`;
}

11
ui/src/views/CronView.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from "react";
function CronView() {
return (
<div>
<h2>Cron</h2>
</div>
);
}
export default CronView;

View File

@ -0,0 +1,179 @@
import React, { useEffect } from "react";
import { connect, ConnectedProps } from "react-redux";
import Container from "@material-ui/core/Container";
import { makeStyles } from "@material-ui/core/styles";
import Grid from "@material-ui/core/Grid";
import Paper from "@material-ui/core/Paper";
import Typography from "@material-ui/core/Typography";
import InfoIcon from "@material-ui/icons/Info";
import {
listQueuesAsync,
pauseQueueAsync,
resumeQueueAsync,
} from "../actions/queuesActions";
import { AppState } from "../store";
import QueueSizeChart from "../components/QueueSizeChart";
import ProcessedTasksChart from "../components/ProcessedTasksChart";
import QueuesOverviewTable from "../components/QueuesOverviewTable";
import Tooltip from "../components/Tooltip";
import { getCurrentUTCDate } from "../timeutil";
const useStyles = makeStyles((theme) => ({
container: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
paper: {
padding: theme.spacing(2),
display: "flex",
overflow: "auto",
flexDirection: "column",
},
chartHeader: {
display: "flex",
alignItems: "center",
marginBottom: theme.spacing(2),
},
chartContainer: {
width: "100%",
height: "300px",
},
infoIcon: {
marginLeft: theme.spacing(1),
color: theme.palette.grey[500],
cursor: "pointer",
},
tooltipSection: {
marginBottom: "4px",
},
}));
function mapStateToProps(state: AppState) {
return {
loading: state.queues.loading,
queues: state.queues.data.map((q) => ({
...q.currentStats,
pauseRequestPending: q.pauseRequestPending,
})),
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = {
listQueuesAsync,
pauseQueueAsync,
resumeQueueAsync,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = ConnectedProps<typeof connector>;
function DashboardView(props: Props) {
const { pollInterval, listQueuesAsync, queues } = props;
const classes = useStyles();
useEffect(() => {
listQueuesAsync();
const interval = setInterval(listQueuesAsync, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listQueuesAsync]);
const processedStats = queues.map((q) => ({
queue: q.queue,
succeeded: q.processed - q.failed,
failed: q.failed,
}));
return (
<Container maxWidth="lg" className={classes.container}>
<Grid container spacing={3}>
<Grid item xs={6}>
<Paper className={classes.paper} variant="outlined">
<div className={classes.chartHeader}>
<Typography variant="h6">Queue Size</Typography>
<Tooltip
title={
<div>
<div className={classes.tooltipSection}>
Total number of tasks in the queue
</div>
<div className={classes.tooltipSection}>
<strong>Active</strong>: number of tasks currently being
processed
</div>
<div className={classes.tooltipSection}>
<strong>Pending</strong>: number of tasks ready to be
processed
</div>
<div className={classes.tooltipSection}>
<strong>Scheduled</strong>: number of tasks scheduled to
be processed in the future
</div>
<div className={classes.tooltipSection}>
<strong>Retry</strong>: number of tasks scheduled to be
retried in the future
</div>
<div>
<strong>Dead</strong>: number of tasks exhausted their
retries
</div>
</div>
}
>
<InfoIcon fontSize="small" className={classes.infoIcon} />
</Tooltip>
</div>
<div className={classes.chartContainer}>
<QueueSizeChart data={queues} />
</div>
</Paper>
</Grid>
<Grid item xs={6}>
<Paper className={classes.paper} variant="outlined">
<div className={classes.chartHeader}>
<Typography variant="h6">Tasks Processed</Typography>
<Tooltip
title={
<div>
<div className={classes.tooltipSection}>
Total number of tasks processed today (
{getCurrentUTCDate()} UTC)
</div>
<div className={classes.tooltipSection}>
<strong>Succeeded</strong>: number of tasks successfully
processed from the queue
</div>
<div>
<strong>Failed</strong>: number of tasks failed to be
processed from the queue
</div>
</div>
}
>
<InfoIcon fontSize="small" className={classes.infoIcon} />
</Tooltip>
</div>
<div className={classes.chartContainer}>
<ProcessedTasksChart data={processedStats} />
</div>
</Paper>
</Grid>
<Grid item xs={12}>
<Paper className={classes.paper} variant="outlined">
{/* TODO: Add loading indicator */}
<QueuesOverviewTable
queues={queues}
onPauseClick={props.pauseQueueAsync}
onResumeClick={props.resumeQueueAsync}
/>
</Paper>
</Grid>
</Grid>
</Container>
);
}
export default connector(DashboardView);

View File

@ -0,0 +1,78 @@
import React, { useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import Container from "@material-ui/core/Container";
import { makeStyles } from "@material-ui/core/styles";
import Grid from "@material-ui/core/Grid";
import Paper from "@material-ui/core/Paper";
import { Typography } from "@material-ui/core";
import Slider from "@material-ui/core/Slider/Slider";
import { pollIntervalChange } from "../actions/settingsActions";
import { AppState } from "../store";
const useStyles = makeStyles((theme) => ({
container: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
paper: {
padding: theme.spacing(2),
display: "flex",
overflow: "auto",
flexDirection: "column",
},
}));
function mapStateToProps(state: AppState) {
return {
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { pollIntervalChange };
const connector = connect(mapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
function SettingsView(props: PropsFromRedux) {
const classes = useStyles();
const [sliderValue, setSliderValue] = useState(props.pollInterval);
const handleSliderValueChange = (event: any, val: number | number[]) => {
setSliderValue(val as number);
};
const handleSliderValueCommited = (event: any, val: number | number[]) => {
props.pollIntervalChange(val as number);
};
return (
<Container maxWidth="lg" className={classes.container}>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="h5">Settings</Typography>
</Grid>
<Grid item xs={12}>
<Paper className={classes.paper} variant="outlined">
<Typography gutterBottom color="primary">
Polling Interval (Every {sliderValue} seconds)
</Typography>
<Slider
value={sliderValue}
onChange={handleSliderValueChange}
onChangeCommitted={handleSliderValueCommited}
aria-labelledby="continuous-slider"
valueLabelDisplay="auto"
step={1}
marks={true}
min={2}
max={20}
/>
</Paper>
</Grid>
</Grid>
</Container>
);
}
export default connector(SettingsView);

View File

@ -0,0 +1,55 @@
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import Container from "@material-ui/core/Container";
import Grid from "@material-ui/core/Grid";
import TasksTable from "../components/TasksTable";
import { useParams, useLocation } from "react-router-dom";
const useStyles = makeStyles((theme) => ({
container: {
paddingLeft: 0,
marginLeft: 0,
height: "100%",
},
gridContainer: {
height: "100%",
paddingBottom: 0,
},
gridItem: {
height: "100%",
paddingBottom: 0,
},
}));
function useQuery(): URLSearchParams {
return new URLSearchParams(useLocation().search);
}
interface RouteParams {
qname: string;
}
const validStatus = ["active", "pending", "scheduled", "retry", "dead"];
const defaultStatus = "active";
function TasksView() {
const classes = useStyles();
const { qname } = useParams<RouteParams>();
const query = useQuery();
let selected = query.get("status");
if (!selected || !validStatus.includes(selected)) {
selected = defaultStatus;
}
return (
<Container maxWidth="lg" className={classes.container}>
<Grid container spacing={0} className={classes.gridContainer}>
<Grid item xs={12} className={classes.gridItem}>
<TasksTable queue={qname} selected={selected} />
</Grid>
</Grid>
</Container>
);
}
export default TasksView;

19
ui/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": ["src"]
}

11859
ui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff