diff --git a/main.go b/main.go
index 201f64e..faa804b 100644
--- a/main.go
+++ b/main.go
@@ -10,6 +10,7 @@ import (
"log"
"net/http"
"path/filepath"
+ "strings"
"time"
"github.com/go-redis/redis/v8"
@@ -20,13 +21,14 @@ import (
// Command-line flags
var (
- flagPort int
- flagRedisAddr string
- flagRedisDB int
- flagRedisPassword string
- flagRedisTLS string
- flagRedisURL string
- flagRedisInsecureTLS bool
+ flagPort int
+ flagRedisAddr string
+ flagRedisDB int
+ flagRedisPassword string
+ flagRedisTLS string
+ flagRedisURL string
+ flagRedisInsecureTLS bool
+ flagRedisClusterNodes string
)
func init() {
@@ -36,7 +38,8 @@ func init() {
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")
flag.StringVar(&flagRedisURL, "redis-url", "", "URL to redis server")
- flag.BoolVar(&flagRedisInsecureTLS, "redis-insecure-tls", false, "Disable TLS certificate host checks")
+ flag.BoolVar(&flagRedisInsecureTLS, "redis-insecure-tls", false, "disable TLS certificate host checks")
+ flag.StringVar(&flagRedisClusterNodes, "redis-cluster-nodes", "", "comma separated list of host:port addresses of cluster nodes")
}
// staticFileServer implements the http.Handler interface, so we can use it
@@ -88,20 +91,26 @@ func (srv *staticFileServer) indexFilePath() string {
return filepath.Join(srv.staticDirPath, srv.indexFileName)
}
-func getRedisOptionsFromFlags() (*redis.Options, error) {
- var err error
- var opts *redis.Options
+func getRedisOptionsFromFlags() (*redis.UniversalOptions, error) {
+ var opts redis.UniversalOptions
- if flagRedisURL != "" {
- opts, err = redis.ParseURL(flagRedisURL)
- if err != nil {
- return nil, err
- }
+ if flagRedisClusterNodes != "" {
+ opts.Addrs = strings.Split(flagRedisClusterNodes, ",")
+ opts.Password = flagRedisPassword
} else {
- opts = &redis.Options{
- Addr: flagRedisAddr,
- DB: flagRedisDB,
- Password: flagRedisPassword,
+ if flagRedisURL != "" {
+ res, err := redis.ParseURL(flagRedisURL)
+ if err != nil {
+ return nil, err
+ }
+ opts.Addrs = append(opts.Addrs, res.Addr)
+ opts.DB = res.DB
+ opts.Password = res.Password
+
+ } else {
+ opts.Addrs = []string{flagRedisAddr}
+ opts.DB = flagRedisDB
+ opts.Password = flagRedisPassword
}
}
@@ -114,7 +123,7 @@ func getRedisOptionsFromFlags() (*redis.Options, error) {
}
opts.TLSConfig.InsecureSkipVerify = true
}
- return opts, nil
+ return &opts, nil
}
//go:embed ui/build/*
@@ -128,16 +137,34 @@ func main() {
log.Fatal(err)
}
- inspector := asynq.NewInspector(asynq.RedisClientOpt{
- Addr: opts.Addr,
- DB: opts.DB,
- Password: opts.Password,
- TLSConfig: opts.TLSConfig,
- })
+ useRedisCluster := flagRedisClusterNodes != ""
+
+ var redisConnOpt asynq.RedisConnOpt
+ if useRedisCluster {
+ redisConnOpt = asynq.RedisClusterClientOpt{
+ Addrs: opts.Addrs,
+ Password: opts.Password,
+ TLSConfig: opts.TLSConfig,
+ }
+ } else {
+ redisConnOpt = asynq.RedisClientOpt{
+ Addr: opts.Addrs[0],
+ DB: opts.DB,
+ Password: opts.Password,
+ TLSConfig: opts.TLSConfig,
+ }
+ }
+
+ inspector := asynq.NewInspector(redisConnOpt)
defer inspector.Close()
- rdb := redis.NewClient(opts)
- defer rdb.Close()
+ var redisClient redis.UniversalClient
+ if useRedisCluster {
+ redisClient = redis.NewClusterClient(opts.Cluster())
+ } else {
+ redisClient = redis.NewClient(opts.Simple())
+ }
+ defer redisClient.Close()
router := mux.NewRouter()
router.Use(loggingMiddleware)
@@ -207,7 +234,11 @@ func main() {
api.HandleFunc("/scheduler_entries/{entry_id}/enqueue_events", newListSchedulerEnqueueEventsHandlerFunc(inspector)).Methods("GET")
// Redis info endpoint.
- api.HandleFunc("/redis_info", newRedisInfoHandlerFunc(rdb)).Methods("GET")
+ if useRedisCluster {
+ api.HandleFunc("/redis_info", newRedisClusterInfoHandlerFunc(redisClient.(*redis.ClusterClient), inspector)).Methods("GET")
+ } else {
+ api.HandleFunc("/redis_info", newRedisInfoHandlerFunc(redisClient.(*redis.Client))).Methods("GET")
+ }
fs := &staticFileServer{
contents: staticContents,
diff --git a/redis_info_handlers.go b/redis_info_handlers.go
index ac3958b..f56fa73 100644
--- a/redis_info_handlers.go
+++ b/redis_info_handlers.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/go-redis/redis/v8"
+ "github.com/hibiken/asynq"
)
// ****************************************************************************
@@ -18,21 +19,86 @@ type RedisInfoResponse struct {
Addr string `json:"address"`
Info map[string]string `json:"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"`
}
-func newRedisInfoHandlerFunc(rdb *redis.Client) http.HandlerFunc {
+type QueueLocationInfo struct {
+ 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) {
ctx := context.Background()
- res, err := rdb.Info(ctx).Result()
+ res, err := client.Info(ctx).Result()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
info := parseRedisInfo(res)
resp := RedisInfoResponse{
- Addr: rdb.Options().Addr,
+ Addr: client.Options().Addr,
Info: info,
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
diff --git a/ui/src/api.ts b/ui/src/api.ts
index eb0bdb9..0f010ca 100644
--- a/ui/src/api.ts
+++ b/ui/src/api.ts
@@ -80,6 +80,18 @@ export interface RedisInfoResponse {
address: string;
info: RedisInfo;
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
}
// Return value from redis INFO command.
diff --git a/ui/src/components/QueueLocationTable.tsx b/ui/src/components/QueueLocationTable.tsx
new file mode 100644
index 0000000..3392421
--- /dev/null
+++ b/ui/src/components/QueueLocationTable.tsx
@@ -0,0 +1,48 @@
+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 (
+
+
+
+
+ Queue
+ KeySlot
+ Node Addresses
+
+
+
+ {props.queueLocations.map((loc) => (
+
+
+ {loc.queue}
+
+ {loc.keyslot}
+ {loc.nodes.join(", ")}
+
+ ))}
+
+
+
+ );
+}
diff --git a/ui/src/reducers/redisInfoReducer.ts b/ui/src/reducers/redisInfoReducer.ts
index f995e78..da560e5 100644
--- a/ui/src/reducers/redisInfoReducer.ts
+++ b/ui/src/reducers/redisInfoReducer.ts
@@ -4,7 +4,7 @@ import {
GET_REDIS_INFO_SUCCESS,
RedisInfoActionTypes,
} from "../actions/redisInfoActions";
-import { RedisInfo } from "../api";
+import { QueueLocation, RedisInfo } from "../api";
interface RedisInfoState {
loading: boolean;
@@ -12,6 +12,9 @@ interface RedisInfoState {
address: string;
data: RedisInfo | null;
rawData: string | null;
+ cluster: boolean;
+ rawClusterNodes: string | null;
+ queueLocations: QueueLocation[] | null;
}
const initialState: RedisInfoState = {
@@ -20,6 +23,9 @@ const initialState: RedisInfoState = {
address: "",
data: null,
rawData: null,
+ cluster: false,
+ rawClusterNodes: null,
+ queueLocations: null,
};
export default function redisInfoReducer(
@@ -47,6 +53,9 @@ export default function redisInfoReducer(
address: action.payload.address,
data: action.payload.info,
rawData: action.payload.raw_info,
+ cluster: action.payload.cluster,
+ rawClusterNodes: action.payload.raw_cluster_nodes,
+ queueLocations: action.payload.queue_locations,
};
default:
diff --git a/ui/src/views/RedisInfoView.tsx b/ui/src/views/RedisInfoView.tsx
index 2209426..10e3cc4 100644
--- a/ui/src/views/RedisInfoView.tsx
+++ b/ui/src/views/RedisInfoView.tsx
@@ -13,6 +13,9 @@ import { getRedisInfoAsync } from "../actions/redisInfoActions";
import { usePolling } from "../hooks";
import { AppState } from "../store";
import { timeAgoUnix } from "../utils";
+import { RedisInfo } from "../api";
+import QueueLocationTable from "../components/QueueLocationTable";
+import Link from "@material-ui/core/Link";
const useStyles = makeStyles((theme) => ({
container: {
@@ -28,6 +31,9 @@ function mapStateToProps(state: AppState) {
redisInfo: state.redis.data,
redisAddress: state.redis.address,
redisInfoRaw: state.redis.rawData,
+ redisClusterEnabled: state.redis.cluster,
+ redisClusterNodesRaw: state.redis.rawClusterNodes,
+ queueLocations: state.redis.queueLocations,
pollInterval: state.settings.pollInterval,
themePreference: state.settings.themePreference,
};
@@ -38,7 +44,15 @@ type Props = ConnectedProps;
function RedisInfoView(props: Props) {
const classes = useStyles();
- const { pollInterval, getRedisInfoAsync, redisInfo, redisInfoRaw } = props;
+ const {
+ pollInterval,
+ getRedisInfoAsync,
+ redisInfo,
+ redisInfoRaw,
+ redisClusterEnabled,
+ redisClusterNodesRaw,
+ queueLocations,
+ } = props;
usePolling(getRedisInfoAsync, pollInterval);
// Metrics to show
@@ -56,101 +70,60 @@ function RedisInfoView(props: Props) {
<>
- Redis Info
-
-
- Connected to: {props.redisAddress}
+ {redisClusterEnabled ? "Redis Cluster Info" : "Redis Info"}
+ {!redisClusterEnabled && (
+
+ Connected to: {props.redisAddress}
+
+ )}
- {redisInfo !== null && (
+ {queueLocations && queueLocations.length > 0 && (
+
+
+ Queue Location in Cluster
+
+
+
+ )}
+ {redisClusterNodesRaw && (
<>
- Server
+
+ CLUSTER NODES
+ {" "}
+ Command Output
+
+ {redisClusterNodesRaw}
+
-
-
-
-
-
-
-
-
-
- Memory
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Connections
-
-
-
-
-
-
-
-
-
-
-
- Persistence
-
-
-
-
-
-
-
-
-
>
)}
- {redisInfoRaw !== null && (
+ {redisInfo && !redisClusterEnabled && (
+
+ )}
+ {redisInfoRaw && (
<>
- INFO Command Output
+ {redisClusterEnabled ? (
+
+ CLUSTER INFO
+
+ ) : (
+
+ INFO
+
+ )}{" "}
+ Command Output
{redisInfoRaw}
@@ -173,6 +146,86 @@ function RedisInfoView(props: Props) {
);
}
+function RedisMetricCards(props: { redisInfo: RedisInfo }) {
+ const { redisInfo } = props;
+ return (
+ <>
+
+
+ Server
+
+
+
+
+
+
+
+
+
+
+
+ Memory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connections
+
+
+
+
+
+
+
+
+
+
+
+ Persistence
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
interface MetricCardProps {
title: string;
content: string;