Add support for Prometheus integration

This commit is contained in:
Ken Hibino 2021-12-19 07:30:16 -08:00 committed by GitHub
parent 711ca8b5c8
commit 1b8d46a35e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 2113 additions and 105 deletions

View File

@ -1,9 +1,12 @@
<img src="https://user-images.githubusercontent.com/11155743/114745460-57760500-9d57-11eb-9a2c-43fa88171807.png" alt="Asynqmon logo" width="360px" />
# A modern web based tool for monitoring & administrating [Asynq](https://github.com/hibiken/asynq) queues, tasks and message broker
# A modern web based tool for monitoring & administrating [Asynq](https://github.com/hibiken/asynq) queues and tasks
## Overview
Asynqmon is a web UI tool for monitoring & administrating [Asynq](https://github.com/hibiken/asynq) queues and tasks.
It optionally integrates with Prometheus to display time-series data.
Asynqmon is both a library that you can include in your web application, as well as a binary that you can simply install and run.
## Version Compatibility
@ -84,7 +87,7 @@ Here's the available flags:
_Note_: Use `--redis-url` to specify address, db-number, and password with one flag value; Alternatively, use `--redis-addr`, `--redis-db`, and `--redis-password` to specify each value.
| Flag | Env | Description | Default |
| ------------------------------- | --------------------- | ------------------------------------------------------------------- | ---------------- |
| --------------------------------- | ------------------------- | ------------------------------------------------------------------- | ---------------- |
| `--port`(int) | `PORT` | port number to use for web ui server | 8080 |
| `---redis-url`(string) | `REDIS_URL` | URL to redis server | "" |
| `--redis-addr`(string) | `REDIS_ADDR` | address of redis server to connect to | "127.0.0.1:6379" |
@ -93,6 +96,18 @@ _Note_: Use `--redis-url` to specify address, db-number, and password with one f
| `--redis-cluster-nodes`(string) | `REDIS_CLUSTER_NODES` | comma separated list of host:port addresses of cluster nodes | "" |
| `--redis-tls`(string) | `REDIS_TLS` | server name for TLS validation used when connecting to redis server | "" |
| `--redis-insecure-tls`(bool) | `REDIS_INSECURE_TLS` | disable TLS certificate host checks | false |
| `--enable-metrics-exporter`(bool) | `ENABLE_METRICS_EXPORTER` | enable prometheus metrics exporter to expose queue metrics | false |
| `--prometheus-addr`(string) | `PROMETHEUS_ADDR` | address of prometheus server to query time series | "" |
### Integration with Prometheus
The binary supports two flags to enable integration with [Prometheus](https://prometheus.io/).
First, enable metrics exporter to expose queue metrics to Prometheus server by passing `--enable-metrics-exporter` flag.
The metrics data is now available under `/metrics` for Prometheus to scape.
Once the metrics data is collected by a Prometheus server, you can pass the address of the Prometheus server to asynqmon to query the time-series data.
The address can be specified via `--prometheus-addr`. This enables the metrics view on the Web UI.
### Examples
@ -100,6 +115,9 @@ _Note_: Use `--redis-url` to specify address, db-number, and password with one f
# with a local binary; custom port and connect to redis server at localhost:6380
./asynqmon --port=3000 --redis-addr=localhost:6380
# with prometheus integration enabled
./asynqmon --enable-metrics-exporter --prometheus-addr=localhost:9090
# with Docker (connect to a Redis server running on the host machine)
docker run --rm \
--name asynqmon \

View File

@ -12,10 +12,12 @@ import (
"time"
"github.com/go-redis/redis/v8"
"github.com/rs/cors"
"github.com/hibiken/asynq"
"github.com/hibiken/asynq/x/metrics"
"github.com/hibiken/asynqmon"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/cors"
)
// Command-line flags
@ -30,6 +32,8 @@ var (
flagRedisClusterNodes string
flagMaxPayloadLength int
flagMaxResultLength int
flagEnableMetricsExporter bool
flagPrometheusServerAddr string
)
func init() {
@ -43,6 +47,8 @@ func init() {
flag.StringVar(&flagRedisClusterNodes, "redis-cluster-nodes", getEnvDefaultString("REDIS_CLUSTER_NODES", ""), "comma separated list of host:port addresses of cluster nodes")
flag.IntVar(&flagMaxPayloadLength, "max-payload-length", getEnvOrDefaultInt("MAX_PAYLOAD_LENGTH", 200), "maximum number of utf8 characters printed in the payload cell in the Web UI")
flag.IntVar(&flagMaxResultLength, "max-result-length", getEnvOrDefaultInt("MAX_RESULT_LENGTH", 200), "maximum number of utf8 characters printed in the result cell in the Web UI")
flag.BoolVar(&flagEnableMetricsExporter, "enable-metrics-exporter", getEnvOrDefaultBool("ENABLE_METRICS_EXPORTER", false), "enable prometheus metrics exporter to expose queue metrics")
flag.StringVar(&flagPrometheusServerAddr, "prometheus-addr", getEnvDefaultString("PROMETHEUS_ADDR", ""), "address of prometheus server to query time series")
}
// TODO: Write test and refactor this code.
@ -107,15 +113,32 @@ func main() {
RedisConnOpt: redisConnOpt,
PayloadFormatter: asynqmon.PayloadFormatterFunc(formatPayload),
ResultFormatter: asynqmon.ResultFormatterFunc(formatResult),
PrometheusAddress: flagPrometheusServerAddr,
})
defer h.Close()
c := cors.New(cors.Options{
AllowedMethods: []string{"GET", "POST", "DELETE"},
})
mux := http.NewServeMux()
mux.Handle("/", c.Handler(h))
if flagEnableMetricsExporter {
// Using NewPedanticRegistry here to test the implementation of Collectors and Metrics.
reg := prometheus.NewPedanticRegistry()
inspector := asynq.NewInspector(redisConnOpt)
reg.MustRegister(
metrics.NewQueueMetricsCollector(inspector),
// Add the standard process and go metrics to the registry
prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{}),
prometheus.NewGoCollector(),
)
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
}
srv := &http.Server{
Handler: c.Handler(h),
Handler: mux,
Addr: fmt.Sprintf(":%d", flagPort),
WriteTimeout: 10 * time.Second,
ReadTimeout: 10 * time.Second,
@ -149,7 +172,7 @@ func truncate(s string, limit int) string {
func getEnvDefaultString(key, def string) string {
v := os.Getenv(key)
if (v == "") {
if v == "" {
return def
}
@ -158,7 +181,7 @@ func getEnvDefaultString(key, def string) string {
func getEnvOrDefaultInt(key string, def int) int {
v, err := strconv.Atoi(os.Getenv(key))
if (err != nil) {
if err != nil {
return def
}
return v
@ -166,7 +189,7 @@ func getEnvOrDefaultInt(key string, def int) int {
func getEnvOrDefaultBool(key string, def bool) bool {
v, err := strconv.ParseBool(os.Getenv(key))
if (err != nil) {
if err != nil {
return def
}
return v

12
go.mod
View File

@ -3,9 +3,15 @@ module github.com/hibiken/asynqmon
go 1.16
require (
github.com/go-redis/redis/v8 v8.11.3
github.com/go-redis/redis/v8 v8.11.4
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/mux v1.8.0
github.com/hibiken/asynq v0.19.0
github.com/hibiken/asynq v0.20.0
github.com/hibiken/asynq/x v0.0.0-20211219150637-8dfabfccb3be // indirect
github.com/prometheus/client_golang v1.11.0 // indirect
github.com/rs/cors v1.7.0
github.com/spf13/cast v1.4.1 // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
google.golang.org/protobuf v1.27.1 // indirect
)

149
go.sum
View File

@ -1,35 +1,46 @@
cloud.google.com/go v0.26.0 h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473 h1:4cmBvAEBNJaGARUEs3/suWRyfyBfhf7I60WBZq+bv2w=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-redis/redis/v8 v8.11.2/go.mod h1:DLomh7y2e3ggQXQLd1YgmvIfecPJoFl7WU5SOQ/r06M=
github.com/go-redis/redis/v8 v8.11.3 h1:GCjoYp8c+yQTJfc0n69iwSiHjvuAdruxl7elnZCxgt8=
github.com/go-redis/redis/v8 v8.11.3/go.mod h1:xNJ9xDG09FsIPwh3bWdk+0oDWHbtF9rPN0F/oD9XeKc=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -38,6 +49,7 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
@ -46,25 +58,45 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/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.18.6 h1:pBjtGh2QhDe1+/0yaSc56ANpdQ77BQgVfMIrj+NJrUM=
github.com/hibiken/asynq v0.18.6/go.mod h1:tyc63ojaW8SJ5SBm8mvI4DDONsguP5HE85EEl4Qr5Ig=
github.com/hibiken/asynq v0.19.0 h1:AoJhoivymyFhF92ZAmVzxd7jr0RM264HdgkbjPc+x+M=
github.com/hibiken/asynq v0.19.0/go.mod h1:tyc63ojaW8SJ5SBm8mvI4DDONsguP5HE85EEl4Qr5Ig=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hibiken/asynq v0.19.1 h1:Xo4arUse8zhjEYj8wUXgvUSGshMRsV1qgtrSGetgUtg=
github.com/hibiken/asynq v0.19.1/go.mod h1:tyc63ojaW8SJ5SBm8mvI4DDONsguP5HE85EEl4Qr5Ig=
github.com/hibiken/asynq v0.20.0 h1:8k+safARLw9zwW91Tk/lbMl2OYTXUIvAibqJWGF6vMM=
github.com/hibiken/asynq v0.20.0/go.mod h1:tyc63ojaW8SJ5SBm8mvI4DDONsguP5HE85EEl4Qr5Ig=
github.com/hibiken/asynq/x v0.0.0-20211219150637-8dfabfccb3be h1:89J7WrDuoqFaKoQjZwqPczQXgXZ71liWYM+z9a8sILs=
github.com/hibiken/asynq/x v0.0.0-20211219150637-8dfabfccb3be/go.mod h1:VmxwMfMKyb6gyv8xG0oOBMXIhquWKPx+zPtbVBd2Q1s=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@ -76,87 +108,127 @@ github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vv
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48=
github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU=
github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0=
github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/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/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
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/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/goleak v0.10.0 h1:G3eWbSNIskeRqtsN/1uI5B+eP73y3JUuBsv9AZjehb4=
go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4 h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3 h1:XQyxROzUlZH+WIQwySDgnISgOivlhjIEwaQaJEJrrN0=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 h1:JWgyZ1qgdTaF3N3oxC+MdTV7qvEEgHo3otj+HB5CM7Q=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/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/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e h1:4nW4NLDYnU28ojHaHO8OVxFHk/aQ33U01a9cjED+pzE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -164,15 +236,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.27.0 h1:rRYRFMVgRv6E0D70Skyfsr28tDXIuuPZyWGMPdMcnXg=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@ -186,20 +255,22 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
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.2/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.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc h1:/hemPrYIhOhy8zYrNj+069zDB68us2sMGsfkFJO0iZs=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -34,6 +34,12 @@ type Options struct {
//
// This field is optional.
ResultFormatter ResultFormatter
// PrometheusAddress specifies the address of the Prometheus to connect to.
//
// This field is optional. If this field is set, asynqmon will query the Prometheus server
// to get the time series data about queue metrics and show them in the web UI.
PrometheusAddress string
}
// HTTPHandler is a http.Handler for asynqmon application.
@ -181,12 +187,16 @@ func muxRouter(opts Options, rc redis.UniversalClient, inspector *asynq.Inspecto
api.HandleFunc("/redis_info", newRedisInfoHandlerFunc(c)).Methods("GET")
}
// Time series metrics endpoints.
api.HandleFunc("/metrics", newGetMetricsHandlerFunc(http.DefaultClient, opts.PrometheusAddress)).Methods("GET")
// Everything else, route to uiAssetsHandler.
router.NotFoundHandler = &uiAssetsHandler{
rootPath: opts.RootPath,
contents: staticContents,
staticDirPath: "ui/build",
indexFileName: "index.html",
prometheusAddr: opts.PrometheusAddress,
}
return router
}

230
metrics_handler.go Normal file
View File

@ -0,0 +1,230 @@
package asynqmon
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type getMetricsResponse struct {
QueueSize *json.RawMessage `json:"queue_size"`
QueueLatency *json.RawMessage `json:"queue_latency_seconds"`
QueueMemUsgApprox *json.RawMessage `json:"queue_memory_usage_approx_bytes"`
ProcessedPerSecond *json.RawMessage `json:"tasks_processed_per_second"`
FailedPerSecond *json.RawMessage `json:"tasks_failed_per_second"`
ErrorRate *json.RawMessage `json:"error_rate"`
PendingTasksByQueue *json.RawMessage `json:"pending_tasks_by_queue"`
RetryTasksByQueue *json.RawMessage `json:"retry_tasks_by_queue"`
ArchivedTasksByQueue *json.RawMessage `json:"archived_tasks_by_queue"`
}
type metricsFetchOptions struct {
// Specifies the number of seconds to scan for metrics.
duration time.Duration
// Specifies the end time when fetching metrics.
endTime time.Time
// Optional filter to speicify a list of queues to get metrics for.
// Empty list indicates no filter (i.e. get metrics for all queues).
queues []string
}
func newGetMetricsHandlerFunc(client *http.Client, prometheusAddr string) http.HandlerFunc {
// res is the result of calling a JSON API endpoint.
type res struct {
query string
msg *json.RawMessage
err error
}
// List of PromQLs.
// Strings are used as template to optionally insert queue filter specified by QUEUE_FILTER.
const (
promQLQueueSize = "asynq_queue_size{QUEUE_FILTER}"
promQLQueueLatency = "asynq_queue_latency_seconds{QUEUE_FILTER}"
promQLMemUsage = "asynq_queue_memory_usage_approx_bytes{QUEUE_FILTER}"
promQLProcessedTasks = "rate(asynq_tasks_processed_total{QUEUE_FILTER}[5m])"
promQLFailedTasks = "rate(asynq_tasks_failed_total{QUEUE_FILTER}[5m])"
promQLErrorRate = "rate(asynq_tasks_failed_total{QUEUE_FILTER}[5m]) / rate(asynq_tasks_processed_total{QUEUE_FILTER}[5m])"
promQLPendingTasks = "asynq_tasks_enqueued_total{state=\"pending\",QUEUE_FILTER}"
promQLRetryTasks = "asynq_tasks_enqueued_total{state=\"retry\",QUEUE_FILTER}"
promQLArchivedTasks = "asynq_tasks_enqueued_total{state=\"archived\",QUEUE_FILTER}"
)
// Optional query params:
// `duration_sec`: specifies the number of seconds to scan
// `end_time`: specifies the end_time in Unix time seconds
return func(w http.ResponseWriter, r *http.Request) {
opts, err := extractMetricsFetchOptions(r)
if err != nil {
http.Error(w, fmt.Sprintf("invalid query parameter: %v", err), http.StatusBadRequest)
return
}
// List of queries (i.e. promQL) to send to prometheus server.
queries := []string{
promQLQueueSize,
promQLQueueLatency,
promQLMemUsage,
promQLProcessedTasks,
promQLFailedTasks,
promQLErrorRate,
promQLPendingTasks,
promQLRetryTasks,
promQLArchivedTasks,
}
resp := getMetricsResponse{}
// Make multiple API calls concurrently
n := len(queries)
ch := make(chan res, len(queries))
for _, q := range queries {
go func(q string) {
url := buildPrometheusURL(prometheusAddr, q, opts)
msg, err := fetchPrometheusMetrics(client, url)
ch <- res{q, msg, err}
}(q)
}
for r := range ch {
n--
if r.err != nil {
http.Error(w, fmt.Sprintf("failed to fetch %q: %v", r.query, err), http.StatusInternalServerError)
return
}
switch r.query {
case promQLQueueSize:
resp.QueueSize = r.msg
case promQLQueueLatency:
resp.QueueLatency = r.msg
case promQLMemUsage:
resp.QueueMemUsgApprox = r.msg
case promQLProcessedTasks:
resp.ProcessedPerSecond = r.msg
case promQLFailedTasks:
resp.FailedPerSecond = r.msg
case promQLErrorRate:
resp.ErrorRate = r.msg
case promQLPendingTasks:
resp.PendingTasksByQueue = r.msg
case promQLRetryTasks:
resp.RetryTasksByQueue = r.msg
case promQLArchivedTasks:
resp.ArchivedTasksByQueue = r.msg
}
if n == 0 {
break // fetched all metrics
}
}
bytes, err := json.Marshal(resp)
if err != nil {
http.Error(w, fmt.Sprintf("failed to marshal response into JSON: %v", err), http.StatusInternalServerError)
return
}
if _, err := w.Write(bytes); err != nil {
http.Error(w, fmt.Sprintf("failed to write to response: %v", err), http.StatusInternalServerError)
return
}
}
}
const prometheusAPIPath = "/api/v1/query_range"
func extractMetricsFetchOptions(r *http.Request) (*metricsFetchOptions, error) {
opts := &metricsFetchOptions{
duration: 60 * time.Minute,
endTime: time.Now(),
}
q := r.URL.Query()
if d := q.Get("duration"); d != "" {
val, err := strconv.Atoi(d)
if err != nil {
return nil, fmt.Errorf("invalid value provided for duration: %q", d)
}
opts.duration = time.Duration(val) * time.Second
}
if t := q.Get("endtime"); t != "" {
val, err := strconv.Atoi(t)
if err != nil {
return nil, fmt.Errorf("invalid value provided for end_time: %q", t)
}
opts.endTime = time.Unix(int64(val), 0)
}
if qs := q.Get("queues"); qs != "" {
opts.queues = strings.Split(qs, ",")
}
return opts, nil
}
func buildPrometheusURL(baseAddr, promQL string, opts *metricsFetchOptions) string {
var b strings.Builder
b.WriteString(strings.TrimSuffix(baseAddr, "/"))
b.WriteString(prometheusAPIPath)
v := url.Values{}
v.Add("query", applyQueueFilter(promQL, opts.queues))
v.Add("start", unixTimeString(opts.endTime.Add(-opts.duration)))
v.Add("end", unixTimeString(opts.endTime))
v.Add("step", strconv.Itoa(int(step(opts).Seconds())))
b.WriteString("?")
b.WriteString(v.Encode())
return b.String()
}
func applyQueueFilter(promQL string, qnames []string) string {
if len(qnames) == 0 {
return strings.ReplaceAll(promQL, "QUEUE_FILTER", "")
}
var b strings.Builder
b.WriteString(`queue=~"`)
for i, q := range qnames {
if i != 0 {
b.WriteString("|")
}
b.WriteString(q)
}
b.WriteByte('"')
return strings.ReplaceAll(promQL, "QUEUE_FILTER", b.String())
}
func fetchPrometheusMetrics(client *http.Client, url string) (*json.RawMessage, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
bytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
msg := json.RawMessage(bytes)
return &msg, err
}
// Returns step to use given the fetch options.
// In general, the longer the duration, longer the each step.
func step(opts *metricsFetchOptions) time.Duration {
if opts.duration <= 6*time.Hour {
// maximum number of data points to return: 6h / 10s = 2160
return 10 * time.Second
}
if opts.duration <= 24*time.Hour {
// maximum number of data points to return: 24h / 1m = 1440
return 1 * time.Minute
}
if opts.duration <= 8*24*time.Hour {
// maximum number of data points to return: (8*24)h / 3m = 3840
return 3 * time.Minute
}
if opts.duration <= 30*24*time.Hour {
// maximum number of data points to return: (30*24)h / 10m = 4320
return 10 * time.Minute
}
return opts.duration / 3000
}
func unixTimeString(t time.Time) string {
return strconv.Itoa(int(t.Unix()))
}

View File

@ -19,6 +19,7 @@ type uiAssetsHandler struct {
contents embed.FS
staticDirPath string
indexFileName string
prometheusAddr string
}
// ServeHTTP inspects the URL path to locate a file within the static dir
@ -60,8 +61,10 @@ func (h *uiAssetsHandler) renderIndexFile(w http.ResponseWriter) error {
}
data := struct {
RootPath string
PrometheusAddr string
}{
RootPath: h.rootPath,
PrometheusAddr: h.prometheusAddr,
}
return tmpl.Execute(w, data)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,253 @@
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/*!
Copyright (c) 2017 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/*! Conditions:: INITIAL */
/*! Production:: $accept : expression $end */
/*! Production:: css_value : ANGLE */
/*! Production:: css_value : CHS */
/*! Production:: css_value : EMS */
/*! Production:: css_value : EXS */
/*! Production:: css_value : FREQ */
/*! Production:: css_value : LENGTH */
/*! Production:: css_value : PERCENTAGE */
/*! Production:: css_value : REMS */
/*! Production:: css_value : RES */
/*! Production:: css_value : SUB css_value */
/*! Production:: css_value : TIME */
/*! Production:: css_value : VHS */
/*! Production:: css_value : VMAXS */
/*! Production:: css_value : VMINS */
/*! Production:: css_value : VWS */
/*! Production:: css_variable : CSS_VAR LPAREN CSS_CPROP COMMA math_expression RPAREN */
/*! Production:: css_variable : CSS_VAR LPAREN CSS_CPROP RPAREN */
/*! Production:: expression : math_expression EOF */
/*! Production:: math_expression : LPAREN math_expression RPAREN */
/*! Production:: math_expression : NESTED_CALC LPAREN math_expression RPAREN */
/*! Production:: math_expression : SUB PREFIX SUB NESTED_CALC LPAREN math_expression RPAREN */
/*! Production:: math_expression : css_value */
/*! Production:: math_expression : css_variable */
/*! Production:: math_expression : math_expression ADD math_expression */
/*! Production:: math_expression : math_expression DIV math_expression */
/*! Production:: math_expression : math_expression MUL math_expression */
/*! Production:: math_expression : math_expression SUB math_expression */
/*! Production:: math_expression : value */
/*! Production:: value : NUMBER */
/*! Production:: value : SUB NUMBER */
/*! Rule:: $ */
/*! Rule:: (--[0-9a-z-A-Z-]*) */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)% */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)Hz\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)ch\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)cm\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)deg\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)dpcm\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)dpi\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)dppx\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)em\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)ex\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)grad\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)in\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)kHz\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)mm\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)ms\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)pc\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)pt\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)px\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)rad\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)rem\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)s\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)turn\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)vh\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)vmax\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)vmin\b */
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)vw\b */
/*! Rule:: ([a-z]+) */
/*! Rule:: (calc) */
/*! Rule:: (var) */
/*! Rule:: , */
/*! Rule:: - */
/*! Rule:: \( */
/*! Rule:: \) */
/*! Rule:: \* */
/*! Rule:: \+ */
/*! Rule:: \/ */
/*! Rule:: \s+ */
/*! decimal.js-light v2.5.1 https://github.com/MikeMcl/decimal.js-light/LICENCE */
/**
* A better abstraction over CSS.
*
* @copyright Oleg Isonen (Slobodskoi) / Isonen 2014-present
* @website https://github.com/cssinjs/jss
* @license MIT
*/
/** @license React v0.19.1
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.10.2
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.14.0
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.14.0
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.14.0
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**!
* @fileOverview Kickass library to create and place poppers near their reference elements.
* @version 1.16.1-lts
* @license
* Copyright (c) 2016 Federico Zivolo and contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -51,6 +51,7 @@
/>
<script>
window.ROOT_PATH = "%PUBLIC_URL%";
window.PROMETHEUS_SERVER_ADDRESS = "/[[.PrometheusAddr]]";
</script>
<title>Asynq - Monitoring</title>
</head>

View File

@ -22,6 +22,7 @@ import LayersIcon from "@material-ui/icons/Layers";
import SettingsIcon from "@material-ui/icons/Settings";
import ScheduleIcon from "@material-ui/icons/Schedule";
import FeedbackIcon from "@material-ui/icons/Feedback";
import TimelineIcon from "@material-ui/icons/Timeline";
import DoubleArrowIcon from "@material-ui/icons/DoubleArrow";
import CloseIcon from "@material-ui/icons/Close";
import { AppState } from "./store";
@ -37,6 +38,7 @@ import TaskDetailsView from "./views/TaskDetailsView";
import SettingsView from "./views/SettingsView";
import ServersView from "./views/ServersView";
import RedisInfoView from "./views/RedisInfoView";
import MetricsView from "./views/MetricsView";
import PageNotFoundView from "./views/PageNotFoundView";
const drawerWidth = 220;
@ -244,6 +246,13 @@ function App(props: ConnectedProps<typeof connector>) {
primary="Redis"
icon={<LayersIcon />}
/>
{window.PROMETHEUS_SERVER_ADDRESS && (
<ListItemLink
to={paths.METRICS}
primary="Metrics"
icon={<TimelineIcon />}
/>
)}
</div>
</List>
<List>
@ -291,6 +300,9 @@ function App(props: ConnectedProps<typeof connector>) {
<Route exact path={paths.HOME}>
<DashboardView />
</Route>
<Route exact path={paths.METRICS}>
<MetricsView />
</Route>
<Route path="*">
<PageNotFoundView />
</Route>

View File

@ -0,0 +1,48 @@
import { Dispatch } from "redux";
import { getMetrics, MetricsResponse } from "../api";
import { toErrorString, toErrorStringWithHttpStatus } from "../utils";
// List of metrics related action types.
export const GET_METRICS_BEGIN = "GET_METRICS_BEGIN";
export const GET_METRICS_SUCCESS = "GET_METRICS_SUCCESS";
export const GET_METRICS_ERROR = "GET_METRICS_ERROR";
interface GetMetricsBeginAction {
type: typeof GET_METRICS_BEGIN;
}
interface GetMetricsSuccessAction {
type: typeof GET_METRICS_SUCCESS;
payload: MetricsResponse;
}
interface GetMetricsErrorAction {
type: typeof GET_METRICS_ERROR;
error: string;
}
// Union of all metrics related actions.
export type MetricsActionTypes =
| GetMetricsBeginAction
| GetMetricsSuccessAction
| GetMetricsErrorAction;
export function getMetricsAsync(
endTime: number,
duration: number,
queues: string[]
) {
return async (dispatch: Dispatch<MetricsActionTypes>) => {
dispatch({ type: GET_METRICS_BEGIN });
try {
const response = await getMetrics(endTime, duration, queues);
dispatch({ type: GET_METRICS_SUCCESS, payload: response });
} catch (error) {
console.error(`getMetricsAsync: ${toErrorStringWithHttpStatus(error)}`);
dispatch({
type: GET_METRICS_ERROR,
error: toErrorString(error),
});
}
};
}

View File

@ -76,6 +76,45 @@ export interface QueueLocation {
nodes: string[]; // node addresses
}
export interface MetricsResponse {
queue_size: PrometheusMetricsResponse;
queue_latency_seconds: PrometheusMetricsResponse;
queue_memory_usage_approx_bytes: PrometheusMetricsResponse;
tasks_processed_per_second: PrometheusMetricsResponse;
tasks_failed_per_second: PrometheusMetricsResponse;
error_rate: PrometheusMetricsResponse;
pending_tasks_by_queue: PrometheusMetricsResponse;
retry_tasks_by_queue: PrometheusMetricsResponse;
archived_tasks_by_queue: PrometheusMetricsResponse;
}
export interface PrometheusMetricsResponse {
status: "success" | "error";
data?: MetricsResult; // present if status === "success"
error?: string; // present if status === "error"
errorType?: string; // present if status === "error"
}
export interface MetricsResult {
resultType: string;
result: Metrics[];
}
export interface Metrics {
metric: MetricsInfo;
values: [number, string][]; // [unixtime, value]
}
export interface MetricsInfo {
__name__: string;
instance: string;
job: string;
// labels (may or may not be present depending on metrics)
queue?: string;
state?: string;
}
// Return value from redis INFO command.
// See https://redis.io/commands/info#return-value.
export interface RedisInfo {
@ -854,3 +893,28 @@ export async function getRedisInfo(): Promise<RedisInfoResponse> {
});
return resp.data;
}
interface MetricsEndpointParams {
endtime: number;
duration: number;
queues?: string; // comma-separated list of queues
}
export async function getMetrics(
endTime: number,
duration: number,
queues: string[]
): Promise<MetricsResponse> {
let params: MetricsEndpointParams = {
endtime: endTime,
duration: duration,
};
if (queues && queues.length > 0) {
params.queues = queues.join(",");
}
const resp = await axios({
method: "get",
url: `${BASE_URL}/metrics?${queryString.stringify(params)}`,
});
return resp.data;
}

View File

@ -30,8 +30,12 @@ export default function DailyStatsChart(props: Props) {
<ResponsiveContainer>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" minTickGap={10} />
<YAxis />
<XAxis
dataKey="date"
minTickGap={10}
stroke={theme.palette.text.secondary}
/>
<YAxis stroke={theme.palette.text.secondary} />
<Tooltip />
<Legend />
<Line

View File

@ -0,0 +1,736 @@
import React from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles, Theme } from "@material-ui/core/styles";
import Button, { ButtonProps } from "@material-ui/core/Button";
import ButtonGroup from "@material-ui/core/ButtonGroup";
import IconButton from "@material-ui/core/IconButton";
import Popover from "@material-ui/core/Popover";
import Radio from "@material-ui/core/Radio";
import RadioGroup from "@material-ui/core/RadioGroup";
import Checkbox from "@material-ui/core/Checkbox";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import FormControl from "@material-ui/core/FormControl";
import FormGroup from "@material-ui/core/FormGroup";
import FormLabel from "@material-ui/core/FormLabel";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import ArrowLeftIcon from "@material-ui/icons/ArrowLeft";
import ArrowRightIcon from "@material-ui/icons/ArrowRight";
import FilterListIcon from "@material-ui/icons/FilterList";
import dayjs from "dayjs";
import { currentUnixtime, parseDuration } from "../utils";
import { AppState } from "../store";
import { isDarkTheme } from "../theme";
function mapStateToProps(state: AppState) {
return { pollInterval: state.settings.pollInterval };
}
const connector = connect(mapStateToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props extends ReduxProps {
// Specifies the endtime in Unix time seconds.
endTimeSec: number;
onEndTimeChange: (t: number, isEndTimeFixed: boolean) => void;
// Specifies the duration in seconds.
durationSec: number;
onDurationChange: (d: number, isEndTimeFixed: boolean) => void;
// All available queues.
queues: string[];
// Selected queues.
selectedQueues: string[];
addQueue: (qname: string) => void;
removeQueue: (qname: string) => void;
}
interface State {
endTimeOption: EndTimeOption;
durationOption: DurationOption;
customEndTime: string; // text shown in input field
customDuration: string; // text shown in input field
customEndTimeError: string;
customDurationError: string;
}
type EndTimeOption = "real_time" | "freeze_at_now" | "custom";
type DurationOption = "1h" | "6h" | "1d" | "8d" | "30d" | "custom";
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
alignItems: "center",
},
endTimeCaption: {
marginRight: theme.spacing(1),
},
shiftButtons: {
marginLeft: theme.spacing(1),
},
buttonGroupRoot: {
height: 29,
position: "relative",
top: 1,
},
endTimeShiftControls: {
padding: theme.spacing(1),
display: "flex",
alignItems: "center",
justifyContent: "center",
borderBottomColor: theme.palette.divider,
borderBottomWidth: 1,
borderBottomStyle: "solid",
},
leftShiftButtons: {
display: "flex",
alignItems: "center",
marginRight: theme.spacing(2),
},
rightShiftButtons: {
display: "flex",
alignItems: "center",
marginLeft: theme.spacing(2),
},
controlsContainer: {
display: "flex",
justifyContent: "flex-end",
},
controlSelectorBox: {
display: "flex",
minWidth: 490,
padding: theme.spacing(2),
},
controlEndTimeSelector: {
width: "50%",
},
controlDurationSelector: {
width: "50%",
},
radioButtonRoot: {
paddingTop: theme.spacing(0.5),
paddingBottom: theme.spacing(0.5),
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
},
formControlLabel: {
fontSize: 14,
},
buttonLabel: {
textTransform: "none",
fontSize: 12,
},
formControlRoot: {
width: "100%",
margin: 0,
},
formLabel: {
fontSize: 14,
fontWeight: 500,
marginBottom: theme.spacing(1),
},
customInputField: {
marginTop: theme.spacing(1),
},
filterButton: {
marginLeft: theme.spacing(1),
},
queueFilters: {
padding: theme.spacing(2),
maxHeight: 400,
},
checkbox: {
padding: 6,
},
}));
// minute, hour, day in seconds
const minute = 60;
const hour = 60 * minute;
const day = 24 * hour;
function getInitialState(endTimeSec: number, durationSec: number): State {
let endTimeOption: EndTimeOption = "real_time";
let customEndTime = "";
let durationOption: DurationOption = "1h";
let customDuration = "";
const now = currentUnixtime();
// Account for 1s difference, may just happen to elapse 1s
// between the parent component's render and this component's render.
if (now <= endTimeSec && endTimeSec <= now + 1) {
endTimeOption = "real_time";
} else {
endTimeOption = "custom";
customEndTime = new Date(endTimeSec * 1000).toISOString();
}
switch (durationSec) {
case 1 * hour:
durationOption = "1h";
break;
case 6 * hour:
durationOption = "6h";
break;
case 1 * day:
durationOption = "1d";
break;
case 8 * day:
durationOption = "8d";
break;
case 30 * day:
durationOption = "30d";
break;
default:
durationOption = "custom";
customDuration = durationSec + "s";
}
return {
endTimeOption,
customEndTime,
customEndTimeError: "",
durationOption,
customDuration,
customDurationError: "",
};
}
function MetricsFetchControls(props: Props) {
const classes = useStyles();
const [state, setState] = React.useState<State>(
getInitialState(props.endTimeSec, props.durationSec)
);
const [timePopoverAnchorElem, setTimePopoverAnchorElem] =
React.useState<HTMLButtonElement | null>(null);
const [queuePopoverAnchorElem, setQueuePopoverAnchorElem] =
React.useState<HTMLButtonElement | null>(null);
const handleEndTimeOptionChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const selectedOpt = (event.target as HTMLInputElement)
.value as EndTimeOption;
setState((prevState) => ({
...prevState,
endTimeOption: selectedOpt,
customEndTime: "",
customEndTimeError: "",
}));
switch (selectedOpt) {
case "real_time":
props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ false);
break;
case "freeze_at_now":
props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ true);
break;
case "custom":
// No-op
}
};
const handleDurationOptionChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const selectedOpt = (event.target as HTMLInputElement)
.value as DurationOption;
setState((prevState) => ({
...prevState,
durationOption: selectedOpt,
customDuration: "",
customDurationError: "",
}));
const isEndTimeFixed = state.endTimeOption !== "real_time";
switch (selectedOpt) {
case "1h":
props.onDurationChange(1 * hour, isEndTimeFixed);
break;
case "6h":
props.onDurationChange(6 * hour, isEndTimeFixed);
break;
case "1d":
props.onDurationChange(1 * day, isEndTimeFixed);
break;
case "8d":
props.onDurationChange(8 * day, isEndTimeFixed);
break;
case "30d":
props.onDurationChange(30 * day, isEndTimeFixed);
break;
case "custom":
// No-op
}
};
const handleCustomDurationChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
event.persist(); // https://reactjs.org/docs/legacy-event-pooling.html
setState((prevState) => ({
...prevState,
customDuration: event.target.value,
}));
};
const handleCustomEndTimeChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
event.persist(); // https://reactjs.org/docs/legacy-event-pooling.html
setState((prevState) => ({
...prevState,
customEndTime: event.target.value,
}));
};
const handleCustomDurationKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
try {
const d = parseDuration(state.customDuration);
setState((prevState) => ({
...prevState,
durationOption: "custom",
customDurationError: "",
}));
props.onDurationChange(d, state.endTimeOption !== "real_time");
} catch (error) {
setState((prevState) => ({
...prevState,
customDurationError: "Duration invalid",
}));
}
}
};
const handleCustomEndTimeKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
const timeUsecOrNaN = Date.parse(state.customEndTime);
if (isNaN(timeUsecOrNaN)) {
setState((prevState) => ({
...prevState,
customEndTimeError: "End time invalid",
}));
return;
}
setState((prevState) => ({
...prevState,
endTimeOption: "custom",
customEndTimeError: "",
}));
props.onEndTimeChange(
Math.floor(timeUsecOrNaN / 1000),
/* isEndTimeFixed= */ true
);
}
};
const handleOpenTimePopover = (
event: React.MouseEvent<HTMLButtonElement>
) => {
setTimePopoverAnchorElem(event.currentTarget);
};
const handleCloseTimePopover = () => {
setTimePopoverAnchorElem(null);
};
const handleOpenQueuePopover = (
event: React.MouseEvent<HTMLButtonElement>
) => {
setQueuePopoverAnchorElem(event.currentTarget);
};
const handleCloseQueuePopover = () => {
setQueuePopoverAnchorElem(null);
};
const isTimePopoverOpen = Boolean(timePopoverAnchorElem);
const isQueuePopoverOpen = Boolean(queuePopoverAnchorElem);
React.useEffect(() => {
if (state.endTimeOption === "real_time") {
const id = setInterval(() => {
props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ false);
}, props.pollInterval * 1000);
return () => clearInterval(id);
}
});
const shiftBy = (deltaSec: number) => {
return () => {
const now = currentUnixtime();
const endTime = props.endTimeSec + deltaSec;
if (now <= endTime) {
setState((prevState) => ({
...prevState,
customEndTime: "",
endTimeOption: "real_time",
}));
props.onEndTimeChange(now, /*isEndTimeFixed=*/ false);
return;
}
setState((prevState) => ({
...prevState,
endTimeOption: "custom",
customEndTime: new Date(endTime * 1000).toISOString(),
}));
props.onEndTimeChange(endTime, /*isEndTimeFixed=*/ true);
};
};
return (
<div className={classes.root}>
<Typography
variant="caption"
color="textPrimary"
className={classes.endTimeCaption}
>
{formatTime(props.endTimeSec)}
</Typography>
<div>
<Button
aria-describedby={isTimePopoverOpen ? "time-popover" : undefined}
variant="outlined"
color="primary"
onClick={handleOpenTimePopover}
size="small"
classes={{
label: classes.buttonLabel,
}}
>
{state.endTimeOption === "real_time" ? "Realtime" : "Historical"}:{" "}
{state.durationOption === "custom"
? state.customDuration
: state.durationOption}
</Button>
<Popover
id={isTimePopoverOpen ? "time-popover" : undefined}
open={isTimePopoverOpen}
anchorEl={timePopoverAnchorElem}
onClose={handleCloseTimePopover}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
>
<div className={classes.endTimeShiftControls}>
<div className={classes.leftShiftButtons}>
<ShiftButton
direction="left"
text="2h"
onClick={shiftBy(-2 * hour)}
dense={true}
/>
<ShiftButton
direction="left"
text="1h"
onClick={shiftBy(-1 * hour)}
dense={true}
/>
<ShiftButton
direction="left"
text="30m"
onClick={shiftBy(-30 * minute)}
dense={true}
/>
<ShiftButton
direction="left"
text="15m"
onClick={shiftBy(-15 * minute)}
dense={true}
/>
<ShiftButton
direction="left"
text="5m"
onClick={shiftBy(-5 * minute)}
dense={true}
/>
</div>
<div className={classes.rightShiftButtons}>
<ShiftButton
direction="right"
text="5m"
onClick={shiftBy(5 * minute)}
dense={true}
/>
<ShiftButton
direction="right"
text="15m"
onClick={shiftBy(15 * minute)}
dense={true}
/>
<ShiftButton
direction="right"
text="30m"
onClick={shiftBy(30 * minute)}
dense={true}
/>
<ShiftButton
direction="right"
text="1h"
onClick={shiftBy(1 * hour)}
dense={true}
/>
<ShiftButton
direction="right"
text="2h"
onClick={shiftBy(2 * hour)}
dense={true}
/>
</div>
</div>
<div className={classes.controlSelectorBox}>
<div className={classes.controlEndTimeSelector}>
<FormControl
component="fieldset"
margin="dense"
classes={{ root: classes.formControlRoot }}
>
<FormLabel className={classes.formLabel} component="legend">
End Time
</FormLabel>
<RadioGroup
aria-label="end_time"
name="end_time"
value={state.endTimeOption}
onChange={handleEndTimeOptionChange}
>
<RadioInput value="real_time" label="Real Time" />
<RadioInput value="freeze_at_now" label="Freeze at now" />
<RadioInput value="custom" label="Custom End Time" />
</RadioGroup>
<div className={classes.customInputField}>
<TextField
id="custom-endtime"
label="yyyy-mm-dd hh:mm:ssz"
variant="outlined"
size="small"
onChange={handleCustomEndTimeChange}
value={state.customEndTime}
onKeyDown={handleCustomEndTimeKeyDown}
error={state.customEndTimeError !== ""}
helperText={state.customEndTimeError}
/>
</div>
</FormControl>
</div>
<div className={classes.controlDurationSelector}>
<FormControl
component="fieldset"
margin="dense"
classes={{ root: classes.formControlRoot }}
>
<FormLabel className={classes.formLabel} component="legend">
Duration
</FormLabel>
<RadioGroup
aria-label="duration"
name="duration"
value={state.durationOption}
onChange={handleDurationOptionChange}
>
<RadioInput value="1h" label="1h" />
<RadioInput value="6h" label="6h" />
<RadioInput value="1d" label="1 day" />
<RadioInput value="8d" label="8 days" />
<RadioInput value="30d" label="30 days" />
<RadioInput value="custom" label="Custom Duration" />
</RadioGroup>
<div className={classes.customInputField}>
<TextField
id="custom-duration"
label="duration"
variant="outlined"
size="small"
onChange={handleCustomDurationChange}
value={state.customDuration}
onKeyDown={handleCustomDurationKeyDown}
error={state.customDurationError !== ""}
helperText={state.customDurationError}
/>
</div>
</FormControl>
</div>
</div>
</Popover>
</div>
<div className={classes.shiftButtons}>
<ButtonGroup
classes={{ root: classes.buttonGroupRoot }}
size="small"
color="primary"
aria-label="shift buttons"
>
<ShiftButton
direction="left"
text={
state.durationOption === "custom" ? "1h" : state.durationOption
}
color="primary"
onClick={
state.durationOption === "custom"
? shiftBy(-1 * hour)
: shiftBy(-props.durationSec)
}
/>
<ShiftButton
direction="right"
text={
state.durationOption === "custom" ? "1h" : state.durationOption
}
color="primary"
onClick={
state.durationOption === "custom"
? shiftBy(1 * hour)
: shiftBy(props.durationSec)
}
/>
</ButtonGroup>
</div>
<div className={classes.filterButton}>
<IconButton
aria-label="filter"
size="small"
onClick={handleOpenQueuePopover}
>
<FilterListIcon />
</IconButton>
<Popover
id={isQueuePopoverOpen ? "queue-popover" : undefined}
open={isQueuePopoverOpen}
anchorEl={queuePopoverAnchorElem}
onClose={handleCloseQueuePopover}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
>
<FormControl className={classes.queueFilters}>
<FormLabel className={classes.formLabel} component="legend">
Queues
</FormLabel>
<FormGroup>
{props.queues.map((qname) => (
<FormControlLabel
key={qname}
control={
<Checkbox
size="small"
checked={props.selectedQueues.includes(qname)}
onChange={() => {
if (props.selectedQueues.includes(qname)) {
props.removeQueue(qname);
} else {
props.addQueue(qname);
}
}}
name={qname}
className={classes.checkbox}
/>
}
label={qname}
classes={{ label: classes.formControlLabel }}
/>
))}
</FormGroup>
</FormControl>
</Popover>
</div>
</div>
);
}
/****************** Helper functions/components *******************/
function formatTime(unixtime: number): string {
const tz = new Date(unixtime * 1000)
.toLocaleTimeString("en-us", { timeZoneName: "short" })
.split(" ")[2];
return dayjs.unix(unixtime).format("ddd, DD MMM YYYY HH:mm:ss ") + tz;
}
interface RadioInputProps {
value: string;
label: string;
}
function RadioInput(props: RadioInputProps) {
const classes = useStyles();
return (
<FormControlLabel
classes={{ label: classes.formControlLabel }}
value={props.value}
control={
<Radio size="small" classes={{ root: classes.radioButtonRoot }} />
}
label={props.label}
/>
);
}
interface ShiftButtonProps extends ButtonProps {
text: string;
onClick: () => void;
direction: "left" | "right";
dense?: boolean;
}
const useShiftButtonStyles = makeStyles((theme: Theme) => ({
root: {
minWidth: 40,
fontWeight: (props: ShiftButtonProps) => (props.dense ? 400 : 500),
},
label: { fontSize: 12, textTransform: "none" },
iconRoot: {
marginRight: (props: ShiftButtonProps) =>
props.direction === "left" ? (props.dense ? -8 : -4) : 0,
marginLeft: (props: ShiftButtonProps) =>
props.direction === "right" ? (props.dense ? -8 : -4) : 0,
color: (props: ShiftButtonProps) =>
props.color
? props.color
: theme.palette.grey[isDarkTheme(theme) ? 200 : 700],
},
}));
function ShiftButton(props: ShiftButtonProps) {
const classes = useShiftButtonStyles(props);
return (
<Button
{...props}
classes={{
root: classes.root,
label: classes.label,
}}
size="small"
>
{props.direction === "left" && (
<ArrowLeftIcon classes={{ root: classes.iconRoot }} />
)}
{props.text}
{props.direction === "right" && (
<ArrowRightIcon classes={{ root: classes.iconRoot }} />
)}
</Button>
);
}
ShiftButton.defaultProps = {
dense: false,
};
export default connect(mapStateToProps)(MetricsFetchControls);

