├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── a_question.md │ ├── bug_report.md │ ├── enhancement.md │ └── task.md └── workflows │ ├── ci.yml │ ├── integration.yml │ └── main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── BUILD.md ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Magefile.go ├── README.md ├── config └── jest-setup.ts ├── data └── dump.rdb ├── docker-compose.yml ├── docker-compose ├── cluster.yml ├── cluster │ ├── Dockerfile │ ├── cluster_tests.sh │ ├── redis.conf │ ├── startup.sh │ └── startup_cluster.sh ├── dev.yml ├── master.yml └── test.yml ├── go.mod ├── go.sum ├── jest.config.js ├── package.json ├── pkg ├── data-frame.go ├── data-frame_test.go ├── datasource.go ├── datasource_test.go ├── main.go ├── models │ ├── custom.go │ ├── redis-gears.go │ ├── redis-graph.go │ ├── redis-json.go │ ├── redis-search.go │ ├── redis-time-series.go │ └── redis.go ├── query.go ├── query_test.go ├── redis-client.go ├── redis-client_test.go ├── redis-cluster.go ├── redis-cluster_integration_test.go ├── redis-cluster_test.go ├── redis-custom.go ├── redis-custom_test.go ├── redis-gears.go ├── redis-gears_integration_test.go ├── redis-gears_test.go ├── redis-graph.go ├── redis-graph_integration_test.go ├── redis-graph_test.go ├── redis-hash.go ├── redis-hash_test.go ├── redis-info.go ├── redis-info_test.go ├── redis-json.go ├── redis-json_test.go ├── redis-search.go ├── redis-search_test.go ├── redis-set.go ├── redis-set_test.go ├── redis-stream.go ├── redis-stream_integration_test.go ├── redis-stream_test.go ├── redis-time-series.go ├── redis-time-series_integration_test.go ├── redis-time-series_test.go ├── redis-tmscan.go ├── redis-tmscan_integration_test.go ├── redis-tmscan_test.go ├── redis-zset.go ├── redis-zset_test.go ├── testing-utilities_integration_test.go ├── testing-utilities_test.go └── types.go ├── provisioning ├── dashboards │ ├── data-types.json │ └── default.yaml └── datasources │ └── redis.yaml ├── src ├── components │ ├── ConfigEditor │ │ ├── ConfigEditor.test.tsx │ │ ├── ConfigEditor.tsx │ │ └── index.ts │ ├── QueryEditor │ │ ├── QueryEditor.test.tsx │ │ ├── QueryEditor.tsx │ │ └── index.ts │ └── index.ts ├── constants.ts ├── dashboards │ ├── redis-streaming-v8.json │ └── redis.json ├── datasource │ ├── datasource.test.ts │ ├── datasource.ts │ └── index.ts ├── img │ ├── datasource.png │ ├── grafana-marketplace.png │ ├── logo.svg │ ├── query.png │ ├── redis-dashboard.png │ ├── redis-streaming.png │ └── variables.png ├── module.test.ts ├── module.ts ├── plugin.json ├── redis │ ├── command.ts │ ├── fieldValuesContainer.ts │ ├── gears.ts │ ├── graph.ts │ ├── index.ts │ ├── info.ts │ ├── json.ts │ ├── query.ts │ ├── redis.ts │ ├── search.ts │ ├── time-series.ts │ └── types.ts ├── tests │ └── utils.ts ├── time-series │ ├── index.ts │ ├── time-series.test.ts │ └── time-series.ts └── types.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 120 11 | 12 | [*.{js,ts,tsx,scss}] 13 | quote_type = single 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/a_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 🤔 3 | about: Usage question or discussion about Redis Data Source for Grafana. 4 | title: '' 5 | labels: 'kind/question' 6 | assignees: '' 7 | 8 | --- 9 | ## Summary 10 | 11 | 12 | ## Relevant information 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 🐞 3 | about: Report a bug 4 | title: '' 5 | labels: 'kind/bug' 6 | assignees: '' 7 | 8 | --- 9 | ### Describe the bug 10 | 11 | 12 | ### Version 13 | 14 | 15 | ### Steps to reproduce 16 | 21 | 22 | ### Expected behavior 23 | 24 | 25 | ### Screenshots 26 | 27 | 28 | ### Additional context 29 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement💡 3 | about: Suggest a enhancement 4 | title: '' 5 | labels: 'kind/enhancement' 6 | assignees: '' 7 | 8 | --- 9 | ### Is your enhancement related to a problem? Please describe. 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Describe alternatives you've considered 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Task 🔧 3 | about: Internal things, technical debt, and to-do tasks to be performed. 4 | title: '' 5 | labels: 'kind/task' 6 | assignees: '' 7 | 8 | --- 9 | ### Is your task related to a problem? Please describe. 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Describe alternatives you've considered 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Setup Node.js environment 13 | uses: actions/setup-node@v2.1.2 14 | with: 15 | node-version: "16.x" 16 | 17 | - name: Get yarn cache directory path 18 | id: yarn-cache-dir-path 19 | run: echo "::set-output name=dir::$(yarn cache dir)" 20 | 21 | - name: Cache yarn cache 22 | uses: actions/cache@v2 23 | id: cache-yarn-cache 24 | with: 25 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - name: Cache node_modules 31 | id: cache-node-modules 32 | uses: actions/cache@v2 33 | with: 34 | path: node_modules 35 | key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }} 36 | restore-keys: | 37 | ${{ runner.os }}-${{ matrix.node-version }}-nodemodules- 38 | 39 | - name: Install dependencies 40 | run: yarn install --frozen-lockfile; 41 | if: | 42 | steps.cache-yarn-cache.outputs.cache-hit != 'true' || 43 | steps.cache-node-modules.outputs.cache-hit != 'true' 44 | 45 | - name: Build and test frontend 46 | run: yarn build 47 | 48 | - name: Check for backend 49 | id: check-for-backend 50 | run: | 51 | if [ -f "Magefile.go" ] 52 | then 53 | echo "::set-output name=has-backend::true" 54 | fi 55 | 56 | - name: Setup Go environment 57 | if: steps.check-for-backend.outputs.has-backend == 'true' 58 | uses: actions/setup-go@v2 59 | with: 60 | go-version: "1.19" 61 | 62 | - name: Test backend 63 | if: steps.check-for-backend.outputs.has-backend == 'true' 64 | uses: magefile/mage-action@v1 65 | with: 66 | version: latest 67 | args: cover 68 | 69 | - name: Build backend 70 | if: steps.check-for-backend.outputs.has-backend == 'true' 71 | uses: magefile/mage-action@v1 72 | with: 73 | version: latest 74 | args: buildAll 75 | 76 | - name: Upload coverage to Codecov 77 | uses: codecov/codecov-action@v2 78 | with: 79 | directory: ./coverage/ 80 | files: ./coverage/lcov.info,./coverage/backend.txt 81 | env_vars: OS,PYTHON 82 | fail_ci_if_error: true 83 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration 2 | 3 | on: 4 | schedule: 5 | - cron: '0 1 * * *' # run at 1 AM UTC 6 | 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Setup Node.js environment 17 | uses: actions/setup-node@v2.1.2 18 | with: 19 | node-version: "16.x" 20 | 21 | - name: Get yarn cache directory path 22 | id: yarn-cache-dir-path 23 | run: echo "::set-output name=dir::$(yarn cache dir)" 24 | 25 | - name: Cache yarn cache 26 | uses: actions/cache@v2 27 | id: cache-yarn-cache 28 | with: 29 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 30 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-yarn- 33 | 34 | - name: Cache node_modules 35 | id: cache-node-modules 36 | uses: actions/cache@v2 37 | with: 38 | path: node_modules 39 | key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }} 40 | restore-keys: | 41 | ${{ runner.os }}-${{ matrix.node-version }}-nodemodules- 42 | 43 | - name: Install dependencies 44 | run: yarn install --frozen-lockfile; 45 | if: | 46 | steps.cache-yarn-cache.outputs.cache-hit != 'true' || 47 | steps.cache-node-modules.outputs.cache-hit != 'true' 48 | 49 | - name: Build and test frontend 50 | run: yarn build 51 | 52 | - name: Check for backend 53 | id: check-for-backend 54 | run: | 55 | if [ -f "Magefile.go" ] 56 | then 57 | echo "::set-output name=has-backend::true" 58 | fi 59 | 60 | - name: Setup Go environment 61 | if: steps.check-for-backend.outputs.has-backend == 'true' 62 | uses: actions/setup-go@v2 63 | with: 64 | go-version: "1.19" 65 | 66 | - name: Test backend 67 | if: steps.check-for-backend.outputs.has-backend == 'true' 68 | uses: magefile/mage-action@v1 69 | with: 70 | version: latest 71 | args: cover 72 | 73 | - name: Build backend 74 | if: steps.check-for-backend.outputs.has-backend == 'true' 75 | uses: magefile/mage-action@v1 76 | with: 77 | version: latest 78 | args: buildAll 79 | 80 | - name: Run integration tests 81 | uses: magefile/mage-action@v1 82 | with: 83 | version: latest 84 | args: integration 85 | 86 | - name: Run cluster integration tests 87 | uses: magefile/mage-action@v1 88 | with: 89 | version: latest 90 | args: cluster 91 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" # Run workflow on version tags, e.g. v1.0.0. 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Setup Node.js environment 16 | uses: actions/setup-node@v2.1.2 17 | with: 18 | node-version: "16.x" 19 | 20 | - name: Get yarn cache directory path 21 | id: yarn-cache-dir-path 22 | run: echo "::set-output name=dir::$(yarn cache dir)" 23 | 24 | - name: Cache yarn cache 25 | uses: actions/cache@v2 26 | id: cache-yarn-cache 27 | with: 28 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | 33 | - name: Cache node_modules 34 | id: cache-node-modules 35 | uses: actions/cache@v2 36 | with: 37 | path: node_modules 38 | key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-${{ matrix.node-version }}-nodemodules- 41 | 42 | - name: Install dependencies 43 | run: yarn install --frozen-lockfile; 44 | if: | 45 | steps.cache-yarn-cache.outputs.cache-hit != 'true' || 46 | steps.cache-node-modules.outputs.cache-hit != 'true' 47 | 48 | - name: Build and test frontend 49 | run: yarn build 50 | 51 | - name: Check for backend 52 | id: check-for-backend 53 | run: | 54 | if [ -f "Magefile.go" ] 55 | then 56 | echo "::set-output name=has-backend::true" 57 | fi 58 | 59 | - name: Setup Go environment 60 | if: steps.check-for-backend.outputs.has-backend == 'true' 61 | uses: actions/setup-go@v2 62 | with: 63 | go-version: "1.19" 64 | 65 | - name: Test backend 66 | if: steps.check-for-backend.outputs.has-backend == 'true' 67 | uses: magefile/mage-action@v1 68 | with: 69 | version: latest 70 | args: cover 71 | 72 | - name: Build backend 73 | if: steps.check-for-backend.outputs.has-backend == 'true' 74 | uses: magefile/mage-action@v1 75 | with: 76 | version: latest 77 | args: buildAll 78 | 79 | - name: Sign plugin 80 | run: yarn sign 81 | env: 82 | GRAFANA_API_KEY: ${{ secrets.GRAFANA_API_KEY }} # Requires a Grafana API key from Grafana.com. 83 | 84 | - name: Get plugin metadata 85 | id: metadata 86 | run: | 87 | sudo apt-get install jq 88 | 89 | export GRAFANA_PLUGIN_ID=$(cat dist/plugin.json | jq -r .id) 90 | export GRAFANA_PLUGIN_VERSION=$(cat dist/plugin.json | jq -r .info.version) 91 | export GRAFANA_PLUGIN_TYPE=$(cat dist/plugin.json | jq -r .type) 92 | export GRAFANA_PLUGIN_ARTIFACT=${GRAFANA_PLUGIN_ID}-${GRAFANA_PLUGIN_VERSION}.zip 93 | export GRAFANA_PLUGIN_ARTIFACT_CHECKSUM=${GRAFANA_PLUGIN_ARTIFACT}.md5 94 | 95 | echo "::set-output name=plugin-id::${GRAFANA_PLUGIN_ID}" 96 | echo "::set-output name=plugin-version::${GRAFANA_PLUGIN_VERSION}" 97 | echo "::set-output name=plugin-type::${GRAFANA_PLUGIN_TYPE}" 98 | echo "::set-output name=archive::${GRAFANA_PLUGIN_ARTIFACT}" 99 | echo "::set-output name=archive-checksum::${GRAFANA_PLUGIN_ARTIFACT_CHECKSUM}" 100 | 101 | echo ::set-output name=github-tag::${GITHUB_REF#refs/*/} 102 | 103 | - name: Read changelog 104 | id: changelog 105 | run: | 106 | awk '/^## / {s++} s == 1 {print}' CHANGELOG.md > release_notes.md 107 | echo "::set-output name=path::release_notes.md" 108 | 109 | - name: Check package version 110 | run: if [ "v${{ steps.metadata.outputs.plugin-version }}" != "${{ steps.metadata.outputs.github-tag }}" ]; then printf "\033[0;31mPlugin version doesn't match tag name\033[0m\n"; exit 1; fi 111 | 112 | - name: Package plugin 113 | id: package-plugin 114 | run: | 115 | mv dist ${{ steps.metadata.outputs.plugin-id }} 116 | zip ${{ steps.metadata.outputs.archive }} ${{ steps.metadata.outputs.plugin-id }} -r 117 | md5sum ${{ steps.metadata.outputs.archive }} > ${{ steps.metadata.outputs.archive-checksum }} 118 | echo "::set-output name=checksum::$(cat ./${{ steps.metadata.outputs.archive-checksum }} | cut -d' ' -f1)" 119 | 120 | - name: Lint plugin 121 | continue-on-error: true 122 | run: | 123 | git clone https://github.com/grafana/plugin-validator 124 | pushd ./plugin-validator/pkg/cmd/plugincheck 125 | go install 126 | popd 127 | plugincheck ${{ steps.metadata.outputs.archive }} 128 | 129 | - name: Create release 130 | id: create_release 131 | uses: actions/create-release@v1 132 | env: 133 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 134 | with: 135 | tag_name: ${{ github.ref }} 136 | release_name: Release ${{ github.ref }} 137 | body_path: ${{ steps.changelog.outputs.path }} 138 | draft: true 139 | 140 | - name: Add plugin to release 141 | id: upload-plugin-asset 142 | uses: actions/upload-release-asset@v1 143 | env: 144 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 145 | with: 146 | upload_url: ${{ steps.create_release.outputs.upload_url }} 147 | asset_path: ./${{ steps.metadata.outputs.archive }} 148 | asset_name: ${{ steps.metadata.outputs.archive }} 149 | asset_content_type: application/zip 150 | 151 | - name: Add checksum to release 152 | id: upload-checksum-asset 153 | uses: actions/upload-release-asset@v1 154 | env: 155 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 156 | with: 157 | upload_url: ${{ steps.create_release.outputs.upload_url }} 158 | asset_path: ./${{ steps.metadata.outputs.archive-checksum }} 159 | asset_name: ${{ steps.metadata.outputs.archive-checksum }} 160 | asset_content_type: text/plain 161 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Directory for instrumented libs generated by jscoverage/JSCover 9 | lib-cov 10 | 11 | # Coverage directory used by tools like istanbul 12 | coverage 13 | 14 | # Compiled binary addons (https://nodejs.org/api/addons.html) 15 | dist/ 16 | artifacts/ 17 | node_modules/ 18 | work/ 19 | ci/ 20 | e2e-results/ 21 | vendor/ 22 | 23 | # Editor 24 | .idea 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dockerignore 3 | .gitkeep 4 | .gitattributes 5 | .gitignore 6 | .editorconfig 7 | .prettierignore 8 | Dockerfile 9 | LICENSE 10 | yarn.lock 11 | go.mod 12 | go.sum 13 | *.go 14 | .github 15 | 16 | # Folders 17 | docker-compose 18 | node_modules 19 | coverage 20 | data 21 | dist 22 | img 23 | .idea 24 | vendor 25 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: false, 3 | tabWidth: 2, 4 | semi: true, 5 | bracketSpacing: true, 6 | arrowParens: 'always', 7 | ...require('./node_modules/@grafana/toolkit/src/config/prettier.plugin.config.json'), 8 | }; 9 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # How to build and install Redis Data Source 2 | 3 | You can find detailed instructions in the [Documentation](https://redisgrafana.github.io/development/redis-datasource/). 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 2 | 3 | WORKDIR /app 4 | ADD . /app 5 | RUN go mod tidy 6 | 7 | CMD ["sleep", "infinity"] 8 | -------------------------------------------------------------------------------- /Magefile.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | "path/filepath" 9 | 10 | // mage:import 11 | build "github.com/grafana/grafana-plugin-sdk-go/build" 12 | "github.com/magefile/mage/sh" 13 | ) 14 | 15 | // runs backend tests and makes a txt coverage report in "atomic" mode and html coverage report. 16 | func Cover() error { 17 | // Create a coverage file if it does not already exist 18 | if err := os.MkdirAll(filepath.Join(".", "coverage"), os.ModePerm); err != nil { 19 | return err 20 | } 21 | 22 | if err := sh.RunV("go", "test", "./pkg/...", "-v", "-cover", "-covermode=atomic", "-coverprofile=coverage/backend.txt"); err != nil { 23 | return err 24 | } 25 | 26 | if err := sh.RunV("go", "tool", "cover", "-html=coverage/backend.txt", "-o", "coverage/backend.html"); err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // up docker-compose environment and run backend tests including integration tests with coverage report 34 | func Integration() error { 35 | // Create a coverage file if it does not already exist 36 | if err := os.MkdirAll(filepath.Join(".", "coverage"), os.ModePerm); err != nil { 37 | return err 38 | } 39 | 40 | if err := Up(); err != nil { 41 | return err 42 | } 43 | 44 | defer Down() 45 | 46 | if err := sh.RunV("go", "test", "./pkg/...", "-tags=integration", "-v", "-cover", "-covermode=atomic", "-coverprofile=coverage/backend.txt"); err != nil { 47 | return err 48 | } 49 | 50 | if err := sh.RunV("go", "tool", "cover", "-html=coverage/backend.txt", "-o", "coverage/backend.html"); err != nil { 51 | return err 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // up docker-compose environment and run cluster tests 58 | func Cluster() error { 59 | if err := sh.Run("docker-compose", "-f", "docker-compose/cluster.yml", "run", "gotest", "bash", "/app/docker-compose/cluster/cluster_tests.sh"); err != nil { 60 | return err 61 | } 62 | return nil 63 | } 64 | 65 | // up docker-compose environment from integration tests 66 | func Up() error { 67 | return sh.RunV("docker-compose", "-f", "docker-compose/test.yml", "-p", "grd-integration", "up", "-d") 68 | } 69 | 70 | // down docker-compose environment from integration tests 71 | func Down() error { 72 | return sh.RunV("docker-compose", "-f", "docker-compose/test.yml", "-p", "grd-integration", "down") 73 | } 74 | 75 | // Default configures the default target. 76 | var Default = build.BuildAll 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redis Data Source for Grafana 2 | 3 | ![Dashboard](https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/master/src/img/redis-dashboard.png) 4 | 5 | [![Grafana 8](https://img.shields.io/badge/Grafana-8-orange)](https://www.grafana.com) 6 | [![Redis Data Source](https://img.shields.io/badge/dynamic/json?color=blue&label=Redis%20Data%20Source&query=%24.version&url=https%3A%2F%2Fgrafana.com%2Fapi%2Fplugins%2Fredis-datasource)](https://grafana.com/grafana/plugins/redis-datasource) 7 | [![Redis Application plugin](https://img.shields.io/badge/dynamic/json?color=blue&label=Redis%20Application%20plugin&query=%24.version&url=https%3A%2F%2Fgrafana.com%2Fapi%2Fplugins%2Fredis-app)](https://grafana.com/grafana/plugins/redis-app) 8 | [![Redis Explorer plugin](https://img.shields.io/badge/dynamic/json?color=blue&label=Redis%20Explorer%20plugin&query=%24.version&url=https%3A%2F%2Fgrafana.com%2Fapi%2Fplugins%2Fredis-explorer-app)](https://grafana.com/grafana/plugins/redis-explorer-app) 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/RedisGrafana/grafana-redis-datasource)](https://goreportcard.com/report/github.com/RedisGrafana/grafana-redis-datasource) 10 | ![CI](https://github.com/RedisGrafana/grafana-redis-datasource/workflows/CI/badge.svg) 11 | [![codecov](https://codecov.io/gh/RedisGrafana/grafana-redis-datasource/branch/master/graph/badge.svg?token=YX7995RPCF)](https://codecov.io/gh/RedisGrafana/grafana-redis-datasource) 12 | [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/RedisGrafana/grafana-redis-datasource.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/RedisGrafana/grafana-redis-datasource/context:javascript) 13 | 14 | ## Introduction 15 | 16 | The Redis Data Source for Grafana is a plugin that allows users to connect to any Redis database On-Premises and in the Cloud. It provides out-of-the-box predefined dashboards and lets you build customized dashboards to monitor Redis and application data. 17 | 18 | ### Demo 19 | 20 | Demo is available on [demo.volkovlabs.io](https://demo.volkovlabs.io): 21 | 22 | - [Redis Overview dashboard](https://demo.volkovlabs.io/d/TgibHBv7z/redis-overview?orgId=1&refresh=1h) 23 | - [Projects](https://demo.volkovlabs.io) 24 | 25 | ### Requirements 26 | 27 | - **Grafana 8.0+** is required for Redis Data Source 2.X. 28 | - **Grafana 7.1+** is required for Redis Data Source 1.X. 29 | 30 | ### Redis Application plugin 31 | 32 | You can add as many data sources as you want to support multiple Redis databases. [Redis Application plugin](https://grafana.com/grafana/plugins/redis-app) helps manage various Redis Data Sources and provides Custom panels. 33 | 34 | ## Getting Started 35 | 36 | Redis Data Source can be installed from the Grafana Marketplace or use the `grafana-cli` tool to install from the command line: 37 | 38 | ```bash 39 | grafana-cli plugins install redis-datasource 40 | ``` 41 | 42 | ![Grafana Marketplace](https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/master/src/img/grafana-marketplace.png) 43 | 44 | For Docker instructions and installation without Internet access, follow the [Quickstart](https://redisgrafana.github.io/quickstart/) page. 45 | 46 | ### Configuration 47 | 48 | Data Source allows to connect to Redis using TCP port, Unix socket, Cluster, Sentinel and supports SSL/TLS authentication. For detailed information, take a look at the [Configuration](https://redisgrafana.github.io/redis-datasource/configuration/) page. 49 | 50 | ![Datasource](https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/master/src/img/datasource.png) 51 | 52 | ## Documentation 53 | 54 | Please take a look at the [Documentation](https://redisgrafana.github.io/redis-datasource/overview/) to learn more about plugin and features. 55 | 56 | ### Supported commands 57 | 58 | List of all supported commands and how to use them with examples you can find in the [Commands](https://redisgrafana.github.io/redis-datasource/commands/) section. 59 | 60 | ![Query](https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/master/src/img/query.png) 61 | 62 | ## Development 63 | 64 | [Developing Redis Data Source](https://redisgrafana.github.io/development/redis-datasource/) page provides instructions on building the data source. 65 | 66 | Are you interested in the latest features and updates? Start nightly built [Docker image for Redis Application plugin](https://redisgrafana.github.io/development/images/), including Redis Data Source. 67 | 68 | ## Feedback 69 | 70 | We love to hear from users, developers, and the whole community interested in this plugin. These are various ways to get in touch with us: 71 | 72 | - Ask a question, request a new feature, and file a bug with [GitHub issues](https://github.com/RedisGrafana/grafana-redis-datasource/issues/new/choose). 73 | - Star the repository to show your support. 74 | 75 | ## Contributing 76 | 77 | - Fork the repository. 78 | - Find an issue to work on and submit a pull request. 79 | - Could not find an issue? Look for documentation, bugs, typos, and missing features. 80 | 81 | ## License 82 | 83 | - Apache License Version 2.0, see [LICENSE](https://github.com/RedisGrafana/grafana-redis-datasource/blob/master/LICENSE). 84 | -------------------------------------------------------------------------------- /config/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | /** 5 | * Configure for React 16 6 | */ 7 | Enzyme.configure({ adapter: new Adapter() }); 8 | -------------------------------------------------------------------------------- /data/dump.rdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/b0d65ae02d6cc0738537c70b6ae4870a4344a7d6/data/dump.rdb -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | redis: 5 | image: redis/redis-stack-server:latest 6 | ports: 7 | - '6379:6379' 8 | 9 | grafana: 10 | container_name: grafana 11 | image: grafana/grafana:latest 12 | ports: 13 | - '3000:3000' 14 | environment: 15 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 16 | - GF_AUTH_ANONYMOUS_ENABLED=true 17 | - GF_AUTH_BASIC_ENABLED=false 18 | - GF_ENABLE_GZIP=true 19 | - GF_USERS_DEFAULT_THEME=light 20 | - GF_DEFAULT_APP_MODE=development 21 | - GF_INSTALL_PLUGINS=redis-datasource 22 | volumes: 23 | - ./provisioning/datasources:/etc/grafana/provisioning/datasources 24 | # Uncomment to preserve Grafana configuration 25 | # - ./data:/var/lib/grafana 26 | -------------------------------------------------------------------------------- /docker-compose/cluster.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | networks: 4 | cluster-network: 5 | driver: bridge 6 | ipam: 7 | driver: default 8 | config: 9 | - subnet: 192.168.57.0/24 10 | 11 | services: 12 | gotest: 13 | container_name: gotest 14 | build: 15 | context: ../ 16 | links: 17 | - redis-cluster1 18 | - redis-cluster2 19 | - redis-cluster3 20 | volumes: 21 | - ../coverage:/app/coverage 22 | networks: 23 | cluster-network: 24 | ipv4_address: 192.168.57.14 25 | redis-cluster1: 26 | container_name: redis1 27 | build: 28 | context: cluster 29 | ports: 30 | - '6379:6379' 31 | - '16379' 32 | networks: 33 | cluster-network: 34 | ipv4_address: 192.168.57.10 35 | redis-cluster2: 36 | container_name: redis2 37 | build: 38 | context: cluster 39 | ports: 40 | - '6380:6379' 41 | - '16379' 42 | networks: 43 | cluster-network: 44 | ipv4_address: 192.168.57.11 45 | redis-cluster3: 46 | container_name: redis3 47 | build: 48 | context: cluster 49 | entrypoint: ['/usr/local/bin/startup_cluster.sh'] 50 | ports: 51 | - '6381:6379' 52 | - '16379' 53 | networks: 54 | cluster-network: 55 | ipv4_address: 192.168.57.12 56 | depends_on: 57 | - redis-cluster1 58 | - redis-cluster2 59 | -------------------------------------------------------------------------------- /docker-compose/cluster/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redis/redis-stack-server:7.2.0-M01 2 | 3 | RUN mkdir -p /redis 4 | 5 | WORKDIR /redis 6 | 7 | COPY redis.conf . 8 | COPY startup.sh /usr/local/bin/ 9 | COPY startup_cluster.sh /usr/local/bin/ 10 | 11 | RUN useradd redis 12 | RUN chown redis:redis /redis/* 13 | RUN chmod +x /usr/local/bin/startup.sh 14 | RUN chmod +x /usr/local/bin/startup_cluster.sh 15 | 16 | EXPOSE 6379 17 | 18 | ENTRYPOINT [ "/usr/local/bin/startup.sh" ] -------------------------------------------------------------------------------- /docker-compose/cluster/cluster_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go test ./pkg/... -tags=clusterIntegration -v 4 | last=$? 5 | if [[ $last != 0 ]]; then 6 | echo "exit code for test: " $last 7 | exit $last 8 | fi 9 | 10 | exit 0 11 | -------------------------------------------------------------------------------- /docker-compose/cluster/redis.conf: -------------------------------------------------------------------------------- 1 | port 6379 2 | 3 | protected-mode no 4 | cluster-enabled yes 5 | cluster-config-file nodes.conf 6 | cluster-node-timeout 5000 7 | daemonize yes 8 | 9 | loadmodule /opt/redis-stack/lib/redisearch.so 10 | loadmodule /opt/redis-stack/lib/redistimeseries.so 11 | loadmodule /opt/redis-stack/lib/rejson.so 12 | loadmodule /opt/redis-stack/lib/redisbloom.so -------------------------------------------------------------------------------- /docker-compose/cluster/startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | /opt/redis-stack/bin/redis-server /redis/redis.conf 4 | sleep infinity -------------------------------------------------------------------------------- /docker-compose/cluster/startup_cluster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | /opt/redis-stack/bin/redis-server /redis/redis.conf 4 | sleep 5 5 | echo hello world 6 | echo yes | redis-cli --cluster create 192.168.57.10:6379 192.168.57.11:6379 192.168.57.12:6379 7 | sleep infinity -------------------------------------------------------------------------------- /docker-compose/dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | redis: 5 | image: redis/redis-stack-server 6 | ports: 7 | - '6379:6379' 8 | volumes: 9 | - '../data:/data/:rw' 10 | 11 | grafana: 12 | container_name: grafana 13 | image: grafana/grafana:latest 14 | ports: 15 | - '3000:3000' 16 | environment: 17 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 18 | - GF_AUTH_ANONYMOUS_ENABLED=true 19 | - GF_AUTH_BASIC_ENABLED=false 20 | - GF_ENABLE_GZIP=true 21 | - GF_USERS_DEFAULT_THEME=light 22 | - GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=redis-datasource 23 | # Uncomment to run in debug mode 24 | # - GF_LOG_LEVEL=debug 25 | volumes: 26 | - ../dist:/var/lib/grafana/plugins/redis-datasource 27 | - ../provisioning:/etc/grafana/provisioning 28 | # Uncomment to preserve Grafana configuration 29 | # - ./data:/var/lib/grafana 30 | -------------------------------------------------------------------------------- /docker-compose/master.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | redis: 5 | image: redis/redis-stack-server 6 | ports: 7 | - '6379:6379' 8 | volumes: 9 | - '../data:/data/:rw' 10 | 11 | grafana: 12 | container_name: grafana 13 | image: grafana/grafana:master 14 | ports: 15 | - '3000:3000' 16 | environment: 17 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 18 | - GF_AUTH_ANONYMOUS_ENABLED=true 19 | - GF_AUTH_BASIC_ENABLED=false 20 | - GF_ENABLE_GZIP=true 21 | - GF_USERS_DEFAULT_THEME=light 22 | - GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=redis-datasource 23 | # Uncomment to run in debug mode 24 | # - GF_LOG_LEVEL=debug 25 | volumes: 26 | - ../dist:/var/lib/grafana/plugins/redis-datasource 27 | - ../provisioning:/etc/grafana/provisioning 28 | # Uncomment to preserve Grafana configuration 29 | # - ./data:/var/lib/grafana 30 | -------------------------------------------------------------------------------- /docker-compose/test.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | redis: 5 | image: redis/redis-stack-server 6 | ports: 7 | - '6379:6379' 8 | volumes: 9 | - '../data:/data/:rw' 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/redisgrafana/grafana-redis-datasource 2 | 3 | go 1.14 4 | 5 | require ( 6 | bitbucket.org/creachadair/shell v0.0.6 7 | github.com/grafana/grafana-plugin-sdk-go v0.164.0 8 | github.com/jhump/protoreflect v1.10.1 // indirect 9 | github.com/magefile/mage v1.14.0 10 | github.com/mattn/go-runewidth v0.0.13 // indirect 11 | github.com/mediocregopher/radix/v3 v3.8.1 12 | github.com/stretchr/testify v1.8.2 13 | ) 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // This file is needed because it is used by vscode and other tools that 2 | // call `jest` directly. However, unless you are doing anything special 3 | // do not edit this file 4 | 5 | const standard = require('@grafana/toolkit/src/config/jest.plugin.config'); 6 | 7 | // This process will use the same config that `yarn test` is using 8 | const grafanaJestConfig = standard.jestConfig(''); 9 | module.exports = { 10 | ...grafanaJestConfig, 11 | coveragePathIgnorePatterns: ['node_modules', 'src/tests'].concat( 12 | grafanaJestConfig.coveragePathIgnorePatterns ? grafanaJestConfig.coveragePathIgnorePatterns : [] 13 | ), 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "RedisGrafana", 3 | "description": "Redis Data Source for Grafana", 4 | "devDependencies": { 5 | "@grafana/data": "8.3.4", 6 | "@grafana/runtime": "8.3.4", 7 | "@grafana/toolkit": "8.3.4", 8 | "@grafana/ui": "8.3.4", 9 | "@types/enzyme": "^3.10.11", 10 | "@types/enzyme-adapter-react-16": "^1.0.6", 11 | "@types/lodash": "4.14.178", 12 | "@wojtekmaj/enzyme-adapter-react-17": "^0.8.0", 13 | "enzyme": "^3.11.0", 14 | "enzyme-adapter-react-16": "^1.15.5", 15 | "@types/react-dom": "18.0.11", 16 | "sinon": "^15.1.0" 17 | }, 18 | "engines": { 19 | "node": ">=14" 20 | }, 21 | "license": "Apache-2.0", 22 | "name": "redis-datasource", 23 | "scripts": { 24 | "build": "grafana-toolkit plugin:build --coverage", 25 | "build:backend": "mage -v lint && mage cover && mage -v", 26 | "dev": "grafana-toolkit plugin:dev", 27 | "format": "prettier --write \"**\"", 28 | "restart:docker:plugin": "docker exec -it grafana pkill -f redis-datasource", 29 | "sign": "grafana-toolkit plugin:sign", 30 | "start": "docker-compose pull && docker-compose up", 31 | "start:dev": "docker-compose -f docker-compose/dev.yml pull && docker-compose -f docker-compose/dev.yml up", 32 | "start:master": "docker-compose -f docker-compose/master.yml pull && docker-compose -f docker-compose/master.yml up", 33 | "stop": "docker-compose down", 34 | "stop:dev": "docker-compose -f docker-compose/dev.yml down", 35 | "test": "grafana-toolkit plugin:test --coverage", 36 | "test:backend": "mage cover", 37 | "test:backend:single": "go test ./pkg/... -v -run TestQueryJsonGet", 38 | "test:integration": "mage integration", 39 | "upgrade": "yarn upgrade --latest", 40 | "watch": "grafana-toolkit plugin:dev --watch" 41 | }, 42 | "version": "2.2.0" 43 | } 44 | -------------------------------------------------------------------------------- /pkg/data-frame.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 7 | "github.com/grafana/grafana-plugin-sdk-go/data" 8 | ) 9 | 10 | /** 11 | * Create frame with single value 12 | * 13 | * @param {string} key Key 14 | * @param {string} value Value 15 | */ 16 | func createFrameValue(key string, value string, field string) *data.Frame { 17 | frame := data.NewFrame(key) 18 | 19 | // Parse Float 20 | if floatValue, err := strconv.ParseFloat(value, 64); err == nil { 21 | frame.Fields = append(frame.Fields, data.NewField(field, nil, []float64{floatValue})) 22 | } else { 23 | frame.Fields = append(frame.Fields, data.NewField(field, nil, []string{value})) 24 | } 25 | 26 | // Return 27 | return frame 28 | } 29 | 30 | /** 31 | * Add Frame Fields from Array 32 | */ 33 | func addFrameFieldsFromArray(values []interface{}, frame *data.Frame) *data.Frame { 34 | for _, value := range values { 35 | pair := value.([]interface{}) 36 | var key string 37 | 38 | // Key 39 | switch k := pair[0].(type) { 40 | case []byte: 41 | key = string(k) 42 | default: 43 | log.DefaultLogger.Error("addFrameFieldsFromArray", "Conversion Error", "Unsupported Key type") 44 | continue 45 | } 46 | 47 | // Value 48 | switch v := pair[1].(type) { 49 | case []byte: 50 | value := string(v) 51 | 52 | // Is it Integer? 53 | if valueInt, err := strconv.ParseInt(value, 10, 64); err == nil { 54 | frame.Fields = append(frame.Fields, data.NewField(key, nil, []int64{valueInt})) 55 | break 56 | } 57 | 58 | // Add as string 59 | frame.Fields = append(frame.Fields, data.NewField(key, nil, []string{value})) 60 | case int64: 61 | frame.Fields = append(frame.Fields, data.NewField(key, nil, []int64{v})) 62 | case float64: 63 | frame.Fields = append(frame.Fields, data.NewField(key, nil, []float64{v})) 64 | default: 65 | log.DefaultLogger.Error("addFrameFieldsFromArray", "Conversion Error", "Unsupported Value type") 66 | } 67 | } 68 | 69 | return frame 70 | } 71 | -------------------------------------------------------------------------------- /pkg/data-frame_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/data" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCreateFrameValue(t *testing.T) { 11 | t.Parallel() 12 | 13 | tests := []struct { 14 | value string 15 | expected interface{} 16 | }{ 17 | {"3.14", 3.14}, 18 | {"3", float64(3)}, 19 | {"somestring", "somestring"}, 20 | } 21 | 22 | for _, tt := range tests { 23 | tt := tt 24 | t.Run(tt.value, func(t *testing.T) { 25 | t.Parallel() 26 | frame := createFrameValue("keyName", tt.value, "Value") 27 | field := frame.Fields[0].At(0) 28 | require.Equal(t, tt.expected, field, "Unexpected conversation") 29 | }) 30 | } 31 | } 32 | 33 | func TestAddFrameFieldsFromArray(t *testing.T) { 34 | t.Parallel() 35 | 36 | tests := []struct { 37 | name string 38 | values []interface{} 39 | fieldsCount int 40 | }{ 41 | { 42 | "should not parse key of not []byte type, and should not create field", 43 | []interface{}{ 44 | []interface{}{"sensor_id", []byte("2")}, 45 | }, 46 | 0, 47 | }, 48 | { 49 | "should parse value of type bytes[] with underlying int", 50 | []interface{}{ 51 | []interface{}{[]byte("sensor_id"), []byte("2")}, 52 | []interface{}{[]byte("area_id"), []byte("32")}, 53 | }, 54 | 2, 55 | }, 56 | { 57 | "should parse value of type bytes[] with underlying non-int value", 58 | []interface{}{ 59 | []interface{}{[]byte("sensor_id"), []byte("some_string")}, 60 | }, 61 | 1, 62 | }, 63 | { 64 | "should parse value of type int64", 65 | []interface{}{ 66 | []interface{}{[]byte("sensor_id"), int64(145)}, 67 | }, 68 | 1, 69 | }, 70 | { 71 | "should not parse value of not bytes[] or int64", 72 | []interface{}{ 73 | []interface{}{[]byte("sensor_id"), float32(3.14)}, 74 | }, 75 | 0, 76 | }, 77 | } 78 | 79 | for _, tt := range tests { 80 | tt := tt 81 | t.Run(tt.name, func(t *testing.T) { 82 | t.Parallel() 83 | frame := data.NewFrame("name") 84 | frame = addFrameFieldsFromArray(tt.values, frame) 85 | require.Len(t, frame.Fields, tt.fieldsCount, "Invalid number of fields created in Frame") 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" 7 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 8 | ) 9 | 10 | /** 11 | * Start listening to requests send from Grafana. 12 | * This call is blocking so it wont finish until Grafana shutsdown the process or the plugin choose to exit close down by itself 13 | */ 14 | func main() { 15 | err := datasource.Serve(newDatasource()) 16 | 17 | // Log any error if we could start the plugin. 18 | if err != nil { 19 | log.DefaultLogger.Error(err.Error()) 20 | os.Exit(1) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/models/custom.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | /** 4 | * Custom Commands 5 | */ 6 | const ( 7 | TMScan = "tmscan" 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/models/redis-gears.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | /** 4 | * RedisGears Commands 5 | */ 6 | const ( 7 | GearsPyStats = "rg.pystats" 8 | GearsDumpRegistrations = "rg.dumpregistrations" 9 | GearsPyExecute = "rg.pyexecute" 10 | GearsPyDumpReqs = "rg.pydumpreqs" 11 | ) 12 | 13 | /** 14 | * RG.PYSTATS Radix marshaling 15 | */ 16 | type PyStats struct { 17 | TotalAllocated int64 `redis:"TotalAllocated"` 18 | PeakAllocated int64 `redis:"PeakAllocated"` 19 | CurrAllocated int64 `redis:"CurrAllocated"` 20 | } 21 | 22 | /** 23 | * RG.DUMPREGISTRATIONS Radix marshaling 24 | */ 25 | type DumpRegistrations struct { 26 | ID string `redis:"id"` 27 | Reader string `redis:"reader"` 28 | Desc string `redis:"desc"` 29 | RegistrationData RegistrationData `redis:"RegistrationData"` 30 | PD string `redis:"PD"` 31 | } 32 | 33 | /** 34 | * Registration data for RG.DUMPREGISTRATIONS Radix marshaling 35 | */ 36 | type RegistrationData struct { 37 | Mode string `redis:"mode"` 38 | NumTriggered int64 `redis:"numTriggered"` 39 | NumSuccess int64 `redis:"numSuccess"` 40 | NumFailures int64 `redis:"numFailures"` 41 | NumAborted int64 `redis:"numAborted"` 42 | LastError string `redis:"lastError"` 43 | Args map[string]interface{} `redis:"args"` 44 | Status string `redis:"status"` 45 | } 46 | 47 | /** 48 | * RG.PYDUMPREQS Radix marshaling 49 | */ 50 | type PyDumpReq struct { 51 | GearReqVersion int64 `redis:"GearReqVersion"` 52 | Name string `redis:"Name"` 53 | IsDownloaded string `redis:"IsDownloaded"` 54 | IsInstalled string `redis:"IsInstalled"` 55 | CompiledOs string `redis:"CompiledOs"` 56 | Wheels interface{} `redis:"Wheels"` 57 | } 58 | -------------------------------------------------------------------------------- /pkg/models/redis-graph.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | /** 4 | * RedisGraph Commands 5 | */ 6 | const ( 7 | GraphConfig = "graph.config" 8 | GraphExplain = "graph.explain" 9 | GraphProfile = "graph.profile" 10 | GraphQuery = "graph.query" 11 | GraphSlowlog = "graph.slowlog" 12 | ) 13 | 14 | /** 15 | * Represents node 16 | */ 17 | type NodeEntry struct { 18 | Id string 19 | Title string 20 | SubTitle string 21 | MainStat string 22 | Arc int64 23 | } 24 | 25 | /** 26 | * Represents edge 27 | */ 28 | type EdgeEntry struct { 29 | Id string 30 | Source string 31 | Target string 32 | MainStat string 33 | } 34 | -------------------------------------------------------------------------------- /pkg/models/redis-json.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | /** 4 | * RedisJSON Commands 5 | */ 6 | const ( 7 | JsonType = "json.type" 8 | JsonGet = "json.get" 9 | JsonObjKeys = "json.objkeys" 10 | JsonObjLen = "json.objlen" 11 | JsonArrLen = "json.arrlen" 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/models/redis-search.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | /** 4 | * RediSearch Commands 5 | */ 6 | const ( 7 | SearchInfo = "ft.info" 8 | Search = "ft.search" 9 | ) 10 | 11 | /** 12 | * FT.INFO field configuration 13 | */ 14 | var SearchInfoConfig = map[string]string{ 15 | "inverted_sz_mb": "decmbytes", 16 | "offset_vectors_sz_mb": "decmbytes", 17 | "doc_table_size_mb": "decmbytes", 18 | "sortable_values_size_mb": "decmbytes", 19 | "key_table_size_mb": "decmbytes", 20 | "percent_indexed": "percentunit", 21 | } 22 | -------------------------------------------------------------------------------- /pkg/models/redis-time-series.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | /** 4 | * RedisTimeSeries Commands 5 | */ 6 | const ( 7 | TimeSeriesGet = "ts.get" 8 | TimeSeriesMGet = "ts.mget" 9 | TimeSeriesInfo = "ts.info" 10 | TimeSeriesQueryIndex = "ts.queryindex" 11 | TimeSeriesRange = "ts.range" 12 | TimeSeriesMRange = "ts.mrange" 13 | ) 14 | -------------------------------------------------------------------------------- /pkg/models/redis.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | /** 4 | * Redis Commands 5 | */ 6 | const ( 7 | ClientList = "clientList" 8 | ClusterInfo = "clusterInfo" 9 | ClusterNodes = "clusterNodes" 10 | Get = "get" 11 | HGet = "hget" 12 | HGetAll = "hgetall" 13 | HKeys = "hkeys" 14 | HLen = "hlen" 15 | HMGet = "hmget" 16 | Info = "info" 17 | LLen = "llen" 18 | SCard = "scard" 19 | SlowlogGet = "slowlogGet" 20 | SMembers = "smembers" 21 | TTL = "ttl" 22 | Type = "type" 23 | ZRange = "zrange" 24 | XInfoStream = "xinfoStream" 25 | XLen = "xlen" 26 | XRange = "xrange" 27 | XRevRange = "xrevrange" 28 | ) 29 | -------------------------------------------------------------------------------- /pkg/query.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/grafana/grafana-plugin-sdk-go/backend" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 9 | "github.com/mediocregopher/radix/v3/resp/resp2" 10 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 11 | ) 12 | 13 | /** 14 | * Query commands 15 | */ 16 | func query(ctx context.Context, query backend.DataQuery, client redisClient, qm queryModel) backend.DataResponse { 17 | // From and To 18 | from := query.TimeRange.From.UnixNano() / 1000000 19 | to := query.TimeRange.To.UnixNano() / 1000000 20 | 21 | // Handle Panic from any command 22 | defer func() { 23 | if err := recover(); err != nil { 24 | log.DefaultLogger.Error("PANIC", "command", err) 25 | } 26 | }() 27 | 28 | /** 29 | * Custom Command using Query 30 | */ 31 | if qm.Query != "" { 32 | return queryCustomCommand(qm, client) 33 | } 34 | 35 | /** 36 | * Supported commands 37 | */ 38 | switch qm.Command { 39 | /** 40 | * Redis Timeseries 41 | */ 42 | case models.TimeSeriesGet: 43 | return queryTsGet(qm, client) 44 | case models.TimeSeriesMGet: 45 | return queryTsMGet(qm, client) 46 | case models.TimeSeriesInfo: 47 | return queryTsInfo(qm, client) 48 | case models.TimeSeriesQueryIndex: 49 | return queryTsQueryIndex(qm, client) 50 | case models.TimeSeriesRange: 51 | return queryTsRange(from, to, qm, client) 52 | case models.TimeSeriesMRange: 53 | return queryTsMRange(from, to, qm, client) 54 | 55 | /** 56 | * Hash, Set, etc. 57 | */ 58 | case models.HGetAll: 59 | return queryHGetAll(qm, client) 60 | case models.HGet: 61 | return queryHGet(qm, client) 62 | case models.HMGet: 63 | return queryHMGet(qm, client) 64 | case models.SMembers, models.HKeys: 65 | return querySMembers(qm, client) 66 | case models.Type, models.Get, models.TTL, models.HLen, models.XLen, models.LLen, models.SCard: 67 | return queryKeyCommand(qm, client) 68 | case models.ZRange: 69 | return queryZRange(qm, client) 70 | 71 | /** 72 | * Info 73 | */ 74 | case models.Info: 75 | return queryInfo(qm, client) 76 | case models.ClientList: 77 | return queryClientList(qm, client) 78 | case models.SlowlogGet: 79 | return querySlowlogGet(qm, client) 80 | 81 | /** 82 | * Streams 83 | */ 84 | case models.XInfoStream: 85 | return queryXInfoStream(qm, client) 86 | case models.XRange: 87 | return queryXRange(from, to, qm, client) 88 | case models.XRevRange: 89 | return queryXRevRange(from, to, qm, client) 90 | 91 | /** 92 | * Cluster 93 | */ 94 | case models.ClusterInfo: 95 | return queryClusterInfo(qm, client) 96 | case models.ClusterNodes: 97 | return queryClusterNodes(qm, client) 98 | 99 | /** 100 | * RediSearch 101 | */ 102 | case models.SearchInfo: 103 | return queryFtInfo(qm, client) 104 | 105 | case models.Search: 106 | return queryFtSearch(qm, client) 107 | 108 | /** 109 | * Custom commands 110 | */ 111 | case models.TMScan: 112 | return queryTMScan(qm, client) 113 | 114 | /** 115 | * Redis Gears 116 | */ 117 | case models.GearsPyStats: 118 | return queryRgPystats(qm, client) 119 | case models.GearsDumpRegistrations: 120 | return queryRgDumpregistrations(qm, client) 121 | case models.GearsPyExecute: 122 | return queryRgPyexecute(qm, client) 123 | case models.GearsPyDumpReqs: 124 | return queryRgPydumpReqs(qm, client) 125 | 126 | /** 127 | * Redis Graph 128 | */ 129 | case models.GraphQuery: 130 | return queryGraphQuery(qm, client) 131 | case models.GraphSlowlog: 132 | return queryGraphSlowlog(qm, client) 133 | case models.GraphExplain: 134 | return queryGraphExplain(qm, client) 135 | case models.GraphProfile: 136 | return queryGraphProfile(qm, client) 137 | case models.GraphConfig: 138 | return queryGraphConfig(qm, client) 139 | 140 | /** 141 | * Redis JSON 142 | */ 143 | case models.JsonGet: 144 | return queryJsonGet(qm, client) 145 | case models.JsonObjKeys: 146 | return queryJsonObjKeys(qm, client) 147 | case models.JsonObjLen, models.JsonArrLen, models.JsonType: 148 | return queryJsonObjLen(qm, client) 149 | 150 | /** 151 | * Default 152 | */ 153 | default: 154 | response := backend.DataResponse{} 155 | log.DefaultLogger.Debug("Query", "Command", qm.Command) 156 | return response 157 | } 158 | } 159 | 160 | /** 161 | * Error Handler 162 | */ 163 | func errorHandler(response backend.DataResponse, err error) backend.DataResponse { 164 | var redisErr resp2.Error 165 | 166 | // Check for RESP2 Error 167 | if errors.As(err, &redisErr) { 168 | response.Error = redisErr.E 169 | } else { 170 | response.Error = err 171 | } 172 | 173 | // Return Response 174 | return response 175 | } 176 | 177 | /** 178 | * Commands with one key parameter and return value 179 | * 180 | * @see https://redis.io/commands/type 181 | * @see https://redis.io/commands/ttl 182 | * @see https://redis.io/commands/hlen 183 | */ 184 | func queryKeyCommand(qm queryModel, client redisClient) backend.DataResponse { 185 | response := backend.DataResponse{} 186 | 187 | // Execute command 188 | var value string 189 | err := client.RunCmd(&value, qm.Command, qm.Key) 190 | 191 | // Check error 192 | if err != nil { 193 | return errorHandler(response, err) 194 | } 195 | 196 | // Add the frames to the response 197 | response.Frames = append(response.Frames, createFrameValue(qm.Key, value, "Value")) 198 | 199 | // Return Response 200 | return response 201 | } 202 | -------------------------------------------------------------------------------- /pkg/query_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/grafana/grafana-plugin-sdk-go/backend" 10 | "github.com/mediocregopher/radix/v3/resp/resp2" 11 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | /** 16 | * Query 17 | */ 18 | func TestQuery(t *testing.T) { 19 | t.Parallel() 20 | 21 | tests := []struct { 22 | qm queryModel 23 | }{ 24 | {queryModel{Command: models.TimeSeriesGet}}, 25 | {queryModel{Command: models.TimeSeriesMGet}}, 26 | {queryModel{Command: models.TimeSeriesInfo}}, 27 | {queryModel{Command: models.TimeSeriesQueryIndex}}, 28 | {queryModel{Command: models.TimeSeriesRange}}, 29 | {queryModel{Command: models.TimeSeriesMRange}}, 30 | {queryModel{Command: models.HGetAll}}, 31 | {queryModel{Command: models.SMembers}}, 32 | {queryModel{Command: models.HKeys}}, 33 | {queryModel{Command: models.HGet}}, 34 | {queryModel{Command: models.HMGet}}, 35 | {queryModel{Command: models.Info}}, 36 | {queryModel{Command: models.ClientList}}, 37 | {queryModel{Command: models.SlowlogGet}}, 38 | {queryModel{Command: models.Type}}, 39 | {queryModel{Command: models.XInfoStream}}, 40 | {queryModel{Command: models.ClusterInfo}}, 41 | {queryModel{Command: models.ClusterNodes}}, 42 | {queryModel{Command: models.SearchInfo}}, 43 | {queryModel{Command: models.Search}}, 44 | {queryModel{Command: models.XInfoStream}}, 45 | {queryModel{Command: models.TMScan}}, 46 | {queryModel{Command: models.GearsPyStats}}, 47 | {queryModel{Command: models.GearsDumpRegistrations}}, 48 | {queryModel{Command: models.GearsPyExecute}}, 49 | {queryModel{Command: models.GearsPyDumpReqs}}, 50 | {queryModel{Command: models.XRange}}, 51 | {queryModel{Command: models.XRevRange}}, 52 | {queryModel{Command: models.GraphConfig}}, 53 | {queryModel{Command: models.GraphExplain}}, 54 | {queryModel{Command: models.GraphProfile}}, 55 | {queryModel{Command: models.GraphQuery}}, 56 | {queryModel{Command: models.GraphSlowlog}}, 57 | {queryModel{Command: models.ZRange}}, 58 | {queryModel{Command: models.JsonGet}}, 59 | {queryModel{Command: models.JsonArrLen}}, 60 | {queryModel{Command: models.JsonObjKeys}}, 61 | {queryModel{Command: models.JsonObjLen}}, 62 | {queryModel{Command: models.JsonType}}, 63 | } 64 | 65 | // Run Tests 66 | for _, tt := range tests { 67 | tt := tt 68 | t.Run(tt.qm.Command, func(t *testing.T) { 69 | t.Parallel() 70 | 71 | // Client 72 | client := testClient{rcv: nil, err: nil} 73 | 74 | // Response 75 | response := query(context.TODO(), backend.DataQuery{ 76 | RefID: "", 77 | QueryType: "", 78 | MaxDataPoints: 100, 79 | Interval: 10, 80 | TimeRange: backend.TimeRange{From: time.Now(), To: time.Now()}, 81 | }, &client, tt.qm) 82 | require.NoError(t, response.Error, "Should not return error") 83 | }) 84 | } 85 | 86 | // Custom Query 87 | t.Run("custom query", func(t *testing.T) { 88 | t.Parallel() 89 | 90 | // Client 91 | client := testClient{rcv: []interface{}{}, err: nil} 92 | qm := queryModel{Query: "Test"} 93 | 94 | // Response 95 | response := query(context.TODO(), backend.DataQuery{ 96 | RefID: "", 97 | QueryType: "", 98 | MaxDataPoints: 100, 99 | Interval: 10, 100 | TimeRange: backend.TimeRange{From: time.Now(), To: time.Now()}, 101 | }, &client, qm) 102 | require.NoError(t, response.Error, "Should not return error") 103 | }) 104 | } 105 | 106 | /** 107 | * Query with Error 108 | */ 109 | func TestQueryWithErrors(t *testing.T) { 110 | t.Parallel() 111 | 112 | // Unknown command 113 | t.Run("Unknown command failure", func(t *testing.T) { 114 | t.Parallel() 115 | 116 | // Client 117 | client := testClient{rcv: nil, err: nil} 118 | qm := queryModel{Command: "unknown"} 119 | 120 | // Response 121 | response := query(context.TODO(), backend.DataQuery{ 122 | RefID: "", 123 | QueryType: "", 124 | MaxDataPoints: 100, 125 | Interval: 10, 126 | TimeRange: backend.TimeRange{From: time.Now(), To: time.Now()}, 127 | }, &client, qm) 128 | 129 | require.NoError(t, response.Error, "Should not return error") 130 | }) 131 | 132 | } 133 | 134 | /** 135 | * Error Handler 136 | */ 137 | func TestErrorHandler(t *testing.T) { 138 | t.Parallel() 139 | 140 | t.Run("Common error", func(t *testing.T) { 141 | t.Parallel() 142 | resp := errorHandler(backend.DataResponse{}, errors.New("common error")) 143 | require.EqualError(t, resp.Error, "common error", "Should return marshalling error") 144 | }) 145 | 146 | t.Run("Redis error", func(t *testing.T) { 147 | t.Parallel() 148 | resp := errorHandler(backend.DataResponse{}, resp2.Error{E: errors.New("redis error")}) 149 | require.EqualError(t, resp.Error, "redis error", "Should return marshalling error") 150 | }) 151 | 152 | } 153 | 154 | /** 155 | * Query Command with Key 156 | */ 157 | func TestQueryKeyCommand(t *testing.T) { 158 | t.Parallel() 159 | 160 | tests := []struct { 161 | name string 162 | qm queryModel 163 | rcv interface{} 164 | fieldsCount int 165 | rowsPerField int 166 | valuesToCheckInResponse []valueToCheckInResponse 167 | err error 168 | }{ 169 | { 170 | "should handle string value", 171 | queryModel{Command: models.Get, Key: "test1"}, 172 | "someStr", 173 | 1, 174 | 1, 175 | []valueToCheckInResponse{ 176 | {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "someStr"}, 177 | }, 178 | nil, 179 | }, 180 | { 181 | "should handle float64 value", 182 | queryModel{Command: models.Get, Key: "test1"}, 183 | "3.14", 184 | 1, 185 | 1, 186 | []valueToCheckInResponse{ 187 | {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: 3.14}, 188 | }, 189 | nil, 190 | }, 191 | { 192 | "should handle error", 193 | queryModel{Command: models.Get}, 194 | nil, 195 | 0, 196 | 0, 197 | nil, 198 | errors.New("error occurred"), 199 | }, 200 | } 201 | 202 | // Run Tests 203 | for _, tt := range tests { 204 | tt := tt 205 | 206 | t.Run(tt.name, func(t *testing.T) { 207 | t.Parallel() 208 | 209 | // Client 210 | client := testClient{rcv: tt.rcv, err: tt.err} 211 | 212 | // Response 213 | response := queryKeyCommand(tt.qm, &client) 214 | if tt.err != nil { 215 | require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") 216 | require.Nil(t, response.Frames, "No frames should be created if failed") 217 | } else { 218 | require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") 219 | require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") 220 | require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") 221 | 222 | if tt.valuesToCheckInResponse != nil { 223 | for _, value := range tt.valuesToCheckInResponse { 224 | require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) 225 | } 226 | } 227 | } 228 | }) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /pkg/redis-client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "strings" 7 | "time" 8 | 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 10 | "github.com/mediocregopher/radix/v3" 11 | ) 12 | 13 | /** 14 | * Configuration Data Model for redisClient 15 | */ 16 | type redisClientConfiguration struct { 17 | URL string 18 | Client string 19 | Timeout int 20 | PoolSize int 21 | PingInterval int 22 | PipelineWindow int 23 | ACL bool 24 | TLSAuth bool 25 | TLSSkipVerify bool 26 | User string 27 | Password string 28 | TLSCACert string 29 | TLSClientCert string 30 | TLSClientKey string 31 | SentinelName string 32 | SentinelPassword string 33 | SentinelACL bool 34 | SentinelUser string 35 | } 36 | 37 | /** 38 | * Interface for running redis commands without explicit dependencies to 3-rd party libraries 39 | */ 40 | type redisClient interface { 41 | RunFlatCmd(rcv interface{}, cmd, key string, args ...interface{}) error 42 | RunCmd(rcv interface{}, cmd string, args ...string) error 43 | RunBatchFlatCmd(commands []flatCommandArgs) error 44 | Close() error 45 | } 46 | 47 | type flatCommandArgs struct { 48 | rcv interface{} 49 | cmd string 50 | key string 51 | args []interface{} 52 | } 53 | 54 | // radixClient is an interface that represents the skeleton of a connection to Redis ( cluster, standalone, or sentinel) 55 | type radixClient interface { 56 | Do(a radix.Action) error 57 | Close() error 58 | } 59 | 60 | // radixV3Impl is an implementation of redisClient using the radix/v3 library 61 | type radixV3Impl struct { 62 | radixClient radixClient 63 | } 64 | 65 | // Execute Radix FlatCmd 66 | func (client *radixV3Impl) RunFlatCmd(rcv interface{}, cmd, key string, args ...interface{}) error { 67 | return client.radixClient.Do(radix.FlatCmd(rcv, cmd, key, args...)) 68 | } 69 | 70 | // Execute Batch FlatCmd 71 | func (client *radixV3Impl) RunBatchFlatCmd(commands []flatCommandArgs) error { 72 | var actions []radix.CmdAction 73 | for _, command := range commands { 74 | actions = append(actions, radix.FlatCmd(command.rcv, command.cmd, command.key, command.args...)) 75 | } 76 | 77 | // Pipeline commands 78 | pipeline := radix.Pipeline(actions...) 79 | return client.radixClient.Do(pipeline) 80 | } 81 | 82 | // Execute Radix Cmd 83 | func (client *radixV3Impl) RunCmd(rcv interface{}, cmd string, args ...string) error { 84 | return client.radixClient.Do(radix.Cmd(rcv, cmd, args...)) 85 | } 86 | 87 | // Close connection 88 | func (client *radixV3Impl) Close() error { 89 | return client.radixClient.Close() 90 | } 91 | 92 | // Get connection options based on the provided configuration 93 | func getConnOpts(configuration redisClientConfiguration) ([]radix.DialOpt, error) { 94 | var err error 95 | opts := []radix.DialOpt{radix.DialTimeout(time.Duration(configuration.Timeout) * time.Second)} 96 | 97 | // TLS Authentication is not required 98 | if !configuration.TLSAuth { 99 | return opts, err 100 | } 101 | 102 | // TLS Config 103 | tlsConfig := &tls.Config{ 104 | InsecureSkipVerify: configuration.TLSSkipVerify, 105 | } 106 | 107 | // Certification Authority 108 | if configuration.TLSCACert != "" { 109 | caPool := x509.NewCertPool() 110 | ok := caPool.AppendCertsFromPEM([]byte(configuration.TLSCACert)) 111 | if ok { 112 | tlsConfig.RootCAs = caPool 113 | } 114 | } 115 | 116 | // Certificate and Key 117 | if configuration.TLSClientCert != "" && configuration.TLSClientKey != "" { 118 | cert, err := tls.X509KeyPair([]byte(configuration.TLSClientCert), []byte(configuration.TLSClientKey)) 119 | if err == nil { 120 | tlsConfig.Certificates = []tls.Certificate{cert} 121 | } else { 122 | log.DefaultLogger.Error("X509KeyPair", "Error", err) 123 | return nil, err 124 | } 125 | } 126 | 127 | // Add TLS Config 128 | return append(opts, radix.DialUseTLS(tlsConfig)), err 129 | } 130 | 131 | // creates new radixV3Impl implementation of redisClient interface 132 | func newRadixV3Client(configuration redisClientConfiguration) (redisClient, error) { 133 | var radixClient radixClient 134 | var err error 135 | 136 | // Set up Redis connection 137 | connFunc := func(network, addr string) (radix.Conn, error) { 138 | opts, err := getConnOpts(configuration) 139 | 140 | // Return if certificate failed 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | // Authentication 146 | if configuration.ACL { 147 | opts = append(opts, radix.DialAuthUser(configuration.User, configuration.Password)) 148 | } else if configuration.Password != "" { 149 | opts = append(opts, radix.DialAuthPass(configuration.Password)) 150 | } 151 | 152 | return radix.Dial(network, addr, opts...) 153 | } 154 | 155 | // Pool with specified Ping Interval, Pipeline Window and Timeout 156 | poolFunc := func(network, addr string) (radix.Client, error) { 157 | return radix.NewPool(network, addr, configuration.PoolSize, radix.PoolConnFunc(connFunc), 158 | radix.PoolPingInterval(time.Duration(configuration.PingInterval)*time.Second/time.Duration(configuration.PoolSize+1)), 159 | radix.PoolPipelineWindow(time.Duration(configuration.PipelineWindow)*time.Microsecond, 0)) 160 | } 161 | 162 | // Client Type 163 | switch configuration.Client { 164 | case "cluster": 165 | radixClient, err = radix.NewCluster(strings.Split(configuration.URL, ","), radix.ClusterPoolFunc(poolFunc)) 166 | case "sentinel": 167 | // Set up Sentinel connection 168 | sentinelConnFunc := func(network, addr string) (radix.Conn, error) { 169 | opts, err := getConnOpts(configuration) 170 | 171 | // Return if certificate failed 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | // Authentication 177 | if configuration.SentinelACL { 178 | opts = append(opts, radix.DialAuthUser(configuration.SentinelUser, configuration.SentinelPassword)) 179 | } else if configuration.SentinelPassword != "" { 180 | opts = append(opts, radix.DialAuthPass(configuration.SentinelPassword)) 181 | } 182 | 183 | return radix.Dial(network, addr, opts...) 184 | } 185 | 186 | radixClient, err = radix.NewSentinel(configuration.SentinelName, strings.Split(configuration.URL, ","), radix.SentinelConnFunc(sentinelConnFunc), 187 | radix.SentinelPoolFunc(poolFunc)) 188 | case "socket": 189 | radixClient, err = poolFunc("unix", configuration.URL) 190 | default: 191 | radixClient, err = poolFunc("tcp", configuration.URL) 192 | } 193 | 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | // Return Radix client 199 | var client = &radixV3Impl{radixClient} 200 | return client, nil 201 | } 202 | -------------------------------------------------------------------------------- /pkg/redis-client_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mediocregopher/radix/v3" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | /** 11 | * Radix v3 12 | */ 13 | func TestRadixV3Impl(t *testing.T) { 14 | t.Parallel() 15 | 16 | // Cmd() 17 | t.Run("should run Cmd", func(t *testing.T) { 18 | t.Parallel() 19 | 20 | // Client 21 | client := radixV3Impl{radix.Stub("tcp", "127.0.0.1:6379", func(args []string) interface{} { 22 | return args 23 | })} 24 | 25 | var result []string 26 | 27 | // Check for Errors 28 | err := client.RunCmd(&result, "Command1", "Arg1", "Arg2") 29 | require.NoError(t, err) 30 | require.Equal(t, []string{"Command1", "Arg1", "Arg2"}, result) 31 | 32 | }) 33 | 34 | // flatCmd() 35 | t.Run("should run flatCmd", func(t *testing.T) { 36 | t.Parallel() 37 | 38 | // Client 39 | client := radixV3Impl{radix.Stub("tcp", "127.0.0.1:6379", func(args []string) interface{} { 40 | return args 41 | })} 42 | var result []string 43 | 44 | // Check for Errors 45 | err := client.RunFlatCmd(&result, "Command2", "SomeKey", "Arg1", "Arg2") 46 | require.NoError(t, err) 47 | require.Equal(t, []string{"Command2", "SomeKey", "Arg1", "Arg2"}, result) 48 | }) 49 | 50 | // Batch 51 | t.Run("should have RunBatchFlatCmd", func(t *testing.T) { 52 | t.Parallel() 53 | 54 | // Client 55 | client := radixV3Impl{radix.Stub("tcp", "127.0.0.1:6379", func(args []string) interface{} { 56 | return args 57 | })} 58 | var result []string 59 | 60 | // Check for Errors 61 | err := client.RunBatchFlatCmd([]flatCommandArgs{{ 62 | rcv: &result, 63 | cmd: "Command2", 64 | key: "SomeKey", 65 | args: []interface{}{"Arg1", "Arg2"}, 66 | }}) 67 | require.NoError(t, err) 68 | require.Equal(t, []string{"Command2", "SomeKey", "Arg1", "Arg2"}, result) 69 | }) 70 | 71 | // Close 72 | t.Run("should have close method", func(t *testing.T) { 73 | t.Parallel() 74 | 75 | // Client 76 | client := radixV3Impl{radix.Stub("tcp", "127.0.0.1:6379", func(args []string) interface{} { 77 | return args 78 | })} 79 | 80 | // Check for Errors 81 | err := client.Close() 82 | require.NoError(t, err) 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/redis-cluster.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/grafana/grafana-plugin-sdk-go/data" 10 | ) 11 | 12 | /** 13 | * CLUSTER INFO 14 | * 15 | * @see https://redis.io/commands/cluster-info 16 | */ 17 | func queryClusterInfo(qm queryModel, client redisClient) backend.DataResponse { 18 | response := backend.DataResponse{} 19 | 20 | // Execute command 21 | var result string 22 | err := client.RunCmd(&result, "CLUSTER", "INFO") 23 | 24 | // Check error 25 | if err != nil { 26 | return errorHandler(response, err) 27 | } 28 | 29 | // Split lines 30 | lines := strings.Split(strings.Replace(result, "\r\n", "\n", -1), "\n") 31 | 32 | // New Frame 33 | frame := data.NewFrame(qm.Command) 34 | 35 | // Parse lines 36 | for _, line := range lines { 37 | fields := strings.Split(line, ":") 38 | 39 | if len(fields) < 2 { 40 | continue 41 | } 42 | 43 | // Add Field 44 | if floatValue, err := strconv.ParseFloat(fields[1], 64); err == nil { 45 | frame.Fields = append(frame.Fields, data.NewField(fields[0], nil, []float64{floatValue})) 46 | } else { 47 | frame.Fields = append(frame.Fields, data.NewField(fields[0], nil, []string{fields[1]})) 48 | } 49 | } 50 | 51 | // Add the frames to the response 52 | response.Frames = append(response.Frames, frame) 53 | 54 | // Return 55 | return response 56 | } 57 | 58 | /** 59 | * CLUSTER NODES 60 | * 61 | * @see https://redis.io/commands/cluster-nodes 62 | */ 63 | func queryClusterNodes(qm queryModel, client redisClient) backend.DataResponse { 64 | response := backend.DataResponse{} 65 | 66 | // Execute command 67 | var result string 68 | err := client.RunCmd(&result, "CLUSTER", "NODES") 69 | 70 | // Check error 71 | if err != nil { 72 | return errorHandler(response, err) 73 | } 74 | 75 | // Split lines 76 | lines := strings.Split(strings.Replace(result, "\r\n", "\n", -1), "\n") 77 | 78 | // New Frame 79 | frame := data.NewFrame(qm.Command, 80 | data.NewField("Id", nil, []string{}), 81 | data.NewField("Address", nil, []string{}), 82 | data.NewField("Flags", nil, []string{}), 83 | data.NewField("Master", nil, []string{}), 84 | data.NewField("Ping", nil, []int64{}), 85 | data.NewField("Pong", nil, []int64{}), 86 | data.NewField("Epoch", nil, []int64{}), 87 | data.NewField("State", nil, []string{}), 88 | data.NewField("Slot", nil, []string{})) 89 | 90 | // Set Field Config 91 | frame.Fields[4].Config = &data.FieldConfig{Unit: "ms"} 92 | frame.Fields[5].Config = &data.FieldConfig{Unit: "ms"} 93 | 94 | // Parse lines 95 | for _, line := range lines { 96 | fields := strings.Split(line, " ") 97 | 98 | // Check number of fields 99 | if len(fields) < 8 { 100 | continue 101 | } 102 | 103 | var ping int64 104 | var pong int64 105 | var epoch int64 106 | slot := "" 107 | 108 | // Parse values 109 | ping, _ = strconv.ParseInt(fields[4], 10, 64) 110 | pong, _ = strconv.ParseInt(fields[5], 10, 64) 111 | epoch, _ = strconv.ParseInt(fields[6], 10, 64) 112 | 113 | // Check Ping and convert 0 114 | if ping == 0 { 115 | ping = time.Now().UnixNano() / 1e6 116 | } 117 | 118 | // Check Pong and convert 0 119 | if pong == 0 { 120 | pong = time.Now().UnixNano() / 1e6 121 | } 122 | 123 | // Add slots which is missing for slaves 124 | if len(fields) > 8 { 125 | slot = fields[8] 126 | } 127 | 128 | // Add Query 129 | frame.AppendRow(fields[0], fields[1], fields[2], fields[3], ping, pong, epoch, fields[7], slot) 130 | } 131 | 132 | // Add the frames to the response 133 | response.Frames = append(response.Frames, frame) 134 | 135 | // Return 136 | return response 137 | } 138 | -------------------------------------------------------------------------------- /pkg/redis-cluster_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build clusterIntegration 2 | // +build clusterIntegration 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/mediocregopher/radix/v3" 8 | "github.com/stretchr/testify/require" 9 | "testing" 10 | ) 11 | 12 | func TestCluster(t *testing.T) { 13 | radixClient, err := radix.NewCluster([]string{"redis://redis-cluster1:6379", "redis://redis-cluster2:6379", "redis://redis-cluster3:6379"}) 14 | 15 | require.Nil(t, err) 16 | var client = &radixV3Impl{radixClient} 17 | var result interface{} 18 | 19 | client.RunCmd(&result, "PING") 20 | 21 | require.Equal(t, "PONG", result.(string)) 22 | } 23 | -------------------------------------------------------------------------------- /pkg/redis-cluster_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | /** 12 | * CLUSTER INFO 13 | */ 14 | func TestQueryClusterInfo(t *testing.T) { 15 | t.Parallel() 16 | 17 | tests := []struct { 18 | name string 19 | qm queryModel 20 | rcv interface{} 21 | fieldsCount int 22 | rowsPerField int 23 | valuesToCheckInResponse []valueToCheckInResponse 24 | err error 25 | }{ 26 | { 27 | "should parse clusterInfo bulk string", 28 | queryModel{Command: models.ClusterInfo}, 29 | "cluster_state:ok\r\ncluster_slots_assigned:16384\r\ncluster_slots_ok:16384\r\ncluster_slots_pfail:0\r\ncluster_slots_fail:0\r\ncluster_known_nodes:6\r\ncluster_size:3\r\ncluster_current_epoch:6\r\ncluster_my_epoch:2\r\ncluster_stats_messages_sent:1483972\r\ncluster_stats_messages_received:1483968", 30 | 11, 31 | 1, 32 | []valueToCheckInResponse{ 33 | {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "ok"}, 34 | {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: float64(16384)}, 35 | {frameIndex: 0, fieldIndex: 6, rowIndex: 0, value: float64(3)}, 36 | }, 37 | nil, 38 | }, 39 | { 40 | "should parse string and ignore non-pairing param", 41 | queryModel{Command: models.ClusterInfo}, 42 | "cluster_state:ok\r\ncluster_slots_assigned\r\ncluster_slots_ok:16384\r\ncluster_slots_pfail:0\r\ncluster_slots_fail:0\r\ncluster_known_nodes:6\r\ncluster_size:3\r\ncluster_current_epoch:6\r\ncluster_my_epoch:2\r\ncluster_stats_messages_sent:1483972\r\ncluster_stats_messages_received:1483968", 43 | 10, 44 | 1, 45 | nil, 46 | nil, 47 | }, 48 | { 49 | "should handle error", 50 | queryModel{Command: models.Info}, 51 | nil, 52 | 0, 53 | 0, 54 | nil, 55 | errors.New("error occurred"), 56 | }, 57 | } 58 | 59 | // Run Tests 60 | for _, tt := range tests { 61 | tt := tt 62 | 63 | t.Run(tt.name, func(t *testing.T) { 64 | t.Parallel() 65 | 66 | // Client 67 | client := testClient{rcv: tt.rcv, err: tt.err} 68 | 69 | // Response 70 | response := queryClusterInfo(tt.qm, &client) 71 | if tt.err != nil { 72 | require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") 73 | require.Nil(t, response.Frames, "No frames should be created if failed") 74 | } else { 75 | require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") 76 | require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") 77 | require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") 78 | 79 | if tt.valuesToCheckInResponse != nil { 80 | for _, value := range tt.valuesToCheckInResponse { 81 | require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) 82 | } 83 | } 84 | } 85 | }) 86 | } 87 | } 88 | 89 | /** 90 | * CLUSTER NODES 91 | */ 92 | func TestQueryClusterNodes(t *testing.T) { 93 | t.Parallel() 94 | 95 | tests := []struct { 96 | name string 97 | qm queryModel 98 | rcv interface{} 99 | fieldsCount int 100 | rowsPerField int 101 | valuesToCheckInResponse []valueToCheckInResponse 102 | err error 103 | }{ 104 | { 105 | "should parse clusterNodes bulk string", 106 | queryModel{Command: models.ClusterNodes}, 107 | "07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004@31004 slave e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 1609783649927 1426238317239 4 connected\r\n67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 127.0.0.1:30002@31002 master - 0 1426238316232 2 connected 5461-10922\r\n292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f 127.0.0.1:30003@31003 master - 0 1426238318243 3 connected 10923-16383\r\n6ec23923021cf3ffec47632106199cb7f496ce01 127.0.0.1:30005@31005 slave 67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 0 1426238316232 5 connected\r\n824fe116063bc5fcf9f4ffd895bc17aee7731ac3 127.0.0.1:30006@31006 slave 292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f 0 1426238317741 6 connected\r\ne7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 127.0.0.1:30001@31001 myself,master - 0 0 1 connected 0-5460", 108 | 9, 109 | 6, 110 | []valueToCheckInResponse{ 111 | {frameIndex: 0, fieldIndex: 0, rowIndex: 2, value: "292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f"}, 112 | {frameIndex: 0, fieldIndex: 1, rowIndex: 3, value: "127.0.0.1:30005@31005"}, 113 | {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: "slave"}, 114 | {frameIndex: 0, fieldIndex: 4, rowIndex: 0, value: int64(1609783649927)}, 115 | {frameIndex: 0, fieldIndex: 6, rowIndex: 3, value: int64(5)}, 116 | {frameIndex: 0, fieldIndex: 8, rowIndex: 2, value: "10923-16383"}, 117 | }, 118 | nil, 119 | }, 120 | { 121 | "should handle string with invalid number of values", 122 | queryModel{Command: models.ClusterNodes}, 123 | "07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004@31004 e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 1609783649927 1426238317239 4 connected", 124 | 9, 125 | 0, 126 | nil, 127 | nil, 128 | }, 129 | { 130 | "should handle error", 131 | queryModel{Command: models.ClusterNodes}, 132 | nil, 133 | 0, 134 | 0, 135 | nil, 136 | errors.New("error occurred"), 137 | }, 138 | } 139 | 140 | // Run Tests 141 | for _, tt := range tests { 142 | tt := tt 143 | 144 | t.Run(tt.name, func(t *testing.T) { 145 | t.Parallel() 146 | 147 | // Client 148 | client := testClient{rcv: tt.rcv, err: tt.err} 149 | 150 | // Response 151 | response := queryClusterNodes(tt.qm, &client) 152 | if tt.err != nil { 153 | require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") 154 | require.Nil(t, response.Frames, "No frames should be created if failed") 155 | } else { 156 | require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") 157 | require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") 158 | require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") 159 | 160 | if tt.valuesToCheckInResponse != nil { 161 | for _, value := range tt.valuesToCheckInResponse { 162 | require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) 163 | } 164 | } 165 | } 166 | }) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /pkg/redis-custom.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | "strings" 8 | "text/tabwriter" 9 | 10 | "bitbucket.org/creachadair/shell" 11 | "github.com/grafana/grafana-plugin-sdk-go/backend" 12 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 13 | "github.com/grafana/grafana-plugin-sdk-go/data" 14 | ) 15 | 16 | // EmptyArray for (empty array) 17 | const EmptyArray = "(empty array)" 18 | 19 | /** 20 | * Execute Query 21 | * Can PANIC if command is wrong 22 | */ 23 | func executeCustomQuery(qm queryModel, client redisClient) (interface{}, error) { 24 | var result interface{} 25 | var err error 26 | 27 | // Split query 28 | query, ok := shell.Split(qm.Query) 29 | 30 | // Check if query is valid 31 | if !ok { 32 | err = fmt.Errorf("query is not valid") 33 | return result, err 34 | } 35 | 36 | // Separate command from params 37 | command, params := query[0], query[1:] 38 | 39 | // Handle Panic from custom command to catch "should never get here" 40 | defer func() { 41 | if err := recover(); err != nil { 42 | log.DefaultLogger.Error("PANIC", "command", err, "query", qm.Query) 43 | } 44 | }() 45 | 46 | // Run command without params 47 | if len(params) == 0 { 48 | err = client.RunCmd(&result, command) 49 | return result, err 50 | } 51 | 52 | // Extract key or 1st parameter as required for RunFlatCmd 53 | key, params := params[0], params[1:] 54 | err = client.RunFlatCmd(&result, command, key, params) 55 | 56 | return result, err 57 | } 58 | 59 | /** 60 | * Parse Value 61 | */ 62 | func parseInterfaceValue(value []interface{}, response backend.DataResponse) ([]string, backend.DataResponse) { 63 | var values []string 64 | 65 | for _, element := range value { 66 | switch element := element.(type) { 67 | case []byte: 68 | values = append(values, string(element)) 69 | case int64: 70 | values = append(values, strconv.FormatInt(element, 10)) 71 | case string: 72 | values = append(values, element) 73 | case []interface{}: 74 | var parsedValues []string 75 | parsedValues, response = parseInterfaceValue(element, response) 76 | 77 | // If no values 78 | if len(parsedValues) == 0 { 79 | parsedValues = append(parsedValues, EmptyArray) 80 | } 81 | 82 | values = append(values, parsedValues...) 83 | default: 84 | response.Error = fmt.Errorf("unsupported array return type") 85 | return values, response 86 | } 87 | } 88 | 89 | return values, response 90 | } 91 | 92 | /** 93 | * Custom Command, used for CLI and Variables 94 | */ 95 | func queryCustomCommand(qm queryModel, client redisClient) backend.DataResponse { 96 | response := backend.DataResponse{} 97 | 98 | // Query is empty 99 | if qm.Query == "" { 100 | response.Error = fmt.Errorf("command is empty") 101 | return response 102 | } 103 | 104 | var result interface{} 105 | var err error 106 | 107 | // Parse and execute query 108 | result, err = executeCustomQuery(qm, client) 109 | 110 | // Check error 111 | if err != nil { 112 | return errorHandler(response, err) 113 | } 114 | 115 | // Command-line mode enabled 116 | if qm.CLI { 117 | var builder strings.Builder 118 | 119 | // Use a tab writer for having CLI-like tabulation aligned right 120 | tabWriter := tabwriter.NewWriter(&builder, 0, 1, 0, ' ', tabwriter.AlignRight) 121 | 122 | // Concatenate everything to string with proper tabs and newlines and pass it to tabWriter for tab formatting 123 | _, err := fmt.Fprint(tabWriter, convertToCLI(result, "")) 124 | 125 | // Check formatting error 126 | if err != nil { 127 | log.DefaultLogger.Error("Error when writing to TabWriter", "error", err.Error(), "query", qm.Query) 128 | } 129 | 130 | // Check tab writer error 131 | err = tabWriter.Flush() 132 | if err != nil { 133 | log.DefaultLogger.Error("Error when flushing TabWriter", "error", err.Error(), "query", qm.Query) 134 | } 135 | 136 | // Get the properly formatted string from the string builder 137 | processed := builder.String() 138 | 139 | // Write result string as a single frame with a single field with name "Value" 140 | response.Frames = append(response.Frames, data.NewFrame(qm.Key, data.NewField("Value", nil, []string{processed}))) 141 | } else { 142 | /** 143 | * Check results and add frames 144 | */ 145 | switch result := result.(type) { 146 | case int64: 147 | // Add Frame 148 | response.Frames = append(response.Frames, 149 | data.NewFrame(qm.Key, 150 | data.NewField("Value", nil, []int64{result}))) 151 | case []byte: 152 | value := string(result) 153 | 154 | // Split lines 155 | values := strings.Split(strings.Replace(value, "\r\n", "\n", -1), "\n") 156 | 157 | // Parse float if only one value 158 | if len(values) == 1 { 159 | response.Frames = append(response.Frames, createFrameValue(qm.Key, values[0], "Value")) 160 | break 161 | } 162 | 163 | // Add Frame 164 | response.Frames = append(response.Frames, 165 | data.NewFrame(qm.Key, 166 | data.NewField("Value", nil, values))) 167 | case string: 168 | // Add Frame 169 | response.Frames = append(response.Frames, createFrameValue(qm.Key, result, "Value")) 170 | case []interface{}: 171 | var values []string 172 | 173 | // Parse values 174 | if len(result) == 0 { 175 | values = append(values, EmptyArray) 176 | } else { 177 | values, response = parseInterfaceValue(result, response) 178 | } 179 | 180 | // Error when parsing interface 181 | if response.Error != nil { 182 | return response 183 | } 184 | 185 | // Add Frame 186 | response.Frames = append(response.Frames, 187 | data.NewFrame(qm.Key, 188 | data.NewField("Value", nil, values))) 189 | case nil: 190 | response.Error = fmt.Errorf("wrong command") 191 | default: 192 | response.Error = fmt.Errorf("unsupported return type") 193 | } 194 | } 195 | // Return Response 196 | return response 197 | } 198 | 199 | /** 200 | * Convert results to CLI format 201 | */ 202 | 203 | func convertToCLI(input interface{}, tabs string) string { 204 | switch value := input.(type) { 205 | case int64: 206 | return fmt.Sprintf("(integer) %d\n", value) 207 | case []byte: 208 | return fmt.Sprintf("\"%v\"\n", string(value)) 209 | case string: 210 | return fmt.Sprintf("\"%v\"\n", value) 211 | case []interface{}: 212 | if len(value) < 1 { 213 | return EmptyArray + "\n" 214 | } 215 | 216 | var builder strings.Builder 217 | for i, member := range value { 218 | additionalTabs := "" 219 | if i != 0 { 220 | additionalTabs = tabs 221 | } 222 | 223 | builder.WriteString(fmt.Sprintf("%v%d)\t %v", additionalTabs, i+1, convertToCLI(member, tabs+"\t"))) 224 | } 225 | 226 | return builder.String() 227 | case nil: 228 | return "(nil)\n" 229 | default: 230 | log.DefaultLogger.Error("Unsupported type for CLI mode", "value", value, "type", reflect.TypeOf(value).String()) 231 | return fmt.Sprintf("\"%v\"\n", value) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /pkg/redis-gears.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/grafana/grafana-plugin-sdk-go/backend" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 11 | "github.com/grafana/grafana-plugin-sdk-go/data" 12 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 13 | ) 14 | 15 | /** 16 | * RG.PYSTATS 17 | * 18 | * Returns memory usage statistics from the Python interpreter 19 | * @see https://oss.redislabs.com/redisgears/commands.html#rgpystats 20 | */ 21 | func queryRgPystats(qm queryModel, client redisClient) backend.DataResponse { 22 | response := backend.DataResponse{} 23 | 24 | // Using radix marshaling of key-value arrays to structs 25 | var stats models.PyStats 26 | 27 | // Run command 28 | err := client.RunCmd(&stats, models.GearsPyStats) 29 | 30 | // Check error 31 | if err != nil { 32 | return errorHandler(response, err) 33 | } 34 | 35 | // New Frame 36 | frame := data.NewFrame(qm.Command) 37 | response.Frames = append(response.Frames, frame) 38 | 39 | // New Fields 40 | frame.Fields = append(frame.Fields, data.NewField("TotalAllocated", nil, []int64{stats.TotalAllocated})) 41 | frame.Fields = append(frame.Fields, data.NewField("PeakAllocated", nil, []int64{stats.PeakAllocated})) 42 | frame.Fields = append(frame.Fields, data.NewField("CurrAllocated", nil, []int64{stats.CurrAllocated})) 43 | 44 | return response 45 | } 46 | 47 | /** 48 | * RG.DUMPREGISTRATIONS 49 | * 50 | * Returns the list of function registrations 51 | * @see https://oss.redislabs.com/redisgears/commands.html#rgdumpregistrations 52 | */ 53 | func queryRgDumpregistrations(qm queryModel, client redisClient) backend.DataResponse { 54 | response := backend.DataResponse{} 55 | 56 | // Using radix marshaling of key-value arrays to structs 57 | var registrations []models.DumpRegistrations 58 | 59 | // Run command 60 | err := client.RunCmd(®istrations, models.GearsDumpRegistrations) 61 | 62 | // Check error 63 | if err != nil { 64 | return errorHandler(response, err) 65 | } 66 | 67 | // New Frame for all data except of RegistrationData.args 68 | frame := data.NewFrame(qm.Command) 69 | response.Frames = append(response.Frames, frame) 70 | 71 | // New Fields 72 | frame.Fields = append(frame.Fields, data.NewField("id", nil, []string{})) 73 | frame.Fields = append(frame.Fields, data.NewField("reader", nil, []string{})) 74 | frame.Fields = append(frame.Fields, data.NewField("desc", nil, []string{})) 75 | frame.Fields = append(frame.Fields, data.NewField("PD", nil, []string{})) 76 | 77 | frame.Fields = append(frame.Fields, data.NewField("mode", nil, []string{})) 78 | frame.Fields = append(frame.Fields, data.NewField("numTriggered", nil, []int64{})) 79 | frame.Fields = append(frame.Fields, data.NewField("numSuccess", nil, []int64{})) 80 | frame.Fields = append(frame.Fields, data.NewField("numFailures", nil, []int64{})) 81 | frame.Fields = append(frame.Fields, data.NewField("numAborted", nil, []int64{})) 82 | frame.Fields = append(frame.Fields, data.NewField("lastError", nil, []string{})) 83 | frame.Fields = append(frame.Fields, data.NewField("args", nil, []string{})) 84 | frame.Fields = append(frame.Fields, data.NewField("status", nil, []string{})) 85 | 86 | // Registrations 87 | for _, registration := range registrations { 88 | // Merging args to string like "key"="value"\n 89 | args := new(bytes.Buffer) 90 | for key, value := range registration.RegistrationData.Args { 91 | fmt.Fprintf(args, "\"%s\"=\"%s\"\n", key, value) 92 | } 93 | 94 | frame.AppendRow(registration.ID, registration.Reader, registration.Desc, registration.PD, registration.RegistrationData.Mode, 95 | registration.RegistrationData.NumTriggered, registration.RegistrationData.NumSuccess, registration.RegistrationData.NumFailures, 96 | registration.RegistrationData.NumAborted, registration.RegistrationData.LastError, args.String(), registration.RegistrationData.Status) 97 | } 98 | 99 | return response 100 | } 101 | 102 | /** 103 | * RG.PYEXECUTE "" [UNBLOCKING] [REQUIREMENTS " ..."] 104 | * 105 | * Executes a Python function 106 | * @see https://oss.redislabs.com/redisgears/commands.html#rgpyexecute 107 | */ 108 | func queryRgPyexecute(qm queryModel, client redisClient) backend.DataResponse { 109 | response := backend.DataResponse{} 110 | 111 | var result interface{} 112 | 113 | // Check and create list of optional parameters 114 | var args []interface{} 115 | if qm.Unblocking { 116 | args = append(args, "UNBLOCKING") 117 | } 118 | 119 | if qm.Requirements != "" { 120 | args = append(args, "REQUIREMENTS", qm.Requirements) 121 | } 122 | 123 | // Run command 124 | err := client.RunFlatCmd(&result, models.GearsPyExecute, qm.Key, args...) 125 | 126 | // Check error 127 | if err != nil { 128 | return errorHandler(response, err) 129 | } 130 | 131 | // UNBLOCKING 132 | if qm.Unblocking { 133 | // when running with UNBLOCKING only operationId is returned 134 | frame := data.NewFrame("operationId") 135 | frame.Fields = append(frame.Fields, data.NewField("operationId", nil, []string{string(result.([]byte))})) 136 | 137 | // Adding frame to response 138 | response.Frames = append(response.Frames, frame) 139 | return response 140 | } 141 | 142 | // New Frame for results 143 | frameWithResults := data.NewFrame("results") 144 | frameWithResults.Fields = append(frameWithResults.Fields, data.NewField("results", nil, []string{})) 145 | 146 | // New Frame for errors 147 | frameWithErrors := data.NewFrame("errors") 148 | frameWithErrors.Fields = append(frameWithErrors.Fields, data.NewField("errors", nil, []string{})) 149 | 150 | // Adding frames to response 151 | response.Frames = append(response.Frames, frameWithResults) 152 | response.Frames = append(response.Frames, frameWithErrors) 153 | 154 | // Parse result 155 | switch value := result.(type) { 156 | case string: 157 | return response 158 | case []interface{}: 159 | // Inserting results 160 | for _, entry := range value[0].([]interface{}) { 161 | frameWithResults.AppendRow(string(entry.([]byte))) 162 | } 163 | 164 | // Inserting errors 165 | for _, entry := range value[1].([]interface{}) { 166 | frameWithErrors.AppendRow(string(entry.([]byte))) 167 | } 168 | return response 169 | default: 170 | log.DefaultLogger.Error("Unexpected type received", "value", value, "type", reflect.TypeOf(value).String()) 171 | return response 172 | } 173 | } 174 | 175 | /** 176 | * RG.PYDUMPREQS 177 | * 178 | * Returns a list of all the python requirements available (with information about each requirement). 179 | * @see https://oss.redislabs.com/redisgears/commands.html#rgpydumpreqs 180 | */ 181 | func queryRgPydumpReqs(qm queryModel, client redisClient) backend.DataResponse { 182 | response := backend.DataResponse{} 183 | 184 | // Using radix marshaling of key-value arrays to structs 185 | var reqs []models.PyDumpReq 186 | 187 | // Run command 188 | err := client.RunCmd(&reqs, models.GearsPyDumpReqs) 189 | 190 | // Check error 191 | if err != nil { 192 | return errorHandler(response, err) 193 | } 194 | 195 | // New Frame 196 | frame := data.NewFrame(qm.Command) 197 | response.Frames = append(response.Frames, frame) 198 | 199 | // New Fields 200 | frame.Fields = append(frame.Fields, data.NewField("GearReqVersion", nil, []int64{})) 201 | frame.Fields = append(frame.Fields, data.NewField("Name", nil, []string{})) 202 | frame.Fields = append(frame.Fields, data.NewField("IsDownloaded", nil, []string{})) 203 | frame.Fields = append(frame.Fields, data.NewField("IsInstalled", nil, []string{})) 204 | frame.Fields = append(frame.Fields, data.NewField("CompiledOs", nil, []string{})) 205 | frame.Fields = append(frame.Fields, data.NewField("Wheels", nil, []string{})) 206 | 207 | // Requirements 208 | for _, req := range reqs { 209 | var wheels string 210 | 211 | // Parse wheels 212 | switch value := req.Wheels.(type) { 213 | case []byte: 214 | wheels = string(value) 215 | case []string: 216 | wheels = strings.Join(value, ", ") 217 | case []interface{}: 218 | var values []string 219 | for _, entry := range value { 220 | values = append(values, string(entry.([]byte))) 221 | } 222 | 223 | wheels = strings.Join(values, ", ") 224 | default: 225 | log.DefaultLogger.Error("Unexpected type received", "value", value, "type", reflect.TypeOf(value).String()) 226 | wheels = "Can't parse output" 227 | } 228 | 229 | frame.AppendRow(req.GearReqVersion, req.Name, req.IsDownloaded, req.IsInstalled, req.CompiledOs, wheels) 230 | } 231 | 232 | return response 233 | } 234 | -------------------------------------------------------------------------------- /pkg/redis-gears_integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mediocregopher/radix/v3" 11 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | /** 16 | * RG.PYSTATS 17 | */ 18 | func TestRgPystatsIntegration(t *testing.T) { 19 | // Client 20 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 21 | client := radixV3Impl{radixClient: radixClient} 22 | 23 | // Response 24 | resp := queryRgPystats(queryModel{Command: models.GearsPyStats}, &client) 25 | require.Len(t, resp.Frames, 1) 26 | require.Len(t, resp.Frames[0].Fields, 3) 27 | require.IsType(t, int64(0), resp.Frames[0].Fields[0].At(0)) 28 | require.IsType(t, int64(0), resp.Frames[0].Fields[1].At(0)) 29 | require.IsType(t, int64(0), resp.Frames[0].Fields[2].At(0)) 30 | require.Equal(t, "TotalAllocated", resp.Frames[0].Fields[0].Name) 31 | require.Equal(t, "PeakAllocated", resp.Frames[0].Fields[1].Name) 32 | require.Equal(t, "CurrAllocated", resp.Frames[0].Fields[2].Name) 33 | } 34 | 35 | /** 36 | * RG.DUMPREGISTRATIONS 37 | */ 38 | func TestRgDumpregistrationsIntegration(t *testing.T) { 39 | // Client 40 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 41 | client := radixV3Impl{radixClient: radixClient} 42 | 43 | // Response 44 | resp := queryRgDumpregistrations(queryModel{Command: models.GearsDumpRegistrations}, &client) 45 | require.Len(t, resp.Frames[0].Fields, 12) 46 | require.Equal(t, "id", resp.Frames[0].Fields[0].Name) 47 | require.Equal(t, "reader", resp.Frames[0].Fields[1].Name) 48 | require.Equal(t, "desc", resp.Frames[0].Fields[2].Name) 49 | require.Equal(t, "PD", resp.Frames[0].Fields[3].Name) 50 | require.Equal(t, "mode", resp.Frames[0].Fields[4].Name) 51 | require.Equal(t, "numTriggered", resp.Frames[0].Fields[5].Name) 52 | require.Equal(t, "numSuccess", resp.Frames[0].Fields[6].Name) 53 | require.Equal(t, "numFailures", resp.Frames[0].Fields[7].Name) 54 | require.Equal(t, "numAborted", resp.Frames[0].Fields[8].Name) 55 | require.Equal(t, "lastError", resp.Frames[0].Fields[9].Name) 56 | require.Equal(t, "args", resp.Frames[0].Fields[10].Name) 57 | for i := 0; i < len(resp.Frames[0].Fields); i++ { 58 | require.Equal(t, 3, resp.Frames[0].Fields[0].Len()) 59 | } 60 | } 61 | 62 | /** 63 | * RG.PYEXECUTE 64 | */ 65 | func TestRgPyexecuteIntegration(t *testing.T) { 66 | // Increase timeout to 30 seconds for requirements 67 | customConnFunc := func(network, addr string) (radix.Conn, error) { 68 | return radix.Dial(network, addr, 69 | radix.DialTimeout(30*time.Second), 70 | ) 71 | } 72 | 73 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10, radix.PoolConnFunc(customConnFunc)) 74 | client := radixV3Impl{radixClient: radixClient} 75 | 76 | // Results 77 | t.Run("Test command with full response", func(t *testing.T) { 78 | resp := queryRgPyexecute(queryModel{Command: models.GearsPyExecute, Key: "GB().run()"}, &client) 79 | require.Len(t, resp.Frames, 2) 80 | require.Len(t, resp.Frames[0].Fields, 1) 81 | require.Equal(t, "results", resp.Frames[0].Name) 82 | require.Equal(t, "results", resp.Frames[0].Fields[0].Name) 83 | require.Greater(t, resp.Frames[0].Fields[0].Len(), 0) 84 | require.IsType(t, "", resp.Frames[0].Fields[0].At(0)) 85 | require.Len(t, resp.Frames[1].Fields, 1) 86 | require.Equal(t, "errors", resp.Frames[1].Name) 87 | require.Equal(t, "errors", resp.Frames[1].Fields[0].Name) 88 | require.NoError(t, resp.Error) 89 | }) 90 | 91 | // UNBLOCKING and REQUIREMENTS 92 | t.Run("Test command with UNBLOCKING and REQUIREMENTS", func(t *testing.T) { 93 | resp := queryRgPyexecute(queryModel{Command: models.GearsPyExecute, Key: "GearsBuilder(reader=\"KeysReader\").run()", Unblocking: true, Requirements: "numpy"}, &client) 94 | require.Len(t, resp.Frames, 1) 95 | require.Len(t, resp.Frames[0].Fields, 1) 96 | require.Equal(t, "operationId", resp.Frames[0].Name) 97 | require.Equal(t, "operationId", resp.Frames[0].Fields[0].Name) 98 | require.Greater(t, resp.Frames[0].Fields[0].Len(), 0) 99 | require.IsType(t, "", resp.Frames[0].Fields[0].At(0)) 100 | }) 101 | 102 | // OK 103 | t.Run("Test command with full OK string", func(t *testing.T) { 104 | resp := queryRgPyexecute(queryModel{Command: models.GearsPyExecute, Key: "GB('CommandReader')"}, &client) 105 | require.Len(t, resp.Frames, 2) 106 | require.Len(t, resp.Frames[0].Fields, 1) 107 | require.Equal(t, "results", resp.Frames[0].Name) 108 | require.Equal(t, "results", resp.Frames[0].Fields[0].Name) 109 | require.Equal(t, 0, resp.Frames[0].Fields[0].Len()) 110 | require.Len(t, resp.Frames[1].Fields, 1) 111 | require.Equal(t, "errors", resp.Frames[1].Name) 112 | require.Equal(t, "errors", resp.Frames[1].Fields[0].Name) 113 | require.Equal(t, 0, resp.Frames[1].Fields[0].Len()) 114 | require.NoError(t, resp.Error) 115 | }) 116 | 117 | // Error 118 | t.Run("Test command with error", func(t *testing.T) { 119 | resp := queryRgPyexecute(queryModel{Command: models.GearsPyExecute, Key: "some key"}, &client) 120 | require.Len(t, resp.Frames, 0) 121 | require.Error(t, resp.Error) 122 | }) 123 | } 124 | 125 | /** 126 | * RG.DUMPREQS 127 | */ 128 | func TestRgDumpReqsIntegration(t *testing.T) { 129 | // Client 130 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 131 | client := radixV3Impl{radixClient: radixClient} 132 | 133 | // Response 134 | resp := queryRgPydumpReqs(queryModel{Command: models.GearsPyDumpReqs}, &client) 135 | 136 | require.Len(t, resp.Frames[0].Fields, 6) 137 | require.Equal(t, "GearReqVersion", resp.Frames[0].Fields[0].Name) 138 | require.Equal(t, "Name", resp.Frames[0].Fields[1].Name) 139 | require.Equal(t, "IsDownloaded", resp.Frames[0].Fields[2].Name) 140 | require.Equal(t, "IsInstalled", resp.Frames[0].Fields[3].Name) 141 | require.Equal(t, "CompiledOs", resp.Frames[0].Fields[4].Name) 142 | require.Equal(t, "Wheels", resp.Frames[0].Fields[5].Name) 143 | for i := 0; i < len(resp.Frames[0].Fields); i++ { 144 | require.Equal(t, 1, resp.Frames[0].Fields[0].Len()) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /pkg/redis-graph_integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/mediocregopher/radix/v3" 10 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | /** 15 | * GRAPH.QUERY 16 | */ 17 | func TestGraphQueryIntegration(t *testing.T) { 18 | // Client 19 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 20 | client := radixV3Impl{radixClient: radixClient} 21 | 22 | // Response 23 | resp := queryGraphQuery(queryModel{Command: models.GraphQuery, Key: "GOT_DEMO", Cypher: "MATCH (w:writer)-[r:wrote]->(b:book) return w,r,b"}, &client) 24 | require.Len(t, resp.Frames, 4) 25 | require.Len(t, resp.Frames[0].Fields, 5) 26 | require.Equal(t, "id", resp.Frames[0].Fields[0].Name) 27 | require.Equal(t, "title", resp.Frames[0].Fields[1].Name) 28 | require.Equal(t, "subTitle", resp.Frames[0].Fields[2].Name) 29 | require.Equal(t, "mainStat", resp.Frames[0].Fields[3].Name) 30 | require.Equal(t, "arc__", resp.Frames[0].Fields[4].Name) 31 | require.Equal(t, 15, resp.Frames[0].Fields[0].Len()) 32 | require.Len(t, resp.Frames[1].Fields, 4) 33 | require.Equal(t, "id", resp.Frames[1].Fields[0].Name) 34 | require.Equal(t, "source", resp.Frames[1].Fields[1].Name) 35 | require.Equal(t, "target", resp.Frames[1].Fields[2].Name) 36 | require.Equal(t, "mainStat", resp.Frames[1].Fields[3].Name) 37 | require.Equal(t, 14, resp.Frames[1].Fields[0].Len()) 38 | } 39 | 40 | func TestGraphQueryIntegrationWithoutRelations(t *testing.T) { 41 | // Client 42 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 43 | client := radixV3Impl{radixClient: radixClient} 44 | 45 | // Response 46 | resp := queryGraphQuery(queryModel{Command: models.GraphQuery, Key: "GOT_DEMO", Cypher: "MATCH (w:writer)-[wrote]->(b:book) return w,b"}, &client) 47 | require.Len(t, resp.Frames, 3) 48 | require.Len(t, resp.Frames[0].Fields, 5) 49 | require.Equal(t, 15, resp.Frames[0].Fields[0].Len()) 50 | require.Len(t, resp.Frames[1].Fields, 2) 51 | require.Equal(t, 14, resp.Frames[1].Fields[0].Len()) 52 | } 53 | 54 | func TestGraphQueryIntegrationWithoutNodes(t *testing.T) { 55 | // Client 56 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 57 | client := radixV3Impl{radixClient: radixClient} 58 | 59 | // Response 60 | resp := queryGraphQuery(queryModel{Command: models.GraphQuery, Key: "GOT_DEMO", Cypher: "MATCH (w:writer)-[r:wrote]->(b:book) return r"}, &client) 61 | require.Len(t, resp.Frames, 3) 62 | require.Len(t, resp.Frames[0].Fields, 4) 63 | require.Equal(t, 14, resp.Frames[0].Fields[0].Len()) 64 | require.Len(t, resp.Frames[1].Fields, 1) 65 | require.Equal(t, 14, resp.Frames[1].Fields[0].Len()) 66 | } 67 | 68 | /** 69 | * GRAPH.SLOWLOG 70 | */ 71 | func TestGraphSlowlogIntegration(t *testing.T) { 72 | // Client 73 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("127.0.0.1:%d", integrationTestPort), 10) 74 | client := radixV3Impl{radixClient: radixClient} 75 | 76 | // Response 77 | resp := queryGraphSlowlog(queryModel{Command: models.GraphSlowlog, Key: "GOT_DEMO"}, &client) 78 | require.Len(t, resp.Frames, 1) 79 | require.Len(t, resp.Frames[0].Fields, 4) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/redis-hash.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "bitbucket.org/creachadair/shell" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/grafana/grafana-plugin-sdk-go/data" 10 | ) 11 | 12 | /** 13 | * HGETALL key 14 | * 15 | * @see https://redis.io/commands/hgetall 16 | */ 17 | func queryHGetAll(qm queryModel, client redisClient) backend.DataResponse { 18 | response := backend.DataResponse{} 19 | 20 | // Execute command 21 | var result []string 22 | err := client.RunFlatCmd(&result, qm.Command, qm.Key) 23 | 24 | // Check error 25 | if err != nil { 26 | return errorHandler(response, err) 27 | } 28 | 29 | // New Frame 30 | frame := data.NewFrame(qm.Command) 31 | 32 | // Add fields and values 33 | for i := 0; i < len(result); i += 2 { 34 | if floatValue, err := strconv.ParseFloat(result[i+1], 64); err == nil { 35 | frame.Fields = append(frame.Fields, data.NewField(result[i], nil, []float64{floatValue})) 36 | } else { 37 | frame.Fields = append(frame.Fields, data.NewField(result[i], nil, []string{result[i+1]})) 38 | } 39 | } 40 | 41 | // Add the frames to the response 42 | response.Frames = append(response.Frames, frame) 43 | 44 | // Return 45 | return response 46 | } 47 | 48 | /** 49 | * HGET key field 50 | * 51 | * @see https://redis.io/commands/hget 52 | */ 53 | func queryHGet(qm queryModel, client redisClient) backend.DataResponse { 54 | response := backend.DataResponse{} 55 | 56 | // Execute command 57 | var value string 58 | err := client.RunFlatCmd(&value, qm.Command, qm.Key, qm.Field) 59 | 60 | // Check error 61 | if err != nil { 62 | return errorHandler(response, err) 63 | } 64 | 65 | // Add the frames to the response 66 | response.Frames = append(response.Frames, createFrameValue(qm.Field, value, qm.Field)) 67 | 68 | // Return 69 | return response 70 | } 71 | 72 | /** 73 | * HMGET key field [field ...] 74 | * 75 | * @see https://redis.io/commands/hmget 76 | */ 77 | func queryHMGet(qm queryModel, client redisClient) backend.DataResponse { 78 | response := backend.DataResponse{} 79 | 80 | // Split Field to array 81 | fields, ok := shell.Split(qm.Field) 82 | 83 | // Check if filter is valid 84 | if !ok { 85 | response.Error = fmt.Errorf("field is not valid") 86 | return response 87 | } 88 | 89 | // Execute command 90 | var result []string 91 | err := client.RunFlatCmd(&result, qm.Command, qm.Key, fields) 92 | 93 | // Check error 94 | if err != nil { 95 | return errorHandler(response, err) 96 | } 97 | 98 | // New Frame 99 | frame := data.NewFrame(qm.Command) 100 | 101 | // Parse results and add fields 102 | for i, value := range result { 103 | if floatValue, err := strconv.ParseFloat(value, 64); err == nil { 104 | frame.Fields = append(frame.Fields, data.NewField(fields[i], nil, []float64{floatValue})) 105 | } else { 106 | frame.Fields = append(frame.Fields, data.NewField(fields[i], nil, []string{value})) 107 | } 108 | } 109 | 110 | // Add the frames to the response 111 | response.Frames = append(response.Frames, frame) 112 | 113 | // Return 114 | return response 115 | } 116 | -------------------------------------------------------------------------------- /pkg/redis-hash_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | /** 12 | * HGETALL 13 | */ 14 | func TestQueryHGetAll(t *testing.T) { 15 | t.Parallel() 16 | 17 | tests := []struct { 18 | name string 19 | qm queryModel 20 | rcv interface{} 21 | fieldsCount int 22 | rowsPerField int 23 | valuesToCheckInResponse []valueToCheckInResponse 24 | err error 25 | }{ 26 | { 27 | "should handle default array of strings", 28 | queryModel{Command: models.HGetAll, Key: "test1"}, 29 | []string{"key1", "value1", "key2", "2", "key3", "3.14"}, 30 | 3, 31 | 1, 32 | []valueToCheckInResponse{ 33 | {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "value1"}, 34 | {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: float64(2)}, 35 | {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: 3.14}, 36 | }, 37 | nil, 38 | }, 39 | { 40 | "should handle error", 41 | queryModel{Command: models.HGetAll}, 42 | nil, 43 | 0, 44 | 0, 45 | nil, 46 | errors.New("error occurred"), 47 | }, 48 | } 49 | 50 | // Run Tests 51 | for _, tt := range tests { 52 | tt := tt 53 | t.Run(tt.name, func(t *testing.T) { 54 | t.Parallel() 55 | 56 | client := testClient{rcv: tt.rcv, err: tt.err} 57 | response := queryHGetAll(tt.qm, &client) 58 | if tt.err != nil { 59 | require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") 60 | require.Nil(t, response.Frames, "No frames should be created if failed") 61 | } else { 62 | require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") 63 | require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") 64 | require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") 65 | 66 | if tt.valuesToCheckInResponse != nil { 67 | for _, value := range tt.valuesToCheckInResponse { 68 | require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) 69 | } 70 | } 71 | } 72 | }) 73 | } 74 | } 75 | 76 | /** 77 | * HGET 78 | */ 79 | func TestQueryHGet(t *testing.T) { 80 | t.Parallel() 81 | 82 | tests := []struct { 83 | name string 84 | qm queryModel 85 | rcv interface{} 86 | fieldsCount int 87 | rowsPerField int 88 | value interface{} 89 | err error 90 | field string 91 | }{ 92 | { 93 | "should handle simple string", 94 | queryModel{Command: models.HGet, Key: "test1", Field: "field1"}, 95 | "value1", 96 | 1, 97 | 1, 98 | "value1", 99 | nil, 100 | "field1", 101 | }, 102 | { 103 | "should handle string with underlying float64 value", 104 | queryModel{Command: models.HGet, Key: "test1", Field: "key1"}, 105 | "3.14", 106 | 1, 107 | 1, 108 | 3.14, 109 | nil, 110 | "key1", 111 | }, 112 | { 113 | "should handle error", 114 | queryModel{Command: models.HGet}, 115 | nil, 116 | 0, 117 | 0, 118 | nil, 119 | errors.New("error occurred"), 120 | "", 121 | }, 122 | } 123 | 124 | // Run Tests 125 | for _, tt := range tests { 126 | tt := tt 127 | t.Run(tt.name, func(t *testing.T) { 128 | t.Parallel() 129 | 130 | client := testClient{rcv: tt.rcv, err: tt.err} 131 | response := queryHGet(tt.qm, &client) 132 | if tt.err != nil { 133 | require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") 134 | require.Nil(t, response.Frames, "No frames should be created if failed") 135 | } else { 136 | require.Equal(t, tt.qm.Field, response.Frames[0].Name, "Invalid frame name") 137 | require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") 138 | require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") 139 | require.Equal(t, tt.value, response.Frames[0].Fields[0].At(0), "Invalid value contained in frame") 140 | require.Equal(t, tt.field, response.Frames[0].Fields[0].Name, "Invalid field name contained in frame") 141 | 142 | } 143 | }) 144 | } 145 | } 146 | 147 | /** 148 | * HMGET 149 | */ 150 | func TestQueryHMGet(t *testing.T) { 151 | t.Parallel() 152 | 153 | tests := []struct { 154 | name string 155 | qm queryModel 156 | rcv interface{} 157 | fieldsCount int 158 | rowsPerField int 159 | shouldCreateFrames bool 160 | valuesToCheckInResponse []valueToCheckInResponse 161 | err error 162 | }{ 163 | { 164 | "should handle 3 fields with different underlying types", 165 | queryModel{Command: models.HMGet, Key: "test1", Field: "field1 field2 field3"}, 166 | []string{"value1", "2", "3.14"}, 167 | 3, 168 | 1, 169 | true, 170 | []valueToCheckInResponse{ 171 | {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "value1"}, 172 | {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: float64(2)}, 173 | {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: 3.14}, 174 | }, 175 | nil, 176 | }, 177 | { 178 | "should handle Field string parsing error and create no fields", 179 | queryModel{Command: models.HMGet, Key: "test1", Field: "field1 field2\"field3"}, 180 | nil, 181 | 0, 182 | 0, 183 | false, 184 | nil, 185 | nil, 186 | }, 187 | { 188 | "should handle error", 189 | queryModel{Command: models.HMGet}, 190 | nil, 191 | 0, 192 | 0, 193 | false, 194 | nil, 195 | errors.New("error occurred"), 196 | }, 197 | } 198 | 199 | // Run Tests 200 | for _, tt := range tests { 201 | tt := tt 202 | t.Run(tt.name, func(t *testing.T) { 203 | t.Parallel() 204 | 205 | client := testClient{rcv: tt.rcv, err: tt.err} 206 | response := queryHMGet(tt.qm, &client) 207 | if tt.err != nil { 208 | require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") 209 | require.Nil(t, response.Frames, "No frames should be created if failed") 210 | } else { 211 | if tt.shouldCreateFrames { 212 | require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") 213 | require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") 214 | require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") 215 | for _, value := range tt.valuesToCheckInResponse { 216 | require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) 217 | } 218 | } else { 219 | require.Nil(t, response.Frames, "Should not create frames in response") 220 | } 221 | } 222 | }) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /pkg/redis-info.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 10 | "github.com/grafana/grafana-plugin-sdk-go/data" 11 | ) 12 | 13 | /** 14 | * INFO [section] 15 | * 16 | * @see https://redis.io/commands/info 17 | */ 18 | func queryInfo(qm queryModel, client redisClient) backend.DataResponse { 19 | response := backend.DataResponse{} 20 | 21 | // Execute command 22 | var result string 23 | err := client.RunCmd(&result, qm.Command, qm.Section) 24 | 25 | // Check error 26 | if err != nil { 27 | return errorHandler(response, err) 28 | } 29 | 30 | // Split lines 31 | lines := strings.Split(strings.Replace(result, "\r\n", "\n", -1), "\n") 32 | 33 | // New Frame 34 | frame := data.NewFrame(qm.Command) 35 | 36 | // Command stats 37 | if qm.Section == "commandstats" { 38 | frame.Fields = append(frame.Fields, data.NewField("Command", nil, []string{}), 39 | data.NewField("Calls", nil, []float64{}), 40 | data.NewField("Usec", nil, []float64{}).SetConfig(&data.FieldConfig{Unit: "µs"}), 41 | data.NewField("Usec_per_call", nil, []float64{}).SetConfig(&data.FieldConfig{Unit: "µs"}), 42 | data.NewField("RejectedCalls", nil, []float64{}), 43 | data.NewField("FailedCalls", nil, []float64{}), 44 | data.NewField("CallsMaster", nil, []float64{}), 45 | ) 46 | 47 | // Parse lines 48 | for _, line := range lines { 49 | fields := strings.Split(line, ":") 50 | 51 | if len(fields) < 2 { 52 | continue 53 | } 54 | 55 | // Stats 56 | stats := strings.Split(fields[1], ",") 57 | values := map[string]float64{} 58 | 59 | for _, stat := range stats { 60 | value := strings.Split(stat, "=") 61 | values[value[0]], _ = strconv.ParseFloat(value[1], 64) 62 | } 63 | 64 | // Command name 65 | cmd := strings.Replace(fields[0], "cmdstat_", "", 1) 66 | 67 | // Add Command 68 | frame.AppendRow(cmd, values["calls"], values["usec"], values["usec_per_call"], values["rejected_calls"], values["failed_calls"], values["calls_master"]) 69 | } 70 | 71 | // Add the frames to the response 72 | response.Frames = append(response.Frames, frame) 73 | 74 | // Return 75 | return response 76 | } 77 | 78 | // Error stats ( added in Redis >= v6.2 ) 79 | if qm.Section == "errorstats" { 80 | // Not Streaming 81 | if !qm.Streaming { 82 | frame.Fields = append(frame.Fields, 83 | data.NewField("Error", nil, []string{}), 84 | data.NewField("Count", nil, []int64{})) 85 | } 86 | 87 | // Parse lines 88 | for _, line := range lines { 89 | fields := strings.Split(line, ":") 90 | 91 | if len(fields) < 2 { 92 | continue 93 | } 94 | 95 | // Parse Error Stats 96 | count := strings.Split(fields[1], "=") 97 | var errorValue int64 98 | 99 | // Parse Error 100 | if len(count) == 2 { 101 | errorValue, _ = strconv.ParseInt(count[1], 10, 64) 102 | } 103 | 104 | // Error prefix 105 | error := strings.Replace(fields[0], "errorstat_", "", 1) 106 | 107 | // Streaming 108 | if qm.Streaming { 109 | frame.Fields = append(frame.Fields, data.NewField(error, nil, []int64{errorValue})) 110 | } else { 111 | frame.AppendRow(error, errorValue) 112 | } 113 | } 114 | 115 | // Add the frames to the response 116 | response.Frames = append(response.Frames, frame) 117 | 118 | // Return 119 | return response 120 | } 121 | 122 | // Parse lines 123 | for _, line := range lines { 124 | fields := strings.Split(line, ":") 125 | 126 | if len(fields) < 2 { 127 | continue 128 | } 129 | 130 | // Add Field 131 | if floatValue, err := strconv.ParseFloat(fields[1], 64); err == nil { 132 | frame.Fields = append(frame.Fields, data.NewField(fields[0], nil, []float64{floatValue})) 133 | } else { 134 | frame.Fields = append(frame.Fields, data.NewField(fields[0], nil, []string{fields[1]})) 135 | } 136 | } 137 | 138 | // Add the frames to the response 139 | response.Frames = append(response.Frames, frame) 140 | 141 | // Return 142 | return response 143 | } 144 | 145 | /** 146 | * CLIENT LIST [TYPE normal|master|replica|pubsub] 147 | * 148 | * @see https://redis.io/commands/client-list 149 | */ 150 | func queryClientList(qm queryModel, client redisClient) backend.DataResponse { 151 | response := backend.DataResponse{} 152 | 153 | // Execute command 154 | var result string 155 | err := client.RunCmd(&result, "CLIENT", "LIST") 156 | 157 | // Check error 158 | if err != nil { 159 | return errorHandler(response, err) 160 | } 161 | 162 | // Split lines 163 | lines := strings.Split(strings.Replace(result, "\r\n", "\n", -1), "\n") 164 | 165 | // New Frame 166 | frame := data.NewFrame(qm.Command) 167 | 168 | // Parse lines 169 | for i, line := range lines { 170 | var values []interface{} 171 | 172 | // Split line to array 173 | fields := strings.Fields(line) 174 | 175 | // Parse lines 176 | for _, field := range fields { 177 | // Split properties 178 | value := strings.Split(field, "=") 179 | 180 | // Skip if less than 2 elements 181 | if len(value) < 2 { 182 | continue 183 | } 184 | 185 | // Add Header for first row 186 | if i == 0 { 187 | if _, err := strconv.ParseInt(value[1], 10, 64); err == nil { 188 | frame.Fields = append(frame.Fields, data.NewField(value[0], nil, []int64{})) 189 | } else { 190 | frame.Fields = append(frame.Fields, data.NewField(value[0], nil, []string{})) 191 | } 192 | } 193 | 194 | // Add Int64 or String value 195 | if intValue, err := strconv.ParseInt(value[1], 10, 64); err == nil { 196 | values = append(values, intValue) 197 | } else { 198 | values = append(values, value[1]) 199 | } 200 | } 201 | 202 | // Add Row 203 | frame.AppendRow(values...) 204 | } 205 | 206 | // Add the frame to the response 207 | response.Frames = append(response.Frames, frame) 208 | 209 | // Return 210 | return response 211 | } 212 | 213 | /** 214 | * SLOWLOG subcommand [argument] 215 | * 216 | * @see https://redis.io/commands/slowlog 217 | */ 218 | func querySlowlogGet(qm queryModel, client redisClient) backend.DataResponse { 219 | response := backend.DataResponse{} 220 | 221 | // Execute command 222 | var result interface{} 223 | var err error 224 | 225 | if qm.Size > 0 { 226 | err = client.RunFlatCmd(&result, "SLOWLOG", "GET", qm.Size) 227 | } else { 228 | err = client.RunCmd(&result, "SLOWLOG", "GET") 229 | } 230 | 231 | // Check error 232 | if err != nil { 233 | return errorHandler(response, err) 234 | } 235 | 236 | // New Frame 237 | frame := data.NewFrame(qm.Command, 238 | data.NewField("Id", nil, []int64{}), 239 | data.NewField("Timestamp", nil, []time.Time{}), 240 | data.NewField("Duration", nil, []int64{}), 241 | data.NewField("Command", nil, []string{})) 242 | 243 | // Set Field Config 244 | frame.Fields[2].Config = &data.FieldConfig{Unit: "µs"} 245 | 246 | // Parse Time-Series data 247 | for _, innerArray := range result.([]interface{}) { 248 | query := innerArray.([]interface{}) 249 | command := "" 250 | 251 | /** 252 | * Redis OSS has arguments as forth element of array 253 | * Redis Enterprise has arguments as fifth 254 | * Redis prior to 4.0 has only 4 fields. 255 | */ 256 | argumentsID := 3 257 | if len(query) > 4 { 258 | switch query[4].(type) { 259 | case []interface{}: 260 | argumentsID = 4 261 | default: 262 | } 263 | } 264 | 265 | /** 266 | * Merge all arguments 267 | */ 268 | for _, arg := range query[argumentsID].([]interface{}) { 269 | 270 | // Add space between command and arguments 271 | if command != "" { 272 | command += " " 273 | } 274 | 275 | // Combine args into single command 276 | switch arg := arg.(type) { 277 | case int64: 278 | command += strconv.FormatInt(arg, 10) 279 | case []byte: 280 | command += string(arg) 281 | case string: 282 | command += arg 283 | default: 284 | log.DefaultLogger.Debug("Slowlog", "default", arg) 285 | } 286 | } 287 | 288 | // Add Query 289 | frame.AppendRow(query[0].(int64), time.Unix(query[1].(int64), 0), query[2].(int64), command) 290 | } 291 | 292 | // Add the frame to the response 293 | response.Frames = append(response.Frames, frame) 294 | 295 | // Return Response 296 | return response 297 | } 298 | -------------------------------------------------------------------------------- /pkg/redis-json.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 10 | "github.com/grafana/grafana-plugin-sdk-go/data" 11 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 12 | ) 13 | 14 | /** 15 | * JSON.OBJKEYS [path] 16 | * 17 | * @see https://oss.redis.com/redisjson/commands/#jsonobjkeys 18 | */ 19 | func queryJsonObjKeys(qm queryModel, client redisClient) backend.DataResponse { 20 | response := backend.DataResponse{} 21 | 22 | // Execute command 23 | var values []string 24 | err := client.RunFlatCmd(&values, qm.Command, qm.Key, qm.Path) 25 | 26 | // Check error 27 | if err != nil { 28 | return errorHandler(response, err) 29 | } 30 | 31 | // New Frame 32 | frame := data.NewFrame(qm.Key, 33 | data.NewField("Value", nil, values)) 34 | 35 | // Add the frames to the response 36 | response.Frames = append(response.Frames, frame) 37 | 38 | // Return 39 | return response 40 | } 41 | 42 | /** 43 | * JSON.OBJLEN [path] 44 | * 45 | * @see https://oss.redis.com/redisjson/commands/#jsonobjlen 46 | */ 47 | func queryJsonObjLen(qm queryModel, client redisClient) backend.DataResponse { 48 | response := backend.DataResponse{} 49 | 50 | // Execute command 51 | var value string 52 | err := client.RunCmd(&value, qm.Command, qm.Key, qm.Path) 53 | 54 | // Check error 55 | if err != nil { 56 | return errorHandler(response, err) 57 | } 58 | 59 | // Add the frames to the response 60 | response.Frames = append(response.Frames, createFrameValue(qm.Key, value, "Value")) 61 | 62 | // Return Response 63 | return response 64 | } 65 | 66 | /** 67 | * JSON.GET [path] 68 | * 69 | * @see https://oss.redis.com/redisjson/commands/#jsonget 70 | */ 71 | func queryJsonGet(qm queryModel, client redisClient) backend.DataResponse { 72 | response := backend.DataResponse{} 73 | 74 | // Execute command 75 | var value string 76 | err := client.RunCmd(&value, qm.Command, qm.Key, qm.Path) 77 | 78 | // Check error 79 | if err != nil { 80 | return errorHandler(response, err) 81 | } 82 | 83 | var result interface{} 84 | err = json.Unmarshal([]byte(value), &result) 85 | 86 | // Check error 87 | if err != nil { 88 | return errorHandler(response, err) 89 | } 90 | 91 | // New Frame 92 | frame := data.NewFrame(qm.Command) 93 | 94 | // Parse result 95 | switch value := result.(type) { 96 | case string: 97 | frame.Fields = append(frame.Fields, data.NewField(qm.Key, nil, []string{value})) 98 | case bool: 99 | frame.Fields = append(frame.Fields, data.NewField(qm.Key, nil, []bool{value})) 100 | case map[string]interface{}: 101 | for i, value := range value { 102 | // Value 103 | switch v := value.(type) { 104 | case string: 105 | frame.Fields = append(frame.Fields, data.NewField(i, nil, []string{v})) 106 | case bool: 107 | frame.Fields = append(frame.Fields, data.NewField(i, nil, []bool{v})) 108 | case float64: 109 | frame.Fields = append(frame.Fields, data.NewField(i, nil, []float64{v})) 110 | default: 111 | log.DefaultLogger.Error(models.JsonGet, "Conversion Error", "Unsupported Value type", reflect.TypeOf(value).String()) 112 | } 113 | } 114 | case []interface{}: 115 | // Map for storing all the fields found in entries 116 | fields := map[string]*data.Field{} 117 | rowscount := 0 118 | 119 | for _, entry := range value { 120 | keysFoundInCurrentEntry := map[string]bool{} 121 | rowscount++ 122 | 123 | // Value 124 | switch e := entry.(type) { 125 | case string, bool, float64: 126 | i := "Value" 127 | if _, ok := fields[i]; !ok { 128 | switch entry.(type) { 129 | case string: 130 | fields[i] = data.NewField(i, nil, []string{}) 131 | for j := 0; j < rowscount-1; j++ { 132 | fields[i].Append("") 133 | } 134 | case bool: 135 | fields[i] = data.NewField(i, nil, []bool{}) 136 | for j := 0; j < rowscount-1; j++ { 137 | fields[i].Append(false) 138 | } 139 | case float64: 140 | fields[i] = data.NewField(i, nil, []float64{}) 141 | for j := 0; j < rowscount-1; j++ { 142 | fields[i].Append(float64(0)) 143 | } 144 | } 145 | frame.Fields = append(frame.Fields, fields[i]) 146 | } 147 | 148 | // Insert value for current row 149 | fields[i].Append(e) 150 | keysFoundInCurrentEntry[i] = true 151 | case map[string]interface{}: 152 | for i, value := range e { 153 | // Value 154 | switch v := value.(type) { 155 | case bool: 156 | if _, ok := fields[i]; !ok { 157 | fields[i] = data.NewField(i, nil, []bool{}) 158 | frame.Fields = append(frame.Fields, fields[i]) 159 | 160 | // Generate empty values for all previous rows 161 | for j := 0; j < rowscount-1; j++ { 162 | fields[i].Append(false) 163 | } 164 | } 165 | 166 | // Insert value for current row 167 | fields[i].Append(v) 168 | keysFoundInCurrentEntry[i] = true 169 | case string: 170 | if _, ok := fields[i]; !ok { 171 | fields[i] = data.NewField(i, nil, []string{}) 172 | frame.Fields = append(frame.Fields, fields[i]) 173 | 174 | // Generate empty values for all previous rows 175 | for j := 0; j < rowscount-1; j++ { 176 | fields[i].Append("") 177 | } 178 | } 179 | 180 | // Insert value for current row 181 | fields[i].Append(v) 182 | keysFoundInCurrentEntry[i] = true 183 | case float64: 184 | if _, ok := fields[i]; !ok { 185 | fields[i] = data.NewField(i, nil, []float64{}) 186 | frame.Fields = append(frame.Fields, fields[i]) 187 | 188 | // Generate empty values for all previous rows 189 | for j := 0; j < rowscount-1; j++ { 190 | fields[i].Append(0.0) 191 | } 192 | } 193 | 194 | // Insert value for current row 195 | fields[i].Append(v) 196 | keysFoundInCurrentEntry[i] = true 197 | case map[string]interface{}: 198 | if _, ok := fields[i]; !ok { 199 | fields[i] = data.NewField(i, nil, []string{}) 200 | frame.Fields = append(frame.Fields, fields[i]) 201 | 202 | // Generate empty values for all previous rows 203 | for j := 0; j < rowscount-1; j++ { 204 | fields[i].Append("") 205 | } 206 | } 207 | 208 | var values []string 209 | for _, entry := range v { 210 | for _, e := range entry.(map[string]interface{}) { 211 | values = append(values, string(e.(string))) 212 | } 213 | } 214 | 215 | fields[i].Append(strings.Join(values, ", ")) 216 | keysFoundInCurrentEntry[i] = true 217 | default: 218 | log.DefaultLogger.Error(models.JsonGet, "Conversion Error", "Unsupported Value type inside interface", reflect.TypeOf(value).String()) 219 | } 220 | } 221 | default: 222 | log.DefaultLogger.Error(models.JsonGet, "Conversion Error", "Unsupported Value type inside interface entry", reflect.TypeOf(entry).String()) 223 | } 224 | 225 | // Iterate over all keys found so far for stream 226 | for key, field := range fields { 227 | // Check if key exist in entry 228 | if _, ok := keysFoundInCurrentEntry[key]; !ok { 229 | if field.Type() == data.FieldTypeFloat64 { 230 | field.Append(0.0) 231 | continue 232 | } 233 | 234 | if field.Type() == data.FieldTypeBool { 235 | field.Append(false) 236 | continue 237 | } 238 | 239 | field.Append("") 240 | } 241 | } 242 | } 243 | default: 244 | log.DefaultLogger.Error(models.JsonGet, "Unexpected type received", "value", value, "type", reflect.TypeOf(value).String()) 245 | } 246 | 247 | // Add the frames to the response 248 | response.Frames = append(response.Frames, frame) 249 | 250 | // Return 251 | return response 252 | } 253 | -------------------------------------------------------------------------------- /pkg/redis-search.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 8 | "github.com/grafana/grafana-plugin-sdk-go/data" 9 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 10 | ) 11 | 12 | func queryFtSearch(qm queryModel, client redisClient) backend.DataResponse { 13 | response := backend.DataResponse{} 14 | 15 | var result interface{} 16 | args := []string{qm.Key} 17 | if qm.SearchQuery == "" { 18 | args = append(args, "*") 19 | } else { 20 | args = append(args, qm.SearchQuery) 21 | } 22 | 23 | if qm.ReturnFields != nil && len(qm.ReturnFields) > 0 { 24 | args = append(args, "RETURN") 25 | args = append(args, strconv.Itoa(len(qm.ReturnFields))) 26 | args = append(args, qm.ReturnFields...) 27 | } 28 | 29 | if qm.Count != 0 || qm.Offset > 0 { 30 | var count int 31 | if qm.Count == 0 { 32 | count = 10 33 | } else { 34 | count = qm.Count 35 | } 36 | args = append(args, "LIMIT", strconv.Itoa(qm.Offset), strconv.Itoa(count)) 37 | } 38 | 39 | if qm.SortBy != "" { 40 | args = append(args, "SORTBY", qm.SortBy, qm.SortDirection) 41 | } 42 | 43 | err := client.RunCmd(&result, qm.Command, args...) 44 | 45 | if err != nil { 46 | return errorHandler(response, err) 47 | } 48 | 49 | frame := data.NewFrame("Results") 50 | fieldValuesMap := make(map[string][]string) 51 | 52 | fieldValuesMap["keyName"] = make([]string, len(result.([]interface{}))/2) 53 | 54 | for i := 1; i < len(result.([]interface{})); i += 2 { 55 | keyName := string((result.([]interface{}))[i].([]uint8)) 56 | fieldValuesMap["keyName"][i/2] = keyName 57 | fieldValueArr := (result.([]interface{}))[i+1].([]interface{}) 58 | 59 | for j := 0; j < len(fieldValueArr); j += 2 { 60 | fieldName := string(fieldValueArr[j].([]uint8)) 61 | 62 | if _, ok := fieldValuesMap[fieldName]; !ok { 63 | fieldValuesMap[fieldName] = make([]string, len(result.([]interface{}))/2) 64 | } 65 | 66 | fieldValue := string(fieldValueArr[j+1].([]uint8)) 67 | fieldValuesMap[fieldName][i/2] = fieldValue 68 | } 69 | } 70 | 71 | for fieldName, slice := range fieldValuesMap { 72 | frame.Fields = append(frame.Fields, data.NewField(fieldName, nil, slice)) 73 | } 74 | 75 | response.Frames = append(response.Frames, frame) 76 | 77 | return response 78 | } 79 | 80 | /** 81 | * FT.INFO {index} 82 | * 83 | * @see https://oss.redislabs.com/redisearch/Commands/#ftinfo 84 | */ 85 | func queryFtInfo(qm queryModel, client redisClient) backend.DataResponse { 86 | response := backend.DataResponse{} 87 | 88 | // Execute command 89 | var result map[string]interface{} 90 | err := client.RunCmd(&result, qm.Command, qm.Key) 91 | 92 | // Check error 93 | if err != nil { 94 | return errorHandler(response, err) 95 | } 96 | 97 | // Create data frame response 98 | frame := data.NewFrame(qm.Key) 99 | 100 | // Add fields and values 101 | for key := range result { 102 | // Value 103 | switch value := result[key].(type) { 104 | case int64: 105 | // Add field 106 | field := data.NewField(key, nil, []int64{value}) 107 | frame.Fields = append(frame.Fields, field) 108 | case []byte: 109 | // Parse Float 110 | if floatValue, err := strconv.ParseFloat(string(value), 64); err == nil { 111 | field := data.NewField(key, nil, []float64{floatValue}) 112 | 113 | // Set unit 114 | if models.SearchInfoConfig[key] != "" { 115 | field.Config = &data.FieldConfig{Unit: models.SearchInfoConfig[key]} 116 | } 117 | 118 | frame.Fields = append(frame.Fields, field) 119 | } else { 120 | frame.Fields = append(frame.Fields, data.NewField(key, nil, []string{string(value)})) 121 | } 122 | case string: 123 | frame.Fields = append(frame.Fields, data.NewField(key, nil, []string{string(value)})) 124 | case []interface{}: 125 | default: 126 | log.DefaultLogger.Error(models.SearchInfo, "Conversion Error", "Unsupported Value type") 127 | } 128 | } 129 | 130 | // Add the frame to the response 131 | response.Frames = append(response.Frames, frame) 132 | 133 | // Return Response 134 | return response 135 | } 136 | -------------------------------------------------------------------------------- /pkg/redis-set.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/grafana/grafana-plugin-sdk-go/backend" 5 | "github.com/grafana/grafana-plugin-sdk-go/data" 6 | ) 7 | 8 | /** 9 | * SMEMBERS key 10 | * 11 | * @see https://redis.io/commands/smembers 12 | */ 13 | func querySMembers(qm queryModel, client redisClient) backend.DataResponse { 14 | response := backend.DataResponse{} 15 | 16 | // Execute command 17 | var values []string 18 | err := client.RunFlatCmd(&values, qm.Command, qm.Key) 19 | 20 | // Check error 21 | if err != nil { 22 | return errorHandler(response, err) 23 | } 24 | 25 | // New Frame 26 | frame := data.NewFrame(qm.Key, 27 | data.NewField("Value", nil, values)) 28 | 29 | // Add the frames to the response 30 | response.Frames = append(response.Frames, frame) 31 | 32 | // Return 33 | return response 34 | } 35 | -------------------------------------------------------------------------------- /pkg/redis-set_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | /** 12 | * SMEMBERS 13 | */ 14 | func TestQuerySMembers(t *testing.T) { 15 | t.Parallel() 16 | 17 | tests := []struct { 18 | name string 19 | qm queryModel 20 | rcv interface{} 21 | fieldsCount int 22 | rowsPerField int 23 | valuesToCheckInResponse []valueToCheckInResponse 24 | err error 25 | }{ 26 | { 27 | "should handle default array of strings", 28 | queryModel{Command: models.SMembers, Key: "test1"}, 29 | []string{"value1", "2", "3.14"}, 30 | 1, 31 | 3, 32 | []valueToCheckInResponse{ 33 | {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "value1"}, 34 | {frameIndex: 0, fieldIndex: 0, rowIndex: 1, value: "2"}, 35 | {frameIndex: 0, fieldIndex: 0, rowIndex: 2, value: "3.14"}, 36 | }, 37 | nil, 38 | }, 39 | { 40 | "should handle error", 41 | queryModel{Command: models.SMembers}, 42 | nil, 43 | 0, 44 | 0, 45 | nil, 46 | errors.New("error occurred"), 47 | }, 48 | } 49 | 50 | // Run Tests 51 | for _, tt := range tests { 52 | tt := tt 53 | t.Run(tt.name, func(t *testing.T) { 54 | t.Parallel() 55 | 56 | // Client 57 | client := testClient{rcv: tt.rcv, err: tt.err} 58 | 59 | // Response 60 | response := querySMembers(tt.qm, &client) 61 | if tt.err != nil { 62 | require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") 63 | require.Nil(t, response.Frames, "No frames should be created if failed") 64 | } else { 65 | require.Equal(t, tt.qm.Key, response.Frames[0].Name, "Invalid frame name") 66 | require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") 67 | require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") 68 | 69 | if tt.valuesToCheckInResponse != nil { 70 | for _, value := range tt.valuesToCheckInResponse { 71 | require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) 72 | } 73 | } 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/redis-stream_integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/mediocregopher/radix/v3" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | /** 14 | * XINFO 15 | */ 16 | func TestXInfoStreamIntegration(t *testing.T) { 17 | // Client 18 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 19 | client := radixV3Impl{radixClient: radixClient} 20 | 21 | // Customers 22 | t.Run("query stream queue:customers", func(t *testing.T) { 23 | resp := queryXInfoStream(queryModel{Key: "queue:customers"}, &client) 24 | require.Len(t, resp.Frames, 1) 25 | require.Len(t, resp.Frames[0].Fields, 9) 26 | require.Equal(t, 1, resp.Frames[0].Fields[0].Len()) 27 | }) 28 | 29 | // Orders 30 | t.Run("query stream queue:orders", func(t *testing.T) { 31 | resp := queryXInfoStream(queryModel{Key: "queue:orders"}, &client) 32 | require.Len(t, resp.Frames, 1) 33 | require.Len(t, resp.Frames[0].Fields, 9) 34 | require.Equal(t, 1, resp.Frames[0].Fields[0].Len()) 35 | }) 36 | } 37 | 38 | /** 39 | * XRANGE 40 | */ 41 | 42 | func TestXRangeStreamIntegration(t *testing.T) { 43 | // Client 44 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 45 | client := radixV3Impl{radixClient: radixClient} 46 | 47 | t.Run("query stream queue:customers", func(t *testing.T) { 48 | resp := queryXRange(1611019111439, 1611019111985, queryModel{Key: "queue:customers"}, &client) 49 | require.Len(t, resp.Frames, 1) 50 | require.Len(t, resp.Frames[0].Fields, 3) 51 | require.Equal(t, "$streamId", resp.Frames[0].Fields[0].Name) 52 | require.Equal(t, "$time", resp.Frames[0].Fields[1].Name) 53 | require.Equal(t, "id", resp.Frames[0].Fields[2].Name) 54 | require.Equal(t, resp.Frames[0].Fields[1].Len(), resp.Frames[0].Fields[0].Len(), resp.Frames[0].Fields[2].Len()) 55 | }) 56 | 57 | t.Run("query stream queue:customers with COUNT", func(t *testing.T) { 58 | resp := queryXRange(1611019111439, 1611019111985, queryModel{Key: "queue:customers", Count: 3}, &client) 59 | require.Len(t, resp.Frames, 1) 60 | require.Len(t, resp.Frames[0].Fields, 3) 61 | require.Equal(t, "$streamId", resp.Frames[0].Fields[0].Name) 62 | require.Equal(t, "$time", resp.Frames[0].Fields[1].Name) 63 | require.Equal(t, "id", resp.Frames[0].Fields[2].Name) 64 | require.Equal(t, resp.Frames[0].Fields[1].Len(), resp.Frames[0].Fields[0].Len(), resp.Frames[0].Fields[2].Len()) 65 | }) 66 | 67 | t.Run("query stream queue:customers with start and end", func(t *testing.T) { 68 | resp := queryXRange(0, 0, queryModel{Key: "queue:customers", Start: "1611019111439-0", End: "1611019111985-0"}, &client) 69 | require.Len(t, resp.Frames, 1) 70 | require.Len(t, resp.Frames[0].Fields, 3) 71 | require.Equal(t, "$streamId", resp.Frames[0].Fields[0].Name) 72 | require.Equal(t, "$time", resp.Frames[0].Fields[1].Name) 73 | require.Equal(t, "id", resp.Frames[0].Fields[2].Name) 74 | require.Equal(t, 7, resp.Frames[0].Fields[0].Len(), resp.Frames[0].Fields[1].Len()) 75 | require.Equal(t, "1611019111439-0", resp.Frames[0].Fields[0].At(0)) 76 | require.Equal(t, "1611019111985-0", resp.Frames[0].Fields[0].At(6)) 77 | }) 78 | } 79 | 80 | /** 81 | * XREVRANGE 82 | */ 83 | 84 | func TestXRevRangeStreamIntegration(t *testing.T) { 85 | // Client 86 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 87 | client := radixV3Impl{radixClient: radixClient} 88 | 89 | t.Run("query stream queue:customers", func(t *testing.T) { 90 | resp := queryXRange(1611019111439, 1611019111985, queryModel{Key: "queue:customers"}, &client) 91 | require.Len(t, resp.Frames, 1) 92 | require.Len(t, resp.Frames[0].Fields, 3) 93 | require.Equal(t, "$streamId", resp.Frames[0].Fields[0].Name) 94 | require.Equal(t, "$time", resp.Frames[0].Fields[1].Name) 95 | require.Equal(t, "id", resp.Frames[0].Fields[2].Name) 96 | require.Equal(t, resp.Frames[0].Fields[1].Len(), resp.Frames[0].Fields[0].Len()) 97 | }) 98 | 99 | t.Run("query stream queue:customers with COUNT", func(t *testing.T) { 100 | resp := queryXRevRange(1611019111439, 1611019111985, queryModel{Key: "queue:customers", Count: 3}, &client) 101 | require.Len(t, resp.Frames, 1) 102 | require.Len(t, resp.Frames[0].Fields, 3) 103 | require.Equal(t, "$streamId", resp.Frames[0].Fields[0].Name) 104 | require.Equal(t, "$time", resp.Frames[0].Fields[1].Name) 105 | require.Equal(t, "id", resp.Frames[0].Fields[2].Name) 106 | require.Equal(t, 3, resp.Frames[0].Fields[0].Len()) 107 | require.Equal(t, 3, resp.Frames[0].Fields[1].Len()) 108 | }) 109 | 110 | t.Run("query stream queue:customers with start and end", func(t *testing.T) { 111 | resp := queryXRevRange(0, 0, queryModel{Key: "queue:customers", End: "1611019111985-0", Start: "1611019111439-0"}, &client) 112 | require.Len(t, resp.Frames, 1) 113 | require.Len(t, resp.Frames[0].Fields, 3) 114 | require.Equal(t, "$streamId", resp.Frames[0].Fields[0].Name) 115 | require.Equal(t, "$time", resp.Frames[0].Fields[1].Name) 116 | require.Equal(t, "id", resp.Frames[0].Fields[2].Name) 117 | require.Equal(t, 7, resp.Frames[0].Fields[0].Len()) 118 | require.Equal(t, 7, resp.Frames[0].Fields[1].Len()) 119 | require.Equal(t, "1611019111439-0", resp.Frames[0].Fields[0].At(6)) 120 | require.Equal(t, "1611019111985-0", resp.Frames[0].Fields[0].At(0)) 121 | }) 122 | } 123 | -------------------------------------------------------------------------------- /pkg/redis-time-series_integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/mediocregopher/radix/v3" 10 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | /** 15 | * TS.INFO 16 | */ 17 | func TestTSInfoIntegration(t *testing.T) { 18 | // Client 19 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 20 | client := radixV3Impl{radixClient: radixClient} 21 | 22 | // Response 23 | resp := queryTsInfo(queryModel{Command: models.TimeSeriesInfo, Key: "test:timeseries2"}, &client) 24 | require.Len(t, resp.Frames, 1) 25 | require.Len(t, resp.Frames[0].Fields, 12) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/redis-tmscan.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/grafana-plugin-sdk-go/data" 8 | ) 9 | 10 | /** 11 | * TMSCAN result row entity 12 | */ 13 | type tmscanRow struct { 14 | keyName string 15 | keyMemory int64 16 | keyType string 17 | } 18 | 19 | /** 20 | * TMSCAN cursor match count 21 | * 22 | * Iterates over the collection of keys and query type and memory usage 23 | * Cursor iteration similar to SCAN command 24 | * @see https://redis.io/commands/scan 25 | * @see https://redis.io/commands/type 26 | * @see https://redis.io/commands/memory-usage 27 | */ 28 | func queryTMScan(qm queryModel, client redisClient) backend.DataResponse { 29 | response := backend.DataResponse{} 30 | 31 | var result []interface{} 32 | 33 | // Cursor 34 | cursor := "0" 35 | if qm.Cursor != "" { 36 | cursor = qm.Cursor 37 | } 38 | 39 | // Match 40 | var args []interface{} 41 | if qm.Match != "" { 42 | args = append(args, "match", qm.Match) 43 | } 44 | 45 | // Count 46 | if qm.Count != 0 { 47 | args = append(args, "count", qm.Count) 48 | } 49 | 50 | // Running CURSOR command 51 | err := client.RunFlatCmd(&result, "SCAN", cursor, args...) 52 | 53 | // Check error 54 | if err != nil { 55 | return errorHandler(response, err) 56 | } 57 | 58 | // New Frames 59 | frame := data.NewFrame(qm.Command) 60 | frameCursor := data.NewFrame("Cursor") 61 | 62 | /** 63 | * Next cursor value is first value ([]byte) in result array 64 | * @see https://redis.io/commands/scan 65 | */ 66 | nextCursor := string(result[0].([]byte)) 67 | 68 | // Add cursor field to frame 69 | frameCursor.Fields = append(frameCursor.Fields, data.NewField("cursor", nil, []string{nextCursor})) 70 | 71 | /** 72 | * Array with keys is second value in result array 73 | * @see https://redis.io/commands/scan 74 | */ 75 | keys := result[1].([]interface{}) 76 | 77 | var typeCommands []flatCommandArgs 78 | var memoryCommands []flatCommandArgs 79 | 80 | // Slices with output values 81 | var rows []*tmscanRow 82 | 83 | // Check memory usage for all keys 84 | for i, key := range keys { 85 | rows = append(rows, &tmscanRow{keyName: string(key.([]byte))}) 86 | 87 | // Arguments 88 | memoryCommandArgs := []interface{}{rows[i].keyName} 89 | if qm.Samples > 0 { 90 | memoryCommandArgs = append(memoryCommandArgs, "SAMPLES", qm.Samples) 91 | } 92 | 93 | // Commands 94 | memoryCommands = append(memoryCommands, flatCommandArgs{cmd: "MEMORY", key: "USAGE", args: memoryCommandArgs, rcv: &(rows[i].keyMemory)}) 95 | } 96 | 97 | // Send batch with MEMORY USAGE commands 98 | err = client.RunBatchFlatCmd(memoryCommands) 99 | 100 | // Check error 101 | if err != nil { 102 | return errorHandler(response, err) 103 | } 104 | 105 | // Add count field to cursor frame 106 | frameCursor.Fields = append(frameCursor.Fields, data.NewField("count", nil, []int64{int64(len(rows))})) 107 | 108 | // Check if size is less than the number of rows and we need to select biggest keys 109 | if qm.Size > 0 && qm.Size < len(rows) { 110 | // Sort by memory usage 111 | sort.Slice(rows, func(i, j int) bool { 112 | // Use reversed condition for Descending sort 113 | return rows[i].keyMemory > rows[j].keyMemory 114 | }) 115 | 116 | // Get first qm.Size keys 117 | rows = rows[:qm.Size] 118 | } 119 | 120 | // Check type for all keys 121 | for _, row := range rows { 122 | typeCommands = append(typeCommands, flatCommandArgs{cmd: "TYPE", key: row.keyName, rcv: &(row.keyType)}) 123 | } 124 | 125 | // Send batch with TYPE commands 126 | err = client.RunBatchFlatCmd(typeCommands) 127 | 128 | // Check error 129 | if err != nil { 130 | return errorHandler(response, err) 131 | } 132 | 133 | // Add key names field to frame 134 | frame.Fields = append(frame.Fields, data.NewField("key", nil, []string{})) 135 | 136 | // Add key types field to frame 137 | frame.Fields = append(frame.Fields, data.NewField("type", nil, []string{})) 138 | 139 | // Add key memory to frame with a proper config 140 | memoryField := data.NewField("memory", nil, []int64{}) 141 | memoryField.Config = &data.FieldConfig{Unit: "decbytes"} 142 | frame.Fields = append(frame.Fields, memoryField) 143 | 144 | // Append result rows to frame 145 | for _, row := range rows { 146 | frame.AppendRow(row.keyName, row.keyType, row.keyMemory) 147 | } 148 | 149 | // Add the frames to the response 150 | response.Frames = append(response.Frames, frame, frameCursor) 151 | 152 | // Return 153 | return response 154 | } 155 | -------------------------------------------------------------------------------- /pkg/redis-tmscan_integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/mediocregopher/radix/v3" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // Types 14 | var types = map[string]string{ 15 | "test:string": "string", 16 | "test:stream": "stream", 17 | "test:set": "set", 18 | "test:list": "list", 19 | "test:float": "string", 20 | "test:hash": "hash", 21 | } 22 | 23 | // Memory 24 | var memory = map[string]int64{ 25 | "test:string": int64(59), 26 | "test:stream": int64(612), 27 | "test:set": int64(265), 28 | "test:list": int64(140), 29 | "test:float": int64(59), 30 | "test:hash": int64(108), 31 | } 32 | 33 | /** 34 | * TMSCAN 35 | */ 36 | func TestTMScanIntegration(t *testing.T) { 37 | // Client 38 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 39 | client := radixV3Impl{radixClient: radixClient} 40 | 41 | // Response 42 | resp := queryTMScan(queryModel{Cursor: "0", Count: 5}, &client) 43 | require.Len(t, resp.Frames, 2) 44 | require.Len(t, resp.Frames[0].Fields, 3) 45 | require.Len(t, resp.Frames[1].Fields, 2) 46 | require.Equal(t, "cursor", resp.Frames[1].Fields[0].Name) 47 | require.Equal(t, "count", resp.Frames[1].Fields[1].Name) 48 | require.GreaterOrEqual(t, resp.Frames[0].Fields[0].Len(), 5) 49 | require.GreaterOrEqual(t, resp.Frames[0].Fields[1].Len(), 5) 50 | require.GreaterOrEqual(t, resp.Frames[0].Fields[2].Len(), 5) 51 | require.IsType(t, "", resp.Frames[0].Fields[0].At(0)) 52 | require.IsType(t, "", resp.Frames[0].Fields[1].At(0)) 53 | require.IsType(t, int64(0), resp.Frames[0].Fields[2].At(0)) 54 | require.NotEqual(t, "0", resp.Frames[1].Fields[0].At(0)) 55 | require.Equal(t, int64(resp.Frames[0].Fields[0].Len()), resp.Frames[1].Fields[1].At(0)) 56 | require.Equal(t, 1, resp.Frames[1].Fields[0].Len()) 57 | 58 | } 59 | 60 | /** 61 | * TMSCAN with Match nomatch 62 | */ 63 | func TestTMScanIntegrationWithNoMatched(t *testing.T) { 64 | // Client 65 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 66 | client := radixV3Impl{radixClient: radixClient} 67 | 68 | // Response 69 | resp := queryTMScan(queryModel{Cursor: "0", Match: "nomatch"}, &client) 70 | require.Len(t, resp.Frames, 2) 71 | require.Len(t, resp.Frames[0].Fields, 3) 72 | require.Len(t, resp.Frames[1].Fields, 2) 73 | require.Equal(t, 1, resp.Frames[1].Fields[0].Len()) 74 | require.Equal(t, 0, resp.Frames[0].Fields[0].Len()) 75 | require.Equal(t, 0, resp.Frames[0].Fields[1].Len()) 76 | require.Equal(t, 0, resp.Frames[0].Fields[2].Len()) 77 | require.NotEqual(t, "0", resp.Frames[1].Fields[0].At(0)) 78 | require.Equal(t, int64(resp.Frames[0].Fields[0].Len()), resp.Frames[1].Fields[1].At(0)) 79 | } 80 | 81 | /** 82 | * TMSCAN with Match test:* 83 | */ 84 | func TestTMScanIntegrationWithMatched(t *testing.T) { 85 | // Client 86 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 87 | client := radixV3Impl{radixClient: radixClient} 88 | 89 | // Response 90 | resp := queryTMScan(queryModel{Cursor: "0", Match: "test:*", Count: 20}, &client) 91 | require.Len(t, resp.Frames, 2) 92 | require.Len(t, resp.Frames[0].Fields, 3) 93 | require.Len(t, resp.Frames[1].Fields, 2) 94 | require.Equal(t, 1, resp.Frames[1].Fields[0].Len()) 95 | require.GreaterOrEqual(t, 9, resp.Frames[0].Fields[0].Len()) 96 | require.GreaterOrEqual(t, 9, resp.Frames[0].Fields[1].Len()) 97 | require.GreaterOrEqual(t, 9, resp.Frames[0].Fields[2].Len()) 98 | require.Equal(t, int64(resp.Frames[0].Fields[0].Len()), resp.Frames[1].Fields[1].At(0)) 99 | 100 | // Keys 101 | keys := map[string]int{} 102 | for i := 0; i < resp.Frames[0].Fields[0].Len(); i++ { 103 | if _, ok := types[resp.Frames[0].Fields[0].At(i).(string)]; ok { 104 | keys[resp.Frames[0].Fields[0].At(i).(string)] = i 105 | } 106 | } 107 | 108 | for key, value := range keys { 109 | require.Equal(t, types[key], resp.Frames[0].Fields[1].At(value), "Invalid type returned") 110 | require.LessOrEqual(t, memory[key], resp.Frames[0].Fields[2].At(value), "Invalid memory size returned") 111 | } 112 | } 113 | 114 | /** 115 | * TMSCAN with Samples count 116 | */ 117 | func TestTMScanIntegrationWithSamples(t *testing.T) { 118 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 119 | 120 | // Client 121 | client := radixV3Impl{radixClient: radixClient} 122 | 123 | // Response 124 | resp := queryTMScan(queryModel{Cursor: "0", Samples: 10}, &client) 125 | require.Len(t, resp.Frames, 2) 126 | require.Len(t, resp.Frames[0].Fields, 3) 127 | require.Len(t, resp.Frames[1].Fields, 2) 128 | require.Equal(t, 1, resp.Frames[1].Fields[0].Len()) 129 | require.GreaterOrEqual(t, resp.Frames[0].Fields[0].Len(), 10) 130 | require.GreaterOrEqual(t, resp.Frames[0].Fields[1].Len(), 10) 131 | require.GreaterOrEqual(t, resp.Frames[0].Fields[2].Len(), 10) 132 | require.NotEqual(t, "0", resp.Frames[1].Fields[0].At(0)) 133 | require.Equal(t, int64(resp.Frames[0].Fields[0].Len()), resp.Frames[1].Fields[1].At(0)) 134 | } 135 | 136 | /** 137 | * TMSCAN with Size 10 138 | */ 139 | func TestTMScanIntegrationWithSize(t *testing.T) { 140 | // Client 141 | radixClient, _ := radix.NewPool("tcp", fmt.Sprintf("%s:%d", integrationTestIP, integrationTestPort), 10) 142 | client := radixV3Impl{radixClient: radixClient} 143 | 144 | // Response 145 | resp := queryTMScan(queryModel{Cursor: "0", Count: 10, Size: 8}, &client) 146 | require.Len(t, resp.Frames, 2) 147 | require.Len(t, resp.Frames[0].Fields, 3) 148 | require.Len(t, resp.Frames[1].Fields, 2) 149 | require.Equal(t, 1, resp.Frames[1].Fields[0].Len()) 150 | require.Equal(t, 8, resp.Frames[0].Fields[0].Len()) 151 | require.Equal(t, 8, resp.Frames[0].Fields[1].Len()) 152 | require.Equal(t, 8, resp.Frames[0].Fields[2].Len()) 153 | 154 | // Check proper sorting by memory 155 | for i := 0; i < 7; i++ { 156 | require.LessOrEqual(t, resp.Frames[0].Fields[2].At(i+1), resp.Frames[0].Fields[2].At(i)) 157 | } 158 | 159 | require.NotEqual(t, "0", resp.Frames[1].Fields[0].At(0)) 160 | require.GreaterOrEqual(t, int64(11), resp.Frames[1].Fields[1].At(0)) 161 | } 162 | -------------------------------------------------------------------------------- /pkg/redis-tmscan_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | /** 12 | * TMSCAN 13 | */ 14 | func TestQueryTMScan(t *testing.T) { 15 | t.Parallel() 16 | 17 | // Cursor 18 | t.Run("should process cursor", func(t *testing.T) { 19 | t.Parallel() 20 | 21 | // Client 22 | client := testClient{ 23 | rcv: []interface{}{ 24 | []byte("24"), 25 | []interface{}{ 26 | []byte("test:string"), 27 | []byte("test:stream"), 28 | []byte("test:set"), 29 | []byte("test:list"), 30 | []byte("test:float"), 31 | []byte("test:hash"), 32 | }, 33 | }, 34 | batchRcv: [][]interface{}{ 35 | { 36 | int64(59), 37 | int64(612), 38 | int64(265), 39 | int64(140), 40 | int64(59), 41 | int64(108), 42 | }, 43 | { 44 | "string", 45 | "stream", 46 | "set", 47 | "list", 48 | "string", 49 | "hash", 50 | }, 51 | }, 52 | err: nil, 53 | } 54 | 55 | // Response 56 | resp := queryTMScan(queryModel{Command: models.TMScan, Match: "test:*", Count: 100, Cursor: "0", Samples: 10}, &client) 57 | require.Len(t, resp.Frames, 2) 58 | require.Len(t, resp.Frames[0].Fields, 3) 59 | require.Len(t, resp.Frames[1].Fields, 2) 60 | require.Equal(t, 1, resp.Frames[1].Fields[0].Len()) 61 | require.Equal(t, 1, resp.Frames[1].Fields[1].Len()) 62 | require.Equal(t, 6, resp.Frames[0].Fields[0].Len()) 63 | require.Equal(t, 6, resp.Frames[0].Fields[1].Len()) 64 | require.Equal(t, 6, resp.Frames[0].Fields[2].Len()) 65 | require.Equal(t, "24", resp.Frames[1].Fields[0].At(0)) 66 | require.Equal(t, int64(resp.Frames[0].Fields[0].Len()), resp.Frames[1].Fields[1].At(0)) 67 | 68 | require.Equal(t, "test:string", resp.Frames[0].Fields[0].At(0)) 69 | require.Equal(t, "test:stream", resp.Frames[0].Fields[0].At(1)) 70 | require.Equal(t, "test:set", resp.Frames[0].Fields[0].At(2)) 71 | require.Equal(t, "test:list", resp.Frames[0].Fields[0].At(3)) 72 | require.Equal(t, "test:float", resp.Frames[0].Fields[0].At(4)) 73 | require.Equal(t, "test:hash", resp.Frames[0].Fields[0].At(5)) 74 | 75 | require.Equal(t, "string", resp.Frames[0].Fields[1].At(0)) 76 | require.Equal(t, "stream", resp.Frames[0].Fields[1].At(1)) 77 | require.Equal(t, "set", resp.Frames[0].Fields[1].At(2)) 78 | require.Equal(t, "list", resp.Frames[0].Fields[1].At(3)) 79 | require.Equal(t, "string", resp.Frames[0].Fields[1].At(4)) 80 | require.Equal(t, "hash", resp.Frames[0].Fields[1].At(5)) 81 | 82 | require.Equal(t, int64(59), resp.Frames[0].Fields[2].At(0)) 83 | require.Equal(t, int64(612), resp.Frames[0].Fields[2].At(1)) 84 | require.Equal(t, int64(265), resp.Frames[0].Fields[2].At(2)) 85 | require.Equal(t, int64(140), resp.Frames[0].Fields[2].At(3)) 86 | require.Equal(t, int64(59), resp.Frames[0].Fields[2].At(4)) 87 | require.Equal(t, int64(108), resp.Frames[0].Fields[2].At(5)) 88 | 89 | }) 90 | 91 | // Size 92 | t.Run("should properly handle Size", func(t *testing.T) { 93 | t.Parallel() 94 | 95 | // Client 96 | client := testClient{ 97 | rcv: []interface{}{ 98 | []byte("0"), 99 | []interface{}{ 100 | []byte("test:string"), 101 | []byte("test:stream"), 102 | []byte("test:set"), 103 | []byte("test:list"), 104 | []byte("test:float"), 105 | []byte("test:hash"), 106 | }, 107 | }, 108 | batchRcv: [][]interface{}{ 109 | { 110 | int64(59), 111 | int64(612), 112 | int64(265), 113 | int64(140), 114 | int64(59), 115 | int64(108), 116 | }, 117 | { 118 | "stream", 119 | "set", 120 | }, 121 | }, 122 | err: nil, 123 | } 124 | 125 | // Response 126 | resp := queryTMScan(queryModel{Command: models.TMScan, Size: 2, Count: 10, Cursor: "0"}, &client) 127 | require.Len(t, resp.Frames, 2) 128 | require.Len(t, resp.Frames[0].Fields, 3) 129 | require.Len(t, resp.Frames[1].Fields, 2) 130 | require.Equal(t, 1, resp.Frames[1].Fields[0].Len()) 131 | require.Equal(t, 1, resp.Frames[1].Fields[1].Len()) 132 | require.Equal(t, 2, resp.Frames[0].Fields[0].Len()) 133 | require.Equal(t, 2, resp.Frames[0].Fields[1].Len()) 134 | require.Equal(t, 2, resp.Frames[0].Fields[2].Len()) 135 | require.Equal(t, "0", resp.Frames[1].Fields[0].At(0)) 136 | require.Equal(t, int64(6), resp.Frames[1].Fields[1].At(0)) 137 | 138 | require.Equal(t, "test:stream", resp.Frames[0].Fields[0].At(0)) 139 | require.Equal(t, "test:set", resp.Frames[0].Fields[0].At(1)) 140 | 141 | require.Equal(t, "stream", resp.Frames[0].Fields[1].At(0)) 142 | require.Equal(t, "set", resp.Frames[0].Fields[1].At(1)) 143 | 144 | require.Equal(t, int64(612), resp.Frames[0].Fields[2].At(0)) 145 | require.Equal(t, int64(265), resp.Frames[0].Fields[2].At(1)) 146 | 147 | }) 148 | 149 | // Cursor Error 150 | t.Run("should handle error during CURSOR", func(t *testing.T) { 151 | t.Parallel() 152 | 153 | // Client 154 | client := testClient{ 155 | rcv: nil, 156 | batchRcv: nil, 157 | err: errors.New("error when call cursor")} 158 | 159 | // Error 160 | resp := queryTMScan(queryModel{Command: models.TMScan, Match: "test:*", Count: 100}, &client) 161 | require.EqualError(t, resp.Error, "error when call cursor") 162 | }) 163 | 164 | // First batch 165 | t.Run("should handle error during first batch", func(t *testing.T) { 166 | t.Parallel() 167 | 168 | // Client 169 | client := testClient{ 170 | rcv: []interface{}{ 171 | []byte("24"), 172 | []interface{}{ 173 | []byte("test:string"), 174 | []byte("test:stream"), 175 | []byte("test:set"), 176 | []byte("test:list"), 177 | []byte("test:float"), 178 | []byte("test:hash"), 179 | }, 180 | }, 181 | batchRcv: [][]interface{}{ 182 | { 183 | int64(59), 184 | int64(612), 185 | int64(265), 186 | int64(140), 187 | int64(59), 188 | int64(108), 189 | }, 190 | { 191 | "string", 192 | "stream", 193 | "set", 194 | "list", 195 | "string", 196 | "hash", 197 | }, 198 | }, 199 | batchErr: []error{errors.New("error when batch types"), nil}, 200 | err: nil, 201 | } 202 | 203 | // Response 204 | resp := queryTMScan(queryModel{Command: models.TMScan, Match: "test:*", Count: 100}, &client) 205 | require.EqualError(t, resp.Error, "error when batch types") 206 | }) 207 | 208 | // Second batch 209 | t.Run("should handle error during second batch", func(t *testing.T) { 210 | t.Parallel() 211 | 212 | // Client 213 | client := testClient{ 214 | rcv: []interface{}{ 215 | []byte("24"), 216 | []interface{}{ 217 | []byte("test:string"), 218 | []byte("test:stream"), 219 | []byte("test:set"), 220 | []byte("test:list"), 221 | []byte("test:float"), 222 | []byte("test:hash"), 223 | }, 224 | }, 225 | batchRcv: [][]interface{}{ 226 | { 227 | int64(59), 228 | int64(612), 229 | int64(265), 230 | int64(140), 231 | int64(59), 232 | int64(108), 233 | }, 234 | { 235 | "string", 236 | "stream", 237 | "set", 238 | "list", 239 | "string", 240 | "hash", 241 | }, 242 | }, 243 | batchErr: []error{nil, errors.New("error when batch memory")}, 244 | err: nil, 245 | } 246 | 247 | // Response 248 | resp := queryTMScan(queryModel{Command: models.TMScan, Match: "test:*", Count: 100}, &client) 249 | require.EqualError(t, resp.Error, "error when batch memory") 250 | }) 251 | } 252 | -------------------------------------------------------------------------------- /pkg/redis-zset.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/grafana-plugin-sdk-go/data" 8 | ) 9 | 10 | /** 11 | * ZRANGE key min max [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES] 12 | * 13 | * @see https://redis.io/commands/zrange 14 | */ 15 | func queryZRange(qm queryModel, client redisClient) backend.DataResponse { 16 | response := backend.DataResponse{} 17 | 18 | // Execute command 19 | var result []string 20 | var err error 21 | 22 | if qm.ZRangeQuery == "" { 23 | err = client.RunFlatCmd(&result, qm.Command, qm.Key, qm.Min, qm.Max, "WITHSCORES") 24 | } else { 25 | err = client.RunFlatCmd(&result, qm.Command, qm.Key, qm.Min, qm.Max, qm.ZRangeQuery, "WITHSCORES") 26 | } 27 | 28 | // Check error 29 | if err != nil { 30 | return errorHandler(response, err) 31 | } 32 | 33 | // New Frame 34 | frame := data.NewFrame(qm.Command) 35 | 36 | // Add fields and scores 37 | for i := 0; i < len(result); i += 2 { 38 | if floatValue, err := strconv.ParseFloat(result[i+1], 64); err == nil { 39 | frame.Fields = append(frame.Fields, data.NewField(result[i], nil, []float64{floatValue})) 40 | } else { 41 | frame.Fields = append(frame.Fields, data.NewField(result[i], nil, []string{result[i+1]})) 42 | } 43 | } 44 | 45 | // Add the frames to the response 46 | response.Frames = append(response.Frames, frame) 47 | 48 | // Return 49 | return response 50 | } 51 | -------------------------------------------------------------------------------- /pkg/redis-zset_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | /** 12 | * ZRANGE 13 | */ 14 | func TestQueryZRange(t *testing.T) { 15 | t.Parallel() 16 | 17 | tests := []struct { 18 | name string 19 | qm queryModel 20 | rcv interface{} 21 | fieldsCount int 22 | rowsPerField int 23 | valuesToCheckInResponse []valueToCheckInResponse 24 | err error 25 | }{ 26 | { 27 | "should handle default array of strings", 28 | queryModel{Command: models.ZRange, Key: "test:zset", Min: "0", Max: "-1"}, 29 | []string{"member1", "10", "member2", "2", "member3", "15"}, 30 | 3, 31 | 1, 32 | []valueToCheckInResponse{ 33 | {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: float64(10)}, 34 | {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: float64(2)}, 35 | {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: float64(15)}, 36 | }, 37 | nil, 38 | }, 39 | { 40 | "should handle default array of strings", 41 | queryModel{Command: models.ZRange, Key: "test:zset", ZRangeQuery: "BYSCORE", Min: "-inf", Max: "+inf"}, 42 | []string{"member1", "test", "member2", "2", "member3", "15"}, 43 | 3, 44 | 1, 45 | []valueToCheckInResponse{ 46 | {frameIndex: 0, fieldIndex: 0, rowIndex: 0, value: "test"}, 47 | {frameIndex: 0, fieldIndex: 1, rowIndex: 0, value: float64(2)}, 48 | {frameIndex: 0, fieldIndex: 2, rowIndex: 0, value: float64(15)}, 49 | }, 50 | nil, 51 | }, 52 | { 53 | "should handle error", 54 | queryModel{Command: models.ZRange}, 55 | nil, 56 | 0, 57 | 0, 58 | nil, 59 | errors.New("error occurred"), 60 | }, 61 | } 62 | 63 | // Run Tests 64 | for _, tt := range tests { 65 | tt := tt 66 | t.Run(tt.name, func(t *testing.T) { 67 | t.Parallel() 68 | 69 | client := testClient{rcv: tt.rcv, err: tt.err} 70 | response := queryZRange(tt.qm, &client) 71 | if tt.err != nil { 72 | require.EqualError(t, response.Error, tt.err.Error(), "Should set error to response if failed") 73 | require.Nil(t, response.Frames, "No frames should be created if failed") 74 | } else { 75 | require.Equal(t, tt.qm.Command, response.Frames[0].Name, "Invalid frame name") 76 | require.Len(t, response.Frames[0].Fields, tt.fieldsCount, "Invalid number of fields created ") 77 | require.Equal(t, tt.rowsPerField, response.Frames[0].Fields[0].Len(), "Invalid number of values in field vectors") 78 | 79 | if tt.valuesToCheckInResponse != nil { 80 | for _, value := range tt.valuesToCheckInResponse { 81 | require.Equalf(t, value.value, response.Frames[value.frameIndex].Fields[value.fieldIndex].At(value.rowIndex), "Invalid value at Frame[%v]:Field[%v]:Row[%v]", value.frameIndex, value.fieldIndex, value.rowIndex) 82 | } 83 | } 84 | } 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/testing-utilities_integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package main 4 | 5 | /** 6 | * Integration Test IP 7 | */ 8 | const integrationTestIP = "127.0.0.1" 9 | 10 | /** 11 | * Integration Test port 12 | */ 13 | const integrationTestPort = 63790 14 | -------------------------------------------------------------------------------- /pkg/testing-utilities_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" 10 | "github.com/redisgrafana/grafana-redis-datasource/pkg/models" 11 | "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | /** 15 | * Test client 16 | */ 17 | type testClient struct { 18 | rcv interface{} 19 | batchRcv [][]interface{} 20 | batchErr []error 21 | expectedArgs []string 22 | expectedCmd string 23 | err error 24 | batchCalls int 25 | mock.Mock 26 | } 27 | 28 | /** 29 | * PANIC 30 | */ 31 | type panickingClient struct { 32 | } 33 | 34 | /** 35 | * Response 36 | */ 37 | type valueToCheckInResponse struct { 38 | frameIndex int 39 | fieldIndex int 40 | rowIndex int 41 | value interface{} 42 | } 43 | 44 | /** 45 | * Response 46 | */ 47 | type valueToCheckByLabelInResponse struct { 48 | frameIndex int 49 | fieldName string 50 | rowIndex int 51 | value interface{} 52 | } 53 | 54 | /** 55 | * Fake Instance manager 56 | */ 57 | type fakeInstanceManager struct { 58 | mock.Mock 59 | } 60 | 61 | /** 62 | * FlatCmd() 63 | */ 64 | func (client *testClient) RunFlatCmd(rcv interface{}, cmd, key string, args ...interface{}) error { 65 | if client.err != nil { 66 | return client.err 67 | } 68 | 69 | assignReceiver(rcv, client.rcv) 70 | return nil 71 | } 72 | 73 | /** 74 | * Cmd() 75 | */ 76 | func (client *testClient) RunCmd(rcv interface{}, cmd string, args ...string) error { 77 | if client.err != nil { 78 | return client.err 79 | } 80 | 81 | if client.expectedArgs != nil { 82 | if !reflect.DeepEqual(args, client.expectedArgs) { 83 | return fmt.Errorf("expected args did not match actuall args\nExpected:%s\nActual:%s\n", client.expectedArgs, args) 84 | } 85 | } 86 | 87 | if client.expectedCmd != "" && client.expectedCmd != cmd { 88 | return fmt.Errorf("incorrect command, Expected:%s - Actual: %s", client.expectedCmd, cmd) 89 | } 90 | 91 | assignReceiver(rcv, client.rcv) 92 | return nil 93 | } 94 | 95 | /** 96 | * Pipeline execution using Batch 97 | */ 98 | func (client *testClient) RunBatchFlatCmd(commands []flatCommandArgs) error { 99 | for i, args := range commands { 100 | assignReceiver(args.rcv, client.batchRcv[client.batchCalls][i]) 101 | } 102 | 103 | var err error 104 | if client.batchErr != nil && client.batchErr[client.batchCalls] != nil { 105 | err = client.batchErr[client.batchCalls] 106 | } 107 | 108 | client.batchCalls++ 109 | return err 110 | } 111 | 112 | /** 113 | * Receiver 114 | */ 115 | func assignReceiver(to interface{}, from interface{}) { 116 | switch to.(type) { 117 | case int: 118 | *(to.(*int)) = from.(int) 119 | case int64: 120 | *(to.(*int64)) = from.(int64) 121 | case float64: 122 | *(to.(*float64)) = from.(float64) 123 | case []string: 124 | *(to.(*[]string)) = from.([]string) 125 | case []interface{}: 126 | *(to.(*[]interface{})) = from.([]interface{}) 127 | case [][]string: 128 | *(to.(*[][]string)) = from.([][]string) 129 | case map[string]string: 130 | *(to.(*map[string]string)) = from.(map[string]string) 131 | case map[string]interface{}: 132 | *(to.(*map[string]interface{})) = from.(map[string]interface{}) 133 | case *string: 134 | *(to.(*string)) = from.(string) 135 | case *models.PyStats: 136 | *(to.(*models.PyStats)) = from.(models.PyStats) 137 | case *xinfo: 138 | *(to.(*xinfo)) = from.(xinfo) 139 | case *[]models.DumpRegistrations: 140 | *(to.(*[]models.DumpRegistrations)) = from.([]models.DumpRegistrations) 141 | case *[]models.PyDumpReq: 142 | *(to.(*[]models.PyDumpReq)) = from.([]models.PyDumpReq) 143 | case interface{}: 144 | switch from.(type) { 145 | case int: 146 | *(to.(*interface{})) = from.(int) 147 | case int64: 148 | _, ok := to.(*int64) 149 | if ok { 150 | *(to.(*int64)) = from.(int64) 151 | } else { 152 | _, ok = to.(*interface{}) 153 | if ok { 154 | *(to.(*interface{})) = from.(int64) 155 | } 156 | } 157 | case float64: 158 | *(to.(*interface{})) = from.(float64) 159 | case []string: 160 | *(to.(*[]string)) = from.([]string) 161 | case []interface{}: 162 | _, ok := to.(*[]interface{}) 163 | if ok { 164 | *(to.(*[]interface{})) = from.([]interface{}) 165 | } else { 166 | _, ok = to.(*interface{}) 167 | if ok { 168 | *(to.(*interface{})) = from.([]interface{}) 169 | } 170 | } 171 | case [][]string: 172 | *(to.(*[][]string)) = from.([][]string) 173 | case map[string]string: 174 | *(to.(*map[string]string)) = from.(map[string]string) 175 | case *string: 176 | *(to.(*string)) = from.(string) 177 | case string: 178 | *(to.(*interface{})) = from.(string) 179 | case []uint8: 180 | *(to.(*interface{})) = from.([]uint8) 181 | case map[string]interface{}: 182 | *(to.(*map[string]interface{})) = from.(map[string]interface{}) 183 | default: 184 | panic("Unsupported type of from rcv: " + reflect.TypeOf(from).String()) 185 | } 186 | default: 187 | panic("Unsupported type of to rcv: " + reflect.TypeOf(to).String()) 188 | } 189 | } 190 | 191 | /** 192 | * Close session 193 | */ 194 | func (client *testClient) Close() error { 195 | args := client.Called() 196 | return args.Error(0) 197 | } 198 | 199 | /** 200 | * FlatCmd() Error 201 | */ 202 | func (client *panickingClient) RunFlatCmd(rcv interface{}, cmd, key string, args ...interface{}) error { 203 | panic("Panic") 204 | } 205 | 206 | /** 207 | * Cmd() Error 208 | */ 209 | func (client *panickingClient) RunCmd(rcv interface{}, cmd string, args ...string) error { 210 | panic("Panic") 211 | } 212 | 213 | /** 214 | * Close() Error 215 | */ 216 | func (client *panickingClient) Close() error { 217 | return nil 218 | } 219 | 220 | /** 221 | * Batch command 222 | */ 223 | func (client *panickingClient) RunBatchFlatCmd(commands []flatCommandArgs) error { 224 | panic("Panic") 225 | } 226 | 227 | /** 228 | * Get 229 | */ 230 | func (im *fakeInstanceManager) Get(ctx context.Context, pluginContext backend.PluginContext) (instancemgmt.Instance, error) { 231 | args := im.Called(pluginContext) 232 | return args.Get(0), args.Error(1) 233 | } 234 | 235 | /** 236 | * Do 237 | */ 238 | func (im *fakeInstanceManager) Do(ctx context.Context, pluginContext backend.PluginContext, fn instancemgmt.InstanceCallbackFunc) error { 239 | args := im.Called(pluginContext, fn) 240 | return args.Error(0) 241 | } 242 | -------------------------------------------------------------------------------- /pkg/types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" 5 | ) 6 | 7 | /** 8 | * Instance Settings 9 | */ 10 | type instanceSettings struct { 11 | client redisClient 12 | } 13 | 14 | /** 15 | * The instance manager can help with lifecycle management of datasource instances in plugins. 16 | */ 17 | type redisDatasource struct { 18 | im instancemgmt.InstanceManager 19 | } 20 | 21 | /** 22 | * Configuration Data Model 23 | */ 24 | type dataModel struct { 25 | PoolSize int `json:"poolSize"` 26 | Timeout int `json:"timeout"` 27 | PingInterval int `json:"pingInterval"` 28 | PipelineWindow int `json:"pipelineWindow"` 29 | TLSAuth bool `json:"tlsAuth"` 30 | TLSSkipVerify bool `json:"tlsSkipVerify"` 31 | Client string `json:"client"` 32 | SentinelName string `json:"sentinelName"` 33 | ACL bool `json:"acl"` 34 | User string `json:"user"` 35 | SentinelACL bool `json:"sentinelAcl"` 36 | SentinelUser string `json:"sentinelUser"` 37 | } 38 | 39 | /* 40 | * Query Model 41 | */ 42 | type queryModel struct { 43 | Type string `json:"type"` 44 | Query string `json:"query"` 45 | Key string `json:"keyName"` 46 | Field string `json:"field"` 47 | Filter string `json:"filter"` 48 | Command string `json:"command"` 49 | Aggregation string `json:"aggregation"` 50 | Bucket int `json:"bucket"` 51 | Legend string `json:"legend"` 52 | Value string `json:"value"` 53 | Section string `json:"section"` 54 | Size int `json:"size"` 55 | Fill bool `json:"fill"` 56 | Streaming bool `json:"streaming"` 57 | StreamingDataType string `json:"streamingDataType"` 58 | CLI bool `json:"cli"` 59 | Cursor string `json:"cursor"` 60 | Match string `json:"match"` 61 | Count int `json:"count"` 62 | Samples int `json:"samples"` 63 | Unblocking bool `json:"unblocking"` 64 | Requirements string `json:"requirements"` 65 | Start string `json:"start"` 66 | End string `json:"end"` 67 | Cypher string `json:"cypher"` 68 | Min string `json:"min"` 69 | Max string `json:"max"` 70 | ZRangeQuery string `json:"zrangeQuery"` 71 | Path string `json:"path"` 72 | TsReducer string `json:"tsReducer"` 73 | TsGroupByLabel string `json:"tsGroupByLabel"` 74 | SearchQuery string `json:"searchQuery"` 75 | SortBy string `json:"sortBy"` 76 | SortDirection string `json:"sortDirection"` 77 | Offset int `json:"offset"` 78 | ReturnFields []string `json:"returnFields"` 79 | } 80 | -------------------------------------------------------------------------------- /provisioning/dashboards/default.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: Default # A uniquely identifiable name for the provider 5 | type: file 6 | options: 7 | path: /etc/grafana/provisioning/dashboards 8 | -------------------------------------------------------------------------------- /provisioning/datasources/redis.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Redis 5 | type: redis-datasource 6 | access: proxy 7 | isDefault: true 8 | orgId: 1 9 | version: 1 10 | url: redis://host.docker.internal:6379 11 | jsonData: 12 | poolSize: 5 13 | timeout: 10 14 | pingInterval: 0 15 | pipelineWindow: 0 16 | editable: true 17 | -------------------------------------------------------------------------------- /src/components/ConfigEditor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ConfigEditor'; 2 | -------------------------------------------------------------------------------- /src/components/QueryEditor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './QueryEditor'; 2 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ConfigEditor'; 2 | export * from './QueryEditor'; 3 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { SelectableValue } from '@grafana/data'; 2 | 3 | /** 4 | * Default Streaming Interval 5 | */ 6 | export const DefaultStreamingInterval = 1000; 7 | 8 | /** 9 | * Default Streaming Capacity 10 | */ 11 | export const DefaultStreamingCapacity = 1000; 12 | 13 | /** 14 | * Client Type Values 15 | */ 16 | export enum ClientTypeValue { 17 | CLUSTER = 'cluster', 18 | SENTINEL = 'sentinel', 19 | SOCKET = 'socket', 20 | STANDALONE = 'standalone', 21 | } 22 | 23 | /** 24 | * Client Types 25 | */ 26 | export const ClientType = [ 27 | { label: 'Standalone', value: ClientTypeValue.STANDALONE }, 28 | { label: 'Cluster', value: ClientTypeValue.CLUSTER }, 29 | { label: 'Sentinel', value: ClientTypeValue.SENTINEL }, 30 | { label: 'Socket', value: ClientTypeValue.SOCKET }, 31 | ]; 32 | 33 | /** 34 | * Streaming Data Type 35 | */ 36 | export enum StreamingDataType { 37 | TIMESERIES = 'TimeSeries', 38 | DATAFRAME = 'DataFrame', 39 | } 40 | 41 | /** 42 | * Streaming 43 | */ 44 | export const StreamingDataTypes: Array> = [ 45 | { 46 | label: 'Time series', 47 | value: StreamingDataType.TIMESERIES, 48 | }, 49 | { 50 | label: 'Data frame', 51 | value: StreamingDataType.DATAFRAME, 52 | }, 53 | ]; 54 | -------------------------------------------------------------------------------- /src/datasource/datasource.ts: -------------------------------------------------------------------------------- 1 | import { lastValueFrom, Observable } from 'rxjs'; 2 | import { map as map$, switchMap as switchMap$ } from 'rxjs/operators'; 3 | import { 4 | DataFrame, 5 | DataQueryRequest, 6 | DataQueryResponse, 7 | DataSourceInstanceSettings, 8 | LoadingState, 9 | MetricFindValue, 10 | ScopedVars, 11 | } from '@grafana/data'; 12 | import { DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime'; 13 | import { DefaultStreamingInterval, StreamingDataType } from '../constants'; 14 | import { RedisQuery } from '../redis'; 15 | import { TimeSeriesStreaming } from '../time-series'; 16 | import { RedisDataSourceOptions } from '../types'; 17 | 18 | /** 19 | * Redis Data Source 20 | */ 21 | export class DataSource extends DataSourceWithBackend { 22 | /** 23 | * Constructor 24 | * 25 | * @param {DataSourceInstanceSettings} instanceSettings Instance Settings 26 | */ 27 | constructor(instanceSettings: DataSourceInstanceSettings) { 28 | super(instanceSettings); 29 | } 30 | 31 | /** 32 | * Variable query action 33 | * 34 | * @param {string} query Query 35 | * @param {any} options Options 36 | * @returns {Promise} Metric Find Values 37 | */ 38 | async metricFindQuery?(query: string, options?: any): Promise { 39 | /** 40 | * If query is not specified 41 | */ 42 | if (!query) { 43 | return Promise.resolve([]); 44 | } 45 | 46 | /** 47 | * Run Query 48 | */ 49 | return lastValueFrom( 50 | this.query({ 51 | targets: [{ refId: 'A', datasource: options.variable.datasource, query }], 52 | } as DataQueryRequest).pipe( 53 | switchMap$((response) => response.data), 54 | switchMap$((data: DataFrame) => data.fields), 55 | map$((field) => { 56 | const values: MetricFindValue[] = []; 57 | field.values.toArray().forEach((value) => { 58 | values.push({ text: value }); 59 | }); 60 | 61 | return values; 62 | }) 63 | ) 64 | ); 65 | } 66 | 67 | /** 68 | * Override to apply template variables 69 | * 70 | * @param {string} query Query 71 | * @param {ScopedVars} scopedVars Scoped variables 72 | */ 73 | applyTemplateVariables(query: RedisQuery, scopedVars: ScopedVars) { 74 | const templateSrv = getTemplateSrv(); 75 | 76 | /** 77 | * Replace variables 78 | */ 79 | return { 80 | ...query, 81 | keyName: query.keyName ? templateSrv.replace(query.keyName, scopedVars) : '', 82 | query: query.query ? templateSrv.replace(query.query, scopedVars) : '', 83 | field: query.field ? templateSrv.replace(query.field, scopedVars) : '', 84 | filter: query.filter ? templateSrv.replace(query.filter, scopedVars) : '', 85 | legend: query.legend ? templateSrv.replace(query.legend, scopedVars) : '', 86 | value: query.value ? templateSrv.replace(query.value, scopedVars) : '', 87 | path: query.path ? templateSrv.replace(query.path, scopedVars) : '', 88 | cypher: query.cypher ? templateSrv.replace(query.cypher, scopedVars) : '', 89 | }; 90 | } 91 | 92 | /** 93 | * Override query to support streaming 94 | */ 95 | query(request: DataQueryRequest): Observable { 96 | /** 97 | * No query 98 | * Need to typescript types narrowing 99 | */ 100 | if (!request.targets.length) { 101 | return super.query(request); 102 | } 103 | 104 | /** 105 | * No streaming enabled 106 | */ 107 | const streaming = request.targets.filter((target) => target.streaming); 108 | if (!streaming.length) { 109 | return super.query(request); 110 | } 111 | 112 | /** 113 | * Streaming enabled 114 | */ 115 | return new Observable((subscriber) => { 116 | const frames: { [id: string]: TimeSeriesStreaming } = {}; 117 | request.targets.forEach((target) => { 118 | /** 119 | * Time-series frame 120 | */ 121 | if (target.streamingDataType !== StreamingDataType.DATAFRAME) { 122 | frames[target.refId] = new TimeSeriesStreaming(target); 123 | } 124 | }); 125 | 126 | /** 127 | * Get minimum Streaming Interval 128 | */ 129 | const streamingInterval = request.targets.map((target) => 130 | target.streamingInterval ? target.streamingInterval : DefaultStreamingInterval 131 | ); 132 | 133 | /** 134 | * Interval 135 | */ 136 | const intervalId = setInterval(async () => { 137 | const response = await lastValueFrom(super.query(request)); 138 | 139 | response.data.forEach(async (frame) => { 140 | if (frames[frame.refId]) { 141 | frame = await frames[frame.refId].update(frame.fields); 142 | } 143 | 144 | subscriber.next({ 145 | data: [frame], 146 | key: frame.refId, 147 | state: LoadingState.Streaming, 148 | }); 149 | }); 150 | }, Math.min(...streamingInterval)); 151 | 152 | return () => { 153 | clearInterval(intervalId); 154 | }; 155 | }); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/datasource/index.ts: -------------------------------------------------------------------------------- 1 | export * from './datasource'; 2 | -------------------------------------------------------------------------------- /src/img/datasource.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/b0d65ae02d6cc0738537c70b6ae4870a4344a7d6/src/img/datasource.png -------------------------------------------------------------------------------- /src/img/grafana-marketplace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/b0d65ae02d6cc0738537c70b6ae4870a4344a7d6/src/img/grafana-marketplace.png -------------------------------------------------------------------------------- /src/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 27 | -------------------------------------------------------------------------------- /src/img/query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/b0d65ae02d6cc0738537c70b6ae4870a4344a7d6/src/img/query.png -------------------------------------------------------------------------------- /src/img/redis-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/b0d65ae02d6cc0738537c70b6ae4870a4344a7d6/src/img/redis-dashboard.png -------------------------------------------------------------------------------- /src/img/redis-streaming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/b0d65ae02d6cc0738537c70b6ae4870a4344a7d6/src/img/redis-streaming.png -------------------------------------------------------------------------------- /src/img/variables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedisGrafana/grafana-redis-datasource/b0d65ae02d6cc0738537c70b6ae4870a4344a7d6/src/img/variables.png -------------------------------------------------------------------------------- /src/module.test.ts: -------------------------------------------------------------------------------- 1 | import { DataSourcePlugin } from '@grafana/data'; 2 | import { plugin } from './module'; 3 | 4 | /** 5 | * Plugin 6 | */ 7 | describe('Plugin', () => { 8 | it('Should be an instance of DataSourcePlugin', () => { 9 | expect(plugin).toBeInstanceOf(DataSourcePlugin); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { DataSourcePlugin } from '@grafana/data'; 2 | import { ConfigEditor, QueryEditor } from './components'; 3 | import { DataSource } from './datasource'; 4 | import { RedisQuery } from './redis'; 5 | import { RedisDataSourceOptions } from './types'; 6 | 7 | /** 8 | * Data Source plugin 9 | */ 10 | export const plugin = new DataSourcePlugin(DataSource) 11 | .setConfigEditor(ConfigEditor) 12 | .setQueryEditor(QueryEditor); 13 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "alerting": true, 3 | "backend": true, 4 | "dependencies": { 5 | "grafanaDependency": ">=8.0.0", 6 | "grafanaVersion": "8.x.x", 7 | "plugins": [] 8 | }, 9 | "executable": "redis-datasource", 10 | "id": "redis-datasource", 11 | "includes": [ 12 | { 13 | "name": "Redis Overview", 14 | "path": "dashboards/redis.json", 15 | "type": "dashboard" 16 | }, 17 | { 18 | "name": "Redis Streaming v8", 19 | "path": "dashboards/redis-streaming-v8.json", 20 | "type": "dashboard" 21 | } 22 | ], 23 | "info": { 24 | "author": { 25 | "name": "RedisGrafana", 26 | "url": "https://redisgrafana.github.io" 27 | }, 28 | "description": "Allows to connect to any Redis database On-Premises and in the Cloud.", 29 | "keywords": ["redis", "timeseries", "backend", "plugin"], 30 | "links": [ 31 | { 32 | "name": "Website", 33 | "url": "https://redisgrafana.github.io" 34 | }, 35 | { 36 | "name": "License", 37 | "url": "https://github.com/RedisGrafana/grafana-redis-datasource/blob/master/LICENSE" 38 | } 39 | ], 40 | "logos": { 41 | "large": "img/logo.svg", 42 | "small": "img/logo.svg" 43 | }, 44 | "screenshots": [ 45 | { 46 | "name": "Dashboard", 47 | "path": "img/redis-dashboard.png" 48 | }, 49 | { 50 | "name": "Streaming Dashboard", 51 | "path": "img/redis-streaming.png" 52 | }, 53 | { 54 | "name": "Datasource", 55 | "path": "img/datasource.png" 56 | }, 57 | { 58 | "name": "Query", 59 | "path": "img/query.png" 60 | }, 61 | { 62 | "name": "Variables", 63 | "path": "img/variables.png" 64 | } 65 | ], 66 | "updated": "%TODAY%", 67 | "version": "%VERSION%" 68 | }, 69 | "metrics": true, 70 | "name": "Redis", 71 | "streaming": true, 72 | "type": "datasource" 73 | } 74 | -------------------------------------------------------------------------------- /src/redis/command.ts: -------------------------------------------------------------------------------- 1 | import { RedisGears, RedisGearsCommands } from './gears'; 2 | import { RedisGraph, RedisGraphCommands } from './graph'; 3 | import { RedisJson, RedisJsonCommands } from './json'; 4 | import { QueryTypeValue } from './query'; 5 | import { Redis, RedisCommands } from './redis'; 6 | import { RediSearch, RediSearchCommands } from './search'; 7 | import { RedisTimeSeries, RedisTimeSeriesCommands } from './time-series'; 8 | 9 | /** 10 | * Commands 11 | */ 12 | export const Commands = { 13 | [QueryTypeValue.REDIS]: RedisCommands, 14 | [QueryTypeValue.TIMESERIES]: RedisTimeSeriesCommands, 15 | [QueryTypeValue.SEARCH]: RediSearchCommands, 16 | [QueryTypeValue.GEARS]: RedisGearsCommands, 17 | [QueryTypeValue.GRAPH]: RedisGraphCommands, 18 | [QueryTypeValue.JSON]: RedisJsonCommands, 19 | }; 20 | 21 | /** 22 | * Input for Commands 23 | */ 24 | export const CommandParameters = { 25 | aggregation: [RedisTimeSeries.RANGE, RedisTimeSeries.MRANGE], 26 | field: [Redis.HGET, Redis.HMGET], 27 | filter: [RedisTimeSeries.MRANGE, RedisTimeSeries.QUERYINDEX, RedisTimeSeries.MGET], 28 | keyName: [ 29 | Redis.GET, 30 | Redis.HGET, 31 | Redis.HGETALL, 32 | Redis.HKEYS, 33 | Redis.HLEN, 34 | Redis.HMGET, 35 | Redis.LLEN, 36 | Redis.SCARD, 37 | Redis.SMEMBERS, 38 | RedisTimeSeries.RANGE, 39 | RedisTimeSeries.GET, 40 | RedisTimeSeries.INFO, 41 | Redis.TTL, 42 | Redis.TYPE, 43 | Redis.XINFO_STREAM, 44 | Redis.XLEN, 45 | RediSearch.INFO, 46 | RediSearch.SEARCH, 47 | Redis.XRANGE, 48 | Redis.XREVRANGE, 49 | RedisGraph.QUERY, 50 | RedisGraph.SLOWLOG, 51 | RedisGraph.EXPLAIN, 52 | RedisGraph.PROFILE, 53 | Redis.ZRANGE, 54 | RedisJson.TYPE, 55 | RedisJson.GET, 56 | RedisJson.OBJKEYS, 57 | RedisJson.OBJLEN, 58 | RedisJson.ARRLEN, 59 | ], 60 | legend: [RedisTimeSeries.RANGE], 61 | legendLabel: [RedisTimeSeries.MRANGE, RedisTimeSeries.MGET], 62 | section: [Redis.INFO], 63 | value: [RedisTimeSeries.RANGE], 64 | valueLabel: [RedisTimeSeries.MRANGE, RedisTimeSeries.MGET], 65 | fill: [RedisTimeSeries.RANGE, RedisTimeSeries.MRANGE], 66 | size: [Redis.SLOWLOG_GET, Redis.TMSCAN], 67 | cursor: [Redis.TMSCAN], 68 | match: [Redis.TMSCAN], 69 | count: [Redis.TMSCAN, Redis.XRANGE, Redis.XREVRANGE], 70 | samples: [Redis.TMSCAN], 71 | min: [Redis.ZRANGE], 72 | max: [Redis.ZRANGE], 73 | start: [Redis.XRANGE, Redis.XREVRANGE], 74 | end: [Redis.XRANGE, Redis.XREVRANGE], 75 | cypher: [RedisGraph.EXPLAIN, RedisGraph.QUERY, RedisGraph.PROFILE], 76 | zrangeQuery: [Redis.ZRANGE], 77 | path: [RedisJson.TYPE, RedisJson.OBJKEYS, RedisJson.GET, RedisJson.OBJLEN, RedisJson.ARRLEN], 78 | pyFunction: [RedisGears.PYEXECUTE], 79 | tsGroupBy: [RedisTimeSeries.MRANGE], 80 | tsReducer: [RedisTimeSeries.MRANGE], 81 | searchQuery: [RediSearch.SEARCH], 82 | offset: [RediSearch.SEARCH], 83 | returnFields: [RediSearch.SEARCH], 84 | limit: [RediSearch.SEARCH], 85 | sortBy: [RediSearch.SEARCH], 86 | }; 87 | -------------------------------------------------------------------------------- /src/redis/fieldValuesContainer.ts: -------------------------------------------------------------------------------- 1 | export interface FieldValuesContainer { 2 | fieldArray?: string[]; 3 | } 4 | -------------------------------------------------------------------------------- /src/redis/gears.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Supported Commands 3 | */ 4 | export enum RedisGears { 5 | DUMPREGISTRATIONS = 'rg.dumpregistrations', 6 | PYSTATS = 'rg.pystats', 7 | PYDUMPREQS = 'rg.pydumpreqs', 8 | PYEXECUTE = 'rg.pyexecute', 9 | } 10 | 11 | /** 12 | * Commands List 13 | */ 14 | export const RedisGearsCommands = [ 15 | { 16 | label: RedisGears.DUMPREGISTRATIONS.toUpperCase(), 17 | description: 'Outputs the list of function registrations', 18 | value: RedisGears.DUMPREGISTRATIONS, 19 | }, 20 | { 21 | label: RedisGears.PYDUMPREQS.toUpperCase(), 22 | description: 'Returns a list of all the python requirements available', 23 | value: RedisGears.PYDUMPREQS, 24 | }, 25 | { 26 | label: RedisGears.PYEXECUTE.toUpperCase(), 27 | description: 'Executes Python functions and registers functions for event-driven processing', 28 | value: RedisGears.PYEXECUTE, 29 | }, 30 | { 31 | label: RedisGears.PYSTATS.toUpperCase(), 32 | description: 'Returns memory usage statistics from the Python interpreter', 33 | value: RedisGears.PYSTATS, 34 | }, 35 | ]; 36 | -------------------------------------------------------------------------------- /src/redis/graph.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Supported Commands 3 | */ 4 | export enum RedisGraph { 5 | CONFIG = 'graph.config', 6 | PROFILE = 'graph.profile', 7 | EXPLAIN = 'graph.explain', 8 | QUERY = 'graph.query', 9 | SLOWLOG = 'graph.slowlog', 10 | } 11 | 12 | /** 13 | * Commands List 14 | */ 15 | export const RedisGraphCommands = [ 16 | { 17 | label: RedisGraph.CONFIG.toUpperCase(), 18 | description: 'Retrieves a RedisGraph configuration', 19 | value: RedisGraph.CONFIG, 20 | }, 21 | { 22 | label: RedisGraph.EXPLAIN.toUpperCase(), 23 | description: 'Constructs a query execution plan but does not run it', 24 | value: RedisGraph.EXPLAIN, 25 | }, 26 | { 27 | label: RedisGraph.PROFILE.toUpperCase(), 28 | description: 29 | "Executes a query and produces an execution plan augmented with metrics for each operation's execution", 30 | value: RedisGraph.PROFILE, 31 | }, 32 | { 33 | label: RedisGraph.QUERY.toUpperCase(), 34 | description: 'Executes the given query against a specified graph', 35 | value: RedisGraph.QUERY, 36 | }, 37 | { 38 | label: RedisGraph.SLOWLOG.toUpperCase(), 39 | description: 'Returns a list containing up to 10 of the slowest queries issued against the given graph ID', 40 | value: RedisGraph.SLOWLOG, 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /src/redis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './command'; 2 | export * from './gears'; 3 | export * from './graph'; 4 | export * from './info'; 5 | export * from './query'; 6 | export * from './redis'; 7 | export * from './time-series'; 8 | export * from './types'; 9 | export * from './json'; 10 | -------------------------------------------------------------------------------- /src/redis/info.ts: -------------------------------------------------------------------------------- 1 | import { SelectableValue } from '@grafana/data'; 2 | 3 | /** 4 | * Info Section Values 5 | */ 6 | export enum InfoSectionValue { 7 | SERVER = 'server', 8 | CLIENTS = 'clients', 9 | MEMORY = 'memory', 10 | PERSISTENCE = 'persistence', 11 | STATS = 'stats', 12 | REPLICATION = 'replication', 13 | CPU = 'cpu', 14 | COMMANDSTATS = 'commandstats', 15 | CLUSTER = 'cluster', 16 | KEYSPACE = 'keyspace', 17 | ERRORSTATS = 'errorstats', 18 | } 19 | 20 | /** 21 | * Info sections 22 | */ 23 | export const InfoSections: Array> = [ 24 | { label: 'Server', description: 'General information about the Redis server', value: InfoSectionValue.SERVER }, 25 | { label: 'Clients', description: 'Client connections section', value: InfoSectionValue.CLIENTS }, 26 | { label: 'Memory', description: 'Memory consumption related information', value: InfoSectionValue.MEMORY }, 27 | { label: 'Persistence', description: 'RDB and AOF related information', value: InfoSectionValue.PERSISTENCE }, 28 | { label: 'Stats', description: 'General statistics', value: InfoSectionValue.STATS }, 29 | { label: 'Replication', description: 'Master/replica replication information', value: InfoSectionValue.REPLICATION }, 30 | { label: 'CPU', description: 'CPU consumption statistics', value: InfoSectionValue.CPU }, 31 | { label: 'Command Stats', description: 'Command statistics', value: InfoSectionValue.COMMANDSTATS }, 32 | { label: 'Cluster', description: 'Cluster section', value: InfoSectionValue.CLUSTER }, 33 | { label: 'Keyspace', description: 'Database related statistics', value: InfoSectionValue.KEYSPACE }, 34 | { label: 'Error Stats', description: 'Error statistics (Redis 6.2)', value: InfoSectionValue.ERRORSTATS }, 35 | ]; 36 | -------------------------------------------------------------------------------- /src/redis/json.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Supported Commands 3 | */ 4 | export enum RedisJson { 5 | OBJKEYS = 'json.objkeys', 6 | OBJLEN = 'json.objlen', 7 | GET = 'json.get', 8 | TYPE = 'json.type', 9 | ARRLEN = 'json.arrlen', 10 | } 11 | 12 | /** 13 | * Commands List 14 | */ 15 | export const RedisJsonCommands = [ 16 | { 17 | label: RedisJson.ARRLEN.toUpperCase(), 18 | description: 'Report the length of the JSON Array at path in key', 19 | value: RedisJson.ARRLEN, 20 | }, 21 | { 22 | label: RedisJson.GET.toUpperCase(), 23 | description: 'Return the value at path', 24 | value: RedisJson.GET, 25 | }, 26 | { 27 | label: RedisJson.OBJKEYS.toUpperCase(), 28 | description: "Return the keys in the object that's referenced by path", 29 | value: RedisJson.OBJKEYS, 30 | }, 31 | { 32 | label: RedisJson.OBJLEN.toUpperCase(), 33 | description: 'Report the number of keys in the JSON Object at path in key', 34 | value: RedisJson.OBJLEN, 35 | }, 36 | { 37 | label: RedisJson.TYPE.toUpperCase(), 38 | description: 'Report the type of JSON value at path', 39 | value: RedisJson.TYPE, 40 | }, 41 | ]; 42 | -------------------------------------------------------------------------------- /src/redis/query.ts: -------------------------------------------------------------------------------- 1 | import { SelectableValue } from '@grafana/data'; 2 | 3 | /** 4 | * Query Type Values 5 | */ 6 | export enum QueryTypeValue { 7 | REDIS = 'command', 8 | TIMESERIES = 'timeSeries', 9 | SEARCH = 'search', 10 | CLI = 'cli', 11 | GEARS = 'gears', 12 | GRAPH = 'graph', 13 | JSON = 'json', 14 | } 15 | 16 | /** 17 | * Query Type 18 | */ 19 | export const QueryType: Array> = [ 20 | { 21 | label: 'Redis', 22 | description: 'Hashes, Sets, Lists, Strings, Streams, etc.', 23 | value: QueryTypeValue.REDIS, 24 | }, 25 | { 26 | label: 'RedisGears', 27 | description: 'Dynamic framework for data processing', 28 | value: QueryTypeValue.GEARS, 29 | }, 30 | { 31 | label: 'RedisJSON', 32 | description: 'JSON data type for Redis', 33 | value: QueryTypeValue.JSON, 34 | }, 35 | { 36 | label: 'RedisGraph', 37 | description: 'Graph database', 38 | value: QueryTypeValue.GRAPH, 39 | }, 40 | { 41 | label: 'RediSearch', 42 | description: 'Secondary Index & Query Engine', 43 | value: QueryTypeValue.SEARCH, 44 | }, 45 | { 46 | label: 'RedisTimeSeries', 47 | description: 'Time Series data structure', 48 | value: QueryTypeValue.TIMESERIES, 49 | }, 50 | ]; 51 | 52 | /** 53 | * Query Type for Command-line interface 54 | */ 55 | export const QueryTypeCli: SelectableValue = { 56 | label: 'Command-line interface (CLI)', 57 | description: 'Be mindful, not all commands are supported', 58 | value: QueryTypeValue.CLI, 59 | }; 60 | -------------------------------------------------------------------------------- /src/redis/redis.ts: -------------------------------------------------------------------------------- 1 | import { SelectableValue } from '@grafana/data'; 2 | 3 | /** 4 | * Supported Commands 5 | */ 6 | export enum Redis { 7 | CLIENT_LIST = 'clientList', 8 | CLUSTER_INFO = 'clusterInfo', 9 | CLUSTER_NODES = 'clusterNodes', 10 | GET = 'get', 11 | HGET = 'hget', 12 | HGETALL = 'hgetall', 13 | HKEYS = 'hkeys', 14 | HLEN = 'hlen', 15 | HMGET = 'hmget', 16 | INFO = 'info', 17 | LLEN = 'llen', 18 | TMSCAN = 'tmscan', 19 | SCARD = 'scard', 20 | SLOWLOG_GET = 'slowlogGet', 21 | SMEMBERS = 'smembers', 22 | TTL = 'ttl', 23 | TYPE = 'type', 24 | ZRANGE = 'zrange', 25 | XINFO_STREAM = 'xinfoStream', 26 | XLEN = 'xlen', 27 | XRANGE = 'xrange', 28 | XREVRANGE = 'xrevrange', 29 | } 30 | 31 | /** 32 | * Commands List 33 | */ 34 | export const RedisCommands = [ 35 | { 36 | label: 'CLIENT LIST', 37 | description: 'Returns information and statistics about the client connections server', 38 | value: Redis.CLIENT_LIST, 39 | }, 40 | { 41 | label: 'CLUSTER INFO', 42 | description: 'Provides INFO style information about Redis Cluster vital parameters', 43 | value: Redis.CLUSTER_INFO, 44 | }, 45 | { 46 | label: 'CLUSTER NODES', 47 | description: 'Provides current cluster configuration, given by the set of known nodes', 48 | value: Redis.CLUSTER_NODES, 49 | }, 50 | { 51 | label: Redis.GET.toUpperCase(), 52 | description: 'Returns the value of key', 53 | value: Redis.GET, 54 | }, 55 | { 56 | label: Redis.HGET.toUpperCase(), 57 | description: 'Returns the value associated with field in the hash stored at key', 58 | value: Redis.HGET, 59 | }, 60 | { 61 | label: Redis.HGETALL.toUpperCase(), 62 | description: 'Returns all fields and values of the hash stored at key', 63 | value: Redis.HGETALL, 64 | }, 65 | { 66 | label: Redis.HKEYS.toUpperCase(), 67 | description: 'Returns all field names in the hash stored at key', 68 | value: Redis.HKEYS, 69 | }, 70 | { 71 | label: Redis.HLEN.toUpperCase(), 72 | description: 'Returns the number of fields contained in the hash stored at key', 73 | value: Redis.HLEN, 74 | }, 75 | { 76 | label: Redis.HMGET.toUpperCase(), 77 | description: 'Returns the values associated with the specified fields in the hash stored at key', 78 | value: Redis.HMGET, 79 | }, 80 | { 81 | label: Redis.INFO.toUpperCase(), 82 | description: 'Returns information and statistics about the server', 83 | value: Redis.INFO, 84 | }, 85 | { label: Redis.LLEN.toUpperCase(), description: 'Returns the length of the list stored at key', value: Redis.LLEN }, 86 | { 87 | label: Redis.TMSCAN.toUpperCase(), 88 | description: 'Returns keys with types and memory usage (CAUSE LATENCY)', 89 | value: Redis.TMSCAN, 90 | }, 91 | { 92 | label: Redis.SCARD.toUpperCase(), 93 | description: 'Returns the set cardinality (number of elements) of the set stored at key', 94 | value: Redis.SCARD, 95 | }, 96 | { 97 | label: 'SLOWLOG GET', 98 | description: 'Returns the Redis slow queries log', 99 | value: Redis.SLOWLOG_GET, 100 | }, 101 | { 102 | label: Redis.SMEMBERS.toUpperCase(), 103 | description: 'Returns all the members of the set value stored at key', 104 | value: Redis.SMEMBERS, 105 | }, 106 | { 107 | label: Redis.TTL.toUpperCase(), 108 | description: 'Returns the string representation of the type of the value stored at key', 109 | value: Redis.TTL, 110 | }, 111 | { 112 | label: Redis.TYPE.toUpperCase(), 113 | description: 'Returns the string representation of the type of the value stored at key', 114 | value: Redis.TYPE, 115 | }, 116 | { 117 | label: Redis.ZRANGE.toUpperCase(), 118 | description: 'Returns the specified range of elements in the sorted set at key', 119 | value: Redis.ZRANGE, 120 | }, 121 | { 122 | label: 'XINFO STREAM', 123 | description: 'Returns general information about the stream stored at the specified key', 124 | value: Redis.XINFO_STREAM, 125 | }, 126 | { 127 | label: Redis.XLEN.toUpperCase(), 128 | description: 'Returns the number of entries inside a stream', 129 | value: Redis.XLEN, 130 | }, 131 | { 132 | label: Redis.XRANGE.toUpperCase(), 133 | description: 'Returns the stream entries matching a given range of IDs', 134 | value: Redis.XRANGE, 135 | }, 136 | { 137 | label: Redis.XREVRANGE.toUpperCase(), 138 | description: 'Returns the stream entries matching a given range of IDs in reverse order', 139 | value: Redis.XREVRANGE, 140 | }, 141 | ]; 142 | 143 | /** 144 | * ZRANGE Query Values 145 | */ 146 | export enum ZRangeQueryValue { 147 | BYINDEX = '', 148 | BYSCORE = 'BYSCORE', 149 | BYLEX = 'BYLEX', 150 | } 151 | 152 | /** 153 | * Aggregations 154 | */ 155 | export const ZRangeQuery: Array> = [ 156 | { 157 | label: 'Index range', 158 | description: 159 | 'The and arguments represent zero-based indexes, where 0 is the first element, 1 is the next element, and so on.', 160 | value: ZRangeQueryValue.BYINDEX, 161 | }, 162 | { 163 | label: 'Score range', 164 | description: 'Returns the range of elements from the sorted set having scores equal or between and ', 165 | value: ZRangeQueryValue.BYSCORE, 166 | }, 167 | ]; 168 | -------------------------------------------------------------------------------- /src/redis/search.ts: -------------------------------------------------------------------------------- 1 | import { SelectableValue } from '@grafana/data'; 2 | 3 | /** 4 | * Supported Commands 5 | */ 6 | export enum RediSearch { 7 | INFO = 'ft.info', 8 | SEARCH = 'ft.search', 9 | } 10 | 11 | /** 12 | * Sort Directions 13 | */ 14 | export enum SortDirectionValue { 15 | NONE = 'None', 16 | ASC = 'ASC', 17 | DESC = 'DESC', 18 | } 19 | /** 20 | * Commands List 21 | */ 22 | export const RediSearchCommands = [ 23 | { 24 | label: RediSearch.INFO.toUpperCase(), 25 | description: 'Returns information and statistics on the index', 26 | value: RediSearch.INFO, 27 | }, 28 | { 29 | label: RediSearch.SEARCH.toUpperCase(), 30 | description: 'Search the index with a textual query, returning either documents or just ids', 31 | value: RediSearch.SEARCH, 32 | }, 33 | ]; 34 | 35 | export const SortDirection: Array> = [ 36 | { 37 | label: 'None', 38 | description: "Don't sort anything.", 39 | value: SortDirectionValue.NONE, 40 | }, 41 | { 42 | label: 'Ascending', 43 | description: 'Sort the field in Ascending order.', 44 | value: SortDirectionValue.ASC, 45 | }, 46 | { 47 | label: 'Descending', 48 | description: 'Sort the values in descending order.', 49 | value: SortDirectionValue.DESC, 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /src/redis/time-series.ts: -------------------------------------------------------------------------------- 1 | import { SelectableValue } from '@grafana/data'; 2 | 3 | /** 4 | * Supported Commands 5 | */ 6 | export enum RedisTimeSeries { 7 | GET = 'ts.get', 8 | INFO = 'ts.info', 9 | MGET = 'ts.mget', 10 | MRANGE = 'ts.mrange', 11 | QUERYINDEX = 'ts.queryindex', 12 | RANGE = 'ts.range', 13 | } 14 | 15 | /** 16 | * Commands List 17 | */ 18 | export const RedisTimeSeriesCommands = [ 19 | { 20 | label: RedisTimeSeries.GET.toUpperCase(), 21 | description: 'Returns the last sample', 22 | value: RedisTimeSeries.GET, 23 | }, 24 | { 25 | label: RedisTimeSeries.INFO.toUpperCase(), 26 | description: 'Returns information and statistics on the time-series', 27 | value: RedisTimeSeries.INFO, 28 | }, 29 | { 30 | label: RedisTimeSeries.MGET.toUpperCase(), 31 | description: 'Returns the last samples matching the specific filter', 32 | value: RedisTimeSeries.MGET, 33 | }, 34 | { 35 | label: RedisTimeSeries.MRANGE.toUpperCase(), 36 | description: 'Query a timestamp range across multiple time-series by filters', 37 | value: RedisTimeSeries.MRANGE, 38 | }, 39 | { 40 | label: RedisTimeSeries.QUERYINDEX.toUpperCase(), 41 | description: 'Query all the keys matching the filter list', 42 | value: RedisTimeSeries.QUERYINDEX, 43 | }, 44 | { label: RedisTimeSeries.RANGE.toUpperCase(), description: 'Query a range', value: RedisTimeSeries.RANGE }, 45 | ]; 46 | 47 | /** 48 | * Aggregation Values 49 | */ 50 | export enum AggregationValue { 51 | NONE = '', 52 | AVG = 'avg', 53 | COUNT = 'count', 54 | MAX = 'max', 55 | MIN = 'min', 56 | RANGE = 'range', 57 | SUM = 'sum', 58 | FIRST = 'first', 59 | LAST = 'last', 60 | STDP = 'std.p', 61 | STDS = 'std.s', 62 | VARP = 'var.p', 63 | VARS = 'var.s', 64 | } 65 | 66 | export enum ReducerValue { 67 | AVG = 'avg', 68 | SUM = 'sum', 69 | MIN = 'min', 70 | MAX = 'max', 71 | RANGE = 'range', 72 | COUNT = 'count', 73 | STDP = 'std.p', 74 | STDS = 'std.s', 75 | VARP = 'var.p', 76 | VARS = 'var.s', 77 | } 78 | 79 | /** 80 | * Aggregations 81 | */ 82 | export const Aggregations: Array> = [ 83 | { label: 'None', description: 'No aggregation', value: AggregationValue.NONE }, 84 | { label: 'Avg', description: 'Average', value: AggregationValue.AVG }, 85 | { label: 'Count', description: 'Count number of samples', value: AggregationValue.COUNT }, 86 | { label: 'Max', description: 'Maximum', value: AggregationValue.MAX }, 87 | { label: 'Min', description: 'Minimum', value: AggregationValue.MIN }, 88 | { label: 'Range', description: 'Diff between maximum and minimum in the bucket', value: AggregationValue.RANGE }, 89 | { label: 'Sum', description: 'Summation', value: AggregationValue.SUM }, 90 | { label: 'First', description: 'The value with the lowest timestamp in the bucket', value: AggregationValue.FIRST }, 91 | { label: 'Last', description: 'The value with the highest timestamp in the bucket', value: AggregationValue.LAST }, 92 | { label: 'Std.p', description: 'Population standard deviation of the values', value: AggregationValue.STDP }, 93 | { label: 'Std.s', description: 'Sample standard deviation of the values', value: AggregationValue.STDS }, 94 | { label: 'Var.p', description: 'Population variance of the values', value: AggregationValue.VARP }, 95 | { label: 'Var.s', description: 'Sample variance of the values', value: AggregationValue.VARS }, 96 | ]; 97 | 98 | /** 99 | * Reducers 100 | */ 101 | export const Reducers: Array> = [ 102 | { label: 'Avg', description: 'Arithmetic mean of all non-NaN values', value: ReducerValue.AVG }, 103 | { label: 'Sum', description: 'Sum of all non-NaN values', value: ReducerValue.SUM }, 104 | { label: 'Min', description: 'Minimum non-NaN value', value: ReducerValue.MIN }, 105 | { label: 'Max', description: 'Maximum non-NaN value', value: ReducerValue.MAX }, 106 | { 107 | label: 'Range', 108 | description: 'Difference between maximum non-Nan value and minimum non-NaN value', 109 | value: ReducerValue.RANGE, 110 | }, 111 | { label: 'Count', description: 'Number of non-NaN values', value: ReducerValue.COUNT }, 112 | { 113 | label: 'Std Population', 114 | description: 'Population standard deviation of all non-NaN values', 115 | value: ReducerValue.STDP, 116 | }, 117 | { label: 'Std Sample', description: 'Sample standard deviation of all non-NaN values', value: ReducerValue.STDS }, 118 | { label: 'Var Population', description: 'Population variance of all non-NaN values', value: ReducerValue.VARP }, 119 | { label: 'Var Sample', description: 'Sample variance of all non-NaN values', value: ReducerValue.VARS }, 120 | ]; 121 | -------------------------------------------------------------------------------- /src/redis/types.ts: -------------------------------------------------------------------------------- 1 | import { ReducerValue, ZRangeQueryValue } from 'redis'; 2 | import { DataQuery } from '@grafana/data'; 3 | import { StreamingDataType } from '../constants'; 4 | import { InfoSectionValue } from './info'; 5 | import { QueryTypeValue } from './query'; 6 | import { AggregationValue } from './time-series'; 7 | import { SortDirectionValue } from './search'; 8 | 9 | /** 10 | * Redis Query 11 | */ 12 | export interface RedisQuery extends DataQuery { 13 | /** 14 | * Type 15 | * 16 | * @type {string} 17 | */ 18 | type: QueryTypeValue; 19 | 20 | /** 21 | * Query command 22 | * 23 | * @type {string} 24 | */ 25 | query?: string; 26 | 27 | /** 28 | * Query for Search Command 29 | * 30 | * @type {string} 31 | */ 32 | searchQuery?: string; 33 | 34 | /** 35 | * search return fields 36 | * 37 | * @type {string[]} 38 | */ 39 | returnFields?: string[]; 40 | 41 | /** 42 | * offset into result set to start at 43 | */ 44 | offset?: number; 45 | 46 | /** 47 | * The direction to sort. 48 | */ 49 | sortDirection?: SortDirectionValue; 50 | 51 | /** 52 | * The value to sort by 53 | */ 54 | sortBy?: string; 55 | 56 | /** 57 | * Field 58 | * 59 | * @type {string} 60 | */ 61 | field?: string; 62 | 63 | /** 64 | * Redis TimeSeries filter 65 | * 66 | * @see https://oss.redislabs.com/redistimeseries/commands/#filtering 67 | * @type {string} 68 | */ 69 | filter?: string; 70 | 71 | /** 72 | * Command 73 | * 74 | * @type {string} 75 | */ 76 | command?: string; 77 | 78 | /** 79 | * Key name 80 | * 81 | * @type {string} 82 | */ 83 | keyName?: string; 84 | 85 | /** 86 | * Value label 87 | * 88 | * @type {string} 89 | */ 90 | value?: string; 91 | 92 | /** 93 | * Aggregation 94 | * 95 | * @see https://oss.redislabs.com/redistimeseries/commands/#aggregation-compaction-downsampling 96 | * @type {AggregationValue} 97 | */ 98 | aggregation?: AggregationValue; 99 | 100 | /** 101 | * The reduction to run to sum-up a group 102 | * 103 | * @type {ReducerValue} 104 | */ 105 | tsReducer?: ReducerValue; 106 | 107 | /** 108 | * The label to group time-series by in an TS.MRANGE. 109 | */ 110 | tsGroupByLabel?: string; 111 | 112 | /** 113 | * ZRANGE Query 114 | * 115 | * @see https://redis.io/commands/zrange 116 | * @type {ZRangeQueryValue} 117 | */ 118 | zrangeQuery?: ZRangeQueryValue; 119 | 120 | /** 121 | * Bucket 122 | * 123 | * @type {number} 124 | */ 125 | bucket?: number; 126 | 127 | /** 128 | * Fill 129 | * 130 | * @type {boolean} 131 | */ 132 | fill?: boolean; 133 | 134 | /** 135 | * Legend label 136 | * 137 | * @type {string} 138 | */ 139 | legend?: string; 140 | 141 | /** 142 | * Info Section 143 | * 144 | * @type {string} 145 | */ 146 | section?: InfoSectionValue; 147 | 148 | /** 149 | * Size 150 | * 151 | * @type {number} 152 | */ 153 | size?: number; 154 | 155 | /** 156 | * Support streaming 157 | * 158 | * @type {boolean} 159 | */ 160 | streaming?: boolean; 161 | 162 | /** 163 | * Streaming interval in milliseconds 164 | * 165 | * @type {number} 166 | */ 167 | streamingInterval?: number; 168 | 169 | /** 170 | * Streaming capacity 171 | * 172 | * @type {number} 173 | */ 174 | streamingCapacity?: number; 175 | 176 | /** 177 | * Streaming data type 178 | * @type {StreamingDataType} 179 | */ 180 | streamingDataType?: StreamingDataType; 181 | 182 | /** 183 | * Cursor for SCAN command 184 | * 185 | * @type {string} 186 | */ 187 | cursor?: string; 188 | 189 | /** 190 | * Match for SCAN command 191 | * 192 | * @type {string} 193 | */ 194 | match?: string; 195 | 196 | /** 197 | * Count for SCAN command 198 | * 199 | * @type {number} 200 | */ 201 | count?: number; 202 | 203 | /** 204 | * Samples for MEMORY USAGE command 205 | * 206 | * @type {number} 207 | */ 208 | samples?: number; 209 | 210 | /** 211 | * Start for Streams 212 | * 213 | * @type {string} 214 | */ 215 | start?: string; 216 | 217 | /** 218 | * Stop for Streams 219 | * 220 | * @type {string} 221 | */ 222 | end?: string; 223 | 224 | /** 225 | * Minimum for ZSet 226 | * 227 | * @type {string} 228 | */ 229 | min?: string; 230 | 231 | /** 232 | * Maximum for ZSet 233 | * 234 | * @type {string} 235 | */ 236 | max?: string; 237 | 238 | /** 239 | * Cypher 240 | * 241 | * @type {string} 242 | */ 243 | cypher?: string; 244 | 245 | /** 246 | * Path 247 | * 248 | * @type {string} 249 | */ 250 | path?: string; 251 | } 252 | -------------------------------------------------------------------------------- /src/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { AggregationValue, InfoSectionValue, QueryTypeValue, RedisQuery } from '../redis'; 2 | 3 | /** 4 | * Query 5 | */ 6 | export const getQuery = (overrideQuery: object = {}): RedisQuery => ({ 7 | keyName: '', 8 | aggregation: AggregationValue.NONE, 9 | bucket: 0, 10 | legend: '', 11 | command: '', 12 | field: '', 13 | path: '', 14 | cypher: '', 15 | filter: '', 16 | value: '', 17 | query: '', 18 | type: QueryTypeValue.CLI, 19 | section: InfoSectionValue.STATS, 20 | size: 1, 21 | fill: true, 22 | streaming: true, 23 | streamingInterval: 1, 24 | streamingCapacity: 1, 25 | refId: '', 26 | ...overrideQuery, 27 | }); 28 | -------------------------------------------------------------------------------- /src/time-series/index.ts: -------------------------------------------------------------------------------- 1 | export * from './time-series'; 2 | -------------------------------------------------------------------------------- /src/time-series/time-series.test.ts: -------------------------------------------------------------------------------- 1 | import { FieldType } from '@grafana/data'; 2 | import { QueryTypeValue } from '../redis'; 3 | import { TimeSeriesStreaming } from './time-series'; 4 | 5 | /** 6 | * Time Series Streaming 7 | */ 8 | describe('TimeSeriesStreaming', () => { 9 | it('Should keep previous values', async () => { 10 | const frame = new TimeSeriesStreaming({ refId: 'A', type: QueryTypeValue.REDIS }); 11 | const data = await frame.update([ 12 | { 13 | name: 'value', 14 | type: FieldType.string, 15 | values: { toArray: jest.fn().mockImplementation(() => ['hello']) }, 16 | }, 17 | ]); 18 | expect(data.length).toEqual(1); 19 | 20 | const data2 = await frame.update([ 21 | { 22 | name: 'value', 23 | type: FieldType.string, 24 | values: { toArray: jest.fn().mockImplementation(() => ['world']) }, 25 | }, 26 | ]); 27 | expect(data2.length).toEqual(2); 28 | expect(data2.fields[0].values.toArray()).toEqual(['hello', 'world']); 29 | }); 30 | 31 | it('If no fields, should work correctly', async () => { 32 | const frame = new TimeSeriesStreaming({ refId: 'A', type: QueryTypeValue.REDIS }); 33 | const data = await frame.update([ 34 | { 35 | name: 'value', 36 | type: FieldType.string, 37 | values: { toArray: jest.fn().mockImplementation(() => ['hello']) }, 38 | }, 39 | ]); 40 | expect(data.length).toEqual(1); 41 | 42 | const data2 = await frame.update([]); 43 | expect(data2.length).toEqual(2); 44 | }); 45 | 46 | it('Should work correctly if no query', () => { 47 | const frame = new TimeSeriesStreaming(undefined as any); 48 | expect(frame).toBeInstanceOf(TimeSeriesStreaming); 49 | }); 50 | 51 | it('Should apply last line if gets more 1 line', async () => { 52 | const frame = new TimeSeriesStreaming({ refId: 'A', type: QueryTypeValue.REDIS }); 53 | const data = await frame.update([ 54 | { 55 | name: 'value', 56 | type: FieldType.string, 57 | values: { toArray: jest.fn().mockImplementation(() => ['hello', 'world', 'bye']) }, 58 | }, 59 | ]); 60 | expect(data.length).toEqual(1); 61 | expect(data.fields.length).toEqual(1); 62 | }); 63 | 64 | it('Should convert string to number if value can be converted', async () => { 65 | const frame = new TimeSeriesStreaming({ refId: 'A', type: QueryTypeValue.REDIS, streamingCapacity: 10 }); 66 | const data = await frame.update([ 67 | { 68 | name: 'value', 69 | type: FieldType.string, 70 | values: { toArray: jest.fn().mockImplementation(() => ['123']) }, 71 | }, 72 | ]); 73 | expect(data.fields[0].name === 'value'); 74 | expect(data.fields[0].type === FieldType.number); 75 | const fieldsArr = data.fields[0].values.toArray(); 76 | expect(fieldsArr.length === 1 && fieldsArr[0] === 123); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/time-series/time-series.ts: -------------------------------------------------------------------------------- 1 | import { CircularDataFrame, Field, FieldType } from '@grafana/data'; 2 | import { DefaultStreamingCapacity } from '../constants'; 3 | import { RedisQuery } from '../redis'; 4 | 5 | /** 6 | * Time Series Streaming 7 | */ 8 | export class TimeSeriesStreaming { 9 | /** 10 | * Frame with all values 11 | */ 12 | frame: CircularDataFrame; 13 | 14 | /** 15 | * Constructor 16 | * 17 | * @param ref 18 | */ 19 | constructor(ref: RedisQuery) { 20 | /** 21 | * This dataframe can have values constantly added, and will never exceed the given capacity 22 | */ 23 | this.frame = new CircularDataFrame({ 24 | append: 'tail', 25 | capacity: ref?.streamingCapacity || DefaultStreamingCapacity, 26 | }); 27 | 28 | /** 29 | * Set refId 30 | */ 31 | this.frame.refId = ref?.refId; 32 | } 33 | 34 | /** 35 | * Add new values for the frame 36 | * 37 | * @param request 38 | */ 39 | async update(fields: any): Promise { 40 | let values: { [index: string]: number } = {}; 41 | 42 | /** 43 | * Add fields to frame fields and return values 44 | */ 45 | fields.forEach((field: Field) => { 46 | /** 47 | * Add new fields if frame does not have the field 48 | */ 49 | const fieldValues = field.values.toArray(); 50 | const value = fieldValues[fieldValues.length - 1]; 51 | 52 | if (!this.frame.fields.some((addedField) => addedField.name === field.name)) { 53 | this.frame.addField({ 54 | name: field.name, 55 | type: field.type === FieldType.string && !isNaN(value) ? FieldType.number : field.type, 56 | }); 57 | } 58 | 59 | /** 60 | * Set values. If values.length > 1, should be set the last line 61 | */ 62 | values[field.name] = value; 63 | }); 64 | 65 | /** 66 | * Add values and return 67 | */ 68 | this.frame.add(values); 69 | return Promise.resolve(this.frame); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DataSourceJsonData } from '@grafana/data'; 2 | import { ClientTypeValue } from './constants'; 3 | 4 | /** 5 | * Options configured for each DataSource instance 6 | */ 7 | export interface RedisDataSourceOptions extends DataSourceJsonData { 8 | /** 9 | * Pool Size 10 | * 11 | * @type {number} 12 | */ 13 | poolSize: number; 14 | 15 | /** 16 | * Timeout 17 | * 18 | * @type {number} 19 | */ 20 | timeout: number; 21 | 22 | /** 23 | * Pool Ping Interval 24 | * 25 | * @type {number} 26 | */ 27 | pingInterval: number; 28 | 29 | /** 30 | * Pool Pipeline Window 31 | * 32 | * @type {number} 33 | */ 34 | pipelineWindow: number; 35 | 36 | /** 37 | * TLS Authentication 38 | * 39 | * @type {boolean} 40 | */ 41 | tlsAuth: boolean; 42 | 43 | /** 44 | * TLS Skip Verify 45 | * 46 | * @type {boolean} 47 | */ 48 | tlsSkipVerify: boolean; 49 | 50 | /** 51 | * Client Type 52 | * 53 | * @type {ClientTypeValue} 54 | */ 55 | client: ClientTypeValue; 56 | 57 | /** 58 | * Sentinel Master group name 59 | * 60 | * @type {string} 61 | */ 62 | sentinelName: string; 63 | 64 | /** 65 | * ACL enabled 66 | * 67 | * @type {boolean} 68 | */ 69 | acl: boolean; 70 | 71 | /** 72 | * CLI disabled 73 | * 74 | * @type {boolean} 75 | */ 76 | cliDisabled: boolean; 77 | 78 | /** 79 | * ACL Username 80 | * 81 | * @type {string} 82 | */ 83 | user: string; 84 | 85 | /** 86 | * ACL enabled for Sentinel 87 | * 88 | * @type {boolean} 89 | */ 90 | sentinelAcl: boolean; 91 | 92 | /** 93 | * ACL Username for Sentinel 94 | * 95 | * @type {string} 96 | */ 97 | sentinelUser: string; 98 | } 99 | 100 | /** 101 | * Value that is used in the backend, but never sent over HTTP to the frontend 102 | */ 103 | export interface RedisSecureJsonData { 104 | /** 105 | * Database password 106 | * 107 | * @type {string} 108 | */ 109 | password?: string; 110 | 111 | /** 112 | * Sentinel password 113 | * 114 | * @type {string} 115 | */ 116 | sentinelPassword?: string; 117 | 118 | /** 119 | * TLS Client Certificate 120 | * 121 | * @type {string} 122 | */ 123 | tlsClientCert?: string; 124 | 125 | /** 126 | * TLS Client Key 127 | * 128 | * @type {string} 129 | */ 130 | tlsClientKey?: string; 131 | 132 | /** 133 | * TLS Authority Certificate 134 | * 135 | * @type {string} 136 | */ 137 | tlsCACert?: string; 138 | } 139 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@grafana/toolkit/src/config/tsconfig.plugin.json", 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "baseUrl": "./src", 7 | "typeRoots": ["./node_modules/@types"] 8 | } 9 | } 10 | --------------------------------------------------------------------------------