Compare commits

..

No commits in common. "master" and "v0.1-alpha" have entirely different histories.

120 changed files with 8828 additions and 15444 deletions

View File

@ -1,16 +0,0 @@
# Files
.dockerignore
.editorconfig
.gitignore
Dockerfile
Makefile
LICENSE
**/*.md
**/*_test.go
*.out
# Folders
.git/
.github/
**/node_modules/

12
.github/FUNDING.yml vendored
View File

@ -1,12 +0,0 @@
# These are supported funding model platforms
github: [hibiken] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm"
directory: "/ui"
schedule:
interval: "daily"

View File

@ -1,60 +0,0 @@
name: "CodeQL"
on:
push:
branches: [master]
paths-ignore:
- ui/build
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
paths-ignore:
- ui/build
schedule:
- cron: "24 0 * * 6"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ["go", "javascript"]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with '+' to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -1,44 +0,0 @@
name: Publish Docker image
on:
push:
branches:
- "master"
tags:
- "v*"
pull_request:
branches:
- "master"
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Docker meta
id: meta
uses: crazy-max/ghaction-docker-meta@v2
with:
images: hibiken/asynqmon
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View File

@ -1,9 +1,7 @@
name: Release
on: on:
release: release:
types: types:
- created - created:
jobs: jobs:
release: release:
@ -20,35 +18,36 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: 1.16 go-version: 1.15
- name: Install Go1.16
run: go get golang.org/dl/go1.16rc1 && go1.16rc1 download
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: "12" node-version: "12"
- name: Get release - name: Install NPM packages
id: release run: cd ui && rm yarn.lock && yarn install
- id: release
uses: bruceadams/get-release@v1.2.2 uses: bruceadams/get-release@v1.2.2
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
- name: Install NPM packages - name: Build release binary
run: cd ui && rm yarn.lock && yarn install
- name: Build Release Binary
run: | run: |
GOOS=${{ matrix.goos }} GOARCH=amd64 make build GOOS=${{ matrix.goos }} GOARCH=amd64 make build
tar -czvf asynqmon_${{ steps.release.outputs.tag_name }}_${{ matrix.goos }}_amd64.tar.gz asynqmon tar -czvf asynqmon_${{ steps.release.outputs.tag_name }}_${{ matrix.goos }}_amd64.tar.gz asynqmon
ls ls
- name: Upload Release Binary - name: Upload release binary
id: upload-go-release-asset uses: actions/upload-release-asset@v1.0.2
uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
with: with:
upload_url: ${{ steps.release.outputs.upload_url }} upload_url: ${{ steps.release.outputs.upload_url }}
asset_path: ./asynqmon_${{ steps.release.outputs.tag_name }}_${{ matrix.goos }}_amd64.tar.gz asset_path: ./asynqmon_${{ steps.release.outputs.tag_name }}_${{ matrix.goos }}-amd64.tar.gz
asset_name: asynqmon_${{ steps.release.outputs.tag_name }}_${{ matrix.goos }}_amd64.tar.gz asset_name: asynqmon_${{ steps.release.outputs.tag_name }}_${{ matrix.goos }}-amd64.tar.gz
asset_content_type: application/gzip asset_content_type: application/gzip

22
.gitignore vendored
View File

@ -1,6 +1,3 @@
# macOS
**/.DS_Store
# Binaries for programs and plugins # Binaries for programs and plugins
*.exe *.exe
*.exe~ *.exe~
@ -16,23 +13,10 @@
# Prevent accidental node_modules installed at root. # Prevent accidental node_modules installed at root.
node_modules/ node_modules/
package.json
yarn.lock
package-json.lock
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out
# binaries # main binary
/cmd/asynqmon/asynqmon asynqmon
/asynqmon dist/
/api
dist/
# Editor configs
.idea/
.vscode/
.editorconfig
# examples
examples/

View File

