mirror of
https://github.com/hibiken/asynqmon.git
synced 2025-01-19 03:05:53 +08:00
Initial commit
This commit is contained in:
commit
7bd35a88e5
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
208
conversion_helpers.go
Normal 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
9
go.mod
Normal 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
51
go.sum
Normal 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
103
main.go
Normal 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
83
queue_handlers.go
Normal 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
169
task_handlers.go
Normal 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
23
ui/.gitignore
vendored
Normal 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
44
ui/README.md
Normal 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 can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t 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 you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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
59
ui/package.json
Normal 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
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
51
ui/public/index.html
Normal 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
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
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
25
ui/public/manifest.json
Normal 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
3
ui/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
9
ui/src/App.test.tsx
Normal file
9
ui/src/App.test.tsx
Normal 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
194
ui/src/App.tsx
Normal 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;
|
158
ui/src/actions/queuesActions.ts
Normal file
158
ui/src/actions/queuesActions.ts
Normal 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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
17
ui/src/actions/settingsActions.ts
Normal file
17
ui/src/actions/settingsActions.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
246
ui/src/actions/tasksActions.ts
Normal file
246
ui/src/actions/tasksActions.ts
Normal 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
209
ui/src/api.ts
Normal 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;
|
||||||
|
}
|
195
ui/src/components/ActiveTasksTable.tsx
Normal file
195
ui/src/components/ActiveTasksTable.tsx
Normal 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);
|
200
ui/src/components/DeadTasksTable.tsx
Normal file
200
ui/src/components/DeadTasksTable.tsx
Normal 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);
|
68
ui/src/components/ListItemLink.tsx
Normal file
68
ui/src/components/ListItemLink.tsx
Normal 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;
|
195
ui/src/components/PendingTasksTable.tsx
Normal file
195
ui/src/components/PendingTasksTable.tsx
Normal 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);
|
45
ui/src/components/ProcessedTasksChart.tsx
Normal file
45
ui/src/components/ProcessedTasksChart.tsx
Normal 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;
|
45
ui/src/components/QueueSizeChart.tsx
Normal file
45
ui/src/components/QueueSizeChart.tsx
Normal 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;
|
348
ui/src/components/QueuesOverviewTable.tsx
Normal file
348
ui/src/components/QueuesOverviewTable.tsx
Normal 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;
|
||||||
|
}
|
204
ui/src/components/RetryTasksTable.tsx
Normal file
204
ui/src/components/RetryTasksTable.tsx
Normal 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);
|
197
ui/src/components/ScheduledTasksTable.tsx
Normal file
197
ui/src/components/ScheduledTasksTable.tsx
Normal 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);
|
107
ui/src/components/TablePaginationActions.tsx
Normal file
107
ui/src/components/TablePaginationActions.tsx
Normal 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;
|
332
ui/src/components/TasksTable.tsx
Normal file
332
ui/src/components/TasksTable.tsx
Normal 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);
|
13
ui/src/components/Tooltip.tsx
Normal file
13
ui/src/components/Tooltip.tsx
Normal 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
27
ui/src/index.tsx
Normal 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
14
ui/src/paths.ts
Normal 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
1
ui/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
143
ui/src/reducers/queuesReducer.ts
Normal file
143
ui/src/reducers/queuesReducer.ts
Normal 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;
|
26
ui/src/reducers/settingsReducer.ts
Normal file
26
ui/src/reducers/settingsReducer.ts
Normal 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;
|
243
ui/src/reducers/tasksReducer.ts
Normal file
243
ui/src/reducers/tasksReducer.ts
Normal 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
149
ui/src/serviceWorker.ts
Normal 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
5
ui/src/setupTests.ts
Normal 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
17
ui/src/store.tsx
Normal 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
18
ui/src/theme.tsx
Normal 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
53
ui/src/timeutil.ts
Normal 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
11
ui/src/views/CronView.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function CronView() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Cron</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CronView;
|
179
ui/src/views/DashboardView.tsx
Normal file
179
ui/src/views/DashboardView.tsx
Normal 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);
|
78
ui/src/views/SettingsView.tsx
Normal file
78
ui/src/views/SettingsView.tsx
Normal 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);
|
55
ui/src/views/TasksView.tsx
Normal file
55
ui/src/views/TasksView.tsx
Normal 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
19
ui/tsconfig.json
Normal 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
11859
ui/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user