View File

@ -27,8 +27,8 @@ function ProcessedTasksChart(props: Props) {
<ResponsiveContainer>
<BarChart data={props.data} maxBarSize={120}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="queue" />
<YAxis />
<XAxis dataKey="queue" stroke={theme.palette.text.secondary} />
<YAxis stroke={theme.palette.text.secondary} />
<Tooltip />
<Legend />
<Bar

View File

@ -0,0 +1,108 @@
import { useTheme } from "@material-ui/core/styles";
import React from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { Metrics } from "../api";
interface Props {
data: Metrics[];
// both startTime and endTime are in unix time (seconds)
startTime: number;
endTime: number;
// (optional): Tick formatter function for YAxis
yAxisTickFormatter?: (val: number) => string;
}
// interface that rechart understands.
interface ChartData {
timestamp: number;
[qname: string]: number;
}
function toChartData(metrics: Metrics[]): ChartData[] {
if (metrics.length === 0) {
return [];
}
let byTimestamp: { [key: number]: ChartData } = {};
for (let x of metrics) {
for (let [ts, val] of x.values) {
if (!byTimestamp[ts]) {
byTimestamp[ts] = { timestamp: ts };
}
const qname = x.metric.queue;
if (qname) {
byTimestamp[ts][qname] = parseFloat(val);
}
}
}
return Object.values(byTimestamp);
}
const lineColors = [
"#2085ec",
"#72b4eb",
"#0a417a",
"#8464a0",
"#cea9bc",
"#323232",
];
function QueueMetricsChart(props: Props) {
const theme = useTheme();
const data = toChartData(props.data);
const keys = props.data.map((x) => x.metric.queue);
return (
<ResponsiveContainer height={260}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
minTickGap={10}
dataKey="timestamp"
domain={[props.startTime, props.endTime]}
tickFormatter={(timestamp: number) =>
new Date(timestamp * 1000).toLocaleTimeString()
}
type="number"
scale="time"
stroke={theme.palette.text.secondary}
/>
<YAxis
tickFormatter={props.yAxisTickFormatter}
stroke={theme.palette.text.secondary}
/>
<Tooltip
labelFormatter={(timestamp: number) => {
return new Date(timestamp * 1000).toLocaleTimeString();
}}
/>
<Legend />
{keys.map((key, idx) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={lineColors[idx % lineColors.length]}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
QueueMetricsChart.defaultProps = {
yAxisTickFormatter: (val: number) => val.toString(),
};
export default QueueMetricsChart;

View File

@ -10,6 +10,7 @@ import {
ResponsiveContainer,
} from "recharts";
import { useHistory } from "react-router-dom";
import { useTheme } from "@material-ui/core/styles";
import { queueDetailsPath } from "../paths";
interface Props {
@ -27,6 +28,7 @@ interface TaskBreakdown {
}
function QueueSizeChart(props: Props) {
const theme = useTheme();
const handleClick = (params: { activeLabel?: string } | null) => {
const allQueues = props.data.map((b) => b.queue);
if (
@ -47,8 +49,8 @@ function QueueSizeChart(props: Props) {
style={{ cursor: "pointer" }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="queue" />
<YAxis />
<XAxis dataKey="queue" stroke={theme.palette.text.secondary} />
<YAxis stroke={theme.palette.text.secondary} />
<Tooltip />
<Legend />
<Bar dataKey="active" stackId="a" fill="#1967d2" />

4
ui/src/global.d.ts vendored
View File

@ -2,4 +2,8 @@ interface Window {
// Root URL path for asynqmon app.
// ROOT_PATH should not have the tailing slash.
ROOT_PATH: string;
// Prometheus server address to query time series data.
// This field is set to empty string by default. Use this field only if it's set.
PROMETHEUS_SERVER_ADDRESS: string;
}

View File

@ -1,4 +1,5 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useLocation } from "react-router-dom";
// usePolling repeatedly calls doFn with a fix time delay specified
// by interval (in millisecond).
@ -9,3 +10,9 @@ export function usePolling(doFn: () => void, interval: number) {
return () => clearInterval(id);
}, [interval, doFn]);
}
// useQuery gets the URL search params from the current URL.
export function useQuery(): URLSearchParams {
const { search } = useLocation();
return useMemo(() => new URLSearchParams(search), [search]);
}

View File

@ -6,6 +6,7 @@ export const paths = {
QUEUE_DETAILS: `${window.ROOT_PATH}/queues/:qname`,
REDIS: `${window.ROOT_PATH}/redis`,
TASK_DETAILS: `${window.ROOT_PATH}/queues/:qname/tasks/:taskId`,
METRICS: `${window.ROOT_PATH}/metrics`,
};
/**************************************************************

View File

@ -0,0 +1,49 @@
import {
GET_METRICS_BEGIN,
GET_METRICS_ERROR,
GET_METRICS_SUCCESS,
MetricsActionTypes,
} from "../actions/metricsActions";
import { MetricsResponse } from "../api";
interface MetricsState {
loading: boolean;
error: string;
data: MetricsResponse | null;
}
const initialState: MetricsState = {
loading: false,
error: "",
data: null,
};
export default function metricsReducer(
state = initialState,
action: MetricsActionTypes
): MetricsState {
switch (action.type) {
case GET_METRICS_BEGIN:
return {
...state,
loading: true,
};
case GET_METRICS_ERROR:
return {
...state,
loading: false,
error: action.error,
};
case GET_METRICS_SUCCESS:
return {
loading: false,
error: "",
data: action.payload,
};
default:
return state;
}
}

View File

@ -7,6 +7,7 @@ import schedulerEntriesReducer from "./reducers/schedulerEntriesReducer";
import snackbarReducer from "./reducers/snackbarReducer";
import queueStatsReducer from "./reducers/queueStatsReducer";
import redisInfoReducer from "./reducers/redisInfoReducer";
import metricsReducer from "./reducers/metricsReducer";
import { loadState } from "./localStorage";
const rootReducer = combineReducers({
@ -18,6 +19,7 @@ const rootReducer = combineReducers({
snackbar: snackbarReducer,
queueStats: queueStatsReducer,
redis: redisInfoReducer,
metrics: metricsReducer,
});
const preloadedState = loadState();

View File

@ -1,4 +1,4 @@
import { createMuiTheme, Theme } from "@material-ui/core/styles";
import { createTheme, Theme } from "@material-ui/core/styles";
import { ThemePreference } from "./reducers/settingsReducer";
import useMediaQuery from "@material-ui/core/useMediaQuery";
@ -9,7 +9,7 @@ export function useTheme(themePreference: ThemePreference): Theme {
} else if (themePreference === ThemePreference.Never) {
prefersDarkMode = false;
}
return createMuiTheme({
return createTheme({
// Got color palette from https://htmlcolors.com/palette/31/stripe
palette: {
primary: {

View File

@ -75,7 +75,7 @@ export function timeAgo(timestamp: string): string {
export function timeAgoUnix(unixtime: number): string {
if (unixtime === 0) {
return ""
return "";
}
const duration = durationBetween(Date.now(), unixtime * 1000);
return stringifyDuration(duration) + " ago";
@ -103,14 +103,12 @@ export function percentage(numerator: number, denominator: number): string {
return `${perc} %`;
}
export function isJsonPayload(p: string) {
try {
JSON.parse(p);
} catch (error) {
return false;
}
return true;
}
@ -118,6 +116,31 @@ export function prettifyPayload(p: string) {
if (isJsonPayload(p)) {
return JSON.stringify(JSON.parse(p), null, 2);
}
return p;
}
// Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC.
export function currentUnixtime(): number {
return Math.floor(Date.now() / 1000);
}
const durationRegex = /([0-9]*(\.[0-9]*)?)[s|m|h]/;
// Parses the given string and returns the number of seconds if the input is valid.
// Otherwise, it throws an error
// Supported time units are "s", "m", "h"
export function parseDuration(s: string): number {
if (!durationRegex.test(s)) {
throw new Error("invalid duration");
}
const val = parseFloat(s.slice(0, -1));
switch (s.slice(-1)) {
case "s":
return val;
case "m":
return val * 60;
case "h":
return val * 60 * 60;
default:
throw new Error("invalid duration unit");
}
}

View File

@ -0,0 +1,329 @@
import React from "react";
import { connect, ConnectedProps } from "react-redux";
import { useHistory } from "react-router-dom";
import queryString from "query-string";
import { makeStyles } from "@material-ui/core/styles";
import Container from "@material-ui/core/Container";
import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import WarningIcon from "@material-ui/icons/Warning";
import InfoIcon from "@material-ui/icons/Info";
import prettyBytes from "pretty-bytes";
import { getMetricsAsync } from "../actions/metricsActions";
import { listQueuesAsync } from "../actions/queuesActions";
import { AppState } from "../store";
import QueueMetricsChart from "../components/QueueMetricsChart";
import Tooltip from "../components/Tooltip";
import { currentUnixtime } from "../utils";
import MetricsFetchControls from "../components/MetricsFetchControls";
import { useQuery } from "../hooks";
import { PrometheusMetricsResponse } from "../api";
const useStyles = makeStyles((theme) => ({
container: {
marginTop: 30,
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
controlsContainer: {
display: "flex",
justifyContent: "flex-end",
position: "fixed",
background: theme.palette.background.paper,
zIndex: theme.zIndex.appBar,
right: 0,
top: 64, // app-bar height
width: "100%",
padding: theme.spacing(2),
},
chartInfo: {
display: "flex",
alignItems: "center",
marginBottom: theme.spacing(1),
},
infoIcon: {
marginLeft: theme.spacing(1),
color: theme.palette.grey[500],
cursor: "pointer",
},
errorMessage: {
marginLeft: "auto",
display: "flex",
alignItems: "center",
},
warningIcon: {
color: "#ff6700",
marginRight: 6,
},
}));
function mapStateToProps(state: AppState) {
return {
loading: state.metrics.loading,
error: state.metrics.error,
data: state.metrics.data,
pollInterval: state.settings.pollInterval,
queues: state.queues.data.map((q) => q.name),
};
}
const connector = connect(mapStateToProps, {
getMetricsAsync,
listQueuesAsync,
});
type Props = ConnectedProps<typeof connector>;
const ENDTIME_URL_PARAM_KEY = "end";
const DURATION_URL_PARAM_KEY = "duration";
function MetricsView(props: Props) {
const classes = useStyles();
const history = useHistory();
const query = useQuery();
const endTimeStr = query.get(ENDTIME_URL_PARAM_KEY);
const endTime = endTimeStr ? parseFloat(endTimeStr) : currentUnixtime(); // default to now
const durationStr = query.get(DURATION_URL_PARAM_KEY);
const duration = durationStr ? parseFloat(durationStr) : 60 * 60; // default to 1h
const { pollInterval, getMetricsAsync, listQueuesAsync, data } = props;
const [endTimeSec, setEndTimeSec] = React.useState(endTime);
const [durationSec, setDurationSec] = React.useState(duration);
const [selectedQueues, setSelectedQueues] = React.useState<string[]>([]);
const handleEndTimeChange = (endTime: number, isEndTimeFixed: boolean) => {
const urlQuery = isEndTimeFixed
? {
[ENDTIME_URL_PARAM_KEY]: endTime,
[DURATION_URL_PARAM_KEY]: durationSec,
}
: {
[DURATION_URL_PARAM_KEY]: durationSec,
};
history.push({
...history.location,
search: queryString.stringify(urlQuery),
});
setEndTimeSec(endTime);
};
const handleDurationChange = (duration: number, isEndTimeFixed: boolean) => {
const urlQuery = isEndTimeFixed
? {
[ENDTIME_URL_PARAM_KEY]: endTimeSec,
[DURATION_URL_PARAM_KEY]: duration,
}
: {
[DURATION_URL_PARAM_KEY]: duration,
};
history.push({
...history.location,
search: queryString.stringify(urlQuery),
});
setDurationSec(duration);
};
const handleAddQueue = (qname: string) => {
if (selectedQueues.includes(qname)) {
return;
}
setSelectedQueues(selectedQueues.concat(qname));
};
const handleRemoveQueue = (qname: string) => {
if (selectedQueues.length === 1) {
return; // ensure that selected queues doesn't go down to zero once user selected
}
if (selectedQueues.length === 0) {
// when user first select filter (remove once of the queues),
// we need to lazily initialize the selectedQueues with the rest (all queues but the selected one).
setSelectedQueues(props.queues.filter((q) => q !== qname));
return;
}
setSelectedQueues(selectedQueues.filter((q) => q !== qname));
};
React.useEffect(() => {
listQueuesAsync();
}, [listQueuesAsync]);
React.useEffect(() => {
getMetricsAsync(endTimeSec, durationSec, selectedQueues);
}, [pollInterval, getMetricsAsync, durationSec, endTimeSec, selectedQueues]);
return (
<Container maxWidth="lg" className={classes.container}>
<div className={classes.controlsContainer}>
<MetricsFetchControls
endTimeSec={endTimeSec}
onEndTimeChange={handleEndTimeChange}
durationSec={durationSec}
onDurationChange={handleDurationChange}
queues={props.queues}
selectedQueues={
// If none are selected (e.g. initial state), no filters should apply.
selectedQueues.length === 0 ? props.queues : selectedQueues
}
addQueue={handleAddQueue}
removeQueue={handleRemoveQueue}
/>
</div>
<Grid container spacing={3}>
{data?.tasks_processed_per_second && (
<Grid item xs={12}>
<ChartRow
title="Tasks Processed"
description="Number of tasks processed (both succeeded and failed) per second."
metrics={data.tasks_processed_per_second}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
/>
</Grid>
)}
{data?.tasks_failed_per_second && (
<Grid item xs={12}>
<ChartRow
title="Tasks Failed"
description="Number of tasks failed per second."
metrics={data.tasks_failed_per_second}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
/>
</Grid>
)}
{data?.error_rate && (
<Grid item xs={12}>
<ChartRow
title="Error Rate"
description="Rate of task failures"
metrics={data.error_rate}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
/>
</Grid>
)}
{data?.queue_size && (
<Grid item xs={12}>
<ChartRow
title="Queue Size"
description="Total number of tasks in a given queue."
metrics={data.queue_size}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
/>
</Grid>
)}
{data?.queue_latency_seconds && (
<Grid item xs={12}>
<ChartRow
title="Queue Latency"
description="Latency of queue, measured by the oldest pending task in the queue."
metrics={data.queue_latency_seconds}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
yAxisTickFormatter={(val: number) => val + "s"}
/>
</Grid>
)}
{data?.queue_size && (
<Grid item xs={12}>
<ChartRow
title="Queue Memory Usage (approx)"
description="Memory usage by queue. Approximate value by sampling a few tasks in a queue."
metrics={data.queue_memory_usage_approx_bytes}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
yAxisTickFormatter={(val: number) => {
try {
return prettyBytes(val);
} catch (error) {
return val + "B";
}
}}
/>
</Grid>
)}
{data?.pending_tasks_by_queue && (
<Grid item xs={12}>
<ChartRow
title="Pending Tasks"
description="Number of pending tasks in a given queue."
metrics={data.pending_tasks_by_queue}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
/>
</Grid>
)}
{data?.retry_tasks_by_queue && (
<Grid item xs={12}>
<ChartRow
title="Retry Tasks"
description="Number of retry tasks in a given queue."
metrics={data.retry_tasks_by_queue}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
/>
</Grid>
)}
{data?.archived_tasks_by_queue && (
<Grid item xs={12}>
<ChartRow
title="Archived Tasks"
description="Number of archived tasks in a given queue."
metrics={data.archived_tasks_by_queue}
endTime={endTimeSec}
startTime={endTimeSec - durationSec}
/>
</Grid>
)}
</Grid>
</Container>
);
}
export default connector(MetricsView);
/******** Helper components ********/
interface ChartRowProps {
title: string;
description: string;
metrics: PrometheusMetricsResponse;
endTime: number;
startTime: number;
yAxisTickFormatter?: (val: number) => string;
}
function ChartRow(props: ChartRowProps) {
const classes = useStyles();
return (
<>
<div className={classes.chartInfo}>
<Typography color="textPrimary">{props.title}</Typography>
<Tooltip title={<div>{props.description}</div>}>
<InfoIcon fontSize="small" className={classes.infoIcon} />
</Tooltip>
{props.metrics.status === "error" && (
<div className={classes.errorMessage}>
<WarningIcon fontSize="small" className={classes.warningIcon} />
<Typography color="textSecondary">
Failed to get metrics data: {props.metrics.error}
</Typography>
</div>
)}
</div>
<QueueMetricsChart
data={
props.metrics.status === "error"
? []
: props.metrics.data?.result || []
}
endTime={props.endTime}
startTime={props.startTime}
yAxisTickFormatter={props.yAxisTickFormatter}
/>
</>
);
}

View File

@ -6,10 +6,11 @@ import Grid from "@material-ui/core/Grid";
import TasksTable from "../components/TasksTable";
import QueueInfoBanner from "../components/QueueInfoBanner";
import QueueBreadCrumb from "../components/QueueBreadcrumb";
import { useParams, useLocation } from "react-router-dom";
import { useParams } from "react-router-dom";
import { listQueuesAsync } from "../actions/queuesActions";
import { AppState } from "../store";
import { QueueDetailsRouteParams } from "../paths";
import { useQuery } from "../hooks";
function mapStateToProps(state: AppState) {
return {
@ -34,10 +35,6 @@ const useStyles = makeStyles((theme) => ({
},
}));
function useQuery(): URLSearchParams {
return new URLSearchParams(useLocation().search);
}
const validStatus = [
"active",
"pending",