@ -1,82 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on ["Keep a Changelog"](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.7.0] - 2022-04-11
Version 0.7 added support for [Task Aggregation](https://github.com/hibiken/asynq/wiki/Task-aggregation) feature
### Added
- (ui): Added tasks view to show aggregated tasks
## [0.6.1] - 2022-03-17
### Fixed
- (ui): Show metrics link in sidebar when --prometheus-addr flag is provided
## [0.6.0] - 2022-03-02
### Added
- (cmd): Added `--read-only` flag to specify read-only mode
- (pkg): Added `Options.ReadOnly` to restrict user to view-only mode
- (ui): Hide action buttons in read-only mode
- (ui): Display queue latency in dashboard page and queue detail page.
- (ui): Added copy-to-clipboard button for task ID in tasks list-view page.
- (ui): Use logo image in the appbar (thank you @koddr!)
### Fixed
- (ui): Pagination in ActiveTasks table is fixed
## [0.5.0] - 2021-12-19
Version 0.5 added support for [Prometheus](https://prometheus.io/) integration.
- (cmd): Added `--enable-metrics-exporter` option to export queue metrics.
- (cmd): Added `--prometheus-addr` to enable metrics view in Web UI.
- (pkg): Added `Options.PrometheusAddress` to enable metrics view in Web UI.
## [0.4.0] - 2021-11-06
- Added "completed" state
- Updated to be compatible with asynq v0.19
## [0.3.2] - 2021-10-22
- (ui): Fixed build
## [0.3.1] - 2021-10-21
### Added
- (cmd): Added --max-payload-length to allow specifying number of characters displayed for payload, defaults to 200 chars
- (pkg): DefaultPayloadFormatter is now exported from the package
## [0.3.0]
### Changed
- Asynqmon is now a go package that can be imported to other projects!
## [0.2.1]
### Addded
- Task details view is added
- Search by task ID feature is added
## [0.2]
### Changed
- Updated to depend on asynq 0.18
## [0.1.0-beta1] - 2021-01-31
Initial Beta Release 🎉

View File

@ -1,60 +0,0 @@
#
# First stage:
# Building a frontend.
#
FROM alpine:3.17 AS frontend
# Move to a working directory (/static).
WORKDIR /static
# https://stackoverflow.com/questions/69692842/error-message-error0308010cdigital-envelope-routinesunsupported
ENV NODE_OPTIONS=--openssl-legacy-provider
# Install npm (with latest nodejs) and yarn (globally, in silent mode).
RUN apk add --update nodejs npm && \
npm i -g -s --unsafe-perm yarn
# Copy only ./ui folder to the working directory.
COPY ui .
# Run yarn scripts (install & build).
RUN yarn install && yarn build
#
# Second stage:
# Building a backend.
#
FROM golang:1.18-alpine AS backend
# Move to a working directory (/build).
WORKDIR /build
# Copy and download dependencies.
COPY go.mod go.sum ./
RUN go mod download
# Copy a source code to the container.
COPY . .
# Copy frontend static files from /static to the root folder of the backend container.
COPY --from=frontend ["/static/build", "ui/build"]
# Set necessary environmet variables needed for the image and build the server.
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
# Run go build (with ldflags to reduce binary size).
RUN go build -ldflags="-s -w" -o asynqmon ./cmd/asynqmon
#
# Third stage:
# Creating and running a new scratch container with the backend binary.
#
FROM scratch
# Copy binary from /build to the root folder of the scratch container.
COPY --from=backend ["/build/asynqmon", "/"]
# Command to run when starting the container.
ENTRYPOINT ["/asynqmon"]

View File

@ -1,23 +1,11 @@
.PHONY: api assets build docker
NODE_PATH ?= $(PWD)/ui/node_modules
assets: assets:
@if [ ! -d "$(NODE_PATH)" ]; then cd ./ui && yarn install --modules-folder $(NODE_PATH); fi cd ./ui && yarn build
cd ./ui && yarn build --modules-folder $(NODE_PATH)
# This target skips the overhead of building UI assets. # TODO: Update this once go1.16 is released.
# Intended to be used during development. go_binary:
api: go1.16rc1 build -o asynqmon .
go build -o api ./cmd/asynqmon
# Target to build a release binary.
build: assets go_binary
# Build a release binary.
build: assets
go build -o asynqmon ./cmd/asynqmon
# Build image and run Asynqmon server (with default settings).
docker:
docker build -t asynqmon .
docker run --rm \
--name asynqmon \
-p 8080:8080 \
asynqmon --redis-addr=host.docker.internal:6379

280
README.md
View File

@ -1,287 +1,43 @@
<img src="https://user-images.githubusercontent.com/11155743/114745460-57760500-9d57-11eb-9a2c-43fa88171807.png" alt="Asynqmon logo" width="360px" /> # Asynqmon
# Web UI for monitoring & administering [Asynq](https://github.com/hibiken/asynq) task queue Asynqmon is a web based tool for monitoring and administrating Asynq queues and tasks.
## Overview ## Installation
Asynqmon is a web UI tool for monitoring and administering [Asynq](https://github.com/hibiken/asynq) queues and tasks.
It supports integration with [Prometheus](https://prometheus.io) 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
Please make sure the version compatibility with the Asynq package you are using.
| Asynq version | WebUI (asynqmon) version |
| -------------- | ------------------------ |
| 0.23.x | 0.7.x |
| 0.22.x | 0.6.x |
| 0.20.x, 0.21.x | 0.5.x |
| 0.19.x | 0.4.x |
| 0.18.x | 0.2.x, 0.3.x |
| 0.16.x, 0.17.x | 0.1.x |
## Install the binary
There're a few options to install the binary:
- [Download a release binary](#release-binaries)
- [Download a docker image](#docker-image)
- [Build a binary from source](building-from-source)
- [Build a docker image from source](#building-docker-image-locally)
### Release binaries ### Release binaries
You can download the release binary for your system from the [releases page](https://github.com/hibiken/asynqmon/releases). You can download the release binary for your system from the
[releases page](https://github.com/hibiken/asynqmon/releases).
### Docker image
To pull the Docker image:
```bash
# Pull the latest image
docker pull hibiken/asynqmon
# Or specify the image by tag
docker pull hibiken/asynqmon[:tag]
```
### Building from source ### Building from source
To build Asynqmon from source code, make sure you have Go installed ([download](https://golang.org/dl/)). Version `1.16` or higher is required. You also need [Node.js](https://nodejs.org/) and [Yarn](https://yarnpkg.com/) installed in order to build the frontend assets. To build Asynqmon from source code, first ensure that have a working
Go environment with [version 1.16 or greater installed](https://golang.org/doc/install).
You also need [Node.js](https://nodejs.org/) and [Yarn](https://yarnpkg.com/)
installed in order to build the frontend assets.
Download the source code of this repository and then run: Download the source code and then run:
```bash ```sh
make build $ make build
``` ```
The `asynqmon` binary should be created in the current directory. The `asynqmon` binary should be created in the current directory.
### Building Docker image locally ## Usage
To build Docker image locally, run: To start the server, run
```bash
make docker
```
## Run the binary
To use the defaults, simply run and open http://localhost:8080.
```bash
# with a binary
./asynqmon
# with a docker image
docker run --rm \
--name asynqmon \
-p 8080:8080 \
hibiken/asynqmon
```
By default, Asynqmon web server listens on port `8080` and connects to a Redis server running on `127.0.0.1:6379`.
To see all available flags, run:
```bash
# with a binary
./asynqmon --help
# with a docker image
docker run hibiken/asynqmon --help
```
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 or sentinel server. See [godoc](https://pkg.go.dev/github.com/hibiken/asynq#ParseRedisURI) for supported format | "" |
| `--redis-addr`(string) | `REDIS_ADDR` | address of redis server to connect to | "127.0.0.1:6379" |
| `--redis-db`(int) | `REDIS_DB` | redis database number | 0 |
| `--redis-password`(string) | `REDIS_PASSWORD` | password to use when connecting to redis server | "" |
| `--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 | "" |
| `--read-only`(bool) | `READ_ONLY` | use web UI in read-only mode | false |
### Connecting to Redis
To connect to a **single redis server**, use either `--redis-url` or (`--redis-addr`, `--redis-db`, and `--redis-password`).
Example:
```sh ```sh
$ ./asynqmon --redis-url=redis://:mypassword@localhost:6380/2 $ asynqmon
$ ./asynqmon --redis-addr=localhost:6380 --redis-db=2 --redis-password=mypassword
``` ```
To connect to **redis-sentinels**, use `--redis-url`. Pass flags to specify port, redis server address, etc.
Example:
```sh ```sh
$ ./asynqmon --redis-url=redis-sentinel://:mypassword@localhost:5000,localhost:5001,localhost:5002?master=mymaster $ asynqmon --port=3000 --redis_addr=localhost:6380
``` ```
To connect to a **redis-cluster**, use `--redis-cluster-nodes`.
Example:
```sh
$ ./asynqmon --redis-cluster-nodes=localhost:7000,localhost:7001,localhost:7002,localhost:7003,localhost:7004,localhost:7006
```
### 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 server to scrape.
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.
<img width="1532" alt="Screen Shot 2021-12-19 at 4 37 19 PM" src="https://user-images.githubusercontent.com/10953044/146696852-25916465-07f0-4ed5-af31-18be02390bcb.png">
### Examples
```bash
# 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=http://localhost:9090
# with Docker (connect to a Redis server running on the host machine)
docker run --rm \
--name asynqmon \
-p 3000:3000 \
hibiken/asynqmon --port=3000 --redis-addr=host.docker.internal:6380
# with Docker (connect to a Redis server running in the Docker container)
docker run --rm \
--name asynqmon \
--network dev-network \
-p 8080:8080 \
hibiken/asynqmon --redis-addr=dev-redis:6379
```
Next, go to [localhost:8080](http://localhost:8080) and see Asynqmon dashboard:
![Web UI Queues View](https://user-images.githubusercontent.com/11155743/114697016-07327f00-9d26-11eb-808c-0ac841dc888e.png)
**Tasks view**
![Web UI TasksView](https://user-images.githubusercontent.com/11155743/114697070-1f0a0300-9d26-11eb-855c-d3ec263865b7.png)
**Settings and adaptive dark mode**
![Web UI Settings and adaptive dark mode](https://user-images.githubusercontent.com/11155743/114697149-3517c380-9d26-11eb-9f7a-ae2dd00aad5b.png)
## Import as a Library
[![GoDoc](https://godoc.org/github.com/hibiken/asynqmon?status.svg)](https://godoc.org/github.com/hibiken/asynqmon)
Asynqmon is also a library which can be imported into an existing web application.
Example with [net/http](https://pkg.go.dev/net/http):
```go
package main
import (
"log"
"net/http"
"github.com/hibiken/asynq"
"github.com/hibiken/asynqmon"
)
func main() {
h := asynqmon.New(asynqmon.Options{
RootPath: "/monitoring", // RootPath specifies the root for asynqmon app
RedisConnOpt: asynq.RedisClientOpt{Addr: ":6379"},
})
// Note: We need the tailing slash when using net/http.ServeMux.
http.Handle(h.RootPath()+"/", h)
// Go to http://localhost:8080/monitoring to see asynqmon homepage.
log.Fatal(http.ListenAndServe(":8080", nil))
}
```
Example with [gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux):
```go
package main
import (
"log"
"net/http"
"github.com/gorilla/mux"
"github.com/hibiken/asynq"
"github.com/hibiken/asynqmon"
)
func main() {
h := asynqmon.New(asynqmon.Options{
RootPath: "/monitoring", // RootPath specifies the root for asynqmon app
RedisConnOpt: asynq.RedisClientOpt{Addr: ":6379"},
})
r := mux.NewRouter()
r.PathPrefix(h.RootPath()).Handler(h)
srv := &http.Server{
Handler: r,
Addr: ":8080",
}
// Go to http://localhost:8080/monitoring to see asynqmon homepage.
log.Fatal(srv.ListenAndServe())
}
```
Example with [labstack/echo](https://github.com/labstack/echo)):
```go
package main
import (
"github.com/labstack/echo/v4"
"github.com/hibiken/asynq"
"github.com/hibiken/asynqmon"
)
func main() {
e := echo.New()
mon := asynqmon.New(asynqmon.Options{
RootPath: "/monitoring/tasks",
RedisConnOpt: asynq.RedisClientOpt{
Addr: ":6379",
Password: "",
DB: 0,
},
})
e.Any("/monitoring/tasks/*", echo.WrapHandler(mon))
e.Start(":8080")
}
```
## License ## License
Copyright (c) 2019-present [Ken Hibino](https://github.com/hibiken) and [Contributors](https://github.com/hibiken/asynqmon/graphs/contributors). `Asynqmon` is free and open-source software licensed under the [MIT License](https://github.com/hibiken/asynq/blob/master/LICENSE). Official logo was created by [Vic Shóstak](https://github.com/koddr) and distributed under [Creative Commons](https://creativecommons.org/publicdomain/zero/1.0/) license (CC0 1.0 Universal). Asynqmon is released under the MIT license. See [LICENSE](https://github.com/hibiken/asynqmon/blob/master/LICENSE).

View File

@ -1,239 +0,0 @@
package main
import (
"bytes"
"crypto/tls"
"flag"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"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"
)
// Config holds configurations for the program provided via the command line.
type Config struct {
// Server port
Port int
// Redis connection options
RedisAddr string
RedisDB int
RedisPassword string
RedisTLS string
RedisURL string
RedisInsecureTLS bool
RedisClusterNodes string
// UI related configs
ReadOnly bool
MaxPayloadLength int
MaxResultLength int
// Prometheus related configs
EnableMetricsExporter bool
PrometheusServerAddr string
// Args are the positional (non-flag) command line arguments
Args []string
}
// parseFlags parses the command-line arguments provided to the program.
// Typically, os.Args[0] is provided as 'progname' and os.args[1:] as 'args'.
// Returns the Config in case parsing succeeded, or an error. In any case, the
// output of the flag.Parse is returned in output.
//
// Reference: https://eli.thegreenplace.net/2020/testing-flag-parsing-in-go-programs/
func parseFlags(progname string, args []string) (cfg *Config, output string, err error) {
flags := flag.NewFlagSet(progname, flag.ContinueOnError)
var buf bytes.Buffer
flags.SetOutput(&buf)
var conf Config
flags.IntVar(&conf.Port, "port", getEnvOrDefaultInt("PORT", 8080), "port number to use for web ui server")
flags.StringVar(&conf.RedisAddr, "redis-addr", getEnvDefaultString("REDIS_ADDR", "127.0.0.1:6379"), "address of redis server to connect to")
flags.IntVar(&conf.RedisDB, "redis-db", getEnvOrDefaultInt("REDIS_DB", 0), "redis database number")
flags.StringVar(&conf.RedisPassword, "redis-password", getEnvDefaultString("REDIS_PASSWORD", ""), "password to use when connecting to redis server")
flags.StringVar(&conf.RedisTLS, "redis-tls", getEnvDefaultString("REDIS_TLS", ""), "server name for TLS validation used when connecting to redis server")
flags.StringVar(&conf.RedisURL, "redis-url", getEnvDefaultString("REDIS_URL", ""), "URL to redis server")
flags.BoolVar(&conf.RedisInsecureTLS, "redis-insecure-tls", getEnvOrDefaultBool("REDIS_INSECURE_TLS", false), "disable TLS certificate host checks")
flags.StringVar(&conf.RedisClusterNodes, "redis-cluster-nodes", getEnvDefaultString("REDIS_CLUSTER_NODES", ""), "comma separated list of host:port addresses of cluster nodes")
flags.IntVar(&conf.MaxPayloadLength, "max-payload-length", getEnvOrDefaultInt("MAX_PAYLOAD_LENGTH", 200), "maximum number of utf8 characters printed in the payload cell in the Web UI")
flags.IntVar(&conf.MaxResultLength, "max-result-length", getEnvOrDefaultInt("MAX_RESULT_LENGTH", 200), "maximum number of utf8 characters printed in the result cell in the Web UI")
flags.BoolVar(&conf.EnableMetricsExporter, "enable-metrics-exporter", getEnvOrDefaultBool("ENABLE_METRICS_EXPORTER", false), "enable prometheus metrics exporter to expose queue metrics")
flags.StringVar(&conf.PrometheusServerAddr, "prometheus-addr", getEnvDefaultString("PROMETHEUS_ADDR", ""), "address of prometheus server to query time series")
flags.BoolVar(&conf.ReadOnly, "read-only", getEnvOrDefaultBool("READ_ONLY", false), "restrict to read-only mode")
err = flags.Parse(args)
if err != nil {
return nil, buf.String(), err
}
conf.Args = flags.Args()
return &conf, buf.String(), nil
}
func makeTLSConfig(cfg *Config) *tls.Config {
if cfg.RedisTLS == "" && !cfg.RedisInsecureTLS {
return nil
}
return &tls.Config{
ServerName: cfg.RedisTLS,
InsecureSkipVerify: cfg.RedisInsecureTLS,
}
}
func makeRedisConnOpt(cfg *Config) (asynq.RedisConnOpt, error) {
// Connecting to redis-cluster
if len(cfg.RedisClusterNodes) > 0 {
return asynq.RedisClusterClientOpt{
Addrs: strings.Split(cfg.RedisClusterNodes, ","),
Password: cfg.RedisPassword,
TLSConfig: makeTLSConfig(cfg),
}, nil
}
// Connecting to redis-sentinels
if strings.HasPrefix(cfg.RedisURL, "redis-sentinel") {
res, err := asynq.ParseRedisURI(cfg.RedisURL)
if err != nil {
return nil, err
}
connOpt := res.(asynq.RedisFailoverClientOpt) // safe to type-assert
connOpt.TLSConfig = makeTLSConfig(cfg)
return connOpt, nil
}
// Connecting to single redis server
var connOpt asynq.RedisClientOpt
if len(cfg.RedisURL) > 0 {
res, err := asynq.ParseRedisURI(cfg.RedisURL)
if err != nil {
return nil, err
}
connOpt = res.(asynq.RedisClientOpt) // safe to type-assert
} else {
connOpt.Addr = cfg.RedisAddr
connOpt.DB = cfg.RedisDB
connOpt.Password = cfg.RedisPassword
}
if connOpt.TLSConfig == nil {
connOpt.TLSConfig = makeTLSConfig(cfg)
}
return connOpt, nil
}
func main() {
cfg, output, err := parseFlags(os.Args[0], os.Args[1:])
if err == flag.ErrHelp {
fmt.Println(output)
os.Exit(2)
} else if err != nil {
fmt.Printf("error: %v\n", err)
fmt.Println(output)
os.Exit(1)
}
redisConnOpt, err := makeRedisConnOpt(cfg)
if err != nil {
log.Fatal(err)
}
h := asynqmon.New(asynqmon.Options{
RedisConnOpt: redisConnOpt,
PayloadFormatter: asynqmon.PayloadFormatterFunc(payloadFormatterFunc(cfg)),
ResultFormatter: asynqmon.ResultFormatterFunc(resultFormatterFunc(cfg)),
PrometheusAddress: cfg.PrometheusServerAddr,
ReadOnly: cfg.ReadOnly,
})
defer h.Close()
c := cors.New(cors.Options{
AllowedMethods: []string{"GET", "POST", "DELETE"},
})
mux := http.NewServeMux()
mux.Handle("/", c.Handler(h))
if cfg.EnableMetricsExporter {
// 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: mux,
Addr: fmt.Sprintf(":%d", cfg.Port),
WriteTimeout: 10 * time.Second,
ReadTimeout: 10 * time.Second,
}
fmt.Printf("Asynq Monitoring WebUI server is listening on port %d\n", cfg.Port)
log.Fatal(srv.ListenAndServe())
}
func payloadFormatterFunc(cfg *Config) func(string, []byte) string {
return func(taskType string, payload []byte) string {
payloadStr := asynqmon.DefaultPayloadFormatter.FormatPayload(taskType, payload)
return truncate(payloadStr, cfg.MaxPayloadLength)
}
}
func resultFormatterFunc(cfg *Config) func(string, []byte) string {
return func(taskType string, result []byte) string {
resultStr := asynqmon.DefaultResultFormatter.FormatResult(taskType, result)
return truncate(resultStr, cfg.MaxResultLength)
}
}
// truncates string s to limit length (in utf8).
func truncate(s string, limit int) string {
i := 0
for pos := range s {
if i == limit {
return s[:pos] + "…"
}
i++
}
return s
}
func getEnvDefaultString(key, def string) string {
v := os.Getenv(key)
if v == "" {
return def
}
return v
}
func getEnvOrDefaultInt(key string, def int) int {
v, err := strconv.Atoi(os.Getenv(key))
if err != nil {
return def
}
return v
}
func getEnvOrDefaultBool(key string, def bool) bool {
v, err := strconv.ParseBool(os.Getenv(key))
if err != nil {
return def
}
return v
}

View File

@ -1,137 +0,0 @@
package main
import (
"crypto/tls"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/hibiken/asynq"
)
func TestParseFlags(t *testing.T) {
tests := []struct {
args []string
want *Config
}{
{
args: []string{"--redis-addr", "localhost:6380", "--redis-db", "3"},
want: &Config{
RedisAddr: "localhost:6380",
RedisDB: 3,
// Default values
Port: 8080,
RedisPassword: "",
RedisTLS: "",
RedisURL: "",
RedisInsecureTLS: false,
RedisClusterNodes: "",
MaxPayloadLength: 200,
MaxResultLength: 200,
EnableMetricsExporter: false,
PrometheusServerAddr: "",
ReadOnly: false,
Args: []string{},
},
},
}
for _, tc := range tests {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
cfg, output, err := parseFlags("asynqmon", tc.args)
if err != nil {
t.Errorf("parseFlags returned error: %v", err)
}
if output != "" {
t.Errorf("parseFlag returned output=%q, want empty", output)
}
if diff := cmp.Diff(tc.want, cfg); diff != "" {
t.Errorf("parseFlag returned Config %v, want %v; (-want,+got)\n%s", cfg, tc.want, diff)
}
})
}
}
func TestMakeRedisConnOpt(t *testing.T) {
var tests = []struct {
desc string
cfg *Config
want asynq.RedisConnOpt
}{
{
desc: "With address, db number and password",
cfg: &Config{
RedisAddr: "localhost:6380",
RedisDB: 1,
RedisPassword: "foo",
},
want: asynq.RedisClientOpt{
Addr: "localhost:6380",
DB: 1,
Password: "foo",
},
},
{
desc: "With TLS server name",
cfg: &Config{
RedisAddr: "localhost:6379",
RedisTLS: "foobar",
},
want: asynq.RedisClientOpt{
Addr: "localhost:6379",
TLSConfig: &tls.Config{ServerName: "foobar"},
},
},
{
desc: "With redis URL",
cfg: &Config{
RedisURL: "redis://:bar@localhost:6381/2",
},
want: asynq.RedisClientOpt{
Addr: "localhost:6381",
DB: 2,
Password: "bar",
},
},
{
desc: "With redis-sentinel URL",
cfg: &Config{
RedisURL: "redis-sentinel://:secretpassword@localhost:5000,localhost:5001,localhost:5002?master=mymaster",
},
want: asynq.RedisFailoverClientOpt{
MasterName: "mymaster",
SentinelAddrs: []string{
"localhost:5000", "localhost:5001", "localhost:5002"},
Password: "secretpassword", // FIXME: Shouldn't this be SentinelPassword instead?
},
},
{
desc: "With cluster nodes",
cfg: &Config{
RedisClusterNodes: "localhost:5000,localhost:5001,localhost:5002,localhost:5003,localhost:5004,localhost:5005",
},
want: asynq.RedisClusterClientOpt{
Addrs: []string{
"localhost:5000", "localhost:5001", "localhost:5002", "localhost:5003", "localhost:5004", "localhost:5005"},
},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
got, err := makeRedisConnOpt(tc.cfg)
if err != nil {
t.Fatalf("makeRedisConnOpt returned error: %v", err)
}
if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreUnexported(tls.Config{})); diff != "" {
t.Errorf("diff found: want=%v, got=%v; (-want,+got)\n%s",
tc.want, got, diff)
}
})
}
}

View File

@ -1,11 +1,10 @@
package asynqmon package main
import ( import (
"time" "time"
"unicode"
"unicode/utf8"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
"github.com/hibiken/asynq/inspeq"
) )
// **************************************************************************** // ****************************************************************************
@ -14,89 +13,19 @@ import (
// - conversion function from an external type to an internal type // - conversion function from an external type to an internal type
// **************************************************************************** // ****************************************************************************
// PayloadFormatter is used to convert payload bytes to a string shown in the UI. type QueueStateSnapshot struct {
type PayloadFormatter interface {
// FormatPayload takes the task's typename and payload and returns a string representation of the payload.
FormatPayload(taskType string, payload []byte) string
}
type PayloadFormatterFunc func(string, []byte) string
func (f PayloadFormatterFunc) FormatPayload(taskType string, payload []byte) string {
return f(taskType, payload)
}
// ResultFormatter is used to convert result bytes to a string shown in the UI.
type ResultFormatter interface {
// FormatResult takes the task's typename and result and returns a string representation of the result.
FormatResult(taskType string, result []byte) string
}
type ResultFormatterFunc func(string, []byte) string
func (f ResultFormatterFunc) FormatResult(taskType string, result []byte) string {
return f(taskType, result)
}
// DefaultPayloadFormatter is the PayloadFormater used by default.
// It prints the given payload bytes as is if the bytes are printable, otherwise it prints a message to indicate
// that the bytes are not printable.
var DefaultPayloadFormatter = PayloadFormatterFunc(func(_ string, payload []byte) string {
if !isPrintable(payload) {
return "non-printable bytes"
}
return string(payload)
})
// DefaultResultFormatter is the ResultFormatter used by default.
// It prints the given result bytes as is if the bytes are printable, otherwise it prints a message to indicate
// that the bytes are not printable.
var DefaultResultFormatter = ResultFormatterFunc(func(_ string, result []byte) string {
if !isPrintable(result) {
return "non-printable bytes"
}
return string(result)
})
// isPrintable reports whether the given data is comprised of all printable runes.
func isPrintable(data []byte) bool {
if !utf8.Valid(data) {
return false
}
isAllSpace := true
for _, r := range string(data) {
if !unicode.IsPrint(r) {
return false
}
if !unicode.IsSpace(r) {
isAllSpace = false
}
}
return !isAllSpace
}
type queueStateSnapshot struct {
// Name of the queue. // Name of the queue.
Queue string `json:"queue"` Queue string `json:"queue"`
// Total number of bytes the queue and its tasks require to be stored in redis. // Total number of bytes the queue and its tasks require to be stored in redis.
MemoryUsage int64 `json:"memory_usage_bytes"` MemoryUsage int64 `json:"memory_usage_bytes"`
// Total number of tasks in the queue. // Total number of tasks in the queue.
Size int `json:"size"` Size int `json:"size"`
// Totoal number of groups in the queue.
Groups int `json:"groups"`
// Latency of the queue in milliseconds.
LatencyMillisec int64 `json:"latency_msec"`
// Latency duration string for display purpose.
DisplayLatency string `json:"display_latency"`
// Number of tasks in each state. // Number of tasks in each state.
Active int `json:"active"` Active int `json:"active"`
Pending int `json:"pending"` Pending int `json:"pending"`
Aggregating int `json:"aggregating"` Scheduled int `json:"scheduled"`
Scheduled int `json:"scheduled"` Retry int `json:"retry"`
Retry int `json:"retry"` Archived int `json:"archived"`
Archived int `json:"archived"`
Completed int `json:"completed"`
// Total number of tasks processed during the given date. // Total number of tasks processed during the given date.
// The number includes both succeeded and failed tasks. // The number includes both succeeded and failed tasks.
@ -111,30 +40,25 @@ type queueStateSnapshot struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
func toQueueStateSnapshot(info *asynq.QueueInfo) *queueStateSnapshot { func toQueueStateSnapshot(s *inspeq.QueueStats) *QueueStateSnapshot {
return &queueStateSnapshot{ return &QueueStateSnapshot{
Queue: info.Queue, Queue: s.Queue,
MemoryUsage: info.MemoryUsage, MemoryUsage: s.MemoryUsage,
Size: info.Size, Size: s.Size,
Groups: info.Groups, Active: s.Active,
LatencyMillisec: info.Latency.Milliseconds(), Pending: s.Pending,
DisplayLatency: info.Latency.Round(10 * time.Millisecond).String(), Scheduled: s.Scheduled,
Active: info.Active, Retry: s.Retry,
Pending: info.Pending, Archived: s.Archived,
Aggregating: info.Aggregating, Processed: s.Processed,
Scheduled: info.Scheduled, Succeeded: s.Processed - s.Failed,
Retry: info.Retry, Failed: s.Failed,
Archived: info.Archived, Paused: s.Paused,
Completed: info.Completed, Timestamp: s.Timestamp,
Processed: info.Processed,
Succeeded: info.Processed - info.Failed,
Failed: info.Failed,
Paused: info.Paused,
Timestamp: info.Timestamp,
} }
} }
type dailyStats struct { type DailyStats struct {
Queue string `json:"queue"` Queue string `json:"queue"`
Processed int `json:"processed"` Processed int `json:"processed"`
Succeeded int `json:"succeeded"` Succeeded int `json:"succeeded"`
@ -142,8 +66,8 @@ type dailyStats struct {
Date string `json:"date"` Date string `json:"date"`
} }
func toDailyStats(s *asynq.DailyStats) *dailyStats { func toDailyStats(s *inspeq.DailyStats) *DailyStats {
return &dailyStats{ return &DailyStats{
Queue: s.Queue, Queue: s.Queue,
Processed: s.Processed, Processed: s.Processed,
Succeeded: s.Processed - s.Failed, Succeeded: s.Processed - s.Failed,
@ -152,100 +76,26 @@ func toDailyStats(s *asynq.DailyStats) *dailyStats {
} }
} }
func toDailyStatsList(in []*asynq.DailyStats) []*dailyStats { func toDailyStatsList(in []*inspeq.DailyStats) []*DailyStats {
out := make([]*dailyStats, len(in)) out := make([]*DailyStats, len(in))
for i, s := range in { for i, s := range in {
out[i] = toDailyStats(s) out[i] = toDailyStats(s)
} }
return out return out
} }
type taskInfo struct { type BaseTask struct {
// ID is the identifier of the task. ID string `json:"id"`
ID string `json:"id"` Type string `json:"type"`
// Queue is the name of the queue in which the task belongs. Payload asynq.Payload `json:"payload"`
Queue string `json:"queue"` Queue string `json:"queue"`
// Type is the type name of the task. MaxRetry int `json:"max_retry"`
Type string `json:"type"` Retried int `json:"retried"`
// Payload is the payload data of the task. LastError string `json:"error_message"`
Payload string `json:"payload"`
// State indicates the task state.
State string `json:"state"`
// MaxRetry is the maximum number of times the task can be retried.
MaxRetry int `json:"max_retry"`
// Retried is the number of times the task has retried so far.
Retried int `json:"retried"`
// LastErr is the error message from the last failure.
LastErr string `json:"error_message"`
// LastFailedAt is the time time of the last failure in RFC3339 format.
// If the task has no failures, empty string.
LastFailedAt string `json:"last_failed_at"`
// Timeout is the number of seconds the task can be processed by Handler before being retried.
Timeout int `json:"timeout_seconds"`
// Deadline is the deadline for the task in RFC3339 format. If not set, empty string.
Deadline string `json:"deadline"`
// NextProcessAt is the time the task is scheduled to be processed in RFC3339 format.
// If not applicable, empty string.
NextProcessAt string `json:"next_process_at"`
// CompletedAt is the time the task was successfully processed in RFC3339 format.
// If not applicable, empty string.
CompletedAt string `json:"completed_at"`
// Result is the result data associated with the task.
Result string `json:"result"`
// TTL is the number of seconds the task has left to be retained in the queue.
// This is calculated by (CompletedAt + ResultTTL) - Now.
TTL int64 `json:"ttl_seconds"`
} }
// taskTTL calculates TTL for the given task. type ActiveTask struct {
func taskTTL(task *asynq.TaskInfo) time.Duration { *BaseTask
if task.State != asynq.TaskStateCompleted {
return 0 // N/A
}
return task.CompletedAt.Add(task.Retention).Sub(time.Now())
}
// formatTimeInRFC3339 formats t in RFC3339 if the value is non-zero.
// If t is zero time (i.e. time.Time{}), returns empty string
func formatTimeInRFC3339(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func toTaskInfo(info *asynq.TaskInfo, pf PayloadFormatter, rf ResultFormatter) *taskInfo {
return &taskInfo{
ID: info.ID,
Queue: info.Queue,
Type: info.Type,
Payload: pf.FormatPayload(info.Type, info.Payload),
State: info.State.String(),
MaxRetry: info.MaxRetry,
Retried: info.Retried,
LastErr: info.LastErr,
LastFailedAt: formatTimeInRFC3339(info.LastFailedAt),
Timeout: int(info.Timeout.Seconds()),
Deadline: formatTimeInRFC3339(info.Deadline),
NextProcessAt: formatTimeInRFC3339(info.NextProcessAt),
CompletedAt: formatTimeInRFC3339(info.CompletedAt),
Result: rf.FormatResult("", info.Result),
TTL: int64(taskTTL(info).Seconds()),
}
}
type baseTask struct {
ID string `json:"id"`
Type string `json:"type"`
Payload string `json:"payload"`
Queue string `json:"queue"`
MaxRetry int `json:"max_retry"`
Retried int `json:"retried"`
LastError string `json:"error_message"`
}
type activeTask struct {
*baseTask
// Started time indicates when a worker started working on ths task. // Started time indicates when a worker started working on ths task.
// //
@ -259,242 +109,163 @@ type activeTask struct {
// Value is either time formatted in RFC3339 format, or "-" which indicates that // Value is either time formatted in RFC3339 format, or "-" which indicates that
// the data is not available yet. // the data is not available yet.
Deadline string `json:"deadline"` Deadline string `json:"deadline"`
// IsOrphaned indicates whether the task is left in active state with no worker processing it.
IsOrphaned bool `json:"is_orphaned"`
} }
func toActiveTask(ti *asynq.TaskInfo, pf PayloadFormatter) *activeTask { func toActiveTask(t *inspeq.ActiveTask) *ActiveTask {
base := &baseTask{ base := &BaseTask{
ID: ti.ID, ID: t.ID,
Type: ti.Type, Type: t.Type,
Payload: pf.FormatPayload(ti.Type, ti.Payload), Payload: t.Payload,
Queue: ti.Queue, Queue: t.Queue,
MaxRetry: ti.MaxRetry, MaxRetry: t.MaxRetry,
Retried: ti.Retried, Retried: t.Retried,
LastError: ti.LastErr, LastError: t.LastError,
} }
return &activeTask{baseTask: base, IsOrphaned: ti.IsOrphaned} return &ActiveTask{BaseTask: base}
} }
func toActiveTasks(in []*asynq.TaskInfo, pf PayloadFormatter) []*activeTask { func toActiveTasks(in []*inspeq.ActiveTask) []*ActiveTask {
out := make([]*activeTask, len(in)) out := make([]*ActiveTask, len(in))
for i, ti := range in { for i, t := range in {
out[i] = toActiveTask(ti, pf) out[i] = toActiveTask(t)
} }
return out return out
} }
// TODO: Maybe we don't need state specific type, just use taskInfo type PendingTask struct {
type pendingTask struct { *BaseTask
*baseTask Key string `json:"key"`
} }
func toPendingTask(ti *asynq.TaskInfo, pf PayloadFormatter) *pendingTask { func toPendingTask(t *inspeq.PendingTask) *PendingTask {
base := &baseTask{ base := &BaseTask{
ID: ti.ID, ID: t.ID,
Type: ti.Type, Type: t.Type,
Payload: pf.FormatPayload(ti.Type, ti.Payload), Payload: t.Payload,
Queue: ti.Queue, Queue: t.Queue,
MaxRetry: ti.MaxRetry, MaxRetry: t.MaxRetry,
Retried: ti.Retried, Retried: t.Retried,
LastError: ti.LastErr, LastError: t.LastError,
} }
return &pendingTask{ return &PendingTask{
baseTask: base, BaseTask: base,
Key: t.Key(),
} }
} }
func toPendingTasks(in []*asynq.TaskInfo, pf PayloadFormatter) []*pendingTask { func toPendingTasks(in []*inspeq.PendingTask) []*PendingTask {
out := make([]*pendingTask, len(in)) out := make([]*PendingTask, len(in))
for i, ti := range in { for i, t := range in {
out[i] = toPendingTask(ti, pf) out[i] = toPendingTask(t)
} }
return out return out
} }
type aggregatingTask struct { type ScheduledTask struct {
*baseTask *BaseTask
Group string `json:"group"` Key string `json:"key"`
}
func toAggregatingTask(ti *asynq.TaskInfo, pf PayloadFormatter) *aggregatingTask {
base := &baseTask{
ID: ti.ID,
Type: ti.Type,
Payload: pf.FormatPayload(ti.Type, ti.Payload),
Queue: ti.Queue,
MaxRetry: ti.MaxRetry,
Retried: ti.Retried,
LastError: ti.LastErr,
}
return &aggregatingTask{
baseTask: base,
Group: ti.Group,
}
}
func toAggregatingTasks(in []*asynq.TaskInfo, pf PayloadFormatter) []*aggregatingTask {
out := make([]*aggregatingTask, len(in))
for i, ti := range in {
out[i] = toAggregatingTask(ti, pf)
}
return out
}
type scheduledTask struct {
*baseTask
NextProcessAt time.Time `json:"next_process_at"` NextProcessAt time.Time `json:"next_process_at"`
} }
func toScheduledTask(ti *asynq.TaskInfo, pf PayloadFormatter) *scheduledTask { func toScheduledTask(t *inspeq.ScheduledTask) *ScheduledTask {
base := &baseTask{ base := &BaseTask{
ID: ti.ID, ID: t.ID,
Type: ti.Type, Type: t.Type,
Payload: pf.FormatPayload(ti.Type, ti.Payload), Payload: t.Payload,
Queue: ti.Queue, Queue: t.Queue,
MaxRetry: ti.MaxRetry, MaxRetry: t.MaxRetry,
Retried: ti.Retried, Retried: t.Retried,
LastError: ti.LastErr, LastError: t.LastError,
} }
return &scheduledTask{ return &ScheduledTask{
baseTask: base, BaseTask: base,
NextProcessAt: ti.NextProcessAt, Key: t.Key(),
NextProcessAt: t.NextProcessAt,
} }
} }
func toScheduledTasks(in []*asynq.TaskInfo, pf PayloadFormatter) []*scheduledTask { func toScheduledTasks(in []*inspeq.ScheduledTask) []*ScheduledTask {
out := make([]*scheduledTask, len(in)) out := make([]*ScheduledTask, len(in))
for i, ti := range in { for i, t := range in {
out[i] = toScheduledTask(ti, pf) out[i] = toScheduledTask(t)
} }
return out return out
} }
type retryTask struct { type RetryTask struct {
*baseTask *BaseTask
Key string `json:"key"`
NextProcessAt time.Time `json:"next_process_at"` NextProcessAt time.Time `json:"next_process_at"`
} }
func toRetryTask(ti *asynq.TaskInfo, pf PayloadFormatter) *retryTask { func toRetryTask(t *inspeq.RetryTask) *RetryTask {
base := &baseTask{ base := &BaseTask{
ID: ti.ID, ID: t.ID,
Type: ti.Type, Type: t.Type,
Payload: pf.FormatPayload(ti.Type, ti.Payload), Payload: t.Payload,
Queue: ti.Queue, Queue: t.Queue,
MaxRetry: ti.MaxRetry, MaxRetry: t.MaxRetry,
Retried: ti.Retried, Retried: t.Retried,
LastError: ti.LastErr, LastError: t.LastError,
} }
return &retryTask{ return &RetryTask{
baseTask: base, BaseTask: base,
NextProcessAt: ti.NextProcessAt, Key: t.Key(),
NextProcessAt: t.NextProcessAt,
} }
} }
func toRetryTasks(in []*asynq.TaskInfo, pf PayloadFormatter) []*retryTask { func toRetryTasks(in []*inspeq.RetryTask) []*RetryTask {
out := make([]*retryTask, len(in)) out := make([]*RetryTask, len(in))
for i, ti := range in { for i, t := range in {
out[i] = toRetryTask(ti, pf) out[i] = toRetryTask(t)
} }
return out return out
} }
type archivedTask struct { type ArchivedTask struct {
*baseTask *BaseTask
Key string `json:"key"`
LastFailedAt time.Time `json:"last_failed_at"` LastFailedAt time.Time `json:"last_failed_at"`
} }
func toArchivedTask(ti *asynq.TaskInfo, pf PayloadFormatter) *archivedTask { func toArchivedTask(t *inspeq.ArchivedTask) *ArchivedTask {
base := &baseTask{ base := &BaseTask{
ID: ti.ID, ID: t.ID,
Type: ti.Type, Type: t.Type,
Payload: pf.FormatPayload(ti.Type, ti.Payload), Payload: t.Payload,
Queue: ti.Queue, Queue: t.Queue,
MaxRetry: ti.MaxRetry, MaxRetry: t.MaxRetry,
Retried: ti.Retried, Retried: t.Retried,
LastError: ti.LastErr, LastError: t.LastError,
} }
return &archivedTask{ return &ArchivedTask{
baseTask: base, BaseTask: base,
LastFailedAt: ti.LastFailedAt, Key: t.Key(),
LastFailedAt: t.LastFailedAt,
} }
} }
func toArchivedTasks(in []*asynq.TaskInfo, pf PayloadFormatter) []*archivedTask { func toArchivedTasks(in []*inspeq.ArchivedTask) []*ArchivedTask {
out := make([]*archivedTask, len(in)) out := make([]*ArchivedTask, len(in))
for i, ti := range in { for i, t := range in {
out[i] = toArchivedTask(ti, pf) out[i] = toArchivedTask(t)
} }
return out return out
} }
type completedTask struct { type SchedulerEntry struct {
*baseTask ID string `json:"id"`
CompletedAt time.Time `json:"completed_at"` Spec string `json:"spec"`
Result string `json:"result"` TaskType string `json:"task_type"`
// Number of seconds left for retention (i.e. (CompletedAt + ResultTTL) - Now) TaskPayload asynq.Payload `json:"task_payload"`
TTL int64 `json:"ttl_seconds"` Opts []string `json:"options"`
} NextEnqueueAt string `json:"next_enqueue_at"`
func toCompletedTask(ti *asynq.TaskInfo, pf PayloadFormatter, rf ResultFormatter) *completedTask {
base := &baseTask{
ID: ti.ID,
Type: ti.Type,
Payload: pf.FormatPayload(ti.Type, ti.Payload),
Queue: ti.Queue,
MaxRetry: ti.MaxRetry,
Retried: ti.Retried,
LastError: ti.LastErr,
}
return &completedTask{
baseTask: base,
CompletedAt: ti.CompletedAt,
TTL: int64(taskTTL(ti).Seconds()),
Result: rf.FormatResult(ti.Type, ti.Result),
}
}
func toCompletedTasks(in []*asynq.TaskInfo, pf PayloadFormatter, rf ResultFormatter) []*completedTask {
out := make([]*completedTask, len(in))
for i, ti := range in {
out[i] = toCompletedTask(ti, pf, rf)
}
return out
}
type groupInfo struct {
Group string `json:"group"`
Size int `json:"size"`
}
func toGroupInfos(in []*asynq.GroupInfo) []*groupInfo {
out := make([]*groupInfo, len(in))
for i, g := range in {
out[i] = toGroupInfo(g)
}
return out
}
func toGroupInfo(in *asynq.GroupInfo) *groupInfo {
return &groupInfo{
Group: in.Group,
Size: in.Size,
}
}
type schedulerEntry struct {
ID string `json:"id"`
Spec string `json:"spec"`
TaskType string `json:"task_type"`
TaskPayload string `json:"task_payload"`
Opts []string `json:"options"`
NextEnqueueAt string `json:"next_enqueue_at"`
// This field is omitted if there were no previous enqueue events. // This field is omitted if there were no previous enqueue events.
PrevEnqueueAt string `json:"prev_enqueue_at,omitempty"` PrevEnqueueAt string `json:"prev_enqueue_at,omitempty"`
} }
func toSchedulerEntry(e *asynq.SchedulerEntry, pf PayloadFormatter) *schedulerEntry { func toSchedulerEntry(e *inspeq.SchedulerEntry) *SchedulerEntry {
opts := make([]string, 0) // create a non-nil, empty slice to avoid null in json output opts := make([]string, 0) // create a non-nil, empty slice to avoid null in json output
for _, o := range e.Opts { for _, o := range e.Opts {
opts = append(opts, o.String()) opts = append(opts, o.String())
@ -503,46 +274,46 @@ func toSchedulerEntry(e *asynq.SchedulerEntry, pf PayloadFormatter) *schedulerEn
if !e.Prev.IsZero() { if !e.Prev.IsZero() {
prev = e.Prev.Format(time.RFC3339) prev = e.Prev.Format(time.RFC3339)
} }
return &schedulerEntry{ return &SchedulerEntry{
ID: e.ID, ID: e.ID,
Spec: e.Spec, Spec: e.Spec,
TaskType: e.Task.Type(), TaskType: e.Task.Type,
TaskPayload: pf.FormatPayload(e.Task.Type(), e.Task.Payload()), TaskPayload: e.Task.Payload,
Opts: opts, Opts: opts,
NextEnqueueAt: e.Next.Format(time.RFC3339), NextEnqueueAt: e.Next.Format(time.RFC3339),
PrevEnqueueAt: prev, PrevEnqueueAt: prev,
} }
} }
func toSchedulerEntries(in []*asynq.SchedulerEntry, pf PayloadFormatter) []*schedulerEntry { func toSchedulerEntries(in []*inspeq.SchedulerEntry) []*SchedulerEntry {
out := make([]*schedulerEntry, len(in)) out := make([]*SchedulerEntry, len(in))
for i, e := range in { for i, e := range in {
out[i] = toSchedulerEntry(e, pf) out[i] = toSchedulerEntry(e)
} }
return out return out
} }
type schedulerEnqueueEvent struct { type SchedulerEnqueueEvent struct {
TaskID string `json:"task_id"` TaskID string `json:"task_id"`
EnqueuedAt string `json:"enqueued_at"` EnqueuedAt string `json:"enqueued_at"`
} }
func toSchedulerEnqueueEvent(e *asynq.SchedulerEnqueueEvent) *schedulerEnqueueEvent { func toSchedulerEnqueueEvent(e *inspeq.SchedulerEnqueueEvent) *SchedulerEnqueueEvent {
return &schedulerEnqueueEvent{ return &SchedulerEnqueueEvent{
TaskID: e.TaskID, TaskID: e.TaskID,
EnqueuedAt: e.EnqueuedAt.Format(time.RFC3339), EnqueuedAt: e.EnqueuedAt.Format(time.RFC3339),
} }
} }
func toSchedulerEnqueueEvents(in []*asynq.SchedulerEnqueueEvent) []*schedulerEnqueueEvent { func toSchedulerEnqueueEvents(in []*inspeq.SchedulerEnqueueEvent) []*SchedulerEnqueueEvent {
out := make([]*schedulerEnqueueEvent, len(in)) out := make([]*SchedulerEnqueueEvent, len(in))
for i, e := range in { for i, e := range in {
out[i] = toSchedulerEnqueueEvent(e) out[i] = toSchedulerEnqueueEvent(e)
} }
return out return out
} }
type serverInfo struct { type ServerInfo struct {
ID string `json:"id"` ID string `json:"id"`
Host string `json:"host"` Host string `json:"host"`
PID int `json:"pid"` PID int `json:"pid"`
@ -551,11 +322,11 @@ type serverInfo struct {
StrictPriority bool `json:"strict_priority_enabled"` StrictPriority bool `json:"strict_priority_enabled"`
Started string `json:"start_time"` Started string `json:"start_time"`
Status string `json:"status"` Status string `json:"status"`
ActiveWorkers []*workerInfo `json:"active_workers"` ActiveWorkers []*WorkerInfo `json:"active_workers"`
} }
func toServerInfo(info *asynq.ServerInfo, pf PayloadFormatter) *serverInfo { func toServerInfo(info *inspeq.ServerInfo) *ServerInfo {
return &serverInfo{ return &ServerInfo{
ID: info.ID, ID: info.ID,
Host: info.Host, Host: info.Host,
PID: info.PID, PID: info.PID,
@ -564,40 +335,34 @@ func toServerInfo(info *asynq.ServerInfo, pf PayloadFormatter) *serverInfo {
StrictPriority: info.StrictPriority, StrictPriority: info.StrictPriority,
Started: info.Started.Format(time.RFC3339), Started: info.Started.Format(time.RFC3339),
Status: info.Status, Status: info.Status,
ActiveWorkers: toWorkerInfoList(info.ActiveWorkers, pf), ActiveWorkers: toWorkerInfoList(info.ActiveWorkers),
} }
} }
func toServerInfoList(in []*asynq.ServerInfo, pf PayloadFormatter) []*serverInfo { func toServerInfoList(in []*inspeq.ServerInfo) []*ServerInfo {
out := make([]*serverInfo, len(in)) out := make([]*ServerInfo, len(in))
for i, s := range in { for i, s := range in {
out[i] = toServerInfo(s, pf) out[i] = toServerInfo(s)
} }
return out return out
} }
type workerInfo struct { type WorkerInfo struct {
TaskID string `json:"task_id"` Task *ActiveTask `json:"task"`
Queue string `json:"queue"` Started string `json:"start_time"`
TaskType string `json:"task_type"`
TaskPayload string `json:"task_payload"`
Started string `json:"start_time"`
} }
func toWorkerInfo(info *asynq.WorkerInfo, pf PayloadFormatter) *workerInfo { func toWorkerInfo(info *inspeq.WorkerInfo) *WorkerInfo {
return &workerInfo{ return &WorkerInfo{
TaskID: info.TaskID, Task: toActiveTask(info.Task),
Queue: info.Queue, Started: info.Started.Format(time.RFC3339),
TaskType: info.TaskType,
TaskPayload: pf.FormatPayload(info.TaskType, info.TaskPayload),
Started: info.Started.Format(time.RFC3339),
} }
} }
func toWorkerInfoList(in []*asynq.WorkerInfo, pf PayloadFormatter) []*workerInfo { func toWorkerInfoList(in []*inspeq.WorkerInfo) []*WorkerInfo {
out := make([]*workerInfo, len(in)) out := make([]*WorkerInfo, len(in))
for i, w := range in { for i, w := range in {
out[i] = toWorkerInfo(w, pf) out[i] = toWorkerInfo(w)
} }
return out return out
} }

View File

@ -1,19 +0,0 @@
package asynqmon_test
import (
"log"
"net/http"
"github.com/hibiken/asynq"
"github.com/hibiken/asynqmon"
)
func ExampleHTTPHandler() {
h := asynqmon.New(asynqmon.Options{
RootPath: "/monitoring",
RedisConnOpt: asynq.RedisClientOpt{Addr: ":6379"},
})
http.Handle(h.RootPath(), h)
log.Fatal(http.ListenAndServe(":8000", nil)) // visit localhost:8000/monitoring to see asynqmon homepage
}

15
go.mod
View File

@ -1,18 +1,11 @@
module github.com/hibiken/asynqmon module asynqmon
go 1.16 go 1.16
require ( require (
github.com/golang/protobuf v1.5.3 // indirect github.com/go-redis/redis/v8 v8.4.4
github.com/google/go-cmp v0.5.7
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/hibiken/asynq v0.24.1 github.com/hibiken/asynq v0.15.0
github.com/hibiken/asynq/x v0.0.0-20211219150637-8dfabfccb3be
github.com/prometheus/client_golang v1.11.1
github.com/redis/go-redis/v9 v9.0.4
github.com/rs/cors v1.7.0 github.com/rs/cors v1.7.0
github.com/spf13/cast v1.5.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
) )

259
go.sum
View File

@ -1,289 +1,120 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
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/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/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 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/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-redis/redis/v7 v7.4.0 h1:7obg6wUoj05T0EpY0o8B59S9w5yeMWql7sw2kwNW1x4=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-redis/redis/v7 v7.4.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-redis/redis/v8 v8.4.4 h1:fGqgxCTR1sydaKI00oQf3OmkU/DIe/I/fYXvGklCIuc=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-redis/redis/v8 v8.4.4/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY=
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.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/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/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 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.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/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 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 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 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/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 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.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 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
github.com/google/go-cmp v0.5.4/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/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
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 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hibiken/asynq v0.19.0/go.mod h1:tyc63ojaW8SJ5SBm8mvI4DDONsguP5HE85EEl4Qr5Ig= github.com/hibiken/asynq v0.14.0 h1:J4qUdkGGrI6XQc2HqtLvqR4kYvp4Rw/Gs48nqpiwOkQ=
github.com/hibiken/asynq v0.24.1 h1:+5iIEAyA9K/lcSPvx3qoPtsKJeKI5u9aOIvUmSsazEw= github.com/hibiken/asynq v0.14.0/go.mod h1:yfQUmjFqSBSUIVxTK0WyW4LPj4gpr283UpWb6hKYaqE=
github.com/hibiken/asynq v0.24.1/go.mod h1:u5qVeSbrnfT+vtG5Mq8ZPzQu/BmCKMHvTGb91uy9Tts= github.com/hibiken/asynq v0.15.0 h1:fhx1EQJwv3oaCCwetnujwTWHVb51FS50HmDrC9bdhdQ=
github.com/hibiken/asynq/x v0.0.0-20211219150637-8dfabfccb3be h1:89J7WrDuoqFaKoQjZwqPczQXgXZ71liWYM+z9a8sILs= github.com/hibiken/asynq v0.15.0/go.mod h1:yfQUmjFqSBSUIVxTK0WyW4LPj4gpr283UpWb6hKYaqE=
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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
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/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 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/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg= github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 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.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.10.4 h1:NiTx7EEvBzu9sFOD1zORteLSt3o8gnlvZZwSE9TnY9U=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.11.1 h1:+4eQaD7vAZ6DsfsxB15hbE0odUjGI5ARs9yskGu1v4s=
github.com/prometheus/client_golang v1.11.1/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/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/redis/go-redis/v9 v9.0.4 h1:FC82T+CHJ/Q/PdyLW++GeCO+Ol59Y4T7R4jbgjvktgc=
github.com/redis/go-redis/v9 v9.0.4/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 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/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= 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/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/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
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/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
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/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.opentelemetry.io/otel v0.15.0 h1:CZFy2lPhxd4HlhZnYK8gRyDotksO3Ip9rBweY1vVYJw=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/otel v0.15.0/go.mod h1:e4GKElweB8W2gWUqbghw0B8t5MCTccc9212eNHnOHwA=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/goleak v0.10.0 h1:G3eWbSNIskeRqtsN/1uI5B+eP73y3JUuBsv9AZjehb4=
go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI= go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI=
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
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-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/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/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-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-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-20190923162816-aa69164e4478/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-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-20201202161906-c7110b5ffcbb h1:eBmm0M9fYhWpKZLjQUUKka/LtIxf46G4fxeEz5KJr9U=
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-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
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-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-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/sync v0.0.0-20210220032951-036812b2e83c/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-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-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-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-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-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/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-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/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-20200519105757-fe76b779f299/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 h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/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.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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
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/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
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=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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/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/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/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-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 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/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.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.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.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/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/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= 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/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -1,40 +0,0 @@
package asynqmon
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/hibiken/asynq"
)
type listGroupsResponse struct {
Queue *queueStateSnapshot `json:"stats"`
Groups []*groupInfo `json:"groups"`
}
func newListGroupsHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"]
groups, err := inspector.Groups(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
qinfo, err := inspector.GetQueueInfo(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := listGroupsResponse{
Queue: toQueueStateSnapshot(qinfo),
Groups: toGroupInfos(groups),
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}

View File

@ -1,237 +0,0 @@
package asynqmon
import (
"embed"
"fmt"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
)
// Options are used to configure HTTPHandler.
type Options struct {
// URL path the handler is responsible for.
// The path is used for the homepage of asynqmon, and every other page is rooted in this subtree.
//
// This field is optional. Default is "/".
RootPath string
// RedisConnOpt specifies the connection to a redis-server or redis-cluster.
//
// This field is required.
RedisConnOpt asynq.RedisConnOpt
// PayloadFormatter is used to convert payload bytes to string shown in the UI.
//
// This field is optional.
PayloadFormatter PayloadFormatter
// ResultFormatter is used to convert result bytes to string shown in the UI.
//
// 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
// Set ReadOnly to true to restrict user to view-only mode.
ReadOnly bool
}
// HTTPHandler is a http.Handler for asynqmon application.
type HTTPHandler struct {
router *mux.Router
closers []func() error
rootPath string // the value should not have the trailing slash
}
func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.router.ServeHTTP(w, r)
}
// New creates a HTTPHandler with the given options.
func New(opts Options) *HTTPHandler {
if opts.RedisConnOpt == nil {
panic("asynqmon.New: RedisConnOpt field is required")
}
rc, ok := opts.RedisConnOpt.MakeRedisClient().(redis.UniversalClient)
if !ok {
panic(fmt.Sprintf("asnyqmon.New: unsupported RedisConnOpt type %T", opts.RedisConnOpt))
}
i := asynq.NewInspector(opts.RedisConnOpt)
// Make sure that RootPath starts with a slash if provided.
if opts.RootPath != "" && !strings.HasPrefix(opts.RootPath, "/") {
panic(fmt.Sprintf("asynqmon.New: RootPath must start with a slash"))
}
// Remove tailing slash from RootPath.
opts.RootPath = strings.TrimSuffix(opts.RootPath, "/")
return &HTTPHandler{
router: muxRouter(opts, rc, i),
closers: []func() error{rc.Close, i.Close},
rootPath: opts.RootPath,
}
}
// Close closes connections to redis.
func (h *HTTPHandler) Close() error {
for _, f := range h.closers {
if err := f(); err != nil {
return err
}
}
return nil
}
// RootPath returns the root URL path used for asynqmon application.
// Returned path string does not have the trailing slash.
func (h *HTTPHandler) RootPath() string {
return h.rootPath
}
//go:embed ui/build/*
var staticContents embed.FS
func muxRouter(opts Options, rc redis.UniversalClient, inspector *asynq.Inspector) *mux.Router {
router := mux.NewRouter().PathPrefix(opts.RootPath).Subrouter()
var payloadFmt PayloadFormatter = DefaultPayloadFormatter
if opts.PayloadFormatter != nil {
payloadFmt = opts.PayloadFormatter
}
var resultFmt ResultFormatter = DefaultResultFormatter
if opts.ResultFormatter != nil {
resultFmt = opts.ResultFormatter
}
api := router.PathPrefix("/api").Subrouter()
// Queue endpoints.
api.HandleFunc("/queues", newListQueuesHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}", newGetQueueHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}", newDeleteQueueHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}:pause", newPauseQueueHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}:resume", newResumeQueueHandlerFunc(inspector)).Methods("POST")
// Queue Historical Stats endpoint.
api.HandleFunc("/queue_stats", newListQueueStatsHandlerFunc(inspector)).Methods("GET")
// Task endpoints.
api.HandleFunc("/queues/{qname}/active_tasks", newListActiveTasksHandlerFunc(inspector, payloadFmt)).Methods("GET")
api.HandleFunc("/queues/{qname}/active_tasks/{task_id}:cancel", newCancelActiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/active_tasks:cancel_all", newCancelAllActiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/active_tasks:batch_cancel", newBatchCancelActiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/pending_tasks", newListPendingTasksHandlerFunc(inspector, payloadFmt)).Methods("GET")
api.HandleFunc("/queues/{qname}/pending_tasks/{task_id}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/pending_tasks:delete_all", newDeleteAllPendingTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/pending_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/pending_tasks/{task_id}:archive", newArchiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/pending_tasks:archive_all", newArchiveAllPendingTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/pending_tasks:batch_archive", newBatchArchiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks", newListScheduledTasksHandlerFunc(inspector, payloadFmt)).Methods("GET")
api.HandleFunc("/queues/{qname}/scheduled_tasks/{task_id}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/scheduled_tasks:delete_all", newDeleteAllScheduledTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/scheduled_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks/{task_id}:run", newRunTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks:run_all", newRunAllScheduledTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks:batch_run", newBatchRunTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks/{task_id}:archive", newArchiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks:archive_all", newArchiveAllScheduledTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks:batch_archive", newBatchArchiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks", newListRetryTasksHandlerFunc(inspector, payloadFmt)).Methods("GET")
api.HandleFunc("/queues/{qname}/retry_tasks/{task_id}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/retry_tasks:delete_all", newDeleteAllRetryTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/retry_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks/{task_id}:run", newRunTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks:run_all", newRunAllRetryTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks:batch_run", newBatchRunTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks/{task_id}:archive", newArchiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks:archive_all", newArchiveAllRetryTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks:batch_archive", newBatchArchiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/archived_tasks", newListArchivedTasksHandlerFunc(inspector, payloadFmt)).Methods("GET")
api.HandleFunc("/queues/{qname}/archived_tasks/{task_id}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/archived_tasks:delete_all", newDeleteAllArchivedTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/archived_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/archived_tasks/{task_id}:run", newRunTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/archived_tasks:run_all", newRunAllArchivedTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/archived_tasks:batch_run", newBatchRunTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/completed_tasks", newListCompletedTasksHandlerFunc(inspector, payloadFmt, resultFmt)).Methods("GET")
api.HandleFunc("/queues/{qname}/completed_tasks/{task_id}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/completed_tasks:delete_all", newDeleteAllCompletedTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/completed_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks", newListAggregatingTasksHandlerFunc(inspector, payloadFmt)).Methods("GET")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks/{task_id}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks:delete_all", newDeleteAllAggregatingTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks/{task_id}:run", newRunTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks:run_all", newRunAllAggregatingTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks:batch_run", newBatchRunTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks/{task_id}:archive", newArchiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks:archive_all", newArchiveAllAggregatingTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/groups/{gname}/aggregating_tasks:batch_archive", newBatchArchiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/tasks/{task_id}", newGetTaskHandlerFunc(inspector, payloadFmt, resultFmt)).Methods("GET")
// Groups endponts
api.HandleFunc("/queues/{qname}/groups", newListGroupsHandlerFunc(inspector)).Methods("GET")
// Servers endpoints.
api.HandleFunc("/servers", newListServersHandlerFunc(inspector, payloadFmt)).Methods("GET")
// Scheduler Entry endpoints.
api.HandleFunc("/scheduler_entries", newListSchedulerEntriesHandlerFunc(inspector, payloadFmt)).Methods("GET")
api.HandleFunc("/scheduler_entries/{entry_id}/enqueue_events", newListSchedulerEnqueueEventsHandlerFunc(inspector)).Methods("GET")
// Redis info endpoint.
switch c := rc.(type) {
case *redis.ClusterClient:
api.HandleFunc("/redis_info", newRedisClusterInfoHandlerFunc(c, inspector)).Methods("GET")
case *redis.Client:
api.HandleFunc("/redis_info", newRedisInfoHandlerFunc(c)).Methods("GET")
}
// Time series metrics endpoints.
api.HandleFunc("/metrics", newGetMetricsHandlerFunc(http.DefaultClient, opts.PrometheusAddress)).Methods("GET")
// Restrict APIs when running in read-only mode.
if opts.ReadOnly {
api.Use(restrictToReadOnly)
}
// Everything else, route to uiAssetsHandler.
router.NotFoundHandler = &uiAssetsHandler{
rootPath: opts.RootPath,
contents: staticContents,
staticDirPath: "ui/build",
indexFileName: "index.html",
prometheusAddr: opts.PrometheusAddress,
readOnly: opts.ReadOnly,
}
return router
}
// restrictToReadOnly is a middleware function to restrict users to perform only GET requests.
func restrictToReadOnly(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "" {
http.Error(w, fmt.Sprintf("API Server is running in read-only mode: %s request is not allowed", r.Method), http.StatusMethodNotAllowed)
return
}
h.ServeHTTP(w, r)
})
}

204
main.go Normal file
View File

@ -0,0 +1,204 @@
package main
import (
"crypto/tls"
"embed"
"errors"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"path/filepath"
"time"
"github.com/go-redis/redis/v8"
"github.com/gorilla/mux"
"github.com/hibiken/asynq"
"github.com/hibiken/asynq/inspeq"
"github.com/rs/cors"
)
// Command-line flags
var (
flagPort int
flagRedisAddr string
flagRedisDB int
flagRedisPassword string
flagRedisTLS string
)
func init() {
flag.IntVar(&flagPort, "port", 8080, "port number to use for web ui server")
flag.StringVar(&flagRedisAddr, "redis_addr", "localhost:6379", "address of redis server to connect to")
flag.IntVar(&flagRedisDB, "redis_db", 0, "redis database number")
flag.StringVar(&flagRedisPassword, "redis_password", "", "password to use when connecting to redis server")
flag.StringVar(&flagRedisTLS, "redis_tls", "", "server name for TLS validation used when connecting to redis server")
}
// 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 {
contents embed.FS
staticDirPath string
indexFileName string
}
// ServeHTTP inspects the URL path to locate a file within the static dir
// on the SPA handler.
// If path '/' is requested, it will serve the index file, otherwise it will
// serve the file specified by the URL path.
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if path == "/" {
path = srv.indexFilePath()
} else {
path = filepath.Join(srv.staticDirPath, path)
}
bytes, err := srv.contents.ReadFile(path)
// If path is error (e.g. file not exist, path is a directory), serve index file.
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
bytes, err = srv.contents.ReadFile(srv.indexFilePath())
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := w.Write(bytes); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (srv *staticFileServer) indexFilePath() string {
return filepath.Join(srv.staticDirPath, srv.indexFileName)
}
//go:embed ui/build/*
var staticContents embed.FS
func main() {
flag.Parse()
var tlsConfig *tls.Config
if flagRedisTLS != "" {
tlsConfig = &tls.Config{ServerName: flagRedisTLS}
}
inspector := inspeq.New(asynq.RedisClientOpt{
Addr: flagRedisAddr,
DB: flagRedisDB,
Password: flagRedisPassword,
TLSConfig: tlsConfig,
})
defer inspector.Close()
rdb := redis.NewClient(&redis.Options{
Addr: flagRedisAddr,
DB: flagRedisDB,
Password: flagRedisPassword,
TLSConfig: tlsConfig,
})
defer rdb.Close()
router := mux.NewRouter()
router.Use(loggingMiddleware)
api := router.PathPrefix("/api").Subrouter()
// Queue endpoints.
api.HandleFunc("/queues", newListQueuesHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}", newGetQueueHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}", newDeleteQueueHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}:pause", newPauseQueueHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}:resume", newResumeQueueHandlerFunc(inspector)).Methods("POST")
// Queue Historical Stats endpoint.
api.HandleFunc("/queue_stats", newListQueueStatsHandlerFunc(inspector)).Methods("GET")
// Task endpoints.
api.HandleFunc("/queues/{qname}/active_tasks", newListActiveTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/active_tasks/{task_id}:cancel", newCancelActiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/active_tasks:cancel_all", newCancelAllActiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/active_tasks:batch_cancel", newBatchCancelActiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/pending_tasks", newListPendingTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/pending_tasks/{task_key}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/pending_tasks:delete_all", newDeleteAllPendingTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/pending_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/pending_tasks/{task_key}:archive", newArchiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/pending_tasks:archive_all", newArchiveAllPendingTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/pending_tasks:batch_archive", newBatchArchiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks", newListScheduledTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/scheduled_tasks/{task_key}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/scheduled_tasks:delete_all", newDeleteAllScheduledTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/scheduled_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks/{task_key}:run", newRunTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks:run_all", newRunAllScheduledTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks:batch_run", newBatchRunTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks/{task_key}:archive", newArchiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks:archive_all", newArchiveAllScheduledTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/scheduled_tasks:batch_archive", newBatchArchiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks", newListRetryTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/retry_tasks/{task_key}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/retry_tasks:delete_all", newDeleteAllRetryTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/retry_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks/{task_key}:run", newRunTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks:run_all", newRunAllRetryTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks:batch_run", newBatchRunTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks/{task_key}:archive", newArchiveTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks:archive_all", newArchiveAllRetryTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/retry_tasks:batch_archive", newBatchArchiveTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/archived_tasks", newListArchivedTasksHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/queues/{qname}/archived_tasks/{task_key}", newDeleteTaskHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/archived_tasks:delete_all", newDeleteAllArchivedTasksHandlerFunc(inspector)).Methods("DELETE")
api.HandleFunc("/queues/{qname}/archived_tasks:batch_delete", newBatchDeleteTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/archived_tasks/{task_key}:run", newRunTaskHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/archived_tasks:run_all", newRunAllArchivedTasksHandlerFunc(inspector)).Methods("POST")
api.HandleFunc("/queues/{qname}/archived_tasks:batch_run", newBatchRunTasksHandlerFunc(inspector)).Methods("POST")
// Servers endpoints.
api.HandleFunc("/servers", newListServersHandlerFunc(inspector)).Methods("GET")
// Scheduler Entry endpoints.
api.HandleFunc("/scheduler_entries", newListSchedulerEntriesHandlerFunc(inspector)).Methods("GET")
api.HandleFunc("/scheduler_entries/{entry_id}/enqueue_events", newListSchedulerEnqueueEventsHandlerFunc(inspector)).Methods("GET")
// Redis info endpoint.
api.HandleFunc("/redis_info", newRedisInfoHandlerFunc(rdb)).Methods("GET")
fs := &staticFileServer{
contents: staticContents,
staticDirPath: "ui/build",
indexFileName: "index.html",
}
router.PathPrefix("/").Handler(fs)
c := cors.New(cors.Options{
AllowedMethods: []string{"GET", "POST", "DELETE"},
})
handler := c.Handler(router)
srv := &http.Server{
Handler: handler,
Addr: fmt.Sprintf(":%d", flagPort),
WriteTimeout: 10 * time.Second,
ReadTimeout: 10 * time.Second,
}
fmt.Printf("Asynq Monitoring WebUI server is listening on port %d\n", flagPort)
log.Fatal(srv.ListenAndServe())
}

View File

@ -1,230 +0,0 @@
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, r.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()))
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"pretty-bytes": "5.5.0"
}
}

View File

@ -1,13 +1,11 @@
package asynqmon package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/hibiken/asynq/inspeq"
"github.com/hibiken/asynq"
) )
// **************************************************************************** // ****************************************************************************
@ -15,40 +13,40 @@ import (
// - http.Handler(s) for queue related endpoints // - http.Handler(s) for queue related endpoints
// **************************************************************************** // ****************************************************************************
func newListQueuesHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newListQueuesHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qnames, err := inspector.Queues() qnames, err := inspector.Queues()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
snapshots := make([]*queueStateSnapshot, len(qnames)) var snapshots []*QueueStateSnapshot
for i, qname := range qnames { for _, qname := range qnames {
qinfo, err := inspector.GetQueueInfo(qname) s, err := inspector.CurrentStats(qname)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
snapshots[i] = toQueueStateSnapshot(qinfo) snapshots = append(snapshots, toQueueStateSnapshot(s))
} }
payload := map[string]interface{}{"queues": snapshots} payload := map[string]interface{}{"queues": snapshots}
json.NewEncoder(w).Encode(payload) json.NewEncoder(w).Encode(payload)
} }
} }
func newGetQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newGetQueueHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
payload := make(map[string]interface{}) payload := make(map[string]interface{})
qinfo, err := inspector.GetQueueInfo(qname) stats, err := inspector.CurrentStats(qname)
if err != nil { if err != nil {
// TODO: Check for queue not found error. // TODO: Check for queue not found error.
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
payload["current"] = toQueueStateSnapshot(qinfo) payload["current"] = toQueueStateSnapshot(stats)
// TODO: make this n a variable // TODO: make this n a variable
data, err := inspector.History(qname, 10) data, err := inspector.History(qname, 10)
@ -56,7 +54,7 @@ func newGetQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
var dailyStats []*dailyStats var dailyStats []*DailyStats
for _, s := range data { for _, s := range data {
dailyStats = append(dailyStats, toDailyStats(s)) dailyStats = append(dailyStats, toDailyStats(s))
} }
@ -65,16 +63,16 @@ func newGetQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
} }
} }
func newDeleteQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newDeleteQueueHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
if err := inspector.DeleteQueue(qname, false); err != nil { if err := inspector.DeleteQueue(qname, false); err != nil {
if errors.Is(err, asynq.ErrQueueNotFound) { if _, ok := err.(*inspeq.ErrQueueNotFound); ok {
http.Error(w, err.Error(), http.StatusNotFound) http.Error(w, err.Error(), http.StatusNotFound)
return return
} }
if errors.Is(err, asynq.ErrQueueNotEmpty) { if _, ok := err.(*inspeq.ErrQueueNotEmpty); ok {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@ -85,7 +83,7 @@ func newDeleteQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
} }
} }
func newPauseQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newPauseQueueHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
@ -97,7 +95,7 @@ func newPauseQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
} }
} }
func newResumeQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newResumeQueueHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
@ -109,18 +107,18 @@ func newResumeQueueHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
} }
} }
type listQueueStatsResponse struct { type ListQueueStatsResponse struct {
Stats map[string][]*dailyStats `json:"stats"` Stats map[string][]*DailyStats `json:"stats"`
} }
func newListQueueStatsHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newListQueueStatsHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qnames, err := inspector.Queues() qnames, err := inspector.Queues()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
resp := listQueueStatsResponse{Stats: make(map[string][]*dailyStats)} resp := ListQueueStatsResponse{Stats: make(map[string][]*DailyStats)}
const numdays = 90 // Get stats for the last 90 days. const numdays = 90 // Get stats for the last 90 days.
for _, qname := range qnames { for _, qname := range qnames {
stats, err := inspector.History(qname, numdays) stats, err := inspector.History(qname, numdays)

View File

@ -1,4 +1,4 @@
package asynqmon package main
import ( import (
"context" "context"
@ -6,8 +6,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/hibiken/asynq" "github.com/go-redis/redis/v8"
"github.com/redis/go-redis/v9"
) )
// **************************************************************************** // ****************************************************************************
@ -15,89 +14,25 @@ import (
// - http.Handler(s) for redis info related endpoints // - http.Handler(s) for redis info related endpoints
// **************************************************************************** // ****************************************************************************
type redisInfoResponse struct { type RedisInfoResponse struct {
Addr string `json:"address"` Addr string `json:"address"`
Info map[string]string `json:"info"` Info map[string]string `json:"info"`
RawInfo string `json:"raw_info"` RawInfo string `json:"raw_info"`
Cluster bool `json:"cluster"`
// Following fields are only set when connected to redis cluster.
RawClusterNodes string `json:"raw_cluster_nodes"`
QueueLocations []*queueLocationInfo `json:"queue_locations"`
} }
type queueLocationInfo struct { func newRedisInfoHandlerFunc(rdb *redis.Client) http.HandlerFunc {
Queue string `json:"queue"` // queue name
KeySlot int64 `json:"keyslot"` // cluster key slot for the queue
Nodes []string `json:"nodes"` // list of cluster node addresses
}
func newRedisInfoHandlerFunc(client *redis.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
res, err := client.Info(context.Background()).Result() ctx := context.Background()
res, err := rdb.Info(ctx).Result()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
info := parseRedisInfo(res) info := parseRedisInfo(res)
resp := redisInfoResponse{ resp := RedisInfoResponse{
Addr: client.Options().Addr, Addr: flagRedisAddr,
Info: info, Info: info,
RawInfo: res, RawInfo: res,
Cluster: false,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func newRedisClusterInfoHandlerFunc(client *redis.ClusterClient, inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
rawClusterInfo, err := client.ClusterInfo(ctx).Result()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
info := parseRedisInfo(rawClusterInfo)
rawClusterNodes, err := client.ClusterNodes(ctx).Result()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
queues, err := inspector.Queues()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var queueLocations []*queueLocationInfo
for _, qname := range queues {
q := queueLocationInfo{Queue: qname}
q.KeySlot, err = inspector.ClusterKeySlot(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
nodes, err := inspector.ClusterNodes(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, n := range nodes {
q.Nodes = append(q.Nodes, n.Addr)
}
queueLocations = append(queueLocations, &q)
}
resp := redisInfoResponse{
Addr: strings.Join(client.Options().Addrs, ","),
Info: info,
RawInfo: rawClusterInfo,
Cluster: true,
RawClusterNodes: rawClusterNodes,
QueueLocations: queueLocations,
} }
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -1,12 +1,11 @@
package asynqmon package main
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/hibiken/asynq/inspeq"
"github.com/hibiken/asynq"
) )
// **************************************************************************** // ****************************************************************************
@ -14,7 +13,7 @@ import (
// - http.Handler(s) for scheduler entry related endpoints // - http.Handler(s) for scheduler entry related endpoints
// **************************************************************************** // ****************************************************************************
func newListSchedulerEntriesHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter) http.HandlerFunc { func newListSchedulerEntriesHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
entries, err := inspector.SchedulerEntries() entries, err := inspector.SchedulerEntries()
if err != nil { if err != nil {
@ -24,9 +23,9 @@ func newListSchedulerEntriesHandlerFunc(inspector *asynq.Inspector, pf PayloadFo
payload := make(map[string]interface{}) payload := make(map[string]interface{})
if len(entries) == 0 { if len(entries) == 0 {
// avoid nil for the entries field in json output. // avoid nil for the entries field in json output.
payload["entries"] = make([]*schedulerEntry, 0) payload["entries"] = make([]*SchedulerEntry, 0)
} else { } else {
payload["entries"] = toSchedulerEntries(entries, pf) payload["entries"] = toSchedulerEntries(entries)
} }
if err := json.NewEncoder(w).Encode(payload); err != nil { if err := json.NewEncoder(w).Encode(payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -35,21 +34,21 @@ func newListSchedulerEntriesHandlerFunc(inspector *asynq.Inspector, pf PayloadFo
} }
} }
type listSchedulerEnqueueEventsResponse struct { type ListSchedulerEnqueueEventsResponse struct {
Events []*schedulerEnqueueEvent `json:"events"` Events []*SchedulerEnqueueEvent `json:"events"`
} }
func newListSchedulerEnqueueEventsHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newListSchedulerEnqueueEventsHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
entryID := mux.Vars(r)["entry_id"] entryID := mux.Vars(r)["entry_id"]
pageSize, pageNum := getPageOptions(r) pageSize, pageNum := getPageOptions(r)
events, err := inspector.ListSchedulerEnqueueEvents( events, err := inspector.ListSchedulerEnqueueEvents(
entryID, asynq.PageSize(pageSize), asynq.Page(pageNum)) entryID, inspeq.PageSize(pageSize), inspeq.Page(pageNum))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
resp := listSchedulerEnqueueEventsResponse{ resp := ListSchedulerEnqueueEventsResponse{
Events: toSchedulerEnqueueEvents(events), Events: toSchedulerEnqueueEvents(events),
} }
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {

View File

@ -1,10 +1,10 @@
package asynqmon package main
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/hibiken/asynq" "github.com/hibiken/asynq/inspeq"
) )
// **************************************************************************** // ****************************************************************************
@ -12,19 +12,19 @@ import (
// - http.Handler(s) for server related endpoints // - http.Handler(s) for server related endpoints
// **************************************************************************** // ****************************************************************************
type listServersResponse struct { type ListServersResponse struct {
Servers []*serverInfo `json:"servers"` Servers []*ServerInfo `json:"servers"`
} }
func newListServersHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter) http.HandlerFunc { func newListServersHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
srvs, err := inspector.Servers() srvs, err := inspector.Servers()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
resp := listServersResponse{ resp := ListServersResponse{
Servers: toServerInfoList(srvs, pf), Servers: toServerInfoList(srvs),
} }
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

114
static.go
View File

@ -1,114 +0,0 @@
package asynqmon
import (
"embed"
"errors"
"html/template"
"io/fs"
"net/http"
"path/filepath"
"strings"
)
// uiAssetsHandler is a http.Handler.
// The path to the static file directory and
// the path to the index file within that static directory are used to
// serve the SPA.
type uiAssetsHandler struct {
rootPath string
contents embed.FS
staticDirPath string
indexFileName string
prometheusAddr string
readOnly bool
}
// ServeHTTP inspects the URL path to locate a file within the static dir
// on the SPA handler.
// If path '/' is requested, it will serve the index file, otherwise it will
// serve the file specified by the URL path.
func (h *uiAssetsHandler) 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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Get the path relative to the root path.
if !strings.HasPrefix(path, h.rootPath) {
http.Error(w, "unexpected path prefix", http.StatusBadRequest)
return
}
path = strings.TrimPrefix(path, h.rootPath)
if code, err := h.serveFile(w, path); err != nil {
http.Error(w, err.Error(), code)
return
}
}
func (h *uiAssetsHandler) indexFilePath() string {
return filepath.Join(h.staticDirPath, h.indexFileName)
}
func (h *uiAssetsHandler) renderIndexFile(w http.ResponseWriter) error {
// Note: Replace the default delimiter ("{{") with a custom one
// since webpack escapes the '{' character when it compiles the index.html file.
// See the "homepage" field in package.json.
tmpl, err := template.New(h.indexFileName).Delims("/[[", "]]").ParseFS(h.contents, h.indexFilePath())
if err != nil {
return err
}
data := struct {
RootPath string
PrometheusAddr string
ReadOnly bool
}{
RootPath: h.rootPath,
PrometheusAddr: h.prometheusAddr,
ReadOnly: h.readOnly,
}
return tmpl.Execute(w, data)
}
// serveFile writes file requested at path and returns http status code and error if any.
// If requested path is root, it serves the index file.
// Otherwise, it looks for file requiested in the static content filesystem
// and serves if a file is found.
// If a requested file is not found in the filesystem, it serves the index file to
// make sure when user refreshes the page in SPA things still work.
func (h *uiAssetsHandler) serveFile(w http.ResponseWriter, path string) (code int, err error) {
if path == "/" || path == "" {
if err := h.renderIndexFile(w); err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
path = filepath.Join(h.staticDirPath, path)
bytes, err := h.contents.ReadFile(path)
if err != nil {
// If path is error (e.g. file not exist, path is a directory), serve index file.
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
if err := h.renderIndexFile(w); err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
return http.StatusInternalServerError, err
}
// Setting the MIME type for .js files manually to application/javascript as
// http.DetectContentType is using https://mimesniff.spec.whatwg.org/ which
// will not recognize application/javascript for security reasons.
if strings.HasSuffix(path, ".js") {
w.Header().Add("Content-Type", "application/javascript; charset=utf-8")
} else {
w.Header().Add("Content-Type", http.DetectContentType(bytes))
}
if _, err := w.Write(bytes); err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}

View File

@ -1,17 +1,14 @@
package asynqmon package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/hibiken/asynq/inspeq"
"github.com/hibiken/asynq"
) )
// **************************************************************************** // ****************************************************************************
@ -19,24 +16,24 @@ import (
// - http.Handler(s) for task related endpoints // - http.Handler(s) for task related endpoints
// **************************************************************************** // ****************************************************************************
type listActiveTasksResponse struct { type ListActiveTasksResponse struct {
Tasks []*activeTask `json:"tasks"` Tasks []*ActiveTask `json:"tasks"`
Stats *queueStateSnapshot `json:"stats"` Stats *QueueStateSnapshot `json:"stats"`
} }
func newListActiveTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter) http.HandlerFunc { func newListActiveTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
pageSize, pageNum := getPageOptions(r) pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListActiveTasks( tasks, err := inspector.ListActiveTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum)) qname, inspeq.PageSize(pageSize), inspeq.Page(pageNum))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
qinfo, err := inspector.GetQueueInfo(qname) stats, err := inspector.CurrentStats(qname)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -46,16 +43,16 @@ func newListActiveTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatt
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// m maps taskID to workerInfo. // m maps taskID to WorkerInfo.
m := make(map[string]*asynq.WorkerInfo) m := make(map[string]*inspeq.WorkerInfo)
for _, srv := range servers { for _, srv := range servers {
for _, w := range srv.ActiveWorkers { for _, w := range srv.ActiveWorkers {
if w.Queue == qname { if w.Task.Queue == qname {
m[w.TaskID] = w m[w.Task.ID] = w
} }
} }
} }
activeTasks := toActiveTasks(tasks, pf) activeTasks := toActiveTasks(tasks)
for _, t := range activeTasks { for _, t := range activeTasks {
workerInfo, ok := m[t.ID] workerInfo, ok := m[t.ID]
if ok { if ok {
@ -67,18 +64,21 @@ func newListActiveTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatt
} }
} }
resp := listActiveTasksResponse{ resp := ListActiveTasksResponse{
Tasks: activeTasks, Tasks: activeTasks,
Stats: toQueueStateSnapshot(qinfo), Stats: toQueueStateSnapshot(stats),
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
writeResponseJSON(w, resp)
} }
} }
func newCancelActiveTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newCancelActiveTaskHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["task_id"] id := mux.Vars(r)["task_id"]
if err := inspector.CancelProcessing(id); err != nil { if err := inspector.CancelActiveTask(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -86,19 +86,19 @@ func newCancelActiveTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc
} }
} }
func newCancelAllActiveTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newCancelAllActiveTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
const batchSize = 100 const batchSize = 100
page := 1 page := 1
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
for { for {
tasks, err := inspector.ListActiveTasks(qname, asynq.Page(page), asynq.PageSize(batchSize)) tasks, err := inspector.ListActiveTasks(qname, inspeq.Page(page), inspeq.PageSize(batchSize))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
for _, t := range tasks { for _, t := range tasks {
if err := inspector.CancelProcessing(t.ID); err != nil { if err := inspector.CancelActiveTask(t.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -121,7 +121,7 @@ type batchCancelTasksResponse struct {
ErrorIDs []string `json:"error_ids"` ErrorIDs []string `json:"error_ids"`
} }
func newBatchCancelActiveTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newBatchCancelActiveTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
@ -139,29 +139,32 @@ func newBatchCancelActiveTasksHandlerFunc(inspector *asynq.Inspector) http.Handl
ErrorIDs: make([]string, 0), ErrorIDs: make([]string, 0),
} }
for _, id := range req.TaskIDs { for _, id := range req.TaskIDs {
if err := inspector.CancelProcessing(id); err != nil { if err := inspector.CancelActiveTask(id); err != nil {
log.Printf("error: could not send cancelation signal to task %s", id) log.Printf("error: could not send cancelation signal to task %s", id)
resp.ErrorIDs = append(resp.ErrorIDs, id) resp.ErrorIDs = append(resp.ErrorIDs, id)
} else { } else {
resp.CanceledIDs = append(resp.CanceledIDs, id) resp.CanceledIDs = append(resp.CanceledIDs, id)
} }
} }
writeResponseJSON(w, resp) if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }
func newListPendingTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter) http.HandlerFunc { func newListPendingTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
pageSize, pageNum := getPageOptions(r) pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListPendingTasks( tasks, err := inspector.ListPendingTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum)) qname, inspeq.PageSize(pageSize), inspeq.Page(pageNum))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
qinfo, err := inspector.GetQueueInfo(qname) stats, err := inspector.CurrentStats(qname)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -169,27 +172,30 @@ func newListPendingTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormat
payload := make(map[string]interface{}) payload := make(map[string]interface{})
if len(tasks) == 0 { if len(tasks) == 0 {
// avoid nil for the tasks field in json output. // avoid nil for the tasks field in json output.
payload["tasks"] = make([]*pendingTask, 0) payload["tasks"] = make([]*PendingTask, 0)
} else { } else {
payload["tasks"] = toPendingTasks(tasks, pf) payload["tasks"] = toPendingTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
if err := json.NewEncoder(w).Encode(payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
payload["stats"] = toQueueStateSnapshot(qinfo)
writeResponseJSON(w, payload)
} }
} }
func newListScheduledTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter) http.HandlerFunc { func newListScheduledTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
pageSize, pageNum := getPageOptions(r) pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListScheduledTasks( tasks, err := inspector.ListScheduledTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum)) qname, inspeq.PageSize(pageSize), inspeq.Page(pageNum))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
qinfo, err := inspector.GetQueueInfo(qname) stats, err := inspector.CurrentStats(qname)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -197,27 +203,30 @@ func newListScheduledTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadForm
payload := make(map[string]interface{}) payload := make(map[string]interface{})
if len(tasks) == 0 { if len(tasks) == 0 {
// avoid nil for the tasks field in json output. // avoid nil for the tasks field in json output.
payload["tasks"] = make([]*scheduledTask, 0) payload["tasks"] = make([]*ScheduledTask, 0)
} else { } else {
payload["tasks"] = toScheduledTasks(tasks, pf) payload["tasks"] = toScheduledTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
if err := json.NewEncoder(w).Encode(payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
payload["stats"] = toQueueStateSnapshot(qinfo)
writeResponseJSON(w, payload)
} }
} }
func newListRetryTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter) http.HandlerFunc { func newListRetryTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
pageSize, pageNum := getPageOptions(r) pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListRetryTasks( tasks, err := inspector.ListRetryTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum)) qname, inspeq.PageSize(pageSize), inspeq.Page(pageNum))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
qinfo, err := inspector.GetQueueInfo(qname) stats, err := inspector.CurrentStats(qname)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -225,27 +234,30 @@ func newListRetryTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatte
payload := make(map[string]interface{}) payload := make(map[string]interface{})
if len(tasks) == 0 { if len(tasks) == 0 {
// avoid nil for the tasks field in json output. // avoid nil for the tasks field in json output.
payload["tasks"] = make([]*retryTask, 0) payload["tasks"] = make([]*RetryTask, 0)
} else { } else {
payload["tasks"] = toRetryTasks(tasks, pf) payload["tasks"] = toRetryTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
if err := json.NewEncoder(w).Encode(payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
payload["stats"] = toQueueStateSnapshot(qinfo)
writeResponseJSON(w, payload)
} }
} }
func newListArchivedTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter) http.HandlerFunc { func newListArchivedTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname := vars["qname"]
pageSize, pageNum := getPageOptions(r) pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListArchivedTasks( tasks, err := inspector.ListArchivedTasks(
qname, asynq.PageSize(pageSize), asynq.Page(pageNum)) qname, inspeq.PageSize(pageSize), inspeq.Page(pageNum))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
qinfo, err := inspector.GetQueueInfo(qname) stats, err := inspector.CurrentStats(qname)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -253,86 +265,27 @@ func newListArchivedTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadForma
payload := make(map[string]interface{}) payload := make(map[string]interface{})
if len(tasks) == 0 { if len(tasks) == 0 {
// avoid nil for the tasks field in json output. // avoid nil for the tasks field in json output.
payload["tasks"] = make([]*archivedTask, 0) payload["tasks"] = make([]*ArchivedTask, 0)
} else { } else {
payload["tasks"] = toArchivedTasks(tasks, pf) payload["tasks"] = toArchivedTasks(tasks)
}
payload["stats"] = toQueueStateSnapshot(stats)
if err := json.NewEncoder(w).Encode(payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} }
payload["stats"] = toQueueStateSnapshot(qinfo)
writeResponseJSON(w, payload)
} }
} }
func newListCompletedTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter, rf ResultFormatter) http.HandlerFunc { func newDeleteTaskHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname := vars["qname"] qname, key := vars["qname"], vars["task_key"]
pageSize, pageNum := getPageOptions(r) if qname == "" || key == "" {
tasks, err := inspector.ListCompletedTasks(qname, asynq.PageSize(pageSize), asynq.Page(pageNum))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
qinfo, err := inspector.GetQueueInfo(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([]*completedTask, 0)
} else {
payload["tasks"] = toCompletedTasks(tasks, pf, rf)
}
payload["stats"] = toQueueStateSnapshot(qinfo)
writeResponseJSON(w, payload)
}
}
func newListAggregatingTasksHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname := vars["qname"]
gname := vars["gname"]
pageSize, pageNum := getPageOptions(r)
tasks, err := inspector.ListAggregatingTasks(
qname, gname, asynq.PageSize(pageSize), asynq.Page(pageNum))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
qinfo, err := inspector.GetQueueInfo(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
groups, err := inspector.Groups(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([]*aggregatingTask, 0)
} else {
payload["tasks"] = toAggregatingTasks(tasks, pf)
}
payload["stats"] = toQueueStateSnapshot(qinfo)
payload["groups"] = toGroupInfos(groups)
writeResponseJSON(w, payload)
}
}
func newDeleteTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname, taskid := vars["qname"], vars["task_id"]
if qname == "" || taskid == "" {
http.Error(w, "route parameters should not be empty", http.StatusBadRequest) http.Error(w, "route parameters should not be empty", http.StatusBadRequest)
return return
} }
if err := inspector.DeleteTask(qname, taskid); err != nil { if err := inspector.DeleteTaskByKey(qname, key); err != nil {
// TODO: Handle task not found error and return 404 // TODO: Handle task not found error and return 404
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -341,15 +294,15 @@ func newDeleteTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
} }
} }
func newRunTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newRunTaskHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname, taskid := vars["qname"], vars["task_id"] qname, key := vars["qname"], vars["task_key"]
if qname == "" || taskid == "" { if qname == "" || key == "" {
http.Error(w, "route parameters should not be empty", http.StatusBadRequest) http.Error(w, "route parameters should not be empty", http.StatusBadRequest)
return return
} }
if err := inspector.RunTask(qname, taskid); err != nil { if err := inspector.RunTaskByKey(qname, key); err != nil {
// TODO: Handle task not found error and return 404 // TODO: Handle task not found error and return 404
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -358,15 +311,15 @@ func newRunTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
} }
} }
func newArchiveTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newArchiveTaskHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
qname, taskid := vars["qname"], vars["task_id"] qname, key := vars["qname"], vars["task_key"]
if qname == "" || taskid == "" { if qname == "" || key == "" {
http.Error(w, "route parameters should not be empty", http.StatusBadRequest) http.Error(w, "route parameters should not be empty", http.StatusBadRequest)
return return
} }
if err := inspector.ArchiveTask(qname, taskid); err != nil { if err := inspector.ArchiveTaskByKey(qname, key); err != nil {
// TODO: Handle task not found error and return 404 // TODO: Handle task not found error and return 404
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -375,12 +328,12 @@ func newArchiveTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
} }
} }
type deleteAllTasksResponse struct { type DeleteAllTasksResponse struct {
// Number of tasks deleted. // Number of tasks deleted.
Deleted int `json:"deleted"` Deleted int `json:"deleted"`
} }
func newDeleteAllPendingTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newDeleteAllPendingTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.DeleteAllPendingTasks(qname) n, err := inspector.DeleteAllPendingTasks(qname)
@ -388,24 +341,15 @@ func newDeleteAllPendingTasksHandlerFunc(inspector *asynq.Inspector) http.Handle
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, deleteAllTasksResponse{n}) resp := DeleteAllTasksResponse{n}
} if err := json.NewEncoder(w).Encode(resp); err != nil {
}
func newDeleteAllAggregatingTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname, gname := vars["qname"], vars["gname"]
n, err := inspector.DeleteAllAggregatingTasks(qname, gname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, deleteAllTasksResponse{n})
} }
} }
func newDeleteAllScheduledTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newDeleteAllScheduledTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.DeleteAllScheduledTasks(qname) n, err := inspector.DeleteAllScheduledTasks(qname)
@ -413,11 +357,15 @@ func newDeleteAllScheduledTasksHandlerFunc(inspector *asynq.Inspector) http.Hand
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, deleteAllTasksResponse{n}) resp := DeleteAllTasksResponse{n}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }
func newDeleteAllRetryTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newDeleteAllRetryTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.DeleteAllRetryTasks(qname) n, err := inspector.DeleteAllRetryTasks(qname)
@ -425,11 +373,15 @@ func newDeleteAllRetryTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerF
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, deleteAllTasksResponse{n}) resp := DeleteAllTasksResponse{n}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }
func newDeleteAllArchivedTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newDeleteAllArchivedTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.DeleteAllArchivedTasks(qname) n, err := inspector.DeleteAllArchivedTasks(qname)
@ -437,158 +389,102 @@ func newDeleteAllArchivedTasksHandlerFunc(inspector *asynq.Inspector) http.Handl
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, deleteAllTasksResponse{n}) resp := DeleteAllTasksResponse{n}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }
func newDeleteAllCompletedTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newRunAllScheduledTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.DeleteAllCompletedTasks(qname) if _, err := inspector.RunAllScheduledTasks(qname); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, deleteAllTasksResponse{n}) w.WriteHeader(http.StatusNoContent)
} }
} }
type runAllTasksResponse struct { func newRunAllRetryTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
// Number of tasks scheduled to run.
Scheduled int `json:"scheduled"`
}
func newRunAllScheduledTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.RunAllScheduledTasks(qname) if _, err := inspector.RunAllRetryTasks(qname); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, runAllTasksResponse{n}) w.WriteHeader(http.StatusNoContent)
} }
} }
func newRunAllRetryTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newRunAllArchivedTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.RunAllRetryTasks(qname) if _, err := inspector.RunAllArchivedTasks(qname); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, runAllTasksResponse{n}) w.WriteHeader(http.StatusNoContent)
} }
} }
func newRunAllArchivedTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newArchiveAllPendingTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.RunAllArchivedTasks(qname) if _, err := inspector.ArchiveAllPendingTasks(qname); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, runAllTasksResponse{n}) w.WriteHeader(http.StatusNoContent)
} }
} }
func newRunAllAggregatingTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newArchiveAllScheduledTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname, gname := vars["qname"], vars["gname"]
n, err := inspector.RunAllAggregatingTasks(qname, gname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeResponseJSON(w, runAllTasksResponse{n})
}
}
type archiveAllTasksResponse struct {
// Number of tasks archived.
Archived int `json:"archived"`
}
func writeResponseJSON(w http.ResponseWriter, resp interface{}) {
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func newArchiveAllPendingTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.ArchiveAllPendingTasks(qname) if _, err := inspector.ArchiveAllScheduledTasks(qname); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, archiveAllTasksResponse{n}) w.WriteHeader(http.StatusNoContent)
} }
} }
func newArchiveAllAggregatingTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newArchiveAllRetryTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname, gname := vars["qname"], vars["gname"]
n, err := inspector.ArchiveAllAggregatingTasks(qname, gname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeResponseJSON(w, archiveAllTasksResponse{n})
}
}
func newArchiveAllScheduledTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
n, err := inspector.ArchiveAllScheduledTasks(qname) if _, err := inspector.ArchiveAllRetryTasks(qname); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeResponseJSON(w, archiveAllTasksResponse{n}) w.WriteHeader(http.StatusNoContent)
}
}
func newArchiveAllRetryTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
qname := mux.Vars(r)["qname"]
n, err := inspector.ArchiveAllRetryTasks(qname)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeResponseJSON(w, archiveAllTasksResponse{n})
} }
} }
// request body used for all batch delete tasks endpoints. // request body used for all batch delete tasks endpoints.
type batchDeleteTasksRequest struct { type batchDeleteTasksRequest struct {
TaskIDs []string `json:"task_ids"` TaskKeys []string `json:"task_keys"`
} }
// Note: Redis does not have any rollback mechanism, so it's possible // Note: Redis does not have any rollback mechanism, so it's possible
// to have partial success when doing a batch operation. // to have partial success when doing a batch operation.
// For this reason this response contains a list of succeeded ids // For this reason this response contains a list of succeeded keys
// and a list of failed ids. // and a list of failed keys.
type batchDeleteTasksResponse struct { type batchDeleteTasksResponse struct {
// task ids that were successfully deleted. // task keys that were successfully deleted.
DeletedIDs []string `json:"deleted_ids"` DeletedKeys []string `json:"deleted_keys"`
// task ids that were not deleted. // task keys that were not deleted.
FailedIDs []string `json:"failed_ids"` FailedKeys []string `json:"failed_keys"`
} }
// Maximum request body size in bytes. // Maximum request body size in bytes.
// Allow up to 1MB in size. // Allow up to 1MB in size.
const maxRequestBodySize = 1000000 const maxRequestBodySize = 1000000
func newBatchDeleteTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newBatchDeleteTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
@ -603,33 +499,36 @@ func newBatchDeleteTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
resp := batchDeleteTasksResponse{ resp := batchDeleteTasksResponse{
// avoid null in the json response // avoid null in the json response
DeletedIDs: make([]string, 0), DeletedKeys: make([]string, 0),
FailedIDs: make([]string, 0), FailedKeys: make([]string, 0),
} }
for _, taskid := range req.TaskIDs { for _, key := range req.TaskKeys {
if err := inspector.DeleteTask(qname, taskid); err != nil { if err := inspector.DeleteTaskByKey(qname, key); err != nil {
log.Printf("error: could not delete task with id %q: %v", taskid, err) log.Printf("error: could not delete task with key %q: %v", key, err)
resp.FailedIDs = append(resp.FailedIDs, taskid) resp.FailedKeys = append(resp.FailedKeys, key)
} else { } else {
resp.DeletedIDs = append(resp.DeletedIDs, taskid) resp.DeletedKeys = append(resp.DeletedKeys, key)
} }
} }
writeResponseJSON(w, resp) if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }
type batchRunTasksRequest struct { type batchRunTasksRequest struct {
TaskIDs []string `json:"task_ids"` TaskKeys []string `json:"task_keys"`
} }
type batchRunTasksResponse struct { type batchRunTasksResponse struct {
// task ids that were successfully moved to the pending state. // task keys that were successfully moved to the pending state.
PendingIDs []string `json:"pending_ids"` PendingKeys []string `json:"pending_keys"`
// task ids that were not able to move to the pending state. // task keys that were not able to move to the pending state.
ErrorIDs []string `json:"error_ids"` ErrorKeys []string `json:"error_keys"`
} }
func newBatchRunTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newBatchRunTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
@ -644,33 +543,36 @@ func newBatchRunTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc {
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
resp := batchRunTasksResponse{ resp := batchRunTasksResponse{
// avoid null in the json response // avoid null in the json response
PendingIDs: make([]string, 0), PendingKeys: make([]string, 0),
ErrorIDs: make([]string, 0), ErrorKeys: make([]string, 0),
} }
for _, taskid := range req.TaskIDs { for _, key := range req.TaskKeys {
if err := inspector.RunTask(qname, taskid); err != nil { if err := inspector.RunTaskByKey(qname, key); err != nil {
log.Printf("error: could not run task with id %q: %v", taskid, err) log.Printf("error: could not run task with key %q: %v", key, err)
resp.ErrorIDs = append(resp.ErrorIDs, taskid) resp.ErrorKeys = append(resp.ErrorKeys, key)
} else { } else {
resp.PendingIDs = append(resp.PendingIDs, taskid) resp.PendingKeys = append(resp.PendingKeys, key)
} }
} }
writeResponseJSON(w, resp) if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }
type batchArchiveTasksRequest struct { type batchArchiveTasksRequest struct {
TaskIDs []string `json:"task_ids"` TaskKeys []string `json:"task_keys"`
} }
type batchArchiveTasksResponse struct { type batchArchiveTasksResponse struct {
// task ids that were successfully moved to the archived state. // task keys that were successfully moved to the archived state.
ArchivedIDs []string `json:"archived_ids"` ArchivedKeys []string `json:"archived_keys"`
// task ids that were not able to move to the archived state. // task keys that were not able to move to the archived state.
ErrorIDs []string `json:"error_ids"` ErrorKeys []string `json:"error_keys"`
} }
func newBatchArchiveTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { func newBatchArchiveTasksHandlerFunc(inspector *inspeq.Inspector) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
@ -685,18 +587,21 @@ func newBatchArchiveTasksHandlerFunc(inspector *asynq.Inspector) http.HandlerFun
qname := mux.Vars(r)["qname"] qname := mux.Vars(r)["qname"]
resp := batchArchiveTasksResponse{ resp := batchArchiveTasksResponse{
// avoid null in the json response // avoid null in the json response
ArchivedIDs: make([]string, 0), ArchivedKeys: make([]string, 0),
ErrorIDs: make([]string, 0), ErrorKeys: make([]string, 0),
} }
for _, taskid := range req.TaskIDs { for _, key := range req.TaskKeys {
if err := inspector.ArchiveTask(qname, taskid); err != nil { if err := inspector.ArchiveTaskByKey(qname, key); err != nil {
log.Printf("error: could not archive task with id %q: %v", taskid, err) log.Printf("error: could not archive task with key %q: %v", key, err)
resp.ErrorIDs = append(resp.ErrorIDs, taskid) resp.ErrorKeys = append(resp.ErrorKeys, key)
} else { } else {
resp.ArchivedIDs = append(resp.ArchivedIDs, taskid) resp.ArchivedKeys = append(resp.ArchivedKeys, key)
} }
} }
writeResponseJSON(w, resp) if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
} }
@ -718,30 +623,3 @@ func getPageOptions(r *http.Request) (pageSize, pageNum int) {
} }
return pageSize, pageNum return pageSize, pageNum
} }
func newGetTaskHandlerFunc(inspector *asynq.Inspector, pf PayloadFormatter, rf ResultFormatter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
qname, taskid := vars["qname"], vars["task_id"]
if qname == "" {
http.Error(w, "queue name cannot be empty", http.StatusBadRequest)
return
}
if taskid == "" {
http.Error(w, "task_id cannot be empty", http.StatusBadRequest)
return
}
info, err := inspector.GetTaskInfo(qname, taskid)
switch {
case errors.Is(err, asynq.ErrQueueNotFound), errors.Is(err, asynq.ErrTaskNotFound):
http.Error(w, strings.TrimPrefix(err.Error(), "asynq: "), http.StatusNotFound)
return
case err != nil:
http.Error(w, strings.TrimPrefix(err.Error(), "asynq: "), http.StatusInternalServerError)
return
}
writeResponseJSON(w, toTaskInfo(info, pf, rf))
}
}

3
ui/.gitignore vendored
View File

@ -8,6 +8,9 @@
# testing # testing
/coverage /coverage
# production
/build
# misc # misc
.DS_Store .DS_Store
.env.local .env.local

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,19 +0,0 @@
{
"files": {
"main.js": "/[[.RootPath]]/static/js/main.5adda2da.chunk.js",
"main.js.map": "/[[.RootPath]]/static/js/main.5adda2da.chunk.js.map",
"runtime-main.js": "/[[.RootPath]]/static/js/runtime-main.9fea6c1a.js",
"runtime-main.js.map": "/[[.RootPath]]/static/js/runtime-main.9fea6c1a.js.map",
"static/js/2.83624df2.chunk.js": "/[[.RootPath]]/static/js/2.83624df2.chunk.js",
"static/js/2.83624df2.chunk.js.map": "/[[.RootPath]]/static/js/2.83624df2.chunk.js.map",
"index.html": "/[[.RootPath]]/index.html",
"static/js/2.83624df2.chunk.js.LICENSE.txt": "/[[.RootPath]]/static/js/2.83624df2.chunk.js.LICENSE.txt",
"static/media/logo-color.c2b0c1f3.svg": "/[[.RootPath]]/static/media/logo-color.c2b0c1f3.svg",
"static/media/logo-white.3fa2ac55.svg": "/[[.RootPath]]/static/media/logo-white.3fa2ac55.svg"
},
"entrypoints": [
"static/js/runtime-main.9fea6c1a.js",
"static/js/2.83624df2.chunk.js",
"static/js/main.5adda2da.chunk.js"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/[[.RootPath]]/favicon.ico"/><link rel="icon" type="image/png" sizes="32x32" href="/[[.RootPath]]/favicon-32x32.png"/><link rel="icon" type="image/png" sizes="16x16" href="/[[.RootPath]]/favicon-16x16.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Asynq monitoring web console"/><link rel="apple-touch-icon" sizes="180x180" href="/[[.RootPath]]/apple-touch-icon.png"/><link rel="manifest" href="/[[.RootPath]]/manifest.json"/><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"/><script>window.FLAG_ROOT_PATH="/[[.RootPath]]",window.FLAG_PROMETHEUS_SERVER_ADDRESS="/[[.PrometheusAddr]]",window.FLAG_READ_ONLY="/[[.ReadOnly]]"</script><title>Asynq - Monitoring</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>!function(e){function t(t){for(var n,i,l=t[0],a=t[1],f=t[2],c=0,s=[];c<l.length;c++)i=l[c],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&s.push(o[i][0]),o[i]=0;for(n in a)Object.prototype.hasOwnProperty.call(a,n)&&(e[n]=a[n]);for(p&&p(t);s.length;)s.shift()();return u.push.apply(u,f||[]),r()}function r(){for(var e,t=0;t<u.length;t++){for(var r=u[t],n=!0,l=1;l<r.length;l++){var a=r[l];0!==o[a]&&(n=!1)}n&&(u.splice(t--,1),e=i(i.s=r[0]))}return e}var n={},o={1:0},u=[];function i(t){if(n[t])return n[t].exports;var r=n[t]={i:t,l:!1,exports:{}};return e[t].call(r.exports,r,r.exports,i),r.l=!0,r.exports}i.m=e,i.c=n,i.d=function(e,t,r){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(i.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var n in e)i.d(r,n,function(t){return e[t]}.bind(null,n));return r},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="/[[.RootPath]]/";var l=this.webpackJsonpui=this.webpackJsonpui||[],a=l.push.bind(l);l.push=t,l=l.slice();for(var f=0;f<l.length;f++)t(l[f]);var p=a;r()}([])</script><script src="/[[.RootPath]]/static/js/2.83624df2.chunk.js"></script><script src="/[[.RootPath]]/static/js/main.5adda2da.chunk.js"></script></body></html>

View File

@ -1,19 +0,0 @@
{
"name": "Asynq Monitoring",
"short_name": "Asynqmon",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

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

File diff suppressed because one or more lines are too long

View File

@ -1,253 +0,0 @@
/*
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

@ -1,2 +0,0 @@
!function(e){function t(t){for(var n,i,l=t[0],a=t[1],f=t[2],c=0,s=[];c<l.length;c++)i=l[c],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&s.push(o[i][0]),o[i]=0;for(n in a)Object.prototype.hasOwnProperty.call(a,n)&&(e[n]=a[n]);for(p&&p(t);s.length;)s.shift()();return u.push.apply(u,f||[]),r()}function r(){for(var e,t=0;t<u.length;t++){for(var r=u[t],n=!0,l=1;l<r.length;l++){var a=r[l];0!==o[a]&&(n=!1)}n&&(u.splice(t--,1),e=i(i.s=r[0]))}return e}var n={},o={1:0},u=[];function i(t){if(n[t])return n[t].exports;var r=n[t]={i:t,l:!1,exports:{}};return e[t].call(r.exports,r,r.exports,i),r.l=!0,r.exports}i.m=e,i.c=n,i.d=function(e,t,r){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},i.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(i.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var n in e)i.d(r,n,function(t){return e[t]}.bind(null,n));return r},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="/[[.RootPath]]/";var l=this.webpackJsonpui=this.webpackJsonpui||[],a=l.push.bind(l);l.push=t,l=l.slice();for(var f=0;f<l.length;f++)t(l[f]);var p=a;r()}([]);
//# sourceMappingURL=runtime-main.9fea6c1a.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -3,39 +3,37 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@material-ui/core": "4.12.3", "@material-ui/core": "4.11.0",
"@material-ui/icons": "4.11.2", "@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.58", "@material-ui/lab": "4.0.0-alpha.56",
"@reduxjs/toolkit": "1.6.2", "@reduxjs/toolkit": "1.4.0",
"@testing-library/jest-dom": "^5.12.0", "@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^13.1.9", "@testing-library/user-event": "^7.1.2",
"@types/jest": "^27.0.2", "@types/jest": "^24.0.0",
"@types/lodash.uniqby": "4.7.6", "@types/lodash.uniqby": "4.7.6",
"@types/node": "^16.3.1", "@types/node": "^12.0.0",
"@types/react": "^17.0.29", "@types/react": "^16.9.0",
"@types/react-dom": "^17.0.9", "@types/react-dom": "^16.9.0",
"@types/react-redux": "7.1.19", "@types/react-redux": "7.1.9",
"@types/react-router-dom": "5.3.1", "@types/react-router-dom": "5.1.6",
"@types/react-syntax-highlighter": "13.5.2", "@types/react-syntax-highlighter": "13.5.0",
"@types/recharts": "1.8.20", "@types/recharts": "1.8.16",
"axios": "0.21.2", "axios": "0.20.0",
"clsx": "1.1.1", "clsx": "1.1.1",
"dayjs": "1.10.7",
"lodash.uniqby": "4.7.0", "lodash.uniqby": "4.7.0",
"query-string": "7.0.1", "query-string": "6.13.7",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-redux": "7.2.4", "react-redux": "7.2.2",
"react-router-dom": "5.3.0", "react-router-dom": "5.2.0",
"react-scripts": "5.0.1", "react-scripts": "3.4.3",
"react-syntax-highlighter": "15.4.3", "react-syntax-highlighter": "15.3.0",
"react-window": "1.8.6", "recharts": "1.8.5",
"recharts": "2.1.4", "typescript": "~3.7.2"
"typescript": "~4.2.4"
}, },
"scripts": { "scripts": {
"start": "export PUBLIC_URL=http://localhost:3000/ && react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject"
@ -56,8 +54,6 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/react-window": "1.8.5",
"redux-devtools": "3.7.0" "redux-devtools": "3.7.0"
}, }
"homepage": "/[[.RootPath]]"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 528 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,7 +1,3 @@
<!--
This file is used as a template for go's html/template package.
Use delimiter "/[[", "]]" to denote actions.
-->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -49,11 +45,6 @@
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons" href="https://fonts.googleapis.com/icon?family=Material+Icons"
/> />
<script>
window.FLAG_ROOT_PATH = "%PUBLIC_URL%";
window.FLAG_PROMETHEUS_SERVER_ADDRESS = "/[[.PrometheusAddr]]";
window.FLAG_READ_ONLY = "/[[.ReadOnly]]";
</script>
<title>Asynq - Monitoring</title> <title>Asynq - Monitoring</title>
</head> </head>
<body> <body>

View File

@ -10,6 +10,7 @@ import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem"; import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon"; import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText"; import ListItemText from "@material-ui/core/ListItemText";
import Typography from "@material-ui/core/Typography";
import Snackbar from "@material-ui/core/Snackbar"; import Snackbar from "@material-ui/core/Snackbar";
import SnackbarContent from "@material-ui/core/SnackbarContent"; import SnackbarContent from "@material-ui/core/SnackbarContent";
import IconButton from "@material-ui/core/IconButton"; import IconButton from "@material-ui/core/IconButton";
@ -21,26 +22,21 @@ import LayersIcon from "@material-ui/icons/Layers";
import SettingsIcon from "@material-ui/icons/Settings"; import SettingsIcon from "@material-ui/icons/Settings";
import ScheduleIcon from "@material-ui/icons/Schedule"; import ScheduleIcon from "@material-ui/icons/Schedule";
import FeedbackIcon from "@material-ui/icons/Feedback"; import FeedbackIcon from "@material-ui/icons/Feedback";
import TimelineIcon from "@material-ui/icons/Timeline";
import DoubleArrowIcon from "@material-ui/icons/DoubleArrow"; import DoubleArrowIcon from "@material-ui/icons/DoubleArrow";
import CloseIcon from "@material-ui/icons/Close"; import CloseIcon from "@material-ui/icons/Close";
import { AppState } from "./store"; import { AppState } from "./store";
import { paths as getPaths } from "./paths"; import { paths } from "./paths";
import { isDarkTheme, useTheme } from "./theme"; import { useTheme } from "./theme";
import { closeSnackbar } from "./actions/snackbarActions"; import { closeSnackbar } from "./actions/snackbarActions";
import { toggleDrawer } from "./actions/settingsActions"; import { toggleDrawer } from "./actions/settingsActions";
import ListItemLink from "./components/ListItemLink"; import ListItemLink from "./components/ListItemLink";
import SchedulersView from "./views/SchedulersView"; import SchedulersView from "./views/SchedulersView";
import DashboardView from "./views/DashboardView"; import DashboardView from "./views/DashboardView";
import TasksView from "./views/TasksView"; import TasksView from "./views/TasksView";
import TaskDetailsView from "./views/TaskDetailsView";
import SettingsView from "./views/SettingsView"; import SettingsView from "./views/SettingsView";
import ServersView from "./views/ServersView"; import ServersView from "./views/ServersView";
import RedisInfoView from "./views/RedisInfoView"; import RedisInfoView from "./views/RedisInfoView";
import MetricsView from "./views/MetricsView";
import PageNotFoundView from "./views/PageNotFoundView"; import PageNotFoundView from "./views/PageNotFoundView";
import { ReactComponent as Logo } from "./images/logo-color.svg";
import { ReactComponent as LogoDarkTheme } from "./images/logo-white.svg";
const drawerWidth = 220; const drawerWidth = 220;
@ -67,14 +63,18 @@ const useStyles = (theme: Theme) =>
zIndex: theme.zIndex.drawer + 1, zIndex: theme.zIndex.drawer + 1,
}, },
menuButton: { menuButton: {
marginRight: theme.spacing(1), marginRight: theme.spacing(2),
color: isDarkTheme(theme) color:
? theme.palette.grey[100] theme.palette.type === "dark"
: theme.palette.grey[700], ? theme.palette.grey[100]
: theme.palette.grey[700],
}, },
menuButtonHidden: { menuButtonHidden: {
display: "none", display: "none",
}, },
title: {
flexGrow: 1,
},
drawerPaper: { drawerPaper: {
position: "relative", position: "relative",
whiteSpace: "nowrap", whiteSpace: "nowrap",
@ -154,7 +154,6 @@ function SlideUpTransition(props: TransitionProps) {
function App(props: ConnectedProps<typeof connector>) { function App(props: ConnectedProps<typeof connector>) {
const theme = useTheme(props.themePreference); const theme = useTheme(props.themePreference);
const classes = useStyles(theme)(); const classes = useStyles(theme)();
const paths = getPaths();
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Router> <Router>
@ -174,11 +173,15 @@ function App(props: ConnectedProps<typeof connector>) {
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
{isDarkTheme(theme) ? ( <Typography
<LogoDarkTheme width={200} height={48} /> component="h1"
) : ( variant="h6"
<Logo width={200} height={48} /> noWrap
)} className={classes.title}
color="textPrimary"
>
Asynq Monitoring
</Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<div className={classes.mainContainer}> <div className={classes.mainContainer}>
@ -241,13 +244,6 @@ function App(props: ConnectedProps<typeof connector>) {
primary="Redis" primary="Redis"
icon={<LayersIcon />} icon={<LayersIcon />}
/> />
{window.PROMETHEUS_SERVER_ADDRESS && (
<ListItemLink
to={paths.QUEUE_METRICS}
primary="Metrics"
icon={<TimelineIcon />}
/>
)}
</div> </div>
</List> </List>
<List> <List>
@ -274,9 +270,6 @@ function App(props: ConnectedProps<typeof connector>) {
<main className={classes.content}> <main className={classes.content}>
<div className={classes.contentWrapper}> <div className={classes.contentWrapper}>
<Switch> <Switch>
<Route exact path={paths.TASK_DETAILS}>
<TaskDetailsView />
</Route>
<Route exact path={paths.QUEUE_DETAILS}> <Route exact path={paths.QUEUE_DETAILS}>
<TasksView /> <TasksView />
</Route> </Route>
@ -295,9 +288,6 @@ function App(props: ConnectedProps<typeof connector>) {
<Route exact path={paths.HOME}> <Route exact path={paths.HOME}>
<DashboardView /> <DashboardView />
</Route> </Route>
<Route exact path={paths.QUEUE_METRICS}>
<MetricsView />
</Route>
<Route path="*"> <Route path="*">
<PageNotFoundView /> <PageNotFoundView />
</Route> </Route>

View File

@ -1,52 +0,0 @@
import { Dispatch } from "redux";
import { listGroups, ListGroupsResponse } from "../api";
import { toErrorString, toErrorStringWithHttpStatus } from "../utils";
// List of groups related action types.
export const LIST_GROUPS_BEGIN = "LIST_GROUPS_BEGIN";
export const LIST_GROUPS_SUCCESS = "LIST_GROUPS_SUCCESS";
export const LIST_GROUPS_ERROR = "LIST_GROUPS_ERROR";
interface ListGroupsBeginAction {
type: typeof LIST_GROUPS_BEGIN;
queue: string;
}
interface ListGroupsSuccessAction {
type: typeof LIST_GROUPS_SUCCESS;
payload: ListGroupsResponse;
queue: string;
}
interface ListGroupsErrorAction {
type: typeof LIST_GROUPS_ERROR;
queue: string;
error: string;
}
// Union of all groups related action types.
export type GroupsActionTypes =
| ListGroupsBeginAction
| ListGroupsSuccessAction
| ListGroupsErrorAction;
export function listGroupsAsync(qname: string) {
return async (dispatch: Dispatch<GroupsActionTypes>) => {
dispatch({ type: LIST_GROUPS_BEGIN, queue: qname });
try {
const response = await listGroups(qname);
dispatch({
type: LIST_GROUPS_SUCCESS,
payload: response,
queue: qname,
});
} catch (error) {
console.error(`listGroupsAsync: ${toErrorStringWithHttpStatus(error)}`);
dispatch({
type: LIST_GROUPS_ERROR,
error: toErrorString(error),
queue: qname,
});
}
};
}

View File

@ -1,48 +0,0 @@
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

@ -1,11 +1,8 @@
import { ThemePreference } from "../reducers/settingsReducer"; import { ThemePreference } from "../reducers/settingsReducer";
import { DailyStatsKey } from "../views/DashboardView";
// List of settings related action types. // List of settings related action types.
export const POLL_INTERVAL_CHANGE = "POLL_INTERVAL_CHANGE"; export const POLL_INTERVAL_CHANGE = "POLL_INTERVAL_CHANGE";
export const THEME_PREFERENCE_CHANGE = "THEME_PREFERENCE_CHANGE"; export const THEME_PREFERENCE_CHANGE = "THEME_PREFERENCE_CHANGE";
export const TOGGLE_DRAWER = "TOGGLE_DRAWER"; export const TOGGLE_DRAWER = "TOGGLE_DRAWER";
export const TASK_ROWS_PER_PAGE_CHANGE = "TASK_ROWS_PER_PAGE_CHANGE";
export const DAILY_STATS_KEY_CHANGE = "DAILY_STATS_KEY_CHANGE";
interface PollIntervalChangeAction { interface PollIntervalChangeAction {
type: typeof POLL_INTERVAL_CHANGE; type: typeof POLL_INTERVAL_CHANGE;
@ -21,23 +18,11 @@ interface ToggleDrawerAction {
type: typeof TOGGLE_DRAWER; type: typeof TOGGLE_DRAWER;
} }
interface TaskRowsPerPageChange {
type: typeof TASK_ROWS_PER_PAGE_CHANGE;
value: number;
}
interface DailyStatsKeyChange {
type: typeof DAILY_STATS_KEY_CHANGE;
value: DailyStatsKey;
}
// Union of all settings related action types. // Union of all settings related action types.
export type SettingsActionTypes = export type SettingsActionTypes =
| PollIntervalChangeAction | PollIntervalChangeAction
| ThemePreferenceChangeAction | ThemePreferenceChangeAction
| ToggleDrawerAction | ToggleDrawerAction;
| TaskRowsPerPageChange
| DailyStatsKeyChange;
export function pollIntervalChange(value: number) { export function pollIntervalChange(value: number) {
return { return {
@ -56,17 +41,3 @@ export function selectTheme(value: ThemePreference) {
export function toggleDrawer() { export function toggleDrawer() {
return { type: TOGGLE_DRAWER }; return { type: TOGGLE_DRAWER };
} }
export function taskRowsPerPageChange(value: number) {
return {
type: TASK_ROWS_PER_PAGE_CHANGE,
value,
};
}
export function dailyStatsKeyChange(value: DailyStatsKey) {
return {
type: DAILY_STATS_KEY_CHANGE,
value,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,24 +4,36 @@ import queryString from "query-string";
// In production build, API server is on listening on the same port as // In production build, API server is on listening on the same port as
// the static file server. // the static file server.
// In developement, we assume that the API server is listening on port 8080. // In developement, we assume that the API server is listening on port 8080.
const getBaseUrl = () => const BASE_URL =
process.env.NODE_ENV === "production" process.env.NODE_ENV === "production" ? "/api" : "http://localhost:8080/api";
? `${window.ROOT_PATH}/api`
: `http://localhost:8080${window.ROOT_PATH}/api`;
export interface ListQueuesResponse { export interface ListQueuesResponse {
queues: Queue[]; queues: Queue[];
} }
export interface ListTasksResponse { export interface ListActiveTasksResponse {
tasks: TaskInfo[]; tasks: ActiveTask[];
stats: Queue; stats: Queue;
} }
export interface ListAggregatingTasksResponse { export interface ListPendingTasksResponse {
tasks: TaskInfo[]; tasks: PendingTask[];
stats: Queue;
}
export interface ListScheduledTasksResponse {
tasks: ScheduledTask[];
stats: Queue;
}
export interface ListRetryTasksResponse {
tasks: RetryTask[];
stats: Queue;
}
export interface ListArchivedTasksResponse {
tasks: ArchivedTask[];
stats: Queue; stats: Queue;
groups: GroupInfo[];
} }
export interface ListServersResponse { export interface ListServersResponse {
@ -42,96 +54,32 @@ export interface BatchCancelTasksResponse {
} }
export interface BatchDeleteTasksResponse { export interface BatchDeleteTasksResponse {
deleted_ids: string[]; deleted_keys: string[];
failed_ids: string[]; failed_keys: string[];
} }
export interface BatchRunTasksResponse { export interface BatchRunTasksResponse {
pending_ids: string[]; pending_keys: string[];
error_ids: string[]; error_keys: string[];
} }
export interface BatchArchiveTasksResponse { export interface BatchArchiveTasksResponse {
archived_ids: string[]; archived_keys: string[];
error_ids: string[]; error_keys: string[];
} }
export interface DeleteAllTasksResponse { export interface DeleteAllTasksResponse {
deleted: number; deleted: number;
} }
export interface ArchiveAllTasksResponse {
archived: number;
}
export interface RunAllTasksResponse {
scheduled: number;
}
export interface ListQueueStatsResponse { export interface ListQueueStatsResponse {
stats: { [qname: string]: DailyStat[] }; stats: { [qname: string]: DailyStat[] };
} }
export interface ListGroupsResponse {
stats: Queue;
groups: GroupInfo[];
}
export interface RedisInfoResponse { export interface RedisInfoResponse {
address: string; address: string;
info: RedisInfo; info: RedisInfo;
raw_info: string; raw_info: string;
cluster: boolean;
// following fields are set only when cluster=true
raw_cluster_nodes: string;
queue_locations: QueueLocation[] | null;
}
// Describes location of a queue in cluster.
export interface QueueLocation {
queue: string; // queue name
keyslot: number; // cluster keyslot
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. // Return value from redis INFO command.
@ -269,26 +217,16 @@ export interface RedisInfo {
used_memory_startup: string; used_memory_startup: string;
} }
export interface GroupInfo {
group: string;
size: number;
}
export interface Queue { export interface Queue {
queue: string; queue: string;
paused: boolean; paused: boolean;
size: number; size: number;
groups: number;
latency_msec: number;
display_latency: string;
memory_usage_bytes: number; memory_usage_bytes: number;
active: number; active: number;
pending: number; pending: number;
aggregating: number;
scheduled: number; scheduled: number;
retry: number; retry: number;
archived: number; archived: number;
completed: number;
processed: number; processed: number;
failed: number; failed: number;
timestamp: string; timestamp: string;
@ -301,25 +239,59 @@ export interface DailyStat {
failed: number; failed: number;
} }
export interface TaskInfo { // BaseTask corresponds to asynq.Task type.
interface BaseTask {
type: string;
payload: { [key: string]: any };
}
export interface ActiveTask extends BaseTask {
id: string; id: string;
queue: string; queue: string;
type: string; start_time: string;
payload: string; deadline: string;
state: string; max_retry: number;
start_time: string; // Only applies to task.state == 'active' retried: number;
error_message: string;
}
export interface PendingTask extends BaseTask {
id: string;
key: string;
queue: string;
max_retry: number;
retried: number;
error_message: string;
}
export interface ScheduledTask extends BaseTask {
id: string;
key: string;
queue: string;
max_retry: number;
retried: number;
error_message: string;
next_process_at: string;
}
export interface RetryTask extends BaseTask {
id: string;
key: string;
queue: string;
next_process_at: string;
max_retry: number;
retried: number;
error_message: string;
}
export interface ArchivedTask extends BaseTask {
id: string;
key: string;
queue: string;
max_retry: number; max_retry: number;
retried: number; retried: number;
last_failed_at: string; last_failed_at: string;
error_message: string; error_message: string;
next_process_at: string;
timeout_seconds: number;
deadline: string;
group: string;
completed_at: string;
result: string;
ttl_seconds: number;
is_orphaned: boolean; // Only applies to task.state == 'active'
} }
export interface ServerInfo { export interface ServerInfo {
@ -335,10 +307,7 @@ export interface ServerInfo {
} }
export interface WorkerInfo { export interface WorkerInfo {
task_id: string; task: ActiveTask;
queue: string;
task_type: string;
task_payload: string;
start_time: string; start_time: string;
} }
@ -346,7 +315,7 @@ export interface SchedulerEntry {
id: string; id: string;
spec: string; spec: string;
task_type: string; task_type: string;
task_payload: string; task_payload: { [key: string]: any };
options: string[]; options: string[];
next_enqueue_at: string; next_enqueue_at: string;
// prev_enqueue_at will be omitted // prev_enqueue_at will be omitted
@ -367,7 +336,7 @@ export interface PaginationOptions extends Record<string, number | undefined> {
export async function listQueues(): Promise<ListQueuesResponse> { export async function listQueues(): Promise<ListQueuesResponse> {
const resp = await axios({ const resp = await axios({
method: "get", method: "get",
url: `${getBaseUrl()}/queues`, url: `${BASE_URL}/queues`,
}); });
return resp.data; return resp.data;
} }
@ -375,48 +344,28 @@ export async function listQueues(): Promise<ListQueuesResponse> {
export async function deleteQueue(qname: string): Promise<void> { export async function deleteQueue(qname: string): Promise<void> {
await axios({ await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}`, url: `${BASE_URL}/queues/${qname}`,
}); });
} }
export async function pauseQueue(qname: string): Promise<void> { export async function pauseQueue(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}:pause`, url: `${BASE_URL}/queues/${qname}:pause`,
}); });
} }
export async function resumeQueue(qname: string): Promise<void> { export async function resumeQueue(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}:resume`, url: `${BASE_URL}/queues/${qname}:resume`,
}); });
} }
export async function listQueueStats(): Promise<ListQueueStatsResponse> { export async function listQueueStats(): Promise<ListQueueStatsResponse> {
const resp = await axios({ const resp = await axios({
method: "get", method: "get",
url: `${getBaseUrl()}/queue_stats`, url: `${BASE_URL}/queue_stats`,
});
return resp.data;
}
export async function listGroups(qname: string): Promise<ListGroupsResponse> {
const resp = await axios({
method: "get",
url: `${getBaseUrl()}/queues/${qname}/groups`,
});
return resp.data;
}
export async function getTaskInfo(
qname: string,
id: string
): Promise<TaskInfo> {
const url = `${getBaseUrl()}/queues/${qname}/tasks/${id}`;
const resp = await axios({
method: "get",
url,
}); });
return resp.data; return resp.data;
} }
@ -424,8 +373,8 @@ export async function getTaskInfo(
export async function listActiveTasks( export async function listActiveTasks(
qname: string, qname: string,
pageOpts?: PaginationOptions pageOpts?: PaginationOptions
): Promise<ListTasksResponse> { ): Promise<ListActiveTasksResponse> {
let url = `${getBaseUrl()}/queues/${qname}/active_tasks`; let url = `${BASE_URL}/queues/${qname}/active_tasks`;
if (pageOpts) { if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`; url += `?${queryString.stringify(pageOpts)}`;
} }
@ -442,14 +391,14 @@ export async function cancelActiveTask(
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/active_tasks/${taskId}:cancel`, url: `${BASE_URL}/queues/${qname}/active_tasks/${taskId}:cancel`,
}); });
} }
export async function cancelAllActiveTasks(qname: string): Promise<void> { export async function cancelAllActiveTasks(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/active_tasks:cancel_all`, url: `${BASE_URL}/queues/${qname}/active_tasks:cancel_all`,
}); });
} }
@ -459,7 +408,7 @@ export async function batchCancelActiveTasks(
): Promise<BatchCancelTasksResponse> { ): Promise<BatchCancelTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/active_tasks:batch_cancel`, url: `${BASE_URL}/queues/${qname}/active_tasks:batch_cancel`,
data: { data: {
task_ids: taskIds, task_ids: taskIds,
}, },
@ -470,8 +419,8 @@ export async function batchCancelActiveTasks(
export async function listPendingTasks( export async function listPendingTasks(
qname: string, qname: string,
pageOpts?: PaginationOptions pageOpts?: PaginationOptions
): Promise<ListTasksResponse> { ): Promise<ListPendingTasksResponse> {
let url = `${getBaseUrl()}/queues/${qname}/pending_tasks`; let url = `${BASE_URL}/queues/${qname}/pending_tasks`;
if (pageOpts) { if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`; url += `?${queryString.stringify(pageOpts)}`;
} }
@ -485,8 +434,8 @@ export async function listPendingTasks(
export async function listScheduledTasks( export async function listScheduledTasks(
qname: string, qname: string,
pageOpts?: PaginationOptions pageOpts?: PaginationOptions
): Promise<ListTasksResponse> { ): Promise<ListScheduledTasksResponse> {
let url = `${getBaseUrl()}/queues/${qname}/scheduled_tasks`; let url = `${BASE_URL}/queues/${qname}/scheduled_tasks`;
if (pageOpts) { if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`; url += `?${queryString.stringify(pageOpts)}`;
} }
@ -500,8 +449,8 @@ export async function listScheduledTasks(
export async function listRetryTasks( export async function listRetryTasks(
qname: string, qname: string,
pageOpts?: PaginationOptions pageOpts?: PaginationOptions
): Promise<ListTasksResponse> { ): Promise<ListRetryTasksResponse> {
let url = `${getBaseUrl()}/queues/${qname}/retry_tasks`; let url = `${BASE_URL}/queues/${qname}/retry_tasks`;
if (pageOpts) { if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`; url += `?${queryString.stringify(pageOpts)}`;
} }
@ -515,39 +464,8 @@ export async function listRetryTasks(
export async function listArchivedTasks( export async function listArchivedTasks(
qname: string, qname: string,
pageOpts?: PaginationOptions pageOpts?: PaginationOptions
): Promise<ListTasksResponse> { ): Promise<ListArchivedTasksResponse> {
let url = `${getBaseUrl()}/queues/${qname}/archived_tasks`; let url = `${BASE_URL}/queues/${qname}/archived_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listCompletedTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListTasksResponse> {
let url = `${getBaseUrl()}/queues/${qname}/completed_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listAggregatingTasks(
qname: string,
gname: string,
pageOpts?: PaginationOptions
): Promise<ListAggregatingTasksResponse> {
let url = `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks`;
if (pageOpts) { if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`; url += `?${queryString.stringify(pageOpts)}`;
} }
@ -560,23 +478,23 @@ export async function listAggregatingTasks(
export async function archivePendingTask( export async function archivePendingTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/pending_tasks/${taskId}:archive`, url: `${BASE_URL}/queues/${qname}/pending_tasks/${taskKey}:archive`,
}); });
} }
export async function batchArchivePendingTasks( export async function batchArchivePendingTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchArchiveTasksResponse> { ): Promise<BatchArchiveTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/pending_tasks:batch_archive`, url: `${BASE_URL}/queues/${qname}/pending_tasks:batch_archive`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -585,29 +503,29 @@ export async function batchArchivePendingTasks(
export async function archiveAllPendingTasks(qname: string): Promise<void> { export async function archiveAllPendingTasks(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/pending_tasks:archive_all`, url: `${BASE_URL}/queues/${qname}/pending_tasks:archive_all`,
}); });
} }
export async function deletePendingTask( export async function deletePendingTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}/pending_tasks/${taskId}`, url: `${BASE_URL}/queues/${qname}/pending_tasks/${taskKey}`,
}); });
} }
export async function batchDeletePendingTasks( export async function batchDeletePendingTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchDeleteTasksResponse> { ): Promise<BatchDeleteTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/pending_tasks:batch_delete`, url: `${BASE_URL}/queues/${qname}/pending_tasks:batch_delete`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -618,161 +536,50 @@ export async function deleteAllPendingTasks(
): Promise<DeleteAllTasksResponse> { ): Promise<DeleteAllTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}/pending_tasks:delete_all`, url: `${BASE_URL}/queues/${qname}/pending_tasks:delete_all`,
});
return resp.data;
}
export async function deleteAggregatingTask(
qname: string,
gname: string,
taskId: string
): Promise<void> {
await axios({
method: "delete",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks/${taskId}`,
});
}
export async function batchDeleteAggregatingTasks(
qname: string,
gname: string,
taskIds: string[]
): Promise<BatchDeleteTasksResponse> {
const resp = await axios({
method: "post",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks:batch_delete`,
data: {
task_ids: taskIds,
},
});
return resp.data;
}
export async function deleteAllAggregatingTasks(
qname: string,
gname: string
): Promise<DeleteAllTasksResponse> {
const resp = await axios({
method: "delete",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks:delete_all`,
});
return resp.data;
}
export async function runAggregatingTask(
qname: string,
gname: string,
taskId: string
): Promise<void> {
await axios({
method: "post",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks/${taskId}:run`,
});
}
export async function batchRunAggregatingTasks(
qname: string,
gname: string,
taskIds: string[]
): Promise<BatchRunTasksResponse> {
const resp = await axios({
method: "post",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks:batch_run`,
data: {
task_ids: taskIds,
},
});
return resp.data;
}
export async function runAllAggregatingTasks(
qname: string,
gname: string
): Promise<RunAllTasksResponse> {
const resp = await axios({
method: "post",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks:run_all`,
});
return resp.data;
}
export async function archiveAggregatingTask(
qname: string,
gname: string,
taskId: string
): Promise<void> {
await axios({
method: "post",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks/${taskId}:archive`,
});
}
export async function batchArchiveAggregatingTasks(
qname: string,
gname: string,
taskIds: string[]
): Promise<BatchArchiveTasksResponse> {
const resp = await axios({
method: "post",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks:batch_archive`,
data: {
task_ids: taskIds,
},
});
return resp.data;
}
export async function archiveAllAggregatingTasks(
qname: string,
gname: string
): Promise<ArchiveAllTasksResponse> {
const resp = await axios({
method: "post",
url: `${getBaseUrl()}/queues/${qname}/groups/${gname}/aggregating_tasks:archive_all`,
}); });
return resp.data; return resp.data;
} }
export async function runScheduledTask( export async function runScheduledTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks/${taskId}:run`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks/${taskKey}:run`,
}); });
} }
export async function archiveScheduledTask( export async function archiveScheduledTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks/${taskId}:archive`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks/${taskKey}:archive`,
}); });
} }
export async function deleteScheduledTask( export async function deleteScheduledTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks/${taskId}`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks/${taskKey}`,
}); });
} }
export async function batchDeleteScheduledTasks( export async function batchDeleteScheduledTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchDeleteTasksResponse> { ): Promise<BatchDeleteTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks:batch_delete`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks:batch_delete`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -783,20 +590,20 @@ export async function deleteAllScheduledTasks(
): Promise<DeleteAllTasksResponse> { ): Promise<DeleteAllTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks:delete_all`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks:delete_all`,
}); });
return resp.data; return resp.data;
} }
export async function batchRunScheduledTasks( export async function batchRunScheduledTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchRunTasksResponse> { ): Promise<BatchRunTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks:batch_run`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks:batch_run`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -805,19 +612,19 @@ export async function batchRunScheduledTasks(
export async function runAllScheduledTasks(qname: string): Promise<void> { export async function runAllScheduledTasks(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks:run_all`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks:run_all`,
}); });
} }
export async function batchArchiveScheduledTasks( export async function batchArchiveScheduledTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchArchiveTasksResponse> { ): Promise<BatchArchiveTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks:batch_archive`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks:batch_archive`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -826,49 +633,49 @@ export async function batchArchiveScheduledTasks(
export async function archiveAllScheduledTasks(qname: string): Promise<void> { export async function archiveAllScheduledTasks(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/scheduled_tasks:archive_all`, url: `${BASE_URL}/queues/${qname}/scheduled_tasks:archive_all`,
}); });
} }
export async function runRetryTask( export async function runRetryTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks/${taskId}:run`, url: `${BASE_URL}/queues/${qname}/retry_tasks/${taskKey}:run`,
}); });
} }
export async function archiveRetryTask( export async function archiveRetryTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks/${taskId}:archive`, url: `${BASE_URL}/queues/${qname}/retry_tasks/${taskKey}:archive`,
}); });
} }
export async function deleteRetryTask( export async function deleteRetryTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks/${taskId}`, url: `${BASE_URL}/queues/${qname}/retry_tasks/${taskKey}`,
}); });
} }
export async function batchDeleteRetryTasks( export async function batchDeleteRetryTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchDeleteTasksResponse> { ): Promise<BatchDeleteTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks:batch_delete`, url: `${BASE_URL}/queues/${qname}/retry_tasks:batch_delete`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -879,20 +686,20 @@ export async function deleteAllRetryTasks(
): Promise<DeleteAllTasksResponse> { ): Promise<DeleteAllTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks:delete_all`, url: `${BASE_URL}/queues/${qname}/retry_tasks:delete_all`,
}); });
return resp.data; return resp.data;
} }
export async function batchRunRetryTasks( export async function batchRunRetryTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchRunTasksResponse> { ): Promise<BatchRunTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks:batch_run`, url: `${BASE_URL}/queues/${qname}/retry_tasks:batch_run`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -901,19 +708,19 @@ export async function batchRunRetryTasks(
export async function runAllRetryTasks(qname: string): Promise<void> { export async function runAllRetryTasks(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks:run_all`, url: `${BASE_URL}/queues/${qname}/retry_tasks:run_all`,
}); });
} }
export async function batchArchiveRetryTasks( export async function batchArchiveRetryTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchArchiveTasksResponse> { ): Promise<BatchArchiveTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks:batch_archive`, url: `${BASE_URL}/queues/${qname}/retry_tasks:batch_archive`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -922,39 +729,39 @@ export async function batchArchiveRetryTasks(
export async function archiveAllRetryTasks(qname: string): Promise<void> { export async function archiveAllRetryTasks(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/retry_tasks:archive_all`, url: `${BASE_URL}/queues/${qname}/retry_tasks:archive_all`,
}); });
} }
export async function runArchivedTask( export async function runArchivedTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/archived_tasks/${taskId}:run`, url: `${BASE_URL}/queues/${qname}/archived_tasks/${taskKey}:run`,
}); });
} }
export async function deleteArchivedTask( export async function deleteArchivedTask(
qname: string, qname: string,
taskId: string taskKey: string
): Promise<void> { ): Promise<void> {
await axios({ await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}/archived_tasks/${taskId}`, url: `${BASE_URL}/queues/${qname}/archived_tasks/${taskKey}`,
}); });
} }
export async function batchDeleteArchivedTasks( export async function batchDeleteArchivedTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchDeleteTasksResponse> { ): Promise<BatchDeleteTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/archived_tasks:batch_delete`, url: `${BASE_URL}/queues/${qname}/archived_tasks:batch_delete`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -965,20 +772,20 @@ export async function deleteAllArchivedTasks(
): Promise<DeleteAllTasksResponse> { ): Promise<DeleteAllTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "delete", method: "delete",
url: `${getBaseUrl()}/queues/${qname}/archived_tasks:delete_all`, url: `${BASE_URL}/queues/${qname}/archived_tasks:delete_all`,
}); });
return resp.data; return resp.data;
} }
export async function batchRunArchivedTasks( export async function batchRunArchivedTasks(
qname: string, qname: string,
taskIds: string[] taskKeys: string[]
): Promise<BatchRunTasksResponse> { ): Promise<BatchRunTasksResponse> {
const resp = await axios({ const resp = await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/archived_tasks:batch_run`, url: `${BASE_URL}/queues/${qname}/archived_tasks:batch_run`,
data: { data: {
task_ids: taskIds, task_keys: taskKeys,
}, },
}); });
return resp.data; return resp.data;
@ -987,48 +794,14 @@ export async function batchRunArchivedTasks(
export async function runAllArchivedTasks(qname: string): Promise<void> { export async function runAllArchivedTasks(qname: string): Promise<void> {
await axios({ await axios({
method: "post", method: "post",
url: `${getBaseUrl()}/queues/${qname}/archived_tasks:run_all`, url: `${BASE_URL}/queues/${qname}/archived_tasks:run_all`,
}); });
} }
export async function deleteCompletedTask(
qname: string,
taskId: string
): Promise<void> {
await axios({
method: "delete",
url: `${getBaseUrl()}/queues/${qname}/completed_tasks/${taskId}`,
});
}
export async function batchDeleteCompletedTasks(
qname: string,
taskIds: string[]
): Promise<BatchDeleteTasksResponse> {
const resp = await axios({
method: "post",
url: `${getBaseUrl()}/queues/${qname}/completed_tasks:batch_delete`,
data: {
task_ids: taskIds,
},
});
return resp.data;
}
export async function deleteAllCompletedTasks(
qname: string
): Promise<DeleteAllTasksResponse> {
const resp = await axios({
method: "delete",
url: `${getBaseUrl()}/queues/${qname}/completed_tasks:delete_all`,
});
return resp.data;
}
export async function listServers(): Promise<ListServersResponse> { export async function listServers(): Promise<ListServersResponse> {
const resp = await axios({ const resp = await axios({
method: "get", method: "get",
url: `${getBaseUrl()}/servers`, url: `${BASE_URL}/servers`,
}); });
return resp.data; return resp.data;
} }
@ -1036,7 +809,7 @@ export async function listServers(): Promise<ListServersResponse> {
export async function listSchedulerEntries(): Promise<ListSchedulerEntriesResponse> { export async function listSchedulerEntries(): Promise<ListSchedulerEntriesResponse> {
const resp = await axios({ const resp = await axios({
method: "get", method: "get",
url: `${getBaseUrl()}/scheduler_entries`, url: `${BASE_URL}/scheduler_entries`,
}); });
return resp.data; return resp.data;
} }
@ -1046,7 +819,7 @@ export async function listSchedulerEnqueueEvents(
): Promise<ListSchedulerEnqueueEventsResponse> { ): Promise<ListSchedulerEnqueueEventsResponse> {
const resp = await axios({ const resp = await axios({
method: "get", method: "get",
url: `${getBaseUrl()}/scheduler_entries/${entryId}/enqueue_events`, url: `${BASE_URL}/scheduler_entries/${entryId}/enqueue_events`,
}); });
return resp.data; return resp.data;
} }
@ -1054,32 +827,7 @@ export async function listSchedulerEnqueueEvents(
export async function getRedisInfo(): Promise<RedisInfoResponse> { export async function getRedisInfo(): Promise<RedisInfoResponse> {
const resp = await axios({ const resp = await axios({
method: "get", method: "get",
url: `${getBaseUrl()}/redis_info`, url: `${BASE_URL}/redis_info`,
});
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: `${getBaseUrl()}/metrics?${queryString.stringify(params)}`,
}); });
return resp.data; return resp.data;
} }

View File

@ -1,27 +1,55 @@
import React, { useState, useCallback } 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 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 Tooltip from "@material-ui/core/Tooltip";
import Paper from "@material-ui/core/Paper";
import Checkbox from "@material-ui/core/Checkbox"; import Checkbox from "@material-ui/core/Checkbox";
import IconButton from "@material-ui/core/IconButton"; import IconButton from "@material-ui/core/IconButton";
import TableCell from "@material-ui/core/TableCell";
import TableRow from "@material-ui/core/TableRow";
import Tooltip from "@material-ui/core/Tooltip";
import CancelIcon from "@material-ui/icons/Cancel"; import CancelIcon from "@material-ui/icons/Cancel";
import FileCopyOutlinedIcon from "@material-ui/icons/FileCopyOutlined";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz"; import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import React from "react";
import { connect, ConnectedProps } from "react-redux";
import { useHistory } from "react-router-dom";
import { taskRowsPerPageChange } from "../actions/settingsActions";
import {
batchCancelActiveTasksAsync,
cancelActiveTaskAsync,
cancelAllActiveTasksAsync,
listActiveTasksAsync,
} from "../actions/tasksActions";
import { taskDetailsPath } from "../paths";
import { AppState } from "../store";
import { TableColumn } from "../types/table";
import { durationBefore, prettifyPayload, timeAgo, uuidPrefix } from "../utils";
import SyntaxHighlighter from "./SyntaxHighlighter"; import SyntaxHighlighter from "./SyntaxHighlighter";
import TasksTable, { RowProps, useRowStyles } from "./TasksTable"; import {
listActiveTasksAsync,
cancelActiveTaskAsync,
batchCancelActiveTasksAsync,
cancelAllActiveTasksAsync,
} from "../actions/tasksActions";
import { AppState } from "../store";
import TablePaginationActions, {
rowsPerPageOptions,
defaultPageSize,
} from "./TablePaginationActions";
import TableActions from "./TableActions";
import { usePolling } from "../hooks";
import { ActiveTaskExtended } from "../reducers/tasksReducer";
import { durationBefore, timeAgo, uuidPrefix } from "../utils";
import { TableColumn } from "../types/table";
const useStyles = makeStyles((theme) => ({
table: {
minWidth: 650,
},
stickyHeaderCell: {
background: theme.palette.background.paper,
},
alert: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
pagination: {
border: "none",
},
}));
function mapStateToProps(state: AppState) { function mapStateToProps(state: AppState) {
return { return {
@ -31,16 +59,14 @@ function mapStateToProps(state: AppState) {
batchActionPending: state.tasks.activeTasks.batchActionPending, batchActionPending: state.tasks.activeTasks.batchActionPending,
allActionPending: state.tasks.activeTasks.allActionPending, allActionPending: state.tasks.activeTasks.allActionPending,
pollInterval: state.settings.pollInterval, pollInterval: state.settings.pollInterval,
pageSize: state.settings.taskRowsPerPage,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
listTasks: listActiveTasksAsync, listActiveTasksAsync,
cancelTask: cancelActiveTaskAsync, cancelActiveTaskAsync,
batchCancelTasks: batchCancelActiveTasksAsync, batchCancelActiveTasksAsync,
cancelAllTasks: cancelAllActiveTasksAsync, cancelAllActiveTasksAsync,
taskRowsPerPageChange,
}; };
const columns: TableColumn[] = [ const columns: TableColumn[] = [
@ -59,48 +85,201 @@ type ReduxProps = ConnectedProps<typeof connector>;
interface Props { interface Props {
queue: string; // name of the queue queue: string; // name of the queue
totalTaskCount: number; // total number of active tasks }
function ActiveTasksTable(props: Props & ReduxProps) {
const { pollInterval, listActiveTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [activeTaskId, setActiveTaskId] = useState<string>("");
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);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = props.tasks.map((t) => t.id);
setSelectedIds(newSelected);
} else {
setSelectedIds([]);
}
};
const handleCancelAllClick = () => {
props.cancelAllActiveTasksAsync(queue);
};
const handleBatchCancelClick = () => {
props
.batchCancelActiveTasksAsync(queue, selectedIds)
.then(() => setSelectedIds([]));
};
const fetchData = useCallback(() => {
const pageOpts = { page: page + 1, size: pageSize };
listActiveTasksAsync(queue, pageOpts);
}, [page, pageSize, queue, listActiveTasksAsync]);
usePolling(fetchData, pollInterval);
if (props.error.length > 0) {
return (
<Alert severity="error" className={classes.alert}>
<AlertTitle>Error</AlertTitle>
{props.error}
</Alert>
);
}
if (props.tasks.length === 0) {
return (
<Alert severity="info" className={classes.alert}>
<AlertTitle>Info</AlertTitle>
No active tasks at this time.
</Alert>
);
}
const rowCount = props.tasks.length;
const numSelected = selectedIds.length;
return (
<div>
<TableActions
showIconButtons={numSelected > 0}
iconButtonActions={[
{
tooltip: "Cancel",
icon: <CancelIcon />,
onClick: handleBatchCancelClick,
disabled: props.batchActionPending,
},
]}
menuItemActions={[
{
label: "Cancel All",
onClick: handleCancelAllClick,
disabled: props.allActionPending,
},
]}
/>
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="active tasks table"
size="small"
>
<TableHead>
<TableRow>
<TableCell
padding="checkbox"
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all tasks shown in the table",
}}
/>
</TableCell>
{columns.map((col) => (
<TableCell
key={col.key}
align={col.align}
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{/* TODO: loading and empty state */}
{props.tasks.map((task) => (
<Row
key={task.id}
task={task}
isSelected={selectedIds.includes(task.id)}
onSelectChange={(checked: boolean) => {
if (checked) {
setSelectedIds(selectedIds.concat(task.id));
} else {
setSelectedIds(selectedIds.filter((id) => id !== task.id));
}
}}
onCancelClick={() => {
props.cancelActiveTaskAsync(queue, task.id);
}}
onActionCellEnter={() => setActiveTaskId(task.id)}
onActionCellLeave={() => setActiveTaskId("")}
showActions={activeTaskId === task.id}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length + 1}
count={props.tasks.length}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
className={classes.pagination}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div>
);
}
interface RowProps {
task: ActiveTaskExtended;
isSelected: boolean;
onSelectChange: (checked: boolean) => void;
onCancelClick: () => void;
showActions: boolean;
onActionCellEnter: () => void;
onActionCellLeave: () => void;
} }
function Row(props: RowProps) { function Row(props: RowProps) {
const { task } = props; const { task } = props;
const classes = useRowStyles();
const history = useHistory();
return ( return (
<TableRow <TableRow key={task.id} selected={props.isSelected}>
key={task.id} <TableCell padding="checkbox">
className={classes.root} <Checkbox
selected={props.isSelected} onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onClick={() => history.push(taskDetailsPath(task.queue, task.id))} props.onSelectChange(event.target.checked)
> }
{!window.READ_ONLY && ( checked={props.isSelected}
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}> />
<IconButton> </TableCell>
<Checkbox <TableCell component="th" scope="row">
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {uuidPrefix(task.id)}
props.onSelectChange(event.target.checked)
}
checked={props.isSelected}
/>
</IconButton>
</TableCell>
)}
<TableCell component="th" scope="row" className={classes.idCell}>
<div className={classes.IdGroup}>
{uuidPrefix(task.id)}
<Tooltip title="Copy full ID to clipboard">
<IconButton
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
}}
size="small"
className={classes.copyButton}
>
<FileCopyOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</TableCell> </TableCell>
<TableCell>{task.type}</TableCell> <TableCell>{task.type}</TableCell>
<TableCell> <TableCell>
@ -108,67 +287,41 @@ function Row(props: RowProps) {
language="json" language="json"
customStyle={{ margin: 0, maxWidth: 400 }} customStyle={{ margin: 0, maxWidth: 400 }}
> >
{prettifyPayload(task.payload)} {JSON.stringify(task.payload)}
</SyntaxHighlighter> </SyntaxHighlighter>
</TableCell> </TableCell>
<TableCell>{task.canceling ? "Canceling" : "Running"}</TableCell>
<TableCell> <TableCell>
{task.canceling {task.start_time === "-" ? "just now" : timeAgo(task.start_time)}
? "Canceling"
: task.is_orphaned
? "Orphaned"
: "Running"}
</TableCell>
<TableCell>
{task.is_orphaned
? "-"
: task.start_time === "-"
? "just now"
: timeAgo(task.start_time)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{task.deadline === "-" ? "-" : durationBefore(task.deadline)} {task.deadline === "-" ? "-" : durationBefore(task.deadline)}
</TableCell> </TableCell>
{!window.READ_ONLY && ( <TableCell
<TableCell align="center"
align="center" onMouseEnter={props.onActionCellEnter}
onMouseEnter={props.onActionCellEnter} onMouseLeave={props.onActionCellLeave}
onMouseLeave={props.onActionCellLeave} >
onClick={(e) => e.stopPropagation()} {props.showActions ? (
> <React.Fragment>
{props.showActions ? ( <Tooltip title="Cancel">
<React.Fragment> <IconButton
<Tooltip title="Cancel"> onClick={props.onCancelClick}
<IconButton disabled={task.requestPending || task.canceling}
onClick={props.onCancelClick} size="small"
disabled={ >
task.requestPending || task.canceling || task.is_orphaned <CancelIcon fontSize="small" />
} </IconButton>
size="small" </Tooltip>
> </React.Fragment>
<CancelIcon fontSize="small" /> ) : (
</IconButton> <IconButton size="small" onClick={props.onActionCellEnter}>
</Tooltip> <MoreHorizIcon fontSize="small" />
</React.Fragment> </IconButton>
) : ( )}
<IconButton size="small" onClick={props.onActionCellEnter}> </TableCell>
<MoreHorizIcon fontSize="small" />
</IconButton>
)}
</TableCell>
)}
</TableRow> </TableRow>
); );
} }
function ActiveTasksTable(props: Props & ReduxProps) {
return (
<TasksTable
taskState="active"
columns={columns}
renderRow={(rowProps: RowProps) => <Row {...rowProps} />}
{...props}
/>
);
}
export default connector(ActiveTasksTable); export default connector(ActiveTasksTable);

View File

@ -1,247 +0,0 @@
import Checkbox from "@material-ui/core/Checkbox";
import IconButton from "@material-ui/core/IconButton";
import TableCell from "@material-ui/core/TableCell";
import TableRow from "@material-ui/core/TableRow";
import Tooltip from "@material-ui/core/Tooltip";
import ArchiveIcon from "@material-ui/icons/Archive";
import DeleteIcon from "@material-ui/icons/Delete";
import FileCopyOutlinedIcon from "@material-ui/icons/FileCopyOutlined";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import React from "react";
import { connect, ConnectedProps } from "react-redux";
import { useHistory } from "react-router-dom";
import { taskRowsPerPageChange } from "../actions/settingsActions";
import {
archiveAggregatingTaskAsync,
archiveAllAggregatingTasksAsync,
batchArchiveAggregatingTasksAsync,
batchDeleteAggregatingTasksAsync,
batchRunAggregatingTasksAsync,
deleteAggregatingTaskAsync,
deleteAllAggregatingTasksAsync,
listAggregatingTasksAsync,
runAggregatingTaskAsync,
runAllAggregatingTasksAsync,
} from "../actions/tasksActions";
import { PaginationOptions } from "../api";
import { taskDetailsPath } from "../paths";
import { AppState } from "../store";
import { TableColumn } from "../types/table";
import { prettifyPayload, uuidPrefix } from "../utils";
import SyntaxHighlighter from "./SyntaxHighlighter";
import TasksTable, { RowProps, useRowStyles } from "./TasksTable";
function mapStateToProps(state: AppState) {
return {
groups: state.groups.data,
groupsError: state.groups.error,
loading: state.tasks.aggregatingTasks.loading,
allActionPending: state.tasks.aggregatingTasks.allActionPending,
batchActionPending: state.tasks.aggregatingTasks.batchActionPending,
error: state.tasks.aggregatingTasks.error,
group: state.tasks.aggregatingTasks.group,
tasks: state.tasks.aggregatingTasks.data,
pollInterval: state.settings.pollInterval,
pageSize: state.settings.taskRowsPerPage,
};
}
const mapDispatchToProps = {
listAggregatingTasksAsync,
deleteAllAggregatingTasksAsync,
archiveAllAggregatingTasksAsync,
runAllAggregatingTasksAsync,
batchDeleteAggregatingTasksAsync,
batchRunAggregatingTasksAsync,
batchArchiveAggregatingTasksAsync,
deleteAggregatingTaskAsync,
runAggregatingTaskAsync,
archiveAggregatingTaskAsync,
taskRowsPerPageChange,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string;
selectedGroup: string;
totalTaskCount: number; // total number of tasks in the group
}
const columns: TableColumn[] = [
{ key: "id", label: "ID", align: "left" },
{ key: "type", label: "Type", align: "left" },
{ key: "paylod", label: "Payload", align: "left" },
{ key: "group", label: "Group", align: "left" },
{ key: "actions", label: "Actions", align: "center" },
];
function Row(props: RowProps) {
const { task } = props;
const classes = useRowStyles();
const history = useHistory();
return (
<TableRow
key={task.id}
className={classes.root}
selected={props.isSelected}
onClick={() => history.push(taskDetailsPath(task.queue, task.id))}
>
{!window.READ_ONLY && (
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
<IconButton>
<Checkbox
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
props.onSelectChange(event.target.checked)
}
checked={props.isSelected}
/>
</IconButton>
</TableCell>
)}
<TableCell component="th" scope="row" className={classes.idCell}>
<div className={classes.IdGroup}>
{uuidPrefix(task.id)}
<Tooltip title="Copy full ID to clipboard">
<IconButton
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
}}
size="small"
className={classes.copyButton}
>
<FileCopyOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>
<SyntaxHighlighter
language="json"
customStyle={{ margin: 0, maxWidth: 400 }}
>
{prettifyPayload(task.payload)}
</SyntaxHighlighter>
</TableCell>
<TableCell>{task.group}</TableCell>
{!window.READ_ONLY && (
<TableCell
align="center"
className={classes.actionCell}
onMouseEnter={props.onActionCellEnter}
onMouseLeave={props.onActionCellLeave}
onClick={(e) => e.stopPropagation()}
>
{props.showActions ? (
<React.Fragment>
<Tooltip title="Delete">
<IconButton
onClick={props.onDeleteClick}
disabled={task.requestPending || props.allActionPending}
size="small"
className={classes.actionButton}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Archive">
<IconButton
onClick={props.onArchiveClick}
disabled={task.requestPending || props.allActionPending}
size="small"
className={classes.actionButton}
>
<ArchiveIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Run">
<IconButton
onClick={props.onRunClick}
disabled={task.requestPending || props.allActionPending}
size="small"
className={classes.actionButton}
>
<PlayArrowIcon fontSize="small" />
</IconButton>
</Tooltip>
</React.Fragment>
) : (
<IconButton size="small" onClick={props.onActionCellEnter}>
<MoreHorizIcon fontSize="small" />
</IconButton>
)}
</TableCell>
)}
</TableRow>
);
}
function AggregatingTasksTable(props: Props & ReduxProps) {
const listTasks = (qname: string, pgn?: PaginationOptions) =>
props.listAggregatingTasksAsync(qname, props.selectedGroup, pgn);
const deleteAllTasks = (qname: string) =>
props.deleteAllAggregatingTasksAsync(qname, props.selectedGroup);
const archiveAllTasks = (qname: string) =>
props.archiveAllAggregatingTasksAsync(qname, props.selectedGroup);
const runAllTasks = (qname: string) =>
props.runAllAggregatingTasksAsync(qname, props.selectedGroup);
const batchDeleteTasks = (qname: string, taskIds: string[]) =>
props.batchDeleteAggregatingTasksAsync(qname, props.selectedGroup, taskIds);
const batchArchiveTasks = (qname: string, taskIds: string[]) =>
props.batchArchiveAggregatingTasksAsync(
qname,
props.selectedGroup,
taskIds
);
const batchRunTasks = (qname: string, taskIds: string[]) =>
props.batchRunAggregatingTasksAsync(qname, props.selectedGroup, taskIds);
const deleteTask = (qname: string, taskId: string) =>
props.deleteAggregatingTaskAsync(qname, props.selectedGroup, taskId);
const archiveTask = (qname: string, taskId: string) =>
props.archiveAggregatingTaskAsync(qname, props.selectedGroup, taskId);
const runTask = (qname: string, taskId: string) =>
props.runAggregatingTaskAsync(qname, props.selectedGroup, taskId);
return (
<TasksTable
queue={props.queue}
totalTaskCount={props.totalTaskCount}
taskState="aggregating"
loading={props.loading}
error={props.error}
tasks={props.tasks}
batchActionPending={props.batchActionPending}
allActionPending={props.allActionPending}
pollInterval={props.pollInterval}
pageSize={props.pageSize}
listTasks={listTasks}
deleteAllTasks={deleteAllTasks}
archiveAllTasks={archiveAllTasks}
runAllTasks={runAllTasks}
batchDeleteTasks={batchDeleteTasks}
batchArchiveTasks={batchArchiveTasks}
batchRunTasks={batchRunTasks}
deleteTask={deleteTask}
archiveTask={archiveTask}
runTask={runTask}
taskRowsPerPageChange={props.taskRowsPerPageChange}
columns={columns}
renderRow={(rowProps: RowProps) => <Row {...rowProps} />}
/>
);
}
export default connector(AggregatingTasksTable);

View File

@ -1,100 +0,0 @@
import { makeStyles } from "@material-ui/core/styles";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import React, { useCallback, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { listGroupsAsync } from "../actions/groupsActions";
import { GroupInfo } from "../api";
import { usePolling } from "../hooks";
import { AppState } from "../store";
import AggregatingTasksTable from "./AggregatingTasksTable";
import GroupSelect from "./GroupSelect";
const useStyles = makeStyles((theme) => ({
groupSelector: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
alert: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
}));
function mapStateToProps(state: AppState) {
return {
groups: state.groups.data,
groupsError: state.groups.error,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = {
listGroupsAsync,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
interface Props {
queue: string;
}
function AggregatingTasksTableContainer(
props: Props & ConnectedProps<typeof connector>
) {
const [selectedGroup, setSelectedGroup] = useState<GroupInfo | null>(null);
const { pollInterval, listGroupsAsync, queue } = props;
const classes = useStyles();
const fetchGroups = useCallback(() => {
listGroupsAsync(queue);
}, [listGroupsAsync, queue]);
usePolling(fetchGroups, pollInterval);
if (props.groupsError.length > 0) {
return (
<Alert severity="error" className={classes.alert}>
<AlertTitle>Error</AlertTitle>
{props.groupsError}
</Alert>
);
}
if (props.groups.length === 0) {
return (
<Alert severity="info" className={classes.alert}>
<AlertTitle>Info</AlertTitle>
No aggregating tasks at this time.
</Alert>
);
}
return (
<div>
<div className={classes.groupSelector}>
<GroupSelect
selected={selectedGroup}
onSelect={setSelectedGroup}
groups={props.groups}
error={props.groupsError}
/>
</div>
{selectedGroup !== null ? (
<AggregatingTasksTable
queue={props.queue}
totalTaskCount={selectedGroup.size}
selectedGroup={selectedGroup.group}
/>
) : (
<Alert severity="info" className={classes.alert}>
<AlertTitle>Info</AlertTitle>
<div>Please select group</div>
</Alert>
)}
</div>
);
}
export default connector(AggregatingTasksTableContainer);

View File

@ -1,31 +1,60 @@
import Checkbox from "@material-ui/core/Checkbox"; import React, { useCallback, useState } from "react";
import IconButton from "@material-ui/core/IconButton"; import clsx from "clsx";
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 TableCell from "@material-ui/core/TableCell";
import Checkbox from "@material-ui/core/Checkbox";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow"; import TableRow from "@material-ui/core/TableRow";
import Tooltip from "@material-ui/core/Tooltip"; import Tooltip from "@material-ui/core/Tooltip";
import DeleteIcon from "@material-ui/icons/Delete"; import Paper from "@material-ui/core/Paper";
import FileCopyOutlinedIcon from "@material-ui/icons/FileCopyOutlined"; import IconButton from "@material-ui/core/IconButton";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import PlayArrowIcon from "@material-ui/icons/PlayArrow"; import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import React from "react"; import DeleteIcon from "@material-ui/icons/Delete";
import { connect, ConnectedProps } from "react-redux"; import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import { useHistory } from "react-router-dom"; import TableFooter from "@material-ui/core/TableFooter";
import { taskRowsPerPageChange } from "../actions/settingsActions"; import TablePagination from "@material-ui/core/TablePagination";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "./SyntaxHighlighter";
import { AppState } from "../store";
import { import {
batchDeleteArchivedTasksAsync, batchDeleteArchivedTasksAsync,
batchRunArchivedTasksAsync, batchRunArchivedTasksAsync,
deleteAllArchivedTasksAsync,
deleteArchivedTaskAsync, deleteArchivedTaskAsync,
deleteAllArchivedTasksAsync,
listArchivedTasksAsync, listArchivedTasksAsync,
runAllArchivedTasksAsync,
runArchivedTaskAsync, runArchivedTaskAsync,
runAllArchivedTasksAsync,
} from "../actions/tasksActions"; } from "../actions/tasksActions";
import { taskDetailsPath } from "../paths"; import TablePaginationActions, {
import { AppState } from "../store"; defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import TableActions from "./TableActions";
import { timeAgo, uuidPrefix } from "../utils";
import { usePolling } from "../hooks";
import { ArchivedTaskExtended } from "../reducers/tasksReducer";
import { TableColumn } from "../types/table"; import { TableColumn } from "../types/table";
import { prettifyPayload, timeAgo, uuidPrefix } from "../utils";
import SyntaxHighlighter from "./SyntaxHighlighter"; const useStyles = makeStyles((theme) => ({
import TasksTable, { RowProps, useRowStyles } from "./TasksTable"; table: {
minWidth: 650,
},
stickyHeaderCell: {
background: theme.palette.background.paper,
},
alert: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
pagination: {
border: "none",
},
}));
function mapStateToProps(state: AppState) { function mapStateToProps(state: AppState) {
return { return {
@ -35,19 +64,17 @@ function mapStateToProps(state: AppState) {
batchActionPending: state.tasks.archivedTasks.batchActionPending, batchActionPending: state.tasks.archivedTasks.batchActionPending,
allActionPending: state.tasks.archivedTasks.allActionPending, allActionPending: state.tasks.archivedTasks.allActionPending,
pollInterval: state.settings.pollInterval, pollInterval: state.settings.pollInterval,
pageSize: state.settings.taskRowsPerPage,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
listTasks: listArchivedTasksAsync, listArchivedTasksAsync,
runTask: runArchivedTaskAsync, runArchivedTaskAsync,
runAllTasks: runAllArchivedTasksAsync, runAllArchivedTasksAsync,
deleteTask: deleteArchivedTaskAsync, deleteArchivedTaskAsync,
deleteAllTasks: deleteAllArchivedTasksAsync, deleteAllArchivedTasksAsync,
batchRunTasks: batchRunArchivedTasksAsync, batchRunArchivedTasksAsync,
batchDeleteTasks: batchDeleteArchivedTasksAsync, batchDeleteArchivedTasksAsync,
taskRowsPerPageChange,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
@ -59,54 +86,246 @@ interface Props {
totalTaskCount: number; // totoal number of archived tasks. totalTaskCount: number; // totoal number of archived tasks.
} }
const columns: TableColumn[] = [ function ArchivedTasksTable(props: Props & ReduxProps) {
{ key: "id", label: "ID", align: "left" }, const { pollInterval, listArchivedTasksAsync, queue } = props;
{ key: "type", label: "Type", align: "left" }, const classes = useStyles();
{ key: "payload", label: "Payload", align: "left" }, const [page, setPage] = useState(0);
{ key: "last_failed", label: "Last Failed", align: "left" }, const [pageSize, setPageSize] = useState(defaultPageSize);
{ key: "last_error", label: "Last Error", align: "left" }, const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
{ key: "actions", label: "Actions", align: "center" }, const [activeTaskId, setActiveTaskId] = useState<string>("");
];
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);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = props.tasks.map((t) => t.key);
setSelectedKeys(newSelected);
} else {
setSelectedKeys([]);
}
};
const handleRunAllClick = () => {
props.runAllArchivedTasksAsync(queue);
};
const handleDeleteAllClick = () => {
props.deleteAllArchivedTasksAsync(queue);
};
const handleBatchRunClick = () => {
props
.batchRunArchivedTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const handleBatchDeleteClick = () => {
props
.batchDeleteArchivedTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const fetchData = useCallback(() => {
const pageOpts = { page: page + 1, size: pageSize };
listArchivedTasksAsync(queue, pageOpts);
}, [page, pageSize, queue, listArchivedTasksAsync]);
usePolling(fetchData, pollInterval);
if (props.error.length > 0) {
return (
<Alert severity="error" className={classes.alert}>
<AlertTitle>Error</AlertTitle>
{props.error}
</Alert>
);
}
if (props.tasks.length === 0) {
return (
<Alert severity="info" className={classes.alert}>
<AlertTitle>Info</AlertTitle>
No archived tasks at this time.
</Alert>
);
}
const columns: TableColumn[] = [
{ key: "id", label: "ID", align: "left" },
{ key: "type", label: "Type", align: "left" },
{ key: "payload", label: "Payload", align: "left" },
{ key: "last_failed", label: "Last Failed", align: "left" },
{ key: "last_error", label: "Last Error", align: "left" },
{ key: "actions", label: "Actions", align: "center" },
];
const rowCount = props.tasks.length;
const numSelected = selectedKeys.length;
return (
<div>
<TableActions
showIconButtons={numSelected > 0}
iconButtonActions={[
{
tooltip: "Delete",
icon: <DeleteIcon />,
onClick: handleBatchDeleteClick,
disabled: props.batchActionPending,
},
{
tooltip: "Run",
icon: <PlayArrowIcon />,
onClick: handleBatchRunClick,
disabled: props.batchActionPending,
},
]}
menuItemActions={[
{
label: "Delete All",
onClick: handleDeleteAllClick,
disabled: props.allActionPending,
},
{
label: "Run All",
onClick: handleRunAllClick,
disabled: props.allActionPending,
},
]}
/>
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="archived tasks table"
size="small"
>
<TableHead>
<TableRow>
<TableCell
padding="checkbox"
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all tasks shown in the table",
}}
/>
</TableCell>
{columns.map((col) => (
<TableCell
key={col.key}
align={col.align}
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row
key={task.key}
task={task}
isSelected={selectedKeys.includes(task.key)}
onSelectChange={(checked: boolean) => {
if (checked) {
setSelectedKeys(selectedKeys.concat(task.key));
} else {
setSelectedKeys(
selectedKeys.filter((key) => key !== task.key)
);
}
}}
onRunClick={() => {
props.runArchivedTaskAsync(queue, task.key);
}}
onDeleteClick={() => {
props.deleteArchivedTaskAsync(queue, task.key);
}}
allActionPending={props.allActionPending}
onActionCellEnter={() => setActiveTaskId(task.id)}
onActionCellLeave={() => setActiveTaskId("")}
showActions={activeTaskId === task.id}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length + 1}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
className={classes.pagination}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div>
);
}
const useRowStyles = makeStyles({
actionCell: {
width: "96px",
},
activeActionCell: {
display: "flex",
justifyContent: "space-between",
},
});
interface RowProps {
task: ArchivedTaskExtended;
isSelected: boolean;
onSelectChange: (checked: boolean) => void;
onRunClick: () => void;
onDeleteClick: () => void;
allActionPending: boolean;
showActions: boolean;
onActionCellEnter: () => void;
onActionCellLeave: () => void;
}
function Row(props: RowProps) { function Row(props: RowProps) {
const { task } = props; const { task } = props;
const classes = useRowStyles(); const classes = useRowStyles();
const history = useHistory();
return ( return (
<TableRow <TableRow key={task.id} selected={props.isSelected}>
key={task.id} <TableCell padding="checkbox">
className={classes.root} <Checkbox
selected={props.isSelected} onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onClick={() => history.push(taskDetailsPath(task.queue, task.id))} props.onSelectChange(event.target.checked)
> }
{!window.READ_ONLY && ( checked={props.isSelected}
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}> />
<IconButton> </TableCell>
<Checkbox <TableCell component="th" scope="row">
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {uuidPrefix(task.id)}
props.onSelectChange(event.target.checked)
}
checked={props.isSelected}
/>
</IconButton>
</TableCell>
)}
<TableCell component="th" scope="row" className={classes.idCell}>
<div className={classes.IdGroup}>
{uuidPrefix(task.id)}
<Tooltip title="Copy full ID to clipboard">
<IconButton
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
}}
size="small"
className={classes.copyButton}
>
<FileCopyOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</TableCell> </TableCell>
<TableCell>{task.type}</TableCell> <TableCell>{task.type}</TableCell>
<TableCell> <TableCell>
@ -114,62 +333,49 @@ function Row(props: RowProps) {
language="json" language="json"
customStyle={{ margin: 0, maxWidth: 400 }} customStyle={{ margin: 0, maxWidth: 400 }}
> >
{prettifyPayload(task.payload)} {JSON.stringify(task.payload)}
</SyntaxHighlighter> </SyntaxHighlighter>
</TableCell> </TableCell>
<TableCell>{timeAgo(task.last_failed_at)}</TableCell> <TableCell>{timeAgo(task.last_failed_at)}</TableCell>
<TableCell>{task.error_message}</TableCell> <TableCell>{task.error_message}</TableCell>
{!window.READ_ONLY && ( <TableCell
<TableCell align="center"
align="center" className={clsx(
className={classes.actionCell} classes.actionCell,
onMouseEnter={props.onActionCellEnter} props.showActions && classes.activeActionCell
onMouseLeave={props.onActionCellLeave} )}
onClick={(e) => e.stopPropagation()} onMouseEnter={props.onActionCellEnter}
> onMouseLeave={props.onActionCellLeave}
{props.showActions ? ( >
<React.Fragment> {props.showActions ? (
<Tooltip title="Delete"> <React.Fragment>
<IconButton <Tooltip title="Delete">
className={classes.actionButton} <IconButton
onClick={props.onDeleteClick} onClick={props.onDeleteClick}
disabled={task.requestPending || props.allActionPending} disabled={task.requestPending || props.allActionPending}
size="small" size="small"
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Run"> <Tooltip title="Run">
<IconButton <IconButton
className={classes.actionButton} onClick={props.onRunClick}
onClick={props.onRunClick} disabled={task.requestPending || props.allActionPending}
disabled={task.requestPending || props.allActionPending} size="small"
size="small" >
> <PlayArrowIcon fontSize="small" />
<PlayArrowIcon fontSize="small" /> </IconButton>
</IconButton> </Tooltip>
</Tooltip> </React.Fragment>
</React.Fragment> ) : (
) : ( <IconButton size="small" onClick={props.onActionCellEnter}>
<IconButton size="small" onClick={props.onActionCellEnter}> <MoreHorizIcon fontSize="small" />
<MoreHorizIcon fontSize="small" /> </IconButton>
</IconButton> )}
)} </TableCell>
</TableCell>
)}
</TableRow> </TableRow>
); );
} }
function ArchivedTasksTable(props: Props & ReduxProps) {
return (
<TasksTable
taskState="archived"
columns={columns}
renderRow={(rowProps: RowProps) => <Row {...rowProps} />}
{...props}
/>
);
}
export default connector(ArchivedTasksTable); export default connector(ArchivedTasksTable);

View File

@ -1,176 +0,0 @@
import Checkbox from "@material-ui/core/Checkbox";
import IconButton from "@material-ui/core/IconButton";
import TableCell from "@material-ui/core/TableCell";
import TableRow from "@material-ui/core/TableRow";
import Tooltip from "@material-ui/core/Tooltip";
import DeleteIcon from "@material-ui/icons/Delete";
import FileCopyOutlinedIcon from "@material-ui/icons/FileCopyOutlined";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import React from "react";
import { connect, ConnectedProps } from "react-redux";
import { useHistory } from "react-router-dom";
import { taskRowsPerPageChange } from "../actions/settingsActions";
import {
batchDeleteCompletedTasksAsync,
deleteAllCompletedTasksAsync,
deleteCompletedTaskAsync,
listCompletedTasksAsync,
} from "../actions/tasksActions";
import { taskDetailsPath } from "../paths";
import { AppState } from "../store";
import { TableColumn } from "../types/table";
import {
durationFromSeconds,
prettifyPayload,
stringifyDuration,
timeAgo,
uuidPrefix,
} from "../utils";
import SyntaxHighlighter from "./SyntaxHighlighter";
import TasksTable, { RowProps, useRowStyles } from "./TasksTable";
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.completedTasks.loading,
error: state.tasks.completedTasks.error,
tasks: state.tasks.completedTasks.data,
batchActionPending: state.tasks.completedTasks.batchActionPending,
allActionPending: state.tasks.completedTasks.allActionPending,
pollInterval: state.settings.pollInterval,
pageSize: state.settings.taskRowsPerPage,
};
}
const mapDispatchToProps = {
listTasks: listCompletedTasksAsync,
deleteTask: deleteCompletedTaskAsync,
deleteAllTasks: deleteAllCompletedTasksAsync,
batchDeleteTasks: batchDeleteCompletedTasksAsync,
taskRowsPerPageChange,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of completed tasks.
}
const columns: TableColumn[] = [
{ key: "id", label: "ID", align: "left" },
{ key: "type", label: "Type", align: "left" },
{ key: "payload", label: "Payload", align: "left" },
{ key: "completed_at", label: "Completed", align: "left" },
{ key: "result", label: "Result", align: "left" },
{ key: "ttl", label: "TTL", align: "left" },
{ key: "actions", label: "Actions", align: "center" },
];
function Row(props: RowProps) {
const { task } = props;
const classes = useRowStyles();
const history = useHistory();
return (
<TableRow
key={task.id}
className={classes.root}
selected={props.isSelected}
onClick={() => history.push(taskDetailsPath(task.queue, task.id))}
>
{!window.READ_ONLY && (
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
<IconButton>
<Checkbox
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
props.onSelectChange(event.target.checked)
}
checked={props.isSelected}
/>
</IconButton>
</TableCell>
)}
<TableCell component="th" scope="row" className={classes.idCell}>
<div className={classes.IdGroup}>
{uuidPrefix(task.id)}
<Tooltip title="Copy full ID to clipboard">
<IconButton
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
}}
size="small"
className={classes.copyButton}
>
<FileCopyOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>
<SyntaxHighlighter
language="json"
customStyle={{ margin: 0, maxWidth: 400 }}
>
{prettifyPayload(task.payload)}
</SyntaxHighlighter>
</TableCell>
<TableCell>{timeAgo(task.completed_at)}</TableCell>
<TableCell>
<SyntaxHighlighter
language="json"
customStyle={{ margin: 0, maxWidth: 400 }}
>
{prettifyPayload(task.result)}
</SyntaxHighlighter>
</TableCell>
<TableCell>
{task.ttl_seconds > 0
? `${stringifyDuration(durationFromSeconds(task.ttl_seconds))} left`
: `expired`}
</TableCell>
{!window.READ_ONLY && (
<TableCell
align="center"
className={classes.actionCell}
onMouseEnter={props.onActionCellEnter}
onMouseLeave={props.onActionCellLeave}
onClick={(e) => e.stopPropagation()}
>
{props.showActions ? (
<React.Fragment>
<Tooltip title="Delete">
<IconButton
className={classes.actionButton}
onClick={props.onDeleteClick}
disabled={task.requestPending || props.allActionPending}
size="small"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</React.Fragment>
) : (
<IconButton size="small" onClick={props.onActionCellEnter}>
<MoreHorizIcon fontSize="small" />
</IconButton>
)}
</TableCell>
)}
</TableRow>
);
}
function CompletedTasksTable(props: Props & ReduxProps) {
return (
<TasksTable
taskState="completed"
columns={columns}
renderRow={(rowProps: RowProps) => <Row {...rowProps} />}
{...props}
/>
);
}
export default connector(CompletedTasksTable);

View File

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

View File

@ -0,0 +1,388 @@
import React, { useCallback, useState } from "react";
import clsx from "clsx";
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 Checkbox from "@material-ui/core/Checkbox";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Tooltip from "@material-ui/core/Tooltip";
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 PlayArrowIcon from "@material-ui/icons/PlayArrow";
import DeleteIcon from "@material-ui/icons/Delete";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
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 "./SyntaxHighlighter";
import { AppState } from "../store";
import {
batchDeleteArchivedTasksAsync,
batchRunArchivedTasksAsync,
deleteArchivedTaskAsync,
deleteAllArchivedTasksAsync,
listArchivedTasksAsync,
runArchivedTaskAsync,
runAllArchivedTasksAsync,
} from "../actions/tasksActions";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import TableActions from "./TableActions";
import { timeAgo, uuidPrefix } from "../utils";
import { usePolling } from "../hooks";
import { ArchivedTaskExtended } from "../reducers/tasksReducer";
import { TableColumn } from "../types/table";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
actionCell: {
width: "96px",
},
activeActionCell: {
display: "flex",
justifyContent: "space-between",
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.archivedTasks.loading,
tasks: state.tasks.archivedTasks.data,
batchActionPending: state.tasks.archivedTasks.batchActionPending,
allActionPending: state.tasks.archivedTasks.allActionPending,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = {
listArchivedTasksAsync,
runArchivedTaskAsync,
runAllArchivedTasksAsync,
deleteArchivedTaskAsync,
deleteAllArchivedTasksAsync,
batchRunArchivedTasksAsync,
batchDeleteArchivedTasksAsync,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of archived tasks.
}
function ArchivedTasksTable(props: Props & ReduxProps) {
const { pollInterval, listArchivedTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [activeTaskId, setActiveTaskId] = useState<string>("");
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);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = props.tasks.map((t) => t.key);
setSelectedKeys(newSelected);
} else {
setSelectedKeys([]);
}
};
const handleRunAllClick = () => {
props.runAllArchivedTasksAsync(queue);
};
const handleDeleteAllClick = () => {
props.deleteAllArchivedTasksAsync(queue);
};
const handleBatchRunClick = () => {
props
.batchRunArchivedTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const handleBatchDeleteClick = () => {
props
.batchDeleteArchivedTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const fetchData = useCallback(() => {
const pageOpts = { page: page + 1, size: pageSize };
listArchivedTasksAsync(queue, pageOpts);
}, [page, pageSize, queue, listArchivedTasksAsync]);
usePolling(fetchData, pollInterval);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No archived tasks at this time.
</Alert>
);
}
const columns: TableColumn[] = [
{ key: "icon", label: "", align: "left" },
{ key: "id", label: "ID", align: "left" },
{ key: "type", label: "Type", align: "left" },
{ key: "last_failed", label: "Last Failed", align: "left" },
{ key: "last_error", label: "Last Error", align: "left" },
{ key: "actions", label: "Actions", align: "center" },
];
const rowCount = props.tasks.length;
const numSelected = selectedKeys.length;
return (
<div>
<TableActions
showIconButtons={numSelected > 0}
iconButtonActions={[
{
tooltip: "Delete",
icon: <DeleteIcon />,
onClick: handleBatchDeleteClick,
disabled: props.batchActionPending,
},
{
tooltip: "Run",
icon: <PlayArrowIcon />,
onClick: handleBatchRunClick,
disabled: props.batchActionPending,
},
]}
menuItemActions={[
{
label: "Delete All",
onClick: handleDeleteAllClick,
disabled: props.allActionPending,
},
{
label: "Run All",
onClick: handleRunAllClick,
disabled: props.allActionPending,
},
]}
/>
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="archived tasks table"
size="small"
>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all tasks shown in the table",
}}
/>
</TableCell>
{columns.map((col) => (
<TableCell key={col.key} align={col.align}>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row
key={task.key}
task={task}
isSelected={selectedKeys.includes(task.key)}
onSelectChange={(checked: boolean) => {
if (checked) {
setSelectedKeys(selectedKeys.concat(task.key));
} else {
setSelectedKeys(
selectedKeys.filter((key) => key !== task.key)
);
}
}}
onRunClick={() => {
props.runArchivedTaskAsync(queue, task.key);
}}
onDeleteClick={() => {
props.deleteArchivedTaskAsync(queue, task.key);
}}
allActionPending={props.allActionPending}
onActionCellEnter={() => setActiveTaskId(task.id)}
onActionCellLeave={() => setActiveTaskId("")}
showActions={activeTaskId === task.id}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length + 1 /* checkbox col */}
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>
</div>
);
}
interface RowProps {
task: ArchivedTaskExtended;
isSelected: boolean;
onSelectChange: (checked: boolean) => void;
onRunClick: () => void;
onDeleteClick: () => void;
allActionPending: boolean;
showActions: boolean;
onActionCellEnter: () => void;
onActionCellLeave: () => void;
}
function Row(props: RowProps) {
const { task } = props;
const [open, setOpen] = useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow
key={task.id}
className={classes.root}
selected={props.isSelected}
>
<TableCell padding="checkbox">
<Checkbox
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
props.onSelectChange(event.target.checked)
}
checked={props.isSelected}
/>
</TableCell>
<TableCell>
<Tooltip title={open ? "Hide Details" : "Show Details"}>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</Tooltip>
</TableCell>
<TableCell component="th" scope="row">
{uuidPrefix(task.id)}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>{timeAgo(task.last_failed_at)}</TableCell>
<TableCell>{task.error_message}</TableCell>
<TableCell
align="center"
className={clsx(
classes.actionCell,
props.showActions && classes.activeActionCell
)}
onMouseEnter={props.onActionCellEnter}
onMouseLeave={props.onActionCellLeave}
>
{props.showActions ? (
<React.Fragment>
<Tooltip title="Delete">
<IconButton
onClick={props.onDeleteClick}
disabled={task.requestPending || props.allActionPending}
size="small"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Run">
<IconButton
onClick={props.onRunClick}
disabled={task.requestPending || props.allActionPending}
size="small"
>
<PlayArrowIcon fontSize="small" />
</IconButton>
</Tooltip>
</React.Fragment>
) : (
<IconButton size="small" onClick={props.onActionCellEnter}>
<MoreHorizIcon fontSize="small" />
</IconButton>
)}
</TableCell>
</TableRow>
<TableRow selected={props.isSelected}>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={7}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json">
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(ArchivedTasksTable);

View File

@ -1,160 +0,0 @@
import React from "react";
import { makeStyles, useTheme } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField";
import Autocomplete from "@material-ui/lab/Autocomplete";
import useMediaQuery from "@material-ui/core/useMediaQuery";
import ListSubheader from "@material-ui/core/ListSubheader";
import { VariableSizeList, ListChildComponentProps } from "react-window";
import { GroupInfo } from "../api";
import { isDarkTheme } from "../theme";
const useStyles = makeStyles((theme) => ({
groupSelectOption: {
display: "flex",
justifyContent: "space-between",
width: "100%",
},
groupSize: {
fontSize: "12px",
color: theme.palette.text.secondary,
background: isDarkTheme(theme)
? "#303030"
: theme.palette.background.default,
textAlign: "center",
padding: "3px 6px",
borderRadius: "10px",
marginRight: "2px",
},
inputRoot: {
borderRadius: 20,
paddingLeft: "12px !important",
},
}));
interface Props {
selected: GroupInfo | null;
onSelect: (newVal: GroupInfo | null) => void;
groups: GroupInfo[];
error: string;
}
export default function GroupSelect(props: Props) {
const classes = useStyles();
const [inputValue, setInputValue] = React.useState("");
return (
<Autocomplete
id="task-group-selector"
value={props.selected}
onChange={(event: any, newValue: GroupInfo | null) => {
props.onSelect(newValue);
}}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
}}
disableListWrap
ListboxComponent={
ListboxComponent as React.ComponentType<
React.HTMLAttributes<HTMLElement>
>
}
options={props.groups}
getOptionLabel={(option: GroupInfo) => option.group}
style={{ width: 300 }}
renderOption={(option: GroupInfo) => (
<div className={classes.groupSelectOption}>
<span>{option.group}</span>
<span className={classes.groupSize}>{option.size}</span>
</div>
)}
renderInput={(params) => (
<TextField {...params} label="Select group" variant="outlined" />
)}
classes={{
inputRoot: classes.inputRoot,
}}
size="small"
/>
);
}
// Virtualized list.
// Reference: https://v4.mui.com/components/autocomplete/#virtualization
const LISTBOX_PADDING = 8; // px
function renderRow(props: ListChildComponentProps) {
const { data, index, style } = props;
return React.cloneElement(data[index], {
style: {
...style,
top: (style.top as number) + LISTBOX_PADDING,
},
});
}
const OuterElementContext = React.createContext({});
const OuterElementType = React.forwardRef<HTMLDivElement>((props, ref) => {
const outerProps = React.useContext(OuterElementContext);
return <div ref={ref} {...props} {...outerProps} />;
});
function useResetCache(data: any) {
const ref = React.useRef<VariableSizeList>(null);
React.useEffect(() => {
if (ref.current != null) {
ref.current.resetAfterIndex(0, true);
}
}, [data]);
return ref;
}
// Adapter for react-window
const ListboxComponent = React.forwardRef<HTMLDivElement>(
function ListboxComponent(props, ref) {
const { children, ...other } = props;
const itemData = React.Children.toArray(children);
const theme = useTheme();
const smUp = useMediaQuery(theme.breakpoints.up("sm"), { noSsr: true });
const itemCount = itemData.length;
const itemSize = smUp ? 36 : 48;
const getChildSize = (child: React.ReactNode) => {
if (React.isValidElement(child) && child.type === ListSubheader) {
return 48;
}
return itemSize;
};
const getHeight = () => {
if (itemCount > 8) {
return 8 * itemSize;
}
return itemData.map(getChildSize).reduce((a, b) => a + b, 0);
};
const gridRef = useResetCache(itemCount);
return (
<div ref={ref}>
<OuterElementContext.Provider value={other}>
<VariableSizeList
itemData={itemData}
height={getHeight() + 2 * LISTBOX_PADDING}
width="100%"
ref={gridRef}
outerElementType={OuterElementType}
innerElementType="ul"
itemSize={(index) => getChildSize(itemData[index])}
overscanCount={5}
itemCount={itemCount}
>
{renderRow}
</VariableSizeList>
</OuterElementContext.Provider>
</div>
);
}
);

View File

@ -10,7 +10,6 @@ import {
Link as RouterLink, Link as RouterLink,
LinkProps as RouterLinkProps, LinkProps as RouterLinkProps,
} from "react-router-dom"; } from "react-router-dom";
import { isDarkTheme } from "../theme";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
listItem: { listItem: {
@ -18,20 +17,23 @@ const useStyles = makeStyles((theme) => ({
borderBottomRightRadius: "24px", borderBottomRightRadius: "24px",
}, },
selected: { selected: {
backgroundColor: isDarkTheme(theme) backgroundColor:
? `${theme.palette.secondary.main}30` theme.palette.type === "dark"
: `${theme.palette.primary.main}30`, ? `${theme.palette.secondary.main}30`
: `${theme.palette.primary.main}30`,
}, },
selectedText: { selectedText: {
fontWeight: 600, fontWeight: 600,
color: isDarkTheme(theme) color:
? theme.palette.secondary.main theme.palette.type === "dark"
: theme.palette.primary.main, ? theme.palette.secondary.main
: theme.palette.primary.main,
}, },
selectedIcon: { selectedIcon: {
color: isDarkTheme(theme) color:
? theme.palette.secondary.main theme.palette.type === "dark"
: theme.palette.primary.main, ? theme.palette.secondary.main
: theme.palette.primary.main,
}, },
})); }));

View File

@ -1,736 +0,0 @@
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

@ -1,31 +1,60 @@
import Checkbox from "@material-ui/core/Checkbox"; import React, { useCallback, useState } from "react";
import IconButton from "@material-ui/core/IconButton"; import clsx from "clsx";
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 TableCell from "@material-ui/core/TableCell";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow"; import TableRow from "@material-ui/core/TableRow";
import Tooltip from "@material-ui/core/Tooltip"; import Tooltip from "@material-ui/core/Tooltip";
import ArchiveIcon from "@material-ui/icons/Archive"; import Paper from "@material-ui/core/Paper";
import Checkbox from "@material-ui/core/Checkbox";
import IconButton from "@material-ui/core/IconButton";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import DeleteIcon from "@material-ui/icons/Delete"; import DeleteIcon from "@material-ui/icons/Delete";
import FileCopyOutlinedIcon from "@material-ui/icons/FileCopyOutlined"; import ArchiveIcon from "@material-ui/icons/Archive";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz"; import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import React from "react"; import SyntaxHighlighter from "./SyntaxHighlighter";
import { connect, ConnectedProps } from "react-redux"; import TablePaginationActions, {
import { useHistory } from "react-router-dom"; defaultPageSize,
import { taskRowsPerPageChange } from "../actions/settingsActions"; rowsPerPageOptions,
} from "./TablePaginationActions";
import TableActions from "./TableActions";
import { import {
archiveAllPendingTasksAsync, listPendingTasksAsync,
archivePendingTaskAsync, deletePendingTaskAsync,
batchArchivePendingTasksAsync,
batchDeletePendingTasksAsync, batchDeletePendingTasksAsync,
deleteAllPendingTasksAsync, deleteAllPendingTasksAsync,
deletePendingTaskAsync, archivePendingTaskAsync,
listPendingTasksAsync, batchArchivePendingTasksAsync,
archiveAllPendingTasksAsync,
} from "../actions/tasksActions"; } from "../actions/tasksActions";
import { taskDetailsPath } from "../paths";
import { AppState } from "../store"; import { AppState } from "../store";
import { usePolling } from "../hooks";
import { uuidPrefix } from "../utils";
import { TableColumn } from "../types/table"; import { TableColumn } from "../types/table";
import { prettifyPayload, uuidPrefix } from "../utils"; import { PendingTaskExtended } from "../reducers/tasksReducer";
import SyntaxHighlighter from "./SyntaxHighlighter";
import TasksTable, { RowProps, useRowStyles } from "./TasksTable"; const useStyles = makeStyles((theme) => ({
table: {
minWidth: 650,
},
stickyHeaderCell: {
background: theme.palette.background.paper,
},
alert: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
pagination: {
border: "none",
},
}));
function mapStateToProps(state: AppState) { function mapStateToProps(state: AppState) {
return { return {
@ -35,19 +64,17 @@ function mapStateToProps(state: AppState) {
batchActionPending: state.tasks.pendingTasks.batchActionPending, batchActionPending: state.tasks.pendingTasks.batchActionPending,
allActionPending: state.tasks.pendingTasks.allActionPending, allActionPending: state.tasks.pendingTasks.allActionPending,
pollInterval: state.settings.pollInterval, pollInterval: state.settings.pollInterval,
pageSize: state.settings.taskRowsPerPage,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
listTasks: listPendingTasksAsync, listPendingTasksAsync,
deleteTask: deletePendingTaskAsync, deletePendingTaskAsync,
batchDeleteTasks: batchDeletePendingTasksAsync, batchDeletePendingTasksAsync,
deleteAllTasks: deleteAllPendingTasksAsync, deleteAllPendingTasksAsync,
archiveTask: archivePendingTaskAsync, archivePendingTaskAsync,
batchArchiveTasks: batchArchivePendingTasksAsync, batchArchivePendingTasksAsync,
archiveAllTasks: archiveAllPendingTasksAsync, archiveAllPendingTasksAsync,
taskRowsPerPageChange,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
@ -59,54 +86,248 @@ interface Props {
totalTaskCount: number; // total number of pending tasks totalTaskCount: number; // total number of pending tasks
} }
const columns: TableColumn[] = [ function PendingTasksTable(props: Props & ReduxProps) {
{ key: "id", label: "ID", align: "left" }, const { pollInterval, listPendingTasksAsync, queue } = props;
{ key: "type", label: "Type", align: "left" }, const classes = useStyles();
{ key: "paylod", label: "Payload", align: "left" }, const [page, setPage] = useState(0);
{ key: "retried", label: "Retried", align: "right" }, const [pageSize, setPageSize] = useState(defaultPageSize);
{ key: "max_retry", label: "Max Retry", align: "right" }, const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
{ key: "actions", label: "Actions", align: "center" }, const [activeTaskId, setActiveTaskId] = useState<string>("");
];
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);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = props.tasks.map((t) => t.key);
setSelectedKeys(newSelected);
} else {
setSelectedKeys([]);
}
};
const handleDeleteAllClick = () => {
props.deleteAllPendingTasksAsync(queue);
};
const handleArchiveAllClick = () => {
props.archiveAllPendingTasksAsync(queue);
};
const handleBatchDeleteClick = () => {
props
.batchDeletePendingTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const handleBatchArchiveClick = () => {
props
.batchArchivePendingTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const fetchData = useCallback(() => {
const pageOpts = { page: page + 1, size: pageSize };
listPendingTasksAsync(queue, pageOpts);
}, [page, pageSize, queue, listPendingTasksAsync]);
usePolling(fetchData, pollInterval);
if (props.error.length > 0) {
return (
<Alert severity="error" className={classes.alert}>
<AlertTitle>Error</AlertTitle>
{props.error}
</Alert>
);
}
if (props.tasks.length === 0) {
return (
<Alert severity="info" className={classes.alert}>
<AlertTitle>Info</AlertTitle>
No pending tasks at this time.
</Alert>
);
}
const columns: TableColumn[] = [
{ key: "id", label: "ID", align: "left" },
{ key: "type", label: "Type", align: "left" },
{ key: "paylod", label: "Payload", align: "left" },
{ key: "retried", label: "Retried", align: "right" },
{ key: "max_retry", label: "Max Retry", align: "right" },
{ key: "actions", label: "Actions", align: "center" },
];
const rowCount = props.tasks.length;
const numSelected = selectedKeys.length;
return (
<div>
<TableActions
showIconButtons={numSelected > 0}
iconButtonActions={[
{
tooltip: "Delete",
icon: <DeleteIcon />,
onClick: handleBatchDeleteClick,
disabled: props.batchActionPending,
},
{
tooltip: "Archive",
icon: <ArchiveIcon />,
onClick: handleBatchArchiveClick,
disabled: props.batchActionPending,
},
]}
menuItemActions={[
{
label: "Delete All",
onClick: handleDeleteAllClick,
disabled: props.allActionPending,
},
{
label: "Archive All",
onClick: handleArchiveAllClick,
disabled: props.allActionPending,
},
]}
/>
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="pending tasks table"
size="small"
>
<TableHead>
<TableRow>
<TableCell
padding="checkbox"
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all tasks shown in the table",
}}
/>
</TableCell>
{columns.map((col) => (
<TableCell
key={col.key}
align={col.align}
classes={{
stickyHeader: classes.stickyHeaderCell,
}}
>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row
key={task.id}
task={task}
isSelected={selectedKeys.includes(task.key)}
onSelectChange={(checked: boolean) => {
if (checked) {
setSelectedKeys(selectedKeys.concat(task.key));
} else {
setSelectedKeys(
selectedKeys.filter((key) => key !== task.key)
);
}
}}
allActionPending={props.allActionPending}
onDeleteClick={() =>
props.deletePendingTaskAsync(queue, task.key)
}
onArchiveClick={() => {
props.archivePendingTaskAsync(queue, task.key);
}}
onActionCellEnter={() => setActiveTaskId(task.id)}
onActionCellLeave={() => setActiveTaskId("")}
showActions={activeTaskId === task.id}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length + 1}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
className={classes.pagination}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div>
);
}
const useRowStyles = makeStyles({
actionCell: {
width: "96px",
},
activeActionCell: {
display: "flex",
justifyContent: "space-between",
},
});
interface RowProps {
task: PendingTaskExtended;
isSelected: boolean;
onSelectChange: (checked: boolean) => void;
onDeleteClick: () => void;
onArchiveClick: () => void;
allActionPending: boolean;
showActions: boolean;
onActionCellEnter: () => void;
onActionCellLeave: () => void;
}
function Row(props: RowProps) { function Row(props: RowProps) {
const { task } = props; const { task } = props;
const classes = useRowStyles(); const classes = useRowStyles();
const history = useHistory();
return ( return (
<TableRow <TableRow key={task.id} selected={props.isSelected}>
key={task.id} <TableCell padding="checkbox">
className={classes.root} <Checkbox
selected={props.isSelected} onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onClick={() => history.push(taskDetailsPath(task.queue, task.id))} props.onSelectChange(event.target.checked)
> }
{!window.READ_ONLY && ( checked={props.isSelected}
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}> />
<IconButton> </TableCell>
<Checkbox <TableCell component="th" scope="row">
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {uuidPrefix(task.id)}
props.onSelectChange(event.target.checked)
}
checked={props.isSelected}
/>
</IconButton>
</TableCell>
)}
<TableCell component="th" scope="row" className={classes.idCell}>
<div className={classes.IdGroup}>
{uuidPrefix(task.id)}
<Tooltip title="Copy full ID to clipboard">
<IconButton
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
}}
size="small"
className={classes.copyButton}
>
<FileCopyOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</TableCell> </TableCell>
<TableCell>{task.type}</TableCell> <TableCell>{task.type}</TableCell>
<TableCell> <TableCell>
@ -114,62 +335,49 @@ function Row(props: RowProps) {
language="json" language="json"
customStyle={{ margin: 0, maxWidth: 400 }} customStyle={{ margin: 0, maxWidth: 400 }}
> >
{prettifyPayload(task.payload)} {JSON.stringify(task.payload)}
</SyntaxHighlighter> </SyntaxHighlighter>
</TableCell> </TableCell>
<TableCell align="right">{task.retried}</TableCell> <TableCell align="right">{task.retried}</TableCell>
<TableCell align="right">{task.max_retry}</TableCell> <TableCell align="right">{task.max_retry}</TableCell>
{!window.READ_ONLY && ( <TableCell
<TableCell align="center"
align="center" className={clsx(
className={classes.actionCell} classes.actionCell,
onMouseEnter={props.onActionCellEnter} props.showActions && classes.activeActionCell
onMouseLeave={props.onActionCellLeave} )}
onClick={(e) => e.stopPropagation()} onMouseEnter={props.onActionCellEnter}
> onMouseLeave={props.onActionCellLeave}
{props.showActions ? ( >
<React.Fragment> {props.showActions ? (
<Tooltip title="Delete"> <React.Fragment>
<IconButton <Tooltip title="Delete">
onClick={props.onDeleteClick} <IconButton
disabled={task.requestPending || props.allActionPending} onClick={props.onDeleteClick}
size="small" disabled={task.requestPending || props.allActionPending}
className={classes.actionButton} size="small"
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Archive"> <Tooltip title="Archive">
<IconButton <IconButton
onClick={props.onArchiveClick} onClick={props.onArchiveClick}
disabled={task.requestPending || props.allActionPending} disabled={task.requestPending || props.allActionPending}
size="small" size="small"
className={classes.actionButton} >
> <ArchiveIcon fontSize="small" />
<ArchiveIcon fontSize="small" /> </IconButton>
</IconButton> </Tooltip>
</Tooltip> </React.Fragment>
</React.Fragment> ) : (
) : ( <IconButton size="small" onClick={props.onActionCellEnter}>
<IconButton size="small" onClick={props.onActionCellEnter}> <MoreHorizIcon fontSize="small" />
<MoreHorizIcon fontSize="small" /> </IconButton>
</IconButton> )}
)} </TableCell>
</TableCell>
)}
</TableRow> </TableRow>
); );
} }
function PendingTasksTable(props: Props & ReduxProps) {
return (
<TasksTable
taskState="pending"
columns={columns}
renderRow={(rowProps: RowProps) => <Row {...rowProps} />}
{...props}
/>
);
}
export default connector(PendingTasksTable); export default connector(PendingTasksTable);

View File

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

View File

@ -6,17 +6,17 @@ import Chip from "@material-ui/core/Chip";
import Menu from "@material-ui/core/Menu"; import Menu from "@material-ui/core/Menu";
import MenuItem from "@material-ui/core/MenuItem"; import MenuItem from "@material-ui/core/MenuItem";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import { paths as getPaths, queueDetailsPath } from "../paths"; import { paths, queueDetailsPath } from "../paths";
import { isDarkTheme } from "../theme";
const StyledBreadcrumb = withStyles((theme: Theme) => ({ const StyledBreadcrumb = withStyles((theme: Theme) => ({
root: { root: {
backgroundColor: isDarkTheme(theme) backgroundColor:
? "#303030" theme.palette.type === "dark"
: theme.palette.background.default, ? "#303030"
: theme.palette.background.default,
height: theme.spacing(3), height: theme.spacing(3),
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontWeight: 400, fontWeight: theme.typography.fontWeightRegular,
"&:hover, &:focus": { "&:hover, &:focus": {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
}, },
@ -31,15 +31,12 @@ interface Props {
// All queue names. // All queue names.
queues: string[]; queues: string[];
// Name of the queue currently selected. // Name of the queue currently selected.
queueName: string; selectedQueue: string;
// ID of the task currently selected (optional).
taskId?: string;
} }
export default function QueueBreadcrumbs(props: Props) { export default function QueueBreadcrumbs(props: Props) {
const history = useHistory(); const history = useHistory();
const [anchorEl, setAnchorEl] = useState<null | Element>(null); const [anchorEl, setAnchorEl] = useState<null | Element>(null);
const paths = getPaths();
const handleClick = (event: React.MouseEvent<Element, MouseEvent>) => { const handleClick = (event: React.MouseEvent<Element, MouseEvent>) => {
event.preventDefault(); event.preventDefault();
@ -60,12 +57,11 @@ export default function QueueBreadcrumbs(props: Props) {
onClick={() => history.push(paths.HOME)} onClick={() => history.push(paths.HOME)}
/> />
<StyledBreadcrumb <StyledBreadcrumb
label={props.queueName} label={props.selectedQueue}
deleteIcon={<ExpandMoreIcon />} deleteIcon={<ExpandMoreIcon />}
onClick={handleClick} onClick={handleClick}
onDelete={handleClick} onDelete={handleClick}
/> />
{props.taskId && <StyledBreadcrumb label={`task:${props.taskId}`} />}
</Breadcrumbs> </Breadcrumbs>
<Menu <Menu
id="queue-breadcrumb-menu" id="queue-breadcrumb-menu"

View File

@ -65,15 +65,6 @@ function QueueInfoBanner(props: Props & ReduxProps) {
</Typography> </Typography>
</div> </div>
<div className={classes.bannerItem}>
<Typography variant="subtitle2" color="textPrimary" gutterBottom>
Task groups
</Typography>
<Typography color="textSecondary">
{queue ? queue.groups : "-"}
</Typography>
</div>
<div className={classes.bannerItem}> <div className={classes.bannerItem}>
<Typography variant="subtitle2" color="textPrimary" gutterBottom> <Typography variant="subtitle2" color="textPrimary" gutterBottom>
Memory usage Memory usage
@ -83,15 +74,6 @@ function QueueInfoBanner(props: Props & ReduxProps) {
</Typography> </Typography>
</div> </div>
<div className={classes.bannerItem}>
<Typography variant="subtitle2" color="textPrimary" gutterBottom>
Latency
</Typography>
<Typography color="textSecondary">
{queue ? queue.display_latency : "-"}
</Typography>
</div>
<div className={classes.bannerItem}> <div className={classes.bannerItem}>
<Typography variant="subtitle2" color="textPrimary" gutterBottom> <Typography variant="subtitle2" color="textPrimary" gutterBottom>
Processed Processed

View File

@ -1,48 +0,0 @@
import React from "react";
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 { QueueLocation } from "../api";
const useStyles = makeStyles((theme) => ({
table: {
minWidth: 650,
},
}));
interface Props {
queueLocations: QueueLocation[];
}
export default function QueueLocationTable(props: Props) {
const classes = useStyles();
return (
<TableContainer>
<Table className={classes.table} aria-label="queue location table">
<TableHead>
<TableRow>
<TableCell>Queue</TableCell>
<TableCell>KeySlot</TableCell>
<TableCell>Node Addresses</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.queueLocations.map((loc) => (
<TableRow key={loc.queue}>
<TableCell component="th" scope="row">
{loc.queue}
</TableCell>
<TableCell>{loc.keyslot}</TableCell>
<TableCell>{loc.nodes.join(", ")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@ -1,108 +0,0 @@
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

@ -9,9 +9,6 @@ import {
Legend, Legend,
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { useHistory } from "react-router-dom";
import { useTheme } from "@material-ui/core/styles";
import { queueDetailsPath } from "../paths";
interface Props { interface Props {
data: TaskBreakdown[]; data: TaskBreakdown[];
@ -21,46 +18,25 @@ interface TaskBreakdown {
queue: string; // name of the queue. queue: string; // name of the queue.
active: number; // number of active tasks in the queue. active: number; // number of active tasks in the queue.
pending: number; // number of pending tasks in the queue. pending: number; // number of pending tasks in the queue.
aggregating: number; // number of aggregating tasks in the queue.
scheduled: number; // number of scheduled tasks in the queue. scheduled: number; // number of scheduled tasks in the queue.
retry: number; // number of retry tasks in the queue. retry: number; // number of retry tasks in the queue.
archived: number; // number of archived tasks in the queue. archived: number; // number of archived tasks in the queue.
completed: number; // number of completed tasks in the queue.
} }
function QueueSizeChart(props: Props) { function QueueSizeChart(props: Props) {
const theme = useTheme();
const handleClick = (params: { activeLabel?: string } | null) => {
const allQueues = props.data.map((b) => b.queue);
if (
params &&
params.activeLabel &&
allQueues.includes(params.activeLabel)
) {
history.push(queueDetailsPath(params.activeLabel));
}
};
const history = useHistory();
return ( return (
<ResponsiveContainer> <ResponsiveContainer>
<BarChart <BarChart data={props.data} maxBarSize={120}>
data={props.data}
maxBarSize={120}
onClick={handleClick}
style={{ cursor: "pointer" }}
>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="queue" stroke={theme.palette.text.secondary} /> <XAxis dataKey="queue" />
<YAxis stroke={theme.palette.text.secondary} /> <YAxis />
<Tooltip /> <Tooltip />
<Legend /> <Legend />
<Bar dataKey="active" stackId="a" fill="#1967d2" /> <Bar dataKey="active" stackId="a" fill="#1967d2" />
<Bar dataKey="pending" stackId="a" fill="#669df6" /> <Bar dataKey="pending" stackId="a" fill="#669df6" />
<Bar dataKey="aggregating" stackId="a" fill="#e69138" />
<Bar dataKey="scheduled" stackId="a" fill="#fdd663" /> <Bar dataKey="scheduled" stackId="a" fill="#fdd663" />
<Bar dataKey="retry" stackId="a" fill="#f666a9" /> <Bar dataKey="retry" stackId="a" fill="#f666a9" />
<Bar dataKey="archived" stackId="a" fill="#ac4776" /> <Bar dataKey="archived" stackId="a" fill="#ac4776" />
<Bar dataKey="completed" stackId="a" fill="#4bb543" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
); );

View File

@ -50,7 +50,6 @@ enum SortBy {
State, State,
Size, Size,
MemoryUsage, MemoryUsage,
Latency,
Processed, Processed,
Failed, Failed,
ErrorRate, ErrorRate,
@ -73,12 +72,6 @@ const colConfigs: SortableTableColumn<SortBy>[] = [
sortBy: SortBy.MemoryUsage, sortBy: SortBy.MemoryUsage,
align: "right", align: "right",
}, },
{
label: "Latency",
key: "latency",
sortBy: SortBy.Latency,
align: "right",
},
{ {
label: "Processed", label: "Processed",
key: "processed", key: "processed",
@ -144,10 +137,6 @@ export default function QueuesOverviewTable(props: Props) {
if (q1.memory_usage_bytes === q2.memory_usage_bytes) return 0; if (q1.memory_usage_bytes === q2.memory_usage_bytes) return 0;
isQ1Smaller = q1.memory_usage_bytes < q2.memory_usage_bytes; isQ1Smaller = q1.memory_usage_bytes < q2.memory_usage_bytes;
break; break;
case SortBy.Latency:
if (q1.latency_msec === q2.latency_msec) return 0;
isQ1Smaller = q1.latency_msec < q2.latency_msec;
break;
case SortBy.Processed: case SortBy.Processed:
if (q1.processed === q2.processed) return 0; if (q1.processed === q2.processed) return 0;
isQ1Smaller = q1.processed < q2.processed; isQ1Smaller = q1.processed < q2.processed;
@ -183,36 +172,30 @@ export default function QueuesOverviewTable(props: Props) {
<Table className={classes.table} aria-label="queues overview table"> <Table className={classes.table} aria-label="queues overview table">
<TableHead> <TableHead>
<TableRow> <TableRow>
{colConfigs {colConfigs.map((cfg, i) => (
.filter((cfg) => { <TableCell
// Filter out actions column in readonly mode. key={cfg.key}
return !window.READ_ONLY || cfg.key !== "actions"; align={cfg.align}
}) className={clsx(i === 0 && classes.fixedCell)}
.map((cfg, i) => ( >
<TableCell {cfg.sortBy !== SortBy.None ? (
key={cfg.key} <TableSortLabel
align={cfg.align} active={sortBy === cfg.sortBy}
className={clsx(i === 0 && classes.fixedCell)} direction={sortDir}
> onClick={createSortClickHandler(cfg.sortBy)}
{cfg.sortBy !== SortBy.None ? ( >
<TableSortLabel {cfg.label}
active={sortBy === cfg.sortBy} </TableSortLabel>
direction={sortDir} ) : (
onClick={createSortClickHandler(cfg.sortBy)} <div>{cfg.label}</div>
> )}
{cfg.label} </TableCell>
</TableSortLabel> ))}
) : (
<div>{cfg.label}</div>
)}
</TableCell>
))}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{sortQueues(props.queues, cmpFunc).map((q) => ( {sortQueues(props.queues, cmpFunc).map((q) => (
<Row <Row
key={q.queue}
queue={q} queue={q}
onPauseClick={() => props.onPauseClick(q.queue)} onPauseClick={() => props.onPauseClick(q.queue)}
onResumeClick={() => props.onResumeClick(q.queue)} onResumeClick={() => props.onResumeClick(q.queue)}
@ -299,56 +282,53 @@ function Row(props: RowProps) {
</TableCell> </TableCell>
<TableCell align="right">{q.size}</TableCell> <TableCell align="right">{q.size}</TableCell>
<TableCell align="right">{prettyBytes(q.memory_usage_bytes)}</TableCell> <TableCell align="right">{prettyBytes(q.memory_usage_bytes)}</TableCell>
<TableCell align="right">{q.display_latency}</TableCell>
<TableCell align="right">{q.processed}</TableCell> <TableCell align="right">{q.processed}</TableCell>
<TableCell align="right">{q.failed}</TableCell> <TableCell align="right">{q.failed}</TableCell>
<TableCell align="right">{percentage(q.failed, q.processed)}</TableCell> <TableCell align="right">{percentage(q.failed, q.processed)}</TableCell>
{!window.READ_ONLY && ( <TableCell
<TableCell align="center"
align="center" onMouseEnter={() => setShowIcons(true)}
onMouseEnter={() => setShowIcons(true)} onMouseLeave={() => setShowIcons(false)}
onMouseLeave={() => setShowIcons(false)} >
> <div className={classes.actionIconsContainer}>
<div className={classes.actionIconsContainer}> {showIcons ? (
{showIcons ? ( <React.Fragment>
<React.Fragment> {q.paused ? (
{q.paused ? ( <Tooltip title="Resume">
<Tooltip title="Resume"> <IconButton
<IconButton color="secondary"
color="secondary" onClick={props.onResumeClick}
onClick={props.onResumeClick} disabled={q.requestPending}
disabled={q.requestPending} size="small"
size="small" >
> <PlayCircleFilledIcon fontSize="small" />
<PlayCircleFilledIcon fontSize="small" />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Pause">
<IconButton
color="primary"
onClick={props.onPauseClick}
disabled={q.requestPending}
size="small"
>
<PauseCircleFilledIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton onClick={props.onDeleteClick} size="small">
<DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</React.Fragment> ) : (
) : ( <Tooltip title="Pause">
<IconButton size="small"> <IconButton
<MoreHorizIcon fontSize="small" /> color="primary"
</IconButton> onClick={props.onPauseClick}
)} disabled={q.requestPending}
</div> size="small"
</TableCell> >
)} <PauseCircleFilledIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton onClick={props.onDeleteClick} size="small">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</React.Fragment>
) : (
<IconButton size="small">
<MoreHorizIcon fontSize="small" />
</IconButton>
)}
</div>
</TableCell>
</TableRow> </TableRow>
); );
} }

View File

@ -1,35 +1,64 @@
import React, { useCallback, 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 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 Tooltip from "@material-ui/core/Tooltip";
import Checkbox from "@material-ui/core/Checkbox"; import Checkbox from "@material-ui/core/Checkbox";
import IconButton from "@material-ui/core/IconButton"; import IconButton from "@material-ui/core/IconButton";
import TableCell from "@material-ui/core/TableCell";
import TableRow from "@material-ui/core/TableRow";
import Tooltip from "@material-ui/core/Tooltip";
import ArchiveIcon from "@material-ui/icons/Archive";
import DeleteIcon from "@material-ui/icons/Delete";
import FileCopyOutlinedIcon from "@material-ui/icons/FileCopyOutlined";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import PlayArrowIcon from "@material-ui/icons/PlayArrow"; import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import React from "react"; import DeleteIcon from "@material-ui/icons/Delete";
import { connect, ConnectedProps } from "react-redux"; import ArchiveIcon from "@material-ui/icons/Archive";
import { useHistory } from "react-router-dom"; import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import { taskRowsPerPageChange } from "../actions/settingsActions"; import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "./SyntaxHighlighter";
import { import {
archiveAllRetryTasksAsync,
archiveRetryTaskAsync,
batchArchiveRetryTasksAsync,
batchDeleteRetryTasksAsync, batchDeleteRetryTasksAsync,
batchRunRetryTasksAsync, batchRunRetryTasksAsync,
batchArchiveRetryTasksAsync,
deleteAllRetryTasksAsync, deleteAllRetryTasksAsync,
deleteRetryTaskAsync,
listRetryTasksAsync,
runAllRetryTasksAsync, runAllRetryTasksAsync,
archiveAllRetryTasksAsync,
listRetryTasksAsync,
deleteRetryTaskAsync,
runRetryTaskAsync, runRetryTaskAsync,
archiveRetryTaskAsync,
} from "../actions/tasksActions"; } from "../actions/tasksActions";
import TasksTable, { RowProps, useRowStyles } from "./TasksTable";
import { taskDetailsPath } from "../paths";
import { AppState } from "../store"; import { AppState } from "../store";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import TableActions from "./TableActions";
import { durationBefore, uuidPrefix } from "../utils";
import { usePolling } from "../hooks";
import { RetryTaskExtended } from "../reducers/tasksReducer";
import clsx from "clsx";
import { TableColumn } from "../types/table"; import { TableColumn } from "../types/table";
import { durationBefore, prettifyPayload, uuidPrefix } from "../utils";
import SyntaxHighlighter from "./SyntaxHighlighter"; const useStyles = makeStyles((theme) => ({
table: {
minWidth: 650,
},
stickyHeaderCell: {
background: theme.palette.background.paper,
},
alert: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
pagination: {
border: "none",
},
}));
function mapStateToProps(state: AppState) { function mapStateToProps(state: AppState) {
return { return {
@ -39,22 +68,20 @@ function mapStateToProps(state: AppState) {
batchActionPending: state.tasks.retryTasks.batchActionPending, batchActionPending: state.tasks.retryTasks.batchActionPending,
allActionPending: state.tasks.retryTasks.allActionPending, allActionPending: state.tasks.retryTasks.allActionPending,
pollInterval: state.settings.pollInterval, pollInterval: state.settings.pollInterval,
pageSize: state.settings.taskRowsPerPage,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
batchDeleteTasks: batchDeleteRetryTasksAsync, batchDeleteRetryTasksAsync,
batchRunTasks: batchRunRetryTasksAsync, batchRunRetryTasksAsync,
batchArchiveTasks: batchArchiveRetryTasksAsync, batchArchiveRetryTasksAsync,
deleteAllTasks: deleteAllRetryTasksAsync, deleteAllRetryTasksAsync,
runAllTasks: runAllRetryTasksAsync, runAllRetryTasksAsync,
archiveAllTasks: archiveAllRetryTasksAsync, archiveAllRetryTasksAsync,
listTasks: listRetryTasksAsync, listRetryTasksAsync,
deleteTask: deleteRetryTaskAsync, deleteRetryTaskAsync,
runTask: runRetryTaskAsync, runRetryTaskAsync,
archiveTask: archiveRetryTaskAsync, archiveRetryTaskAsync,
taskRowsPerPageChange,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
@ -66,57 +93,273 @@ interface Props {
totalTaskCount: number; // totoal number of scheduled tasks. totalTaskCount: number; // totoal number of scheduled tasks.
} }
const columns: TableColumn[] = [ function RetryTasksTable(props: Props & ReduxProps) {
{ key: "id", label: "ID", align: "left" }, const { pollInterval, listRetryTasksAsync, queue } = props;
{ key: "type", label: "Type", align: "left" }, const classes = useStyles();
{ key: "payload", label: "Payload", align: "left" }, const [page, setPage] = useState(0);
{ key: "retry_in", label: "Retry In", align: "left" }, const [pageSize, setPageSize] = useState(defaultPageSize);
{ key: "last_error", label: "Last Error", align: "left" }, const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
{ key: "retried", label: "Retried", align: "right" }, const [activeTaskId, setActiveTaskId] = useState<string>("");
{ key: "max_retry", label: "Max Retry", align: "right" },
{ key: "actions", label: "Actions", align: "center" }, 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);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = props.tasks.map((t) => t.key);
setSelectedKeys(newSelected);
} else {
setSelectedKeys([]);
}
};
const handleRunAllClick = () => {
props.runAllRetryTasksAsync(queue);
};
const handleDeleteAllClick = () => {
props.deleteAllRetryTasksAsync(queue);
};
const handleArchiveAllClick = () => {
props.archiveAllRetryTasksAsync(queue);
};
const handleBatchRunClick = () => {
props
.batchRunRetryTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const handleBatchDeleteClick = () => {
props
.batchDeleteRetryTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const handleBatchArchiveClick = () => {
props
.batchArchiveRetryTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const fetchData = useCallback(() => {
const pageOpts = { page: page + 1, size: pageSize };
listRetryTasksAsync(queue, pageOpts);
}, [page, pageSize, queue, listRetryTasksAsync]);
usePolling(fetchData, pollInterval);
if (props.error.length > 0) {
return (
<Alert severity="error" className={classes.alert}>
<AlertTitle>Error</AlertTitle>
{props.error}
</Alert>
);
}
if (props.tasks.length === 0) {
return (
<Alert severity="info" className={classes.alert}>
<AlertTitle>Info</AlertTitle>
No retry tasks at this time.
</Alert>
);
}
const columns: TableColumn[] = [
{ key: "id", label: "ID", align: "left" },
{ key: "type", label: "Type", align: "left" },
{ key: "payload", label: "Payload", align: "left" },
{ key: "retry_in", label: "Retry In", align: "left" },
{ key: "last_error", label: "Last Error", align: "left" },
{ key: "retried", label: "Retried", align: "right" },
{ key: "max_retry", label: "Max Retry", align: "right" },
{ key: "actions", label: "Actions", align: "center" },
];
const rowCount = props.tasks.length;
const numSelected = selectedKeys.length;
return (
<div>
<TableActions
showIconButtons={numSelected > 0}
iconButtonActions={[
{
tooltip: "Delete",
icon: <DeleteIcon />,
onClick: handleBatchDeleteClick,
disabled: props.batchActionPending,
},
{
tooltip: "Archive",
icon: <ArchiveIcon />,
onClick: handleBatchArchiveClick,
disabled: props.batchActionPending,
},
{
tooltip: "Run",
icon: <PlayArrowIcon />,
onClick: handleBatchRunClick,
disabled: props.batchActionPending,
},
]}
menuItemActions={[
{
label: "Delete All",
onClick: handleDeleteAllClick,
disabled: props.allActionPending,
},
{
label: "Archive All",
onClick: handleArchiveAllClick,
disabled: props.allActionPending,
},
{
label: "Run All",
onClick: handleRunAllClick,
disabled: props.allActionPending,
},
]}
/>
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="retry tasks table"
size="small"
>
<TableHead>
<TableRow>
<TableCell
padding="checkbox"
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all tasks shown in the table",
}}
/>
</TableCell>
{columns.map((col) => (
<TableCell
key={col.label}
align={col.align}
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row
key={task.id}
task={task}
allActionPending={props.allActionPending}
isSelected={selectedKeys.includes(task.key)}
onSelectChange={(checked: boolean) => {
if (checked) {
setSelectedKeys(selectedKeys.concat(task.key));
} else {
setSelectedKeys(
selectedKeys.filter((key) => key !== task.key)
);
}
}}
onRunClick={() => {
props.runRetryTaskAsync(task.queue, task.key);
}}
onDeleteClick={() => {
props.deleteRetryTaskAsync(task.queue, task.key);
}}
onArchiveClick={() => {
props.archiveRetryTaskAsync(task.queue, task.key);
}}
onActionCellEnter={() => setActiveTaskId(task.id)}
onActionCellLeave={() => setActiveTaskId("")}
showActions={activeTaskId === task.id}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length + 1}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
className={classes.pagination}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div>
);
}
const useRowStyles = makeStyles({
actionCell: {
width: "140px",
},
activeActionCell: {
display: "flex",
justifyContent: "space-between",
},
});
interface RowProps {
task: RetryTaskExtended;
isSelected: boolean;
onSelectChange: (checked: boolean) => void;
onDeleteClick: () => void;
onRunClick: () => void;
onArchiveClick: () => void;
allActionPending: boolean;
showActions: boolean;
onActionCellEnter: () => void;
onActionCellLeave: () => void;
}
function Row(props: RowProps) { function Row(props: RowProps) {
const { task } = props; const { task } = props;
const classes = useRowStyles(); const classes = useRowStyles();
const history = useHistory();
return ( return (
<TableRow <TableRow key={task.id} selected={props.isSelected}>
key={task.id} <TableCell padding="checkbox">
className={classes.root} <Checkbox
selected={props.isSelected} onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onClick={() => history.push(taskDetailsPath(task.queue, task.id))} props.onSelectChange(event.target.checked)
> }
{!window.READ_ONLY && ( checked={props.isSelected}
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}> />
<IconButton> </TableCell>
<Checkbox <TableCell component="th" scope="row">
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {uuidPrefix(task.id)}
props.onSelectChange(event.target.checked)
}
checked={props.isSelected}
/>
</IconButton>
</TableCell>
)}
<TableCell component="th" scope="row" className={classes.idCell}>
<div className={classes.IdGroup}>
{uuidPrefix(task.id)}
<Tooltip title="Copy full ID to clipboard">
<IconButton
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
}}
size="small"
className={classes.copyButton}
>
<FileCopyOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</TableCell> </TableCell>
<TableCell>{task.type}</TableCell> <TableCell>{task.type}</TableCell>
<TableCell> <TableCell>
@ -124,74 +367,60 @@ function Row(props: RowProps) {
language="json" language="json"
customStyle={{ margin: 0, maxWidth: 400 }} customStyle={{ margin: 0, maxWidth: 400 }}
> >
{prettifyPayload(task.payload)} {JSON.stringify(task.payload)}
</SyntaxHighlighter> </SyntaxHighlighter>
</TableCell> </TableCell>
<TableCell>{durationBefore(task.next_process_at)}</TableCell> <TableCell>{durationBefore(task.next_process_at)}</TableCell>
<TableCell>{task.error_message}</TableCell> <TableCell>{task.error_message}</TableCell>
<TableCell align="right">{task.retried}</TableCell> <TableCell align="right">{task.retried}</TableCell>
<TableCell align="right">{task.max_retry}</TableCell> <TableCell align="right">{task.max_retry}</TableCell>
{!window.READ_ONLY && ( <TableCell
<TableCell align="center"
align="center" className={clsx(
className={classes.actionCell} classes.actionCell,
onMouseEnter={props.onActionCellEnter} props.showActions && classes.activeActionCell
onMouseLeave={props.onActionCellLeave} )}
onClick={(e) => e.stopPropagation()} onMouseEnter={props.onActionCellEnter}
> onMouseLeave={props.onActionCellLeave}
{props.showActions ? ( >
<React.Fragment> {props.showActions ? (
<Tooltip title="Delete"> <React.Fragment>
<IconButton <Tooltip title="Delete">
onClick={props.onDeleteClick} <IconButton
disabled={task.requestPending || props.allActionPending} onClick={props.onDeleteClick}
size="small" disabled={task.requestPending || props.allActionPending}
className={classes.actionButton} size="small"
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Archive"> <Tooltip title="Archive">
<IconButton <IconButton
onClick={props.onArchiveClick} onClick={props.onArchiveClick}
disabled={task.requestPending || props.allActionPending} disabled={task.requestPending || props.allActionPending}
size="small" size="small"
className={classes.actionButton} >
> <ArchiveIcon fontSize="small" />
<ArchiveIcon fontSize="small" /> </IconButton>
</IconButton> </Tooltip>
</Tooltip> <Tooltip title="Run">
<Tooltip title="Run"> <IconButton
<IconButton onClick={props.onRunClick}
onClick={props.onRunClick} disabled={task.requestPending || props.allActionPending}
disabled={task.requestPending || props.allActionPending} size="small"
size="small" >
className={classes.actionButton} <PlayArrowIcon fontSize="small" />
> </IconButton>
<PlayArrowIcon fontSize="small" /> </Tooltip>
</IconButton> </React.Fragment>
</Tooltip> ) : (
</React.Fragment> <IconButton size="small" onClick={props.onActionCellEnter}>
) : ( <MoreHorizIcon fontSize="small" />
<IconButton size="small" onClick={props.onActionCellEnter}> </IconButton>
<MoreHorizIcon fontSize="small" /> )}
</IconButton> </TableCell>
)}
</TableCell>
)}
</TableRow> </TableRow>
); );
} }
function RetryTasksTable(props: Props & ReduxProps) {
return (
<TasksTable
taskState="retry"
columns={columns}
renderRow={(rowProps: RowProps) => <Row {...rowProps} />}
{...props}
/>
);
}
export default connector(RetryTasksTable); export default connector(RetryTasksTable);

View File

@ -1,18 +1,26 @@
import React from "react"; import React, { useState, useCallback } from "react";
import clsx from "clsx";
import { connect, ConnectedProps } from "react-redux"; import { connect, ConnectedProps } from "react-redux";
import { useHistory } from "react-router-dom"; import { makeStyles } from "@material-ui/core/styles";
import TableRow from "@material-ui/core/TableRow"; import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell"; 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 TablePagination from "@material-ui/core/TablePagination";
import Paper from "@material-ui/core/Paper";
import Tooltip from "@material-ui/core/Tooltip";
import Checkbox from "@material-ui/core/Checkbox"; import Checkbox from "@material-ui/core/Checkbox";
import IconButton from "@material-ui/core/IconButton"; import IconButton from "@material-ui/core/IconButton";
import Tooltip from "@material-ui/core/Tooltip"; import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import FileCopyOutlinedIcon from "@material-ui/icons/FileCopyOutlined";
import DeleteIcon from "@material-ui/icons/Delete"; import DeleteIcon from "@material-ui/icons/Delete";
import ArchiveIcon from "@material-ui/icons/Archive"; import ArchiveIcon from "@material-ui/icons/Archive";
import MoreHorizIcon from "@material-ui/icons/MoreHoriz"; import MoreHorizIcon from "@material-ui/icons/MoreHoriz";
import PlayArrowIcon from "@material-ui/icons/PlayArrow"; import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "./SyntaxHighlighter"; import SyntaxHighlighter from "./SyntaxHighlighter";
import TasksTable, { RowProps, useRowStyles } from "./TasksTable";
import { import {
batchDeleteScheduledTasksAsync, batchDeleteScheduledTasksAsync,
batchRunScheduledTasksAsync, batchRunScheduledTasksAsync,
@ -25,11 +33,32 @@ import {
runScheduledTaskAsync, runScheduledTaskAsync,
archiveScheduledTaskAsync, archiveScheduledTaskAsync,
} from "../actions/tasksActions"; } from "../actions/tasksActions";
import { taskRowsPerPageChange } from "../actions/settingsActions";
import { AppState } from "../store"; import { AppState } from "../store";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import TableActions from "./TableActions";
import { durationBefore, uuidPrefix } from "../utils";
import { usePolling } from "../hooks";
import { ScheduledTaskExtended } from "../reducers/tasksReducer";
import { TableColumn } from "../types/table"; import { TableColumn } from "../types/table";
import { durationBefore, prettifyPayload, uuidPrefix } from "../utils";
import { taskDetailsPath } from "../paths"; const useStyles = makeStyles((theme) => ({
table: {
minWidth: 650,
},
stickyHeaderCell: {
background: theme.palette.background.paper,
},
alert: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
pagination: {
border: "none",
},
}));
function mapStateToProps(state: AppState) { function mapStateToProps(state: AppState) {
return { return {
@ -39,22 +68,20 @@ function mapStateToProps(state: AppState) {
batchActionPending: state.tasks.scheduledTasks.batchActionPending, batchActionPending: state.tasks.scheduledTasks.batchActionPending,
allActionPending: state.tasks.scheduledTasks.allActionPending, allActionPending: state.tasks.scheduledTasks.allActionPending,
pollInterval: state.settings.pollInterval, pollInterval: state.settings.pollInterval,
pageSize: state.settings.taskRowsPerPage,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
listTasks: listScheduledTasksAsync, listScheduledTasksAsync,
batchDeleteTasks: batchDeleteScheduledTasksAsync, batchDeleteScheduledTasksAsync,
batchRunTasks: batchRunScheduledTasksAsync, batchRunScheduledTasksAsync,
batchArchiveTasks: batchArchiveScheduledTasksAsync, batchArchiveScheduledTasksAsync,
deleteAllTasks: deleteAllScheduledTasksAsync, deleteAllScheduledTasksAsync,
runAllTasks: runAllScheduledTasksAsync, runAllScheduledTasksAsync,
archiveAllTasks: archiveAllScheduledTasksAsync, archiveAllScheduledTasksAsync,
deleteTask: deleteScheduledTaskAsync, deleteScheduledTaskAsync,
runTask: runScheduledTaskAsync, runScheduledTaskAsync,
archiveTask: archiveScheduledTaskAsync, archiveScheduledTaskAsync,
taskRowsPerPageChange,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
@ -66,53 +93,270 @@ interface Props {
totalTaskCount: number; // totoal number of scheduled tasks. totalTaskCount: number; // totoal number of scheduled tasks.
} }
const columns: TableColumn[] = [ function ScheduledTasksTable(props: Props & ReduxProps) {
{ key: "id", label: "ID", align: "left" }, const { pollInterval, listScheduledTasksAsync, queue } = props;
{ key: "type", label: "Type", align: "left" }, const classes = useStyles();
{ key: "payload", label: "Payload", align: "left" }, const [page, setPage] = useState(0);
{ key: "process_in", label: "Process In", align: "left" }, const [pageSize, setPageSize] = useState(defaultPageSize);
{ key: "actions", label: "Actions", align: "center" }, const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
]; const [activeTaskId, setActiveTaskId] = useState<string>("");
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);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = props.tasks.map((t) => t.key);
setSelectedKeys(newSelected);
} else {
setSelectedKeys([]);
}
};
const handleRunAllClick = () => {
props.runAllScheduledTasksAsync(queue);
};
const handleDeleteAllClick = () => {
props.deleteAllScheduledTasksAsync(queue);
};
const handleArchiveAllClick = () => {
props.archiveAllScheduledTasksAsync(queue);
};
const handleBatchRunClick = () => {
props
.batchRunScheduledTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const handleBatchDeleteClick = () => {
props
.batchDeleteScheduledTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const handleBatchArchiveClick = () => {
props
.batchArchiveScheduledTasksAsync(queue, selectedKeys)
.then(() => setSelectedKeys([]));
};
const fetchData = useCallback(() => {
const pageOpts = { page: page + 1, size: pageSize };
listScheduledTasksAsync(queue, pageOpts);
}, [page, pageSize, queue, listScheduledTasksAsync]);
usePolling(fetchData, pollInterval);
if (props.error.length > 0) {
return (
<Alert severity="error" className={classes.alert}>
<AlertTitle>Error</AlertTitle>
{props.error}
</Alert>
);
}
if (props.tasks.length === 0) {
return (
<Alert severity="info" className={classes.alert}>
<AlertTitle>Info</AlertTitle>
No scheduled tasks at this time.
</Alert>
);
}
const columns: TableColumn[] = [
{ key: "id", label: "ID", align: "left" },
{ key: "type", label: "Type", align: "left" },
{ key: "payload", label: "Payload", align: "left" },
{ key: "process_in", label: "Process In", align: "left" },
{ key: "actions", label: "Actions", align: "center" },
];
const rowCount = props.tasks.length;
const numSelected = selectedKeys.length;
return (
<div>
<TableActions
showIconButtons={numSelected > 0}
iconButtonActions={[
{
tooltip: "Delete",
icon: <DeleteIcon />,
onClick: handleBatchDeleteClick,
disabled: props.batchActionPending,
},
{
tooltip: "Archive",
icon: <ArchiveIcon />,
onClick: handleBatchArchiveClick,
disabled: props.batchActionPending,
},
{
tooltip: "Run",
icon: <PlayArrowIcon />,
onClick: handleBatchRunClick,
disabled: props.batchActionPending,
},
]}
menuItemActions={[
{
label: "Delete All",
onClick: handleDeleteAllClick,
disabled: props.allActionPending,
},
{
label: "Archive All",
onClick: handleArchiveAllClick,
disabled: props.allActionPending,
},
{
label: "Run All",
onClick: handleRunAllClick,
disabled: props.allActionPending,
},
]}
/>
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="scheduled tasks table"
size="small"
>
<TableHead>
<TableRow>
<TableCell
padding="checkbox"
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all tasks shown in the table",
}}
/>
</TableCell>
{columns.map((col) => (
<TableCell
key={col.label}
align={col.align}
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row
key={task.id}
task={task}
allActionPending={props.allActionPending}
isSelected={selectedKeys.includes(task.key)}
onSelectChange={(checked: boolean) => {
if (checked) {
setSelectedKeys(selectedKeys.concat(task.key));
} else {
setSelectedKeys(
selectedKeys.filter((key) => key !== task.key)
);
}
}}
onRunClick={() => {
props.runScheduledTaskAsync(queue, task.key);
}}
onDeleteClick={() => {
props.deleteScheduledTaskAsync(queue, task.key);
}}
onArchiveClick={() => {
props.archiveScheduledTaskAsync(queue, task.key);
}}
onActionCellEnter={() => setActiveTaskId(task.id)}
onActionCellLeave={() => setActiveTaskId("")}
showActions={activeTaskId === task.id}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length + 1}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
className={classes.pagination}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div>
);
}
const useRowStyles = makeStyles({
actionCell: {
width: "140px",
},
activeActionCell: {
display: "flex",
justifyContent: "space-between",
},
});
interface RowProps {
task: ScheduledTaskExtended;
isSelected: boolean;
onSelectChange: (checked: boolean) => void;
onRunClick: () => void;
onDeleteClick: () => void;
onArchiveClick: () => void;
allActionPending: boolean;
showActions: boolean;
onActionCellEnter: () => void;
onActionCellLeave: () => void;
}
function Row(props: RowProps) { function Row(props: RowProps) {
const { task } = props; const { task } = props;
const classes = useRowStyles(); const classes = useRowStyles();
const history = useHistory();
return ( return (
<TableRow <TableRow key={task.id} selected={props.isSelected}>
key={task.id} <TableCell padding="checkbox">
className={classes.root} <Checkbox
selected={props.isSelected} onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onClick={() => history.push(taskDetailsPath(task.queue, task.id))} props.onSelectChange(event.target.checked)
> }
{!window.READ_ONLY && ( checked={props.isSelected}
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}> />
<IconButton> </TableCell>
<Checkbox <TableCell component="th" scope="row">
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {uuidPrefix(task.id)}
props.onSelectChange(event.target.checked)
}
checked={props.isSelected}
/>
</IconButton>
</TableCell>
)}
<TableCell component="th" scope="row" className={classes.idCell}>
<div className={classes.IdGroup}>
{uuidPrefix(task.id)}
<Tooltip title="Copy full ID to clipboard">
<IconButton
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
}}
size="small"
className={classes.copyButton}
>
<FileCopyOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
</TableCell> </TableCell>
<TableCell>{task.type}</TableCell> <TableCell>{task.type}</TableCell>
<TableCell> <TableCell>
@ -120,71 +364,56 @@ function Row(props: RowProps) {
language="json" language="json"
customStyle={{ margin: 0, maxWidth: 400 }} customStyle={{ margin: 0, maxWidth: 400 }}
> >
{prettifyPayload(task.payload)} {JSON.stringify(task.payload)}
</SyntaxHighlighter> </SyntaxHighlighter>
</TableCell> </TableCell>
<TableCell>{durationBefore(task.next_process_at)}</TableCell> <TableCell>{durationBefore(task.next_process_at)}</TableCell>
{!window.READ_ONLY && ( <TableCell
<TableCell align="center"
align="center" className={clsx(
className={classes.actionCell} classes.actionCell,
onMouseEnter={props.onActionCellEnter} props.showActions && classes.activeActionCell
onMouseLeave={props.onActionCellLeave} )}
onClick={(e) => e.stopPropagation()} onMouseEnter={props.onActionCellEnter}
> onMouseLeave={props.onActionCellLeave}
{props.showActions ? ( >
<React.Fragment> {props.showActions ? (
<Tooltip title="Delete"> <React.Fragment>
<IconButton <Tooltip title="Delete">
onClick={props.onDeleteClick} <IconButton
disabled={task.requestPending || props.allActionPending} onClick={props.onDeleteClick}
size="small" disabled={task.requestPending || props.allActionPending}
className={classes.actionButton} size="small"
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Archive"> <Tooltip title="Archive">
<IconButton <IconButton
onClick={props.onArchiveClick} onClick={props.onArchiveClick}
disabled={task.requestPending || props.allActionPending} disabled={task.requestPending || props.allActionPending}
size="small" size="small"
className={classes.actionButton} >
> <ArchiveIcon fontSize="small" />
<ArchiveIcon fontSize="small" /> </IconButton>
</IconButton> </Tooltip>
</Tooltip> <Tooltip title="Run">
<Tooltip title="Run"> <IconButton
<IconButton onClick={props.onRunClick}
onClick={props.onRunClick} disabled={task.requestPending || props.allActionPending}
disabled={task.requestPending || props.allActionPending} size="small"
size="small" >
className={classes.actionButton} <PlayArrowIcon fontSize="small" />
> </IconButton>
<PlayArrowIcon fontSize="small" /> </Tooltip>
</IconButton> </React.Fragment>
</Tooltip> ) : (
</React.Fragment> <IconButton size="small" onClick={props.onActionCellEnter}>
) : ( <MoreHorizIcon fontSize="small" />
<IconButton size="small" onClick={props.onActionCellEnter}> </IconButton>
<MoreHorizIcon fontSize="small" /> )}
</IconButton> </TableCell>
)}
</TableCell>
)}
</TableRow> </TableRow>
); );
} }
function ScheduledTasksTable(props: Props & ReduxProps) {
return (
<TasksTable
taskState="scheduled"
columns={columns}
renderRow={(rowProps: RowProps) => <Row {...rowProps} />}
{...props}
/>
);
}
export default connector(ScheduledTasksTable); export default connector(ScheduledTasksTable);

View File

@ -18,7 +18,7 @@ import { SortDirection, SortableTableColumn } from "../types/table";
import TableSortLabel from "@material-ui/core/TableSortLabel"; import TableSortLabel from "@material-ui/core/TableSortLabel";
import SyntaxHighlighter from "./SyntaxHighlighter"; import SyntaxHighlighter from "./SyntaxHighlighter";
import { SchedulerEntry } from "../api"; import { SchedulerEntry } from "../api";
import { timeAgo, durationBefore, prettifyPayload } from "../utils"; import { timeAgo, durationBefore } from "../utils";
import SchedulerEnqueueEventsTable from "./SchedulerEnqueueEventsTable"; import SchedulerEnqueueEventsTable from "./SchedulerEnqueueEventsTable";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
@ -288,7 +288,7 @@ function Row(props: RowProps) {
</TableCell> </TableCell>
<TableCell className={clsx(isLastRow && classes.noBorder)}> <TableCell className={clsx(isLastRow && classes.noBorder)}>
<SyntaxHighlighter language="json"> <SyntaxHighlighter language="json">
{prettifyPayload(entry.task_payload)} {JSON.stringify(entry.task_payload)}
</SyntaxHighlighter> </SyntaxHighlighter>
</TableCell> </TableCell>
<TableCell className={clsx(isLastRow && classes.noBorder)}> <TableCell className={clsx(isLastRow && classes.noBorder)}>

View File

@ -21,7 +21,7 @@ import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "./SyntaxHighlighter"; import SyntaxHighlighter from "./SyntaxHighlighter";
import { ServerInfo } from "../api"; import { ServerInfo } from "../api";
import { SortDirection, SortableTableColumn } from "../types/table"; import { SortDirection, SortableTableColumn } from "../types/table";
import { timeAgo, uuidPrefix, prettifyPayload } from "../utils"; import { timeAgo, uuidPrefix } from "../utils";
import { queueDetailsPath } from "../paths"; import { queueDetailsPath } from "../paths";
import Typography from "@material-ui/core/Typography"; import Typography from "@material-ui/core/Typography";
@ -273,19 +273,19 @@ function Row(props: RowProps) {
</TableHead> </TableHead>
<TableBody> <TableBody>
{server.active_workers.map((worker) => ( {server.active_workers.map((worker) => (
<TableRow key={worker.task_id}> <TableRow key={worker.task.id}>
<TableCell component="th" scope="row"> <TableCell component="th" scope="row">
{uuidPrefix(worker.task_id)} {uuidPrefix(worker.task.id)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<SyntaxHighlighter <SyntaxHighlighter
language="json" language="json"
customStyle={{ margin: 0 }} customStyle={{ margin: 0 }}
> >
{prettifyPayload(worker.task_payload)} {JSON.stringify(worker.task.payload)}
</SyntaxHighlighter> </SyntaxHighlighter>
</TableCell> </TableCell>
<TableCell>{worker.queue}</TableCell> <TableCell>{worker.task.queue}</TableCell>
<TableCell>{timeAgo(worker.start_time)}</TableCell> <TableCell>{timeAgo(worker.start_time)}</TableCell>
</TableRow> </TableRow>
))} ))}

View File

@ -9,7 +9,6 @@ import Paper from "@material-ui/core/Paper";
import Popper from "@material-ui/core/Popper"; import Popper from "@material-ui/core/Popper";
import MenuItem from "@material-ui/core/MenuItem"; import MenuItem from "@material-ui/core/MenuItem";
import MenuList from "@material-ui/core/MenuList"; import MenuList from "@material-ui/core/MenuList";
import { isDarkTheme } from "../theme";
interface Option { interface Option {
label: string; label: string;
@ -27,9 +26,10 @@ const useStyles = makeStyles((theme) => ({
zIndex: 2, zIndex: 2,
}, },
buttonContained: { buttonContained: {
backgroundColor: isDarkTheme(theme) backgroundColor:
? "#303030" theme.palette.type === "dark"
: theme.palette.background.default, ? "#303030"
: theme.palette.background.default,
color: theme.palette.text.primary, color: theme.palette.text.primary,
"&:hover": { "&:hover": {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,

View File

@ -1,10 +1,8 @@
import React from "react"; import React from "react";
import { useTheme, Theme } from "@material-ui/core/styles"; import { useTheme, Theme } from "@material-ui/core/styles";
import { Light as ReactSyntaxHighlighter } from "react-syntax-highlighter"; import ReactSyntaxHighlighter from "react-syntax-highlighter";
import json from "react-syntax-highlighter/dist/esm/languages/hljs/json";
import styleDark from "react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark"; import styleDark from "react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark";
import styleLight from "react-syntax-highlighter/dist/esm/styles/hljs/atom-one-light"; import styleLight from "react-syntax-highlighter/dist/esm/styles/hljs/atom-one-light";
import { isDarkTheme } from "../theme";
interface Props { interface Props {
language: string; language: string;
@ -12,12 +10,10 @@ interface Props {
customStyle?: object; customStyle?: object;
} }
ReactSyntaxHighlighter.registerLanguage("json", json);
// Theme aware syntax-highlighter component. // Theme aware syntax-highlighter component.
export default function SyntaxHighlighter(props: Props) { export default function SyntaxHighlighter(props: Props) {
const theme = useTheme<Theme>(); const theme = useTheme<Theme>();
const style = isDarkTheme(theme) ? styleDark : styleLight; const style = theme.palette.type === "dark" ? styleDark : styleLight;
return ( return (
<ReactSyntaxHighlighter <ReactSyntaxHighlighter
language={props.language} language={props.language}

View File

@ -24,7 +24,7 @@ interface TablePaginationActionsProps {
count: number; count: number;
page: number; page: number;
rowsPerPage: number; rowsPerPage: number;
onPageChange: ( onChangePage: (
event: React.MouseEvent<HTMLButtonElement>, event: React.MouseEvent<HTMLButtonElement>,
newPage: number newPage: number
) => void; ) => void;
@ -33,30 +33,30 @@ interface TablePaginationActionsProps {
function TablePaginationActions(props: TablePaginationActionsProps) { function TablePaginationActions(props: TablePaginationActionsProps) {
const classes = useStyles(); const classes = useStyles();
const theme = useTheme(); const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props; const { count, page, rowsPerPage, onChangePage } = props;
const handleFirstPageButtonClick = ( const handleFirstPageButtonClick = (
event: React.MouseEvent<HTMLButtonElement> event: React.MouseEvent<HTMLButtonElement>
) => { ) => {
onPageChange(event, 0); onChangePage(event, 0);
}; };
const handleBackButtonClick = ( const handleBackButtonClick = (
event: React.MouseEvent<HTMLButtonElement> event: React.MouseEvent<HTMLButtonElement>
) => { ) => {
onPageChange(event, page - 1); onChangePage(event, page - 1);
}; };
const handleNextButtonClick = ( const handleNextButtonClick = (
event: React.MouseEvent<HTMLButtonElement> event: React.MouseEvent<HTMLButtonElement>
) => { ) => {
onPageChange(event, page + 1); onChangePage(event, page + 1);
}; };
const handleLastPageButtonClick = ( const handleLastPageButtonClick = (
event: React.MouseEvent<HTMLButtonElement> event: React.MouseEvent<HTMLButtonElement>
) => { ) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
}; };
return ( return (

View File

@ -1,378 +1,172 @@
import React, { useState, useCallback } from "react"; import React from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table"; import Typography from "@material-ui/core/Typography";
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 TablePagination from "@material-ui/core/TablePagination";
import Paper from "@material-ui/core/Paper"; import Paper from "@material-ui/core/Paper";
import Checkbox from "@material-ui/core/Checkbox"; import Chip from "@material-ui/core/Chip";
import IconButton from "@material-ui/core/IconButton"; import ActiveTasksTable from "./ActiveTasksTable";
import PlayArrowIcon from "@material-ui/icons/PlayArrow"; import PendingTasksTable from "./PendingTasksTable";
import DeleteIcon from "@material-ui/icons/Delete"; import ScheduledTasksTable from "./ScheduledTasksTable";
import ArchiveIcon from "@material-ui/icons/Archive"; import RetryTasksTable from "./RetryTasksTable";
import CancelIcon from "@material-ui/icons/Cancel"; import ArchivedTasksTable from "./ArchivedTasksTable";
import Alert from "@material-ui/lab/Alert"; import { useHistory } from "react-router-dom";
import AlertTitle from "@material-ui/lab/AlertTitle"; import { queueDetailsPath } from "../paths";
import TablePaginationActions, { import { QueueInfo } from "../reducers/queuesReducer";
rowsPerPageOptions, import { AppState } from "../store";
} from "./TablePaginationActions";
import TableActions from "./TableActions";
import { usePolling } from "../hooks";
import { TaskInfoExtended } from "../reducers/tasksReducer";
import { TableColumn } from "../types/table";
import { PaginationOptions } from "../api";
import { TaskState } from "../types/taskState";
const useStyles = makeStyles((theme) => ({ interface TabPanelProps {
table: { children?: React.ReactNode;
minWidth: 650, selected: string; // currently selected value
}, value: string; // tab panel will be shown if selected value equals to the value
stickyHeaderCell: {
background: theme.palette.background.paper,
},
alert: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
pagination: {
border: "none",
},
}));
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of tasks in the given state.
taskState: TaskState;
loading: boolean;
error: string;
tasks: TaskInfoExtended[];
batchActionPending: boolean;
allActionPending: boolean;
pollInterval: number;
pageSize: number;
columns: TableColumn[];
// actions
listTasks: (qname: string, pgn: PaginationOptions) => void;
batchDeleteTasks?: (qname: string, taskIds: string[]) => Promise<void>;
batchRunTasks?: (qname: string, taskIds: string[]) => Promise<void>;
batchArchiveTasks?: (qname: string, taskIds: string[]) => Promise<void>;
batchCancelTasks?: (qname: string, taskIds: string[]) => Promise<void>;
deleteAllTasks?: (qname: string) => Promise<void>;
runAllTasks?: (qname: string) => Promise<void>;
archiveAllTasks?: (qname: string) => Promise<void>;
cancelAllTasks?: (qname: string) => Promise<void>;
deleteTask?: (qname: string, taskId: string) => Promise<void>;
runTask?: (qname: string, taskId: string) => Promise<void>;
archiveTask?: (qname: string, taskId: string) => Promise<void>;
cancelTask?: (qname: string, taskId: string) => Promise<void>;
taskRowsPerPageChange: (n: number) => void;
renderRow: (rowProps: RowProps) => JSX.Element;
} }
export default function TasksTable(props: Props) { function TabPanel(props: TabPanelProps) {
const { pollInterval, listTasks, queue, pageSize } = props; const { children, value, selected, ...other } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [activeTaskId, setActiveTaskId] = useState<string>("");
const handlePageChange = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
props.taskRowsPerPageChange(parseInt(event.target.value, 10));
setPage(0);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = props.tasks.map((t) => t.id);
setSelectedIds(newSelected);
} else {
setSelectedIds([]);
}
};
function createAllActionHandler(action: (qname: string) => Promise<void>) {
return () => action(queue);
}
function createBatchActionHandler(
action: (qname: string, taskIds: string[]) => Promise<void>
) {
return () => action(queue, selectedIds).then(() => setSelectedIds([]));
}
function createSingleActionHandler(
action: (qname: string, taskId: string) => Promise<void>,
taskId: string
) {
return () => action(queue, taskId);
}
let allActions = [];
if (props.deleteAllTasks) {
allActions.push({
label: "Delete All",
onClick: createAllActionHandler(props.deleteAllTasks),
disabled: props.allActionPending,
});
}
if (props.archiveAllTasks) {
allActions.push({
label: "Archive All",
onClick: createAllActionHandler(props.archiveAllTasks),
disabled: props.allActionPending,
});
}
if (props.runAllTasks) {
allActions.push({
label: "Run All",
onClick: createAllActionHandler(props.runAllTasks),
disabled: props.allActionPending,
});
}
if (props.cancelAllTasks) {
allActions.push({
label: "Cancel All",
onClick: createAllActionHandler(props.cancelAllTasks),
disabled: props.allActionPending,
});
}
let batchActions = [];
if (props.batchDeleteTasks) {
batchActions.push({
tooltip: "Delete",
icon: <DeleteIcon />,
disabled: props.batchActionPending,
onClick: createBatchActionHandler(props.batchDeleteTasks),
});
}
if (props.batchArchiveTasks) {
batchActions.push({
tooltip: "Archive",
icon: <ArchiveIcon />,
disabled: props.batchActionPending,
onClick: createBatchActionHandler(props.batchArchiveTasks),
});
}
if (props.batchRunTasks) {
batchActions.push({
tooltip: "Run",
icon: <PlayArrowIcon />,
disabled: props.batchActionPending,
onClick: createBatchActionHandler(props.batchRunTasks),
});
}
if (props.batchCancelTasks) {
batchActions.push({
tooltip: "Cancel",
icon: <CancelIcon />,
disabled: props.batchActionPending,
onClick: createBatchActionHandler(props.batchCancelTasks),
});
}
const fetchData = useCallback(() => {
const pageOpts = { page: page + 1, size: pageSize };
listTasks(queue, pageOpts);
}, [page, pageSize, queue, listTasks]);
usePolling(fetchData, pollInterval);
if (props.error.length > 0) {
return (
<Alert severity="error" className={classes.alert}>
<AlertTitle>Error</AlertTitle>
{props.error}
</Alert>
);
}
if (props.tasks.length === 0) {
return (
<Alert severity="info" className={classes.alert}>
<AlertTitle>Info</AlertTitle>
{props.taskState === "aggregating" ? (
<div>Selected group is empty.</div>
) : (
<div>No {props.taskState} tasks at this time.</div>
)}
</Alert>
);
}
const rowCount = props.tasks.length;
const numSelected = selectedIds.length;
return ( return (
<div> <div
{!window.READ_ONLY && ( role="tabpanel"
<TableActions hidden={value !== selected}
showIconButtons={numSelected > 0} id={`scrollable-auto-tabpanel-${selected}`}
iconButtonActions={batchActions} aria-labelledby={`scrollable-auto-tab-${selected}`}
menuItemActions={allActions} style={{ flex: 1, overflowY: "scroll" }}
/> {...other}
)} >
<TableContainer component={Paper}> {value === selected && children}
<Table
stickyHeader={true}
className={classes.table}
aria-label={`${props.taskState} tasks table`}
size="small"
>
<TableHead>
<TableRow>
{!window.READ_ONLY && (
<TableCell
padding="checkbox"
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
<IconButton>
<Checkbox
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={handleSelectAllClick}
inputProps={{
"aria-label": "select all tasks shown in the table",
}}
/>
</IconButton>
</TableCell>
)}
{props.columns
.filter((col) => {
// Filter out actions column in readonly mode.
return !window.READ_ONLY || col.key !== "actions";
})
.map((col) => (
<TableCell
key={col.label}
align={col.align}
classes={{ stickyHeader: classes.stickyHeaderCell }}
>
{col.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => {
return props.renderRow({
key: task.id,
task: task,
allActionPending: props.allActionPending,
isSelected: selectedIds.includes(task.id),
onSelectChange: (checked: boolean) => {
if (checked) {
setSelectedIds(selectedIds.concat(task.id));
} else {
setSelectedIds(selectedIds.filter((id) => id !== task.id));
}
},
onRunClick: props.runTask
? createSingleActionHandler(props.runTask, task.id)
: undefined,
onDeleteClick: props.deleteTask
? createSingleActionHandler(props.deleteTask, task.id)
: undefined,
onArchiveClick: props.archiveTask
? createSingleActionHandler(props.archiveTask, task.id)
: undefined,
onCancelClick: props.cancelTask
? createSingleActionHandler(props.cancelTask, task.id)
: undefined,
onActionCellEnter: () => setActiveTaskId(task.id),
onActionCellLeave: () => setActiveTaskId(""),
showActions: activeTaskId === task.id,
});
})}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={props.columns.length + 1}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
ActionsComponent={TablePaginationActions}
className={classes.pagination}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div> </div>
); );
} }
export const useRowStyles = makeStyles((theme) => ({ function mapStatetoProps(state: AppState, ownProps: Props) {
root: { // TODO: Add loading state for each queue.
cursor: "pointer", const queueInfo = state.queues.data.find(
"& #copy-button": { (q: QueueInfo) => q.name === ownProps.queue
display: "none", );
}, const currentStats = queueInfo
"&:hover": { ? queueInfo.currentStats
boxShadow: theme.shadows[2], : {
"& #copy-button": { queue: ownProps.queue,
display: "inline-block", paused: false,
}, size: 0,
}, active: 0,
"&:hover $copyButton": { pending: 0,
display: "inline-block", scheduled: 0,
}, retry: 0,
"&:hover .MuiTableCell-root": { archived: 0,
borderBottomColor: theme.palette.background.paper, processed: 0,
}, failed: 0,
timestamp: "n/a",
};
return { currentStats };
}
const connector = connect(mapStatetoProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string;
selected: string;
}
const useStyles = makeStyles((theme) => ({
container: {
width: "100%",
height: "100%",
background: theme.palette.background.paper,
}, },
actionCell: { header: {
width: "140px",
},
actionButton: {
marginLeft: 3,
marginRight: 3,
},
idCell: {
width: "200px",
},
copyButton: {
display: "none",
},
IdGroup: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
paddingTop: theme.spacing(1),
},
heading: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
chip: {
marginLeft: theme.spacing(1),
},
taskcount: {
fontSize: "12px",
color: theme.palette.text.secondary,
background:
theme.palette.type === "dark"
? "#303030"
: theme.palette.background.default,
textAlign: "center",
padding: "3px 6px",
borderRadius: "10px",
marginLeft: "2px",
}, },
})); }));
export interface RowProps { function TasksTable(props: Props & ReduxProps) {
key: string; const { currentStats } = props;
task: TaskInfoExtended; const classes = useStyles();
isSelected: boolean; const history = useHistory();
onSelectChange: (checked: boolean) => void; const chips = [
onRunClick?: () => void; { key: "active", label: "Active", count: currentStats.active },
onDeleteClick?: () => void; { key: "pending", label: "Pending", count: currentStats.pending },
onArchiveClick?: () => void; { key: "scheduled", label: "Scheduled", count: currentStats.scheduled },
onCancelClick?: () => void; { key: "retry", label: "Retry", count: currentStats.retry },
allActionPending: boolean; { key: "archived", label: "Archived", count: currentStats.archived },
showActions: boolean; ];
onActionCellEnter: () => void;
onActionCellLeave: () => void; return (
<Paper variant="outlined" className={classes.container}>
<div className={classes.header}>
<Typography color="textPrimary" className={classes.heading}>
Tasks
</Typography>
<div>
{chips.map((c) => (
<Chip
key={c.key}
className={classes.chip}
label={
<div>
{c.label} <span className={classes.taskcount}>{c.count}</span>
</div>
}
variant="outlined"
color={props.selected === c.key ? "primary" : "default"}
onClick={() => history.push(queueDetailsPath(props.queue, c.key))}
/>
))}
</div>
</div>
<TabPanel value="active" selected={props.selected}>
<ActiveTasksTable queue={props.queue} />
</TabPanel>
<TabPanel value="pending" selected={props.selected}>
<PendingTasksTable
queue={props.queue}
totalTaskCount={currentStats.pending}
/>
</TabPanel>
<TabPanel value="scheduled" selected={props.selected}>
<ScheduledTasksTable
queue={props.queue}
totalTaskCount={currentStats.scheduled}
/>
</TabPanel>
<TabPanel value="retry" selected={props.selected}>
<RetryTasksTable
queue={props.queue}
totalTaskCount={currentStats.retry}
/>
</TabPanel>
<TabPanel value="archived" selected={props.selected}>
<ArchivedTasksTable
queue={props.queue}
totalTaskCount={currentStats.archived}
/>
</TabPanel>
</Paper>
);
} }
export default connector(TasksTable);

View File

@ -1,262 +0,0 @@
import React, { useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Typography from "@material-ui/core/Typography";
import Paper from "@material-ui/core/Paper";
import Chip from "@material-ui/core/Chip";
import InputBase from "@material-ui/core/InputBase";
import SearchIcon from "@material-ui/icons/Search";
import ActiveTasksTable from "./ActiveTasksTable";
import PendingTasksTable from "./PendingTasksTable";
import ScheduledTasksTable from "./ScheduledTasksTable";
import RetryTasksTable from "./RetryTasksTable";
import ArchivedTasksTable from "./ArchivedTasksTable";
import CompletedTasksTable from "./CompletedTasksTable";
import AggregatingTasksTableContainer from "./AggregatingTasksTableContainer";
import { useHistory } from "react-router-dom";
import { queueDetailsPath, taskDetailsPath } from "../paths";
import { QueueInfo } from "../reducers/queuesReducer";
import { AppState } from "../store";
import { isDarkTheme } from "../theme";
interface TabPanelProps {
children?: React.ReactNode;
selected: string; // currently selected value
value: string; // tab panel will be shown if selected value equals to the value
}
function TabPanel(props: TabPanelProps) {
const { children, value, selected, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== selected}
id={`scrollable-auto-tabpanel-${selected}`}
aria-labelledby={`scrollable-auto-tab-${selected}`}
style={{ flex: 1, overflowY: "scroll" }}
{...other}
>
{value === selected && children}
</div>
);
}
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,
groups: 0,
active: 0,
pending: 0,
aggregating: 0,
scheduled: 0,
retry: 0,
archived: 0,
completed: 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;
}
const useStyles = makeStyles((theme) => ({
container: {
width: "100%",
height: "100%",
background: theme.palette.background.paper,
},
header: {
display: "flex",
alignItems: "center",
paddingTop: theme.spacing(1),
},
heading: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
chip: {
marginLeft: theme.spacing(1),
},
taskcount: {
fontSize: "12px",
color: theme.palette.text.secondary,
background: isDarkTheme(theme)
? "#303030"
: theme.palette.background.default,
textAlign: "center",
padding: "3px 6px",
borderRadius: "10px",
marginLeft: "2px",
},
searchbar: {
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
marginRight: theme.spacing(1),
flex: 1,
},
search: {
position: "relative",
maxWidth: 400,
borderRadius: "18px",
backgroundColor: isDarkTheme(theme) ? "#303030" : theme.palette.grey[100],
"&:hover, &:focus": {
backgroundColor: isDarkTheme(theme) ? "#303030" : theme.palette.grey[200],
},
},
searchIcon: {
padding: theme.spacing(0, 2),
height: "100%",
position: "absolute",
pointerEvents: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
inputRoot: {
color: "inherit",
width: "100%",
},
inputInput: {
padding: theme.spacing(1, 1, 1, 0),
// vertical padding + font size from searchIcon
paddingLeft: `calc(1em + ${theme.spacing(4)}px)`,
width: "100%",
fontSize: "0.85rem",
},
}));
function TasksTableContainer(props: Props & ReduxProps) {
const { currentStats } = props;
const classes = useStyles();
const history = useHistory();
const chips = [
{ key: "active", label: "Active", count: currentStats.active },
{ key: "pending", label: "Pending", count: currentStats.pending },
{
key: "aggregating",
label: "Aggregating",
count: currentStats.aggregating,
},
{ key: "scheduled", label: "Scheduled", count: currentStats.scheduled },
{ key: "retry", label: "Retry", count: currentStats.retry },
{ key: "archived", label: "Archived", count: currentStats.archived },
{ key: "completed", label: "Completed", count: currentStats.completed },
];
const [searchQuery, setSearchQuery] = useState<string>("");
return (
<Paper variant="outlined" className={classes.container}>
<div className={classes.header}>
<Typography color="textPrimary" className={classes.heading}>
Tasks
</Typography>
<div>
{chips.map((c) => (
<Chip
key={c.key}
className={classes.chip}
label={
<div>
{c.label} <span className={classes.taskcount}>{c.count}</span>
</div>
}
variant="outlined"
color={props.selected === c.key ? "primary" : "default"}
onClick={() => history.push(queueDetailsPath(props.queue, c.key))}
/>
))}
</div>
<div className={classes.searchbar}>
<div className={classes.search}>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
<InputBase
placeholder="Search by ID"
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
}}
inputProps={{
"aria-label": "search",
onKeyDown: (e) => {
if (e.key === "Enter") {
history.push(
taskDetailsPath(props.queue, searchQuery.trim())
);
}
},
}}
/>
</div>
</div>
</div>
<TabPanel value="active" selected={props.selected}>
<ActiveTasksTable
queue={props.queue}
totalTaskCount={currentStats.active}
/>
</TabPanel>
<TabPanel value="pending" selected={props.selected}>
<PendingTasksTable
queue={props.queue}
totalTaskCount={currentStats.pending}
/>
</TabPanel>
<TabPanel value="aggregating" selected={props.selected}>
<AggregatingTasksTableContainer queue={props.queue} />
</TabPanel>
<TabPanel value="scheduled" selected={props.selected}>
<ScheduledTasksTable
queue={props.queue}
totalTaskCount={currentStats.scheduled}
/>
</TabPanel>
<TabPanel value="retry" selected={props.selected}>
<RetryTasksTable
queue={props.queue}
totalTaskCount={currentStats.retry}
/>
</TabPanel>
<TabPanel value="archived" selected={props.selected}>
<ArchivedTasksTable
queue={props.queue}
totalTaskCount={currentStats.archived}
/>
</TabPanel>
<TabPanel value="completed" selected={props.selected}>
<CompletedTasksTable
queue={props.queue}
totalTaskCount={currentStats.completed}
/>
</TabPanel>
</Paper>
);
}
export default connector(TasksTableContainer);

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

@ -1,18 +0,0 @@
interface Window {
// FLAG values are assigned by server under the window object.
// parseFlagsUnderWindow function parses these values and assigns the interpretted value under the window.
FLAG_ROOT_PATH: string;
FLAG_PROMETHEUS_SERVER_ADDRESS: string;
FLAG_READ_ONLY: string;
// 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;
// If true, app hides buttons/links to make non-GET requests to the API server.
READ_ONLY: boolean;
}

View File

@ -1,5 +1,4 @@
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { useLocation } from "react-router-dom";
// usePolling repeatedly calls doFn with a fix time delay specified // usePolling repeatedly calls doFn with a fix time delay specified
// by interval (in millisecond). // by interval (in millisecond).
@ -10,9 +9,3 @@ export function usePolling(doFn: () => void, interval: number) {
return () => clearInterval(id); return () => clearInterval(id);
}, [interval, doFn]); }, [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]);
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -4,13 +4,10 @@ import CssBaseline from "@material-ui/core/CssBaseline";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import App from "./App"; import App from "./App";
import store from "./store"; import store from "./store";
import parseFlagsUnderWindow from "./parseFlags";
import * as serviceWorker from "./serviceWorker"; import * as serviceWorker from "./serviceWorker";
import { saveState } from "./localStorage"; import { saveState } from "./localStorage";
import { SettingsState } from "./reducers/settingsReducer"; import { SettingsState } from "./reducers/settingsReducer";
parseFlagsUnderWindow();
let currentSettings: SettingsState | undefined = undefined; let currentSettings: SettingsState | undefined = undefined;
store.subscribe(() => { store.subscribe(() => {
const prevSettings = currentSettings; const prevSettings = currentSettings;

View File

@ -1,24 +1,16 @@
import { initialState as settingsInitialState } from "./reducers/settingsReducer"
import { AppState } from "./store"; import { AppState } from "./store";
const LOCAL_STORAGE_KEY = "asynqmon:state"; const LOCAL_STORAGE_KEY = "asynqmon:state";
export function loadState(): Partial<AppState> { export function loadState(): AppState | undefined {
try { try {
const serializedState = localStorage.getItem(LOCAL_STORAGE_KEY); const serializedState = localStorage.getItem(LOCAL_STORAGE_KEY);
if (serializedState === null) { if (serializedState === null) {
return {}; return undefined;
}
const savedState = JSON.parse(serializedState);
return {
settings: {
...settingsInitialState,
...(savedState.settings || {}),
}
} }
return JSON.parse(serializedState);
} catch (err) { } catch (err) {
console.log("loadState: could not load state ", err) return undefined;
return {};
} }
} }

View File

@ -1,43 +0,0 @@
// Prefix used for go template
const goTmplActionPrefix = "/[[";
// paseses flags (string values) assigned under the window objects by server.
export default function parseFlagsUnderWindow() {
// ROOT_PATH
if (window.FLAG_ROOT_PATH === undefined) {
console.log("ROOT_PATH is not defined. Falling back to emtpy string");
window.ROOT_PATH = "";
} else {
window.ROOT_PATH = window.FLAG_ROOT_PATH;
}
// PROMETHEUS_SERVER_ADDRESS
if (window.FLAG_PROMETHEUS_SERVER_ADDRESS === undefined) {
console.log(
"PROMETHEUS_SERVER_ADDRESS is not defined. Falling back to emtpy string"
);
window.PROMETHEUS_SERVER_ADDRESS = "";
} else if (
window.FLAG_PROMETHEUS_SERVER_ADDRESS.startsWith(goTmplActionPrefix)
) {
console.log(
"PROMETHEUS_SERVER_ADDRESS was not evaluated by the server. Falling back to empty string"
);
window.PROMETHEUS_SERVER_ADDRESS = "";
} else {
window.PROMETHEUS_SERVER_ADDRESS = window.FLAG_PROMETHEUS_SERVER_ADDRESS;
}
// READ_ONLY
if (window.FLAG_READ_ONLY === undefined) {
console.log("READ_ONLY is not defined. Falling back to false");
window.READ_ONLY = false;
} else if (window.FLAG_READ_ONLY.startsWith(goTmplActionPrefix)) {
console.log(
"READ_ONLY was not evaluated by the server. Falling back to false"
);
window.READ_ONLY = false;
} else {
window.READ_ONLY = window.FLAG_READ_ONLY === "true";
}
}

View File

@ -1,41 +1,16 @@
export const paths = () => ({ export const paths = {
HOME: `${window.ROOT_PATH}/`, HOME: "/",
SETTINGS: `${window.ROOT_PATH}/settings`, SETTINGS: "/settings",
SERVERS: `${window.ROOT_PATH}/servers`, SERVERS: "/servers",
SCHEDULERS: `${window.ROOT_PATH}/schedulers`, SCHEDULERS: "/schedulers",
QUEUE_DETAILS: `${window.ROOT_PATH}/queues/:qname`, QUEUE_DETAILS: "/queues/:qname",
REDIS: `${window.ROOT_PATH}/redis`, REDIS: "/redis",
TASK_DETAILS: `${window.ROOT_PATH}/queues/:qname/tasks/:taskId`, };
QUEUE_METRICS: `${window.ROOT_PATH}/q/metrics`,
});
/**************************************************************
Path Helper functions
**************************************************************/
export function queueDetailsPath(qname: string, taskStatus?: string): string { export function queueDetailsPath(qname: string, taskStatus?: string): string {
const path = paths().QUEUE_DETAILS.replace(":qname", qname); const path = paths.QUEUE_DETAILS.replace(":qname", qname);
if (taskStatus) { if (taskStatus) {
return `${path}?status=${taskStatus}`; return `${path}?status=${taskStatus}`;
} }
return path; return path;
} }
export function taskDetailsPath(qname: string, taskId: string): string {
return paths()
.TASK_DETAILS.replace(":qname", qname)
.replace(":taskId", taskId);
}
/**************************************************************
URL Params
**************************************************************/
export interface QueueDetailsRouteParams {
qname: string;
}
export interface TaskDetailsRouteParams {
qname: string;
taskId: string;
}

View File

@ -1,55 +0,0 @@
import {
GroupsActionTypes,
LIST_GROUPS_BEGIN,
LIST_GROUPS_ERROR,
LIST_GROUPS_SUCCESS,
} from "../actions/groupsActions";
import {
LIST_AGGREGATING_TASKS_SUCCESS,
TasksActionTypes,
} from "../actions/tasksActions";
import { GroupInfo } from "../api";
interface GroupsState {
loading: boolean;
data: GroupInfo[];
error: string;
}
const initialState: GroupsState = {
data: [],
loading: false,
error: "",
};
function groupsReducer(
state = initialState,
action: GroupsActionTypes | TasksActionTypes
): GroupsState {
switch (action.type) {
case LIST_GROUPS_BEGIN:
return { ...state, loading: true };
case LIST_GROUPS_ERROR:
return { ...state, loading: false, error: action.error };
case LIST_GROUPS_SUCCESS:
return {
...state,
loading: false,
error: "",
data: action.payload.groups,
};
case LIST_AGGREGATING_TASKS_SUCCESS:
return {
...state,
data: action.payload.groups,
};
default:
return state;
}
}
export default groupsReducer;

View File

@ -1,49 +0,0 @@
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;
}
}

Some files were not shown because too many files have changed in this diff Show More