mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
234 Commits
24.02.0-de
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43bb32a8f4 | ||
|
|
f5e2a4c0fc | ||
|
|
4e200b4a08 | ||
|
|
5ae1f70d9e | ||
|
|
3f781d262b | ||
|
|
a34c1591e3 | ||
|
|
9f76fda18c | ||
|
|
d8ae679937 | ||
|
|
f3b0b60abd | ||
|
|
df8be91a07 | ||
|
|
c9ddd2a7d6 | ||
|
|
6b1e910126 | ||
|
|
14550a9a6c | ||
|
|
b80c5f6a7c | ||
|
|
e46d44f208 | ||
|
|
3c8de770fd | ||
|
|
a1a4bc9d3e | ||
|
|
0900178d24 | ||
|
|
5d31429ca8 | ||
|
|
2f35ceb803 | ||
|
|
8e6c02ecde | ||
|
|
231fd36d46 | ||
|
|
0b6a53a079 | ||
|
|
6167edf97c | ||
|
|
4ed0ad3c9c | ||
|
|
2375f0b05f | ||
|
|
eced377ae4 | ||
|
|
84262fe143 | ||
|
|
612eb8c630 | ||
|
|
866fb48afb | ||
|
|
353776e8e1 | ||
|
|
594e2f24ef | ||
|
|
3275a9e459 | ||
|
|
3bad8c8e8c | ||
|
|
d0af4499d6 | ||
|
|
4357ea56ae | ||
|
|
5df5ca87a2 | ||
|
|
8387fe6fcb | ||
|
|
e95de2ee4c | ||
|
|
71902e5933 | ||
|
|
53eab14cef | ||
|
|
925bb91d8e | ||
|
|
ec2ca6f986 | ||
|
|
96ea0194e8 | ||
|
|
2776992101 | ||
|
|
85f001982e | ||
|
|
d03a2c4096 | ||
|
|
8c5890482a | ||
|
|
10ce280a96 | ||
|
|
0dd7ac3d2e | ||
|
|
4ee53a9445 | ||
|
|
c08292d90e | ||
|
|
3142131cdd | ||
|
|
530c1a0734 | ||
|
|
52dc1769a1 | ||
|
|
b9583c0b48 | ||
|
|
89d7f54e90 | ||
|
|
d884da2b0b | ||
|
|
f7d485082c | ||
|
|
130ab1fe1a | ||
|
|
2ff83679fe | ||
|
|
de49b73855 | ||
|
|
c12e68f5d1 | ||
|
|
baa9bbd505 | ||
|
|
349cd5d031 | ||
|
|
49277d27f8 | ||
|
|
2aae5705c9 | ||
|
|
38d0579660 | ||
|
|
673ba769c7 | ||
|
|
b922730482 | ||
|
|
ba973eb1fe | ||
|
|
d8dde6c544 | ||
|
|
d359a716a7 | ||
|
|
ba4293912b | ||
|
|
ee359120ee | ||
|
|
04a25f4327 | ||
|
|
7c22756e66 | ||
|
|
a03668f5b2 | ||
|
|
e4a841a0c5 | ||
|
|
38dc31a49b | ||
|
|
c42b15125c | ||
|
|
590d39bc8d | ||
|
|
79bbb248bb | ||
|
|
5cf0b7b038 | ||
|
|
fb1a056561 | ||
|
|
75e1ce4c9c | ||
|
|
d6c6e3bb7a | ||
|
|
821c1a9488 | ||
|
|
76eeea1f64 | ||
|
|
2ab07f9fc3 | ||
|
|
a85b9d7801 | ||
|
|
3330815081 | ||
|
|
c25c65bc04 | ||
|
|
79a4c4c9c9 | ||
|
|
58a7438cc8 | ||
|
|
c073c1e154 | ||
|
|
159a329e26 | ||
|
|
9de135c0bd | ||
|
|
285c2b6e56 | ||
|
|
b1fe2d4162 | ||
|
|
a4f92a8fb5 | ||
|
|
51ef625a30 | ||
|
|
a2611b89a3 | ||
|
|
a531597016 | ||
|
|
e59c02f497 | ||
|
|
c1a60bf6d2 | ||
|
|
72203655ec | ||
|
|
5257e39282 | ||
|
|
ec70ff4408 | ||
|
|
ed8c05f634 | ||
|
|
86b75db82e | ||
|
|
660d04b0f1 | ||
|
|
fc1e1f7a01 | ||
|
|
8725fa4737 | ||
|
|
ea0b3cbe3a | ||
|
|
714b950fde | ||
|
|
a9c9f085af | ||
|
|
a69f7fb2fe | ||
|
|
c244e75352 | ||
|
|
80f7ba1b91 | ||
|
|
d2745e5acc | ||
|
|
4114227471 | ||
|
|
8fc4ce1494 | ||
|
|
ebb0e2c9ad | ||
|
|
57a79bc96b | ||
|
|
77f108dd09 | ||
|
|
dd1a9b96da | ||
|
|
d9282b2688 | ||
|
|
28c39219af | ||
|
|
a37ef3b235 | ||
|
|
0056aa68f8 | ||
|
|
76b5a30fd9 | ||
|
|
db4fdd003e | ||
|
|
4cb32fc1c3 | ||
|
|
a6c728b99c | ||
|
|
01e036d0a9 | ||
|
|
17fe69f551 | ||
|
|
bceaab0496 | ||
|
|
70dd05916f | ||
|
|
60a12e906e | ||
|
|
ec051a8939 | ||
|
|
60d3c66a8b | ||
|
|
bd4ba96c43 | ||
|
|
10a46fd33c | ||
|
|
c874eb6b11 | ||
|
|
f3a323695f | ||
|
|
408ba78bd0 | ||
|
|
58cc49bc88 | ||
|
|
753ea846ff | ||
|
|
1b946b59ec | ||
|
|
4569191113 | ||
|
|
62890c3ec4 | ||
|
|
bd115e7f5f | ||
|
|
bd17662005 | ||
|
|
b7f22b1896 | ||
|
|
897c683980 | ||
|
|
2b974e12ed | ||
|
|
372adfed6b | ||
|
|
dbab9cadb4 | ||
|
|
06244716e6 | ||
|
|
f09760389a | ||
|
|
84e6d3cad5 | ||
|
|
3399e3761e | ||
|
|
1c48b2218b | ||
|
|
5ac5d86f5e | ||
|
|
5e4764af9c | ||
|
|
e2a39de7d1 | ||
|
|
6c68b48917 | ||
|
|
7e8a61c73d | ||
|
|
991e94dd6a | ||
|
|
2ffeecb813 | ||
|
|
3dd855aef1 | ||
|
|
713aca440a | ||
|
|
70bb684d9e | ||
|
|
4034f791c3 | ||
|
|
b9875a231b | ||
|
|
062a70cf20 | ||
|
|
c12d45077a | ||
|
|
6d6412753d | ||
|
|
275e12e7c1 | ||
|
|
77d7508cee | ||
|
|
9601660751 | ||
|
|
45c6fa0591 | ||
|
|
95ecb8e229 | ||
|
|
cb0707176c | ||
|
|
d7247f8b84 | ||
|
|
776703fab7 | ||
|
|
34cde71238 | ||
|
|
f631075be3 | ||
|
|
3f19534301 | ||
|
|
24dec192ee | ||
|
|
82d88ed4eb | ||
|
|
af0773c58a | ||
|
|
15e6583d72 | ||
|
|
4eb5f4e47f | ||
|
|
a0f5c706ff | ||
|
|
702a550659 | ||
|
|
38a06c7ab9 | ||
|
|
a6074878bb | ||
|
|
fb348c7116 | ||
|
|
24419863ec | ||
|
|
c4d3d9c683 | ||
|
|
1672cd9280 | ||
|
|
6575a6499a | ||
|
|
e360e4658e | ||
|
|
107933c363 | ||
|
|
667a696ca5 | ||
|
|
7d0d242072 | ||
|
|
d554136f70 | ||
|
|
34723e2f3e | ||
|
|
11794b3fe3 | ||
|
|
3997916d77 | ||
|
|
b09a2256dc | ||
|
|
95a45bb4dc | ||
|
|
7cd03c797c | ||
|
|
1200f9887a | ||
|
|
81d22f1eb2 | ||
|
|
2fe0326280 | ||
|
|
094984f564 | ||
|
|
52cd6ff006 | ||
|
|
939bec2114 | ||
|
|
320fddfd52 | ||
|
|
ab39283ae6 | ||
|
|
6386905616 | ||
|
|
d986b976e5 | ||
|
|
a600921c0b | ||
|
|
af2f4af8a2 | ||
|
|
49a5e74283 | ||
|
|
b98b5f2ba4 | ||
|
|
d245ff7bb1 | ||
|
|
97db492531 | ||
|
|
30e7392933 | ||
|
|
a54171f2c2 | ||
|
|
cd03da3260 |
@@ -1,11 +1,11 @@
|
|||||||
FROM cypress/browsers:node16.18.0-chrome90-ff88
|
FROM cypress/browsers:node18.12.0-chrome106-ff106
|
||||||
|
|
||||||
ENV APP /usr/src/app
|
ENV APP /usr/src/app
|
||||||
WORKDIR $APP
|
WORKDIR $APP
|
||||||
|
|
||||||
COPY package.json yarn.lock .yarnrc $APP/
|
COPY package.json yarn.lock .yarnrc $APP/
|
||||||
COPY viz-lib $APP/viz-lib
|
COPY viz-lib $APP/viz-lib
|
||||||
RUN npm install yarn@1.22.19 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null
|
RUN npm install yarn@1.22.22 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null
|
||||||
|
|
||||||
COPY . $APP
|
COPY . $APP
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: '2.2'
|
|
||||||
services:
|
services:
|
||||||
redash:
|
redash:
|
||||||
build: ../
|
build: ../
|
||||||
@@ -19,7 +18,7 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
postgres:
|
postgres:
|
||||||
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
|
image: pgautoupgrade/pgautoupgrade:latest
|
||||||
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
|
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
version: "2.2"
|
|
||||||
x-redash-service: &redash-service
|
x-redash-service: &redash-service
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
@@ -67,7 +66,7 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
postgres:
|
postgres:
|
||||||
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
|
image: pgautoupgrade/pgautoupgrade:latest
|
||||||
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
|
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
client/.tmp/
|
client/.tmp/
|
||||||
client/dist/
|
|
||||||
node_modules/
|
node_modules/
|
||||||
viz-lib/node_modules/
|
viz-lib/node_modules/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|||||||
163
.github/workflows/ci.yml
vendored
163
.github/workflows/ci.yml
vendored
@@ -4,16 +4,23 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 16.20.1
|
NODE_VERSION: 18
|
||||||
|
YARN_VERSION: 1.22.22
|
||||||
jobs:
|
jobs:
|
||||||
backend-lint:
|
backend-lint:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- if: github.event.pull_request.mergeable == 'false'
|
||||||
|
name: Exit if PR is not mergeable
|
||||||
|
run: exit 1
|
||||||
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- uses: actions/setup-python@v4
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.8'
|
python-version: '3.8'
|
||||||
- run: sudo pip install black==23.1.0 ruff==0.0.287
|
- run: sudo pip install black==23.1.0 ruff==0.0.287
|
||||||
@@ -24,14 +31,18 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: backend-lint
|
needs: backend-lint
|
||||||
env:
|
env:
|
||||||
COMPOSE_FILE: .ci/docker-compose.ci.yml
|
COMPOSE_FILE: .ci/compose.ci.yaml
|
||||||
COMPOSE_PROJECT_NAME: redash
|
COMPOSE_PROJECT_NAME: redash
|
||||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- if: github.event.pull_request.mergeable == 'false'
|
||||||
|
name: Exit if PR is not mergeable
|
||||||
|
run: exit 1
|
||||||
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- name: Build Docker Images
|
- name: Build Docker Images
|
||||||
run: |
|
run: |
|
||||||
set -x
|
set -x
|
||||||
@@ -49,15 +60,17 @@ jobs:
|
|||||||
mkdir -p /tmp/test-results/unit-tests
|
mkdir -p /tmp/test-results/unit-tests
|
||||||
docker cp tests:/app/coverage.xml ./coverage.xml
|
docker cp tests:/app/coverage.xml ./coverage.xml
|
||||||
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
|
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
|
||||||
- name: Upload coverage reports to Codecov
|
# - name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
# uses: codecov/codecov-action@v3
|
||||||
|
# with:
|
||||||
|
# token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
- name: Store Test Results
|
- name: Store Test Results
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: backend-test-results
|
||||||
path: /tmp/test-results
|
path: /tmp/test-results
|
||||||
- name: Store Coverage Results
|
- name: Store Coverage Results
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage
|
name: coverage
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
@@ -65,39 +78,47 @@ jobs:
|
|||||||
frontend-lint:
|
frontend-lint:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- if: github.event.pull_request.mergeable == 'false'
|
||||||
|
name: Exit if PR is not mergeable
|
||||||
|
run: exit 1
|
||||||
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- uses: actions/setup-node@v3
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
npm install --global --force yarn@1.22.19
|
npm install --global --force yarn@$YARN_VERSION
|
||||||
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
||||||
- name: Run Lint
|
- name: Run Lint
|
||||||
run: yarn lint:ci
|
run: yarn lint:ci
|
||||||
- name: Store Test Results
|
- name: Store Test Results
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: frontend-test-results
|
||||||
path: /tmp/test-results
|
path: /tmp/test-results
|
||||||
|
|
||||||
frontend-unit-tests:
|
frontend-unit-tests:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: frontend-lint
|
needs: frontend-lint
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- if: github.event.pull_request.mergeable == 'false'
|
||||||
|
name: Exit if PR is not mergeable
|
||||||
|
run: exit 1
|
||||||
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- uses: actions/setup-node@v3
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
npm install --global --force yarn@1.22.19
|
npm install --global --force yarn@$YARN_VERSION
|
||||||
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
||||||
- name: Run App Tests
|
- name: Run App Tests
|
||||||
run: yarn test
|
run: yarn test
|
||||||
@@ -109,18 +130,22 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: frontend-lint
|
needs: frontend-lint
|
||||||
env:
|
env:
|
||||||
COMPOSE_FILE: .ci/docker-compose.cypress.yml
|
COMPOSE_FILE: .ci/compose.cypress.yaml
|
||||||
COMPOSE_PROJECT_NAME: cypress
|
COMPOSE_PROJECT_NAME: cypress
|
||||||
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
|
|
||||||
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
|
|
||||||
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
|
|
||||||
CYPRESS_INSTALL_BINARY: 0
|
CYPRESS_INSTALL_BINARY: 0
|
||||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||||
|
# PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
|
||||||
|
# CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||||
|
# CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- if: github.event.pull_request.mergeable == 'false'
|
||||||
|
name: Exit if PR is not mergeable
|
||||||
|
run: exit 1
|
||||||
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- uses: actions/setup-node@v3
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
@@ -130,7 +155,7 @@ jobs:
|
|||||||
echo "CODE_COVERAGE=true" >> "$GITHUB_ENV"
|
echo "CODE_COVERAGE=true" >> "$GITHUB_ENV"
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
npm install --global --force yarn@1.22.19
|
npm install --global --force yarn@$YARN_VERSION
|
||||||
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
||||||
- name: Setup Redash Server
|
- name: Setup Redash Server
|
||||||
run: |
|
run: |
|
||||||
@@ -146,93 +171,7 @@ jobs:
|
|||||||
- name: Copy Code Coverage Results
|
- name: Copy Code Coverage Results
|
||||||
run: docker cp cypress:/usr/src/app/coverage ./coverage || true
|
run: docker cp cypress:/usr/src/app/coverage ./coverage || true
|
||||||
- name: Store Coverage Results
|
- name: Store Coverage Results
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage
|
name: coverage
|
||||||
path: coverage
|
path: coverage
|
||||||
|
|
||||||
build-skip-check:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
outputs:
|
|
||||||
skip: ${{ steps.skip-check.outputs.skip }}
|
|
||||||
steps:
|
|
||||||
- name: Skip?
|
|
||||||
id: skip-check
|
|
||||||
run: |
|
|
||||||
if [[ "${{ vars.DOCKER_USER }}" == '' ]]; then
|
|
||||||
echo 'Docker user is empty. Skipping build+push'
|
|
||||||
echo skip=true >> "$GITHUB_OUTPUT"
|
|
||||||
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
|
|
||||||
echo 'Docker password is empty. Skipping build+push'
|
|
||||||
echo skip=true >> "$GITHUB_OUTPUT"
|
|
||||||
elif [[ "${{ github.ref_name }}" != 'master' ]]; then
|
|
||||||
echo 'Ref name is not `master`. Skipping build+push'
|
|
||||||
echo skip=true >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo 'Docker user and password are set and branch is `master`.'
|
|
||||||
echo 'Building + pushing `preview` image.'
|
|
||||||
echo skip=false >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-docker-image:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
needs:
|
|
||||||
- backend-unit-tests
|
|
||||||
- frontend-unit-tests
|
|
||||||
- frontend-e2e-tests
|
|
||||||
- build-skip-check
|
|
||||||
if: needs.build-skip-check.outputs.skip == 'false'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'yarn'
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: |
|
|
||||||
npm install --global --force yarn@1.22.19
|
|
||||||
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
timeout-minutes: 1
|
|
||||||
uses: docker/setup-qemu-action@v2.2.0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ vars.DOCKER_USER }}
|
|
||||||
password: ${{ secrets.DOCKER_PASS }}
|
|
||||||
|
|
||||||
- name: Bump version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
set -x
|
|
||||||
.ci/update_version
|
|
||||||
VERSION=$(jq -r .version package.json)
|
|
||||||
VERSION_TAG="${VERSION}.b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}"
|
|
||||||
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Build and push preview image to Docker Hub
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
redash/redash:preview
|
|
||||||
redash/preview:${{ steps.version.outputs.VERSION_TAG }}
|
|
||||||
context: .
|
|
||||||
build-args: |
|
|
||||||
test_all_deps=true
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
platforms: linux/amd64
|
|
||||||
env:
|
|
||||||
DOCKER_CONTENT_TRUST: true
|
|
||||||
|
|
||||||
- name: "Failure: output container logs to console"
|
|
||||||
if: failure()
|
|
||||||
run: docker compose logs
|
|
||||||
|
|||||||
82
.github/workflows/periodic-snapshot.yml
vendored
82
.github/workflows/periodic-snapshot.yml
vendored
@@ -1,26 +1,86 @@
|
|||||||
name: Periodic Snapshot
|
name: Periodic Snapshot
|
||||||
|
|
||||||
# 10 minutes after midnight on the first of every month
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "10 0 1 * *"
|
- cron: '10 0 1 * *' # 10 minutes after midnight on the first day of every month
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: 'Bump the last digit of the version'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
version:
|
||||||
|
description: 'Specific version to set'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: write
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump-version-and-tag:
|
bump-version-and-tag:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref_name == github.event.repository.default_branch
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ssh-key: ${{ secrets.ACTION_PUSH_KEY }}
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
date="$(date +%y.%m).0-dev"
|
git config user.name 'github-actions[bot]'
|
||||||
gawk -i inplace -F: -v q=\" -v tag=$date '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json
|
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||||
gawk -i inplace -F= -v q=\" -v tag=$date '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py
|
|
||||||
gawk -i inplace -F= -v q=\" -v tag=$date '/^version =/ { print $1 FS, q tag q; next} { print }' pyproject.toml
|
# Function to bump the version
|
||||||
git config user.name github-actions
|
bump_version() {
|
||||||
git config user.email github-actions@github.com
|
local version="$1"
|
||||||
|
local IFS=.
|
||||||
|
read -r major minor patch <<< "$version"
|
||||||
|
patch=$((patch + 1))
|
||||||
|
echo "$major.$minor.$patch-dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine the new version tag
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
BUMP_INPUT="${{ github.event.inputs.bump }}"
|
||||||
|
SPECIFIC_VERSION="${{ github.event.inputs.version }}"
|
||||||
|
|
||||||
|
# Check if both bump and specific version are provided
|
||||||
|
if [ "$BUMP_INPUT" = "true" ] && [ -n "$SPECIFIC_VERSION" ]; then
|
||||||
|
echo "::error::Error: Cannot specify both bump and specific version."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$SPECIFIC_VERSION" ]; then
|
||||||
|
TAG_NAME="$SPECIFIC_VERSION-dev"
|
||||||
|
elif [ "$BUMP_INPUT" = "true" ]; then
|
||||||
|
CURRENT_VERSION=$(grep '"version":' package.json | awk -F\" '{print $4}')
|
||||||
|
TAG_NAME=$(bump_version "$CURRENT_VERSION")
|
||||||
|
else
|
||||||
|
echo "No version bump or specific version provided for manual dispatch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
TAG_NAME="$(date +%y.%m).0-dev"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "New version tag: $TAG_NAME"
|
||||||
|
|
||||||
|
# Update version in files
|
||||||
|
gawk -i inplace -F: -v q=\" -v tag=${TAG_NAME} '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json
|
||||||
|
gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py
|
||||||
|
gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^version =/ { print $1 FS, q tag q; next} { print }' pyproject.toml
|
||||||
|
|
||||||
git add package.json redash/__init__.py pyproject.toml
|
git add package.json redash/__init__.py pyproject.toml
|
||||||
git commit -m "Snapshot: ${date}"
|
git commit -m "Snapshot: ${TAG_NAME}"
|
||||||
git tag $date
|
git tag ${TAG_NAME}
|
||||||
git push --atomic origin master refs/tags/$date
|
git push --atomic origin master refs/tags/${TAG_NAME}
|
||||||
|
|
||||||
|
# Run the 'preview-image' workflow if run this workflow manually
|
||||||
|
# For more information, please see the: https://docs.github.com/en/actions/security-guides/automatic-token-authentication
|
||||||
|
if [ "$BUMP_INPUT" = "true" ] || [ -n "$SPECIFIC_VERSION" ]; then
|
||||||
|
gh workflow run preview-image.yml --ref $TAG_NAME
|
||||||
|
fi
|
||||||
|
|||||||
185
.github/workflows/preview-image.yml
vendored
Normal file
185
.github/workflows/preview-image.yml
vendored
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
name: Preview Image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*-dev'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dockerRepository:
|
||||||
|
description: 'Docker repository'
|
||||||
|
required: true
|
||||||
|
default: 'preview'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- preview
|
||||||
|
- redash
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 18
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-skip-check:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
outputs:
|
||||||
|
skip: ${{ steps.skip-check.outputs.skip }}
|
||||||
|
steps:
|
||||||
|
- name: Skip?
|
||||||
|
id: skip-check
|
||||||
|
run: |
|
||||||
|
if [[ "${{ vars.DOCKER_USER }}" == '' ]]; then
|
||||||
|
echo 'Docker user is empty. Skipping build+push'
|
||||||
|
echo skip=true >> "$GITHUB_OUTPUT"
|
||||||
|
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
|
||||||
|
echo 'Docker password is empty. Skipping build+push'
|
||||||
|
echo skip=true >> "$GITHUB_OUTPUT"
|
||||||
|
elif [[ "${{ vars.DOCKER_REPOSITORY }}" == '' ]]; then
|
||||||
|
echo 'Docker repository is empty. Skipping build+push'
|
||||||
|
echo skip=true >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo 'Docker user and password are set and branch is `master`.'
|
||||||
|
echo 'Building + pushing `preview` image.'
|
||||||
|
echo skip=false >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-docker-image:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
arch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
os: ubuntu-22.04
|
||||||
|
- arch: arm64
|
||||||
|
os: ubuntu-22.04-arm
|
||||||
|
outputs:
|
||||||
|
VERSION_TAG: ${{ steps.version.outputs.VERSION_TAG }}
|
||||||
|
needs:
|
||||||
|
- build-skip-check
|
||||||
|
if: needs.build-skip-check.outputs.skip == 'false'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
ref: ${{ github.event.push.after }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKER_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_PASS }}
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
env:
|
||||||
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
|
||||||
|
run: |
|
||||||
|
npm install --global --force yarn@1.22.22
|
||||||
|
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
||||||
|
|
||||||
|
- name: Set version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
.ci/update_version
|
||||||
|
VERSION_TAG=$(jq -r .version package.json)
|
||||||
|
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build and push preview image to Docker Hub
|
||||||
|
id: build-preview
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
|
||||||
|
with:
|
||||||
|
tags: |
|
||||||
|
${{ vars.DOCKER_REPOSITORY }}/redash
|
||||||
|
${{ vars.DOCKER_REPOSITORY }}/preview
|
||||||
|
context: .
|
||||||
|
build-args: |
|
||||||
|
test_all_deps=true
|
||||||
|
outputs: type=image,push-by-digest=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||||
|
env:
|
||||||
|
DOCKER_CONTENT_TRUST: true
|
||||||
|
|
||||||
|
- name: Build and push release image to Docker Hub
|
||||||
|
id: build-release
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
|
||||||
|
with:
|
||||||
|
tags: |
|
||||||
|
${{ vars.DOCKER_REPOSITORY }}/redash:${{ steps.version.outputs.VERSION_TAG }}
|
||||||
|
context: .
|
||||||
|
build-args: |
|
||||||
|
test_all_deps=true
|
||||||
|
outputs: type=image,push-by-digest=false,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||||
|
env:
|
||||||
|
DOCKER_CONTENT_TRUST: true
|
||||||
|
|
||||||
|
- name: "Failure: output container logs to console"
|
||||||
|
if: failure()
|
||||||
|
run: docker compose logs
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p ${{ runner.temp }}/digests
|
||||||
|
if [[ "${{ github.event.inputs.dockerRepository }}" == 'preview' || !github.event.workflow_run ]]; then
|
||||||
|
digest="${{ steps.build-preview.outputs.digest}}"
|
||||||
|
else
|
||||||
|
digest="${{ steps.build-release.outputs.digest}}"
|
||||||
|
fi
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.arch }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
merge-docker-image:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: build-docker-image
|
||||||
|
steps:
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKER_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_PASS }}
|
||||||
|
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Create and push manifest for the preview image
|
||||||
|
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:preview \
|
||||||
|
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:preview@sha256:%s ' *)
|
||||||
|
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
|
||||||
|
$(printf '${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Create and push manifest for the release image
|
||||||
|
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
|
||||||
|
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
|
||||||
36
.github/workflows/restyled.yml
vendored
Normal file
36
.github/workflows/restyled.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Restyled
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
restyled:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- uses: restyled-io/actions/setup@v4
|
||||||
|
- id: restyler
|
||||||
|
uses: restyled-io/actions/run@v4
|
||||||
|
with:
|
||||||
|
fail-on-differences: true
|
||||||
|
|
||||||
|
- if: |
|
||||||
|
!cancelled() &&
|
||||||
|
steps.restyler.outputs.success == 'true' &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
base: ${{ steps.restyler.outputs.restyled-base }}
|
||||||
|
branch: ${{ steps.restyler.outputs.restyled-head }}
|
||||||
|
title: ${{ steps.restyler.outputs.restyled-title }}
|
||||||
|
body: ${{ steps.restyler.outputs.restyled-body }}
|
||||||
|
labels: "restyled"
|
||||||
|
reviewers: ${{ github.event.pull_request.user.login }}
|
||||||
|
delete-branch: true
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ client/dist
|
|||||||
_build
|
_build
|
||||||
.vscode
|
.vscode
|
||||||
.env
|
.env
|
||||||
|
.tool-versions
|
||||||
|
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ request_review: author
|
|||||||
#
|
#
|
||||||
# These can be used to tell other automation to avoid our PRs.
|
# These can be used to tell other automation to avoid our PRs.
|
||||||
#
|
#
|
||||||
labels: ["Skip CI"]
|
labels:
|
||||||
|
- restyled
|
||||||
|
- "Skip CI"
|
||||||
|
|
||||||
# Labels to ignore
|
# Labels to ignore
|
||||||
#
|
#
|
||||||
@@ -50,13 +52,13 @@ labels: ["Skip CI"]
|
|||||||
# Restylers to run, and how
|
# Restylers to run, and how
|
||||||
restylers:
|
restylers:
|
||||||
- name: black
|
- name: black
|
||||||
image: restyled/restyler-black:v19.10b0
|
image: restyled/restyler-black:v24.4.2
|
||||||
include:
|
include:
|
||||||
- redash
|
- redash
|
||||||
- tests
|
- tests
|
||||||
- migrations/versions
|
- migrations/versions
|
||||||
- name: prettier
|
- name: prettier
|
||||||
image: restyled/restyler-prettier:v1.19.1-2
|
image: restyled/restyler-prettier:v3.3.2-2
|
||||||
command:
|
command:
|
||||||
- prettier
|
- prettier
|
||||||
- --write
|
- --write
|
||||||
|
|||||||
56
Dockerfile
56
Dockerfile
@@ -1,6 +1,6 @@
|
|||||||
FROM node:16.20.1-bookworm as frontend-builder
|
FROM node:18-bookworm AS frontend-builder
|
||||||
|
|
||||||
RUN npm install --global --force yarn@1.22.19
|
RUN npm install --global --force yarn@1.22.22
|
||||||
|
|
||||||
# Controls whether to build the frontend assets
|
# Controls whether to build the frontend assets
|
||||||
ARG skip_frontend_build
|
ARG skip_frontend_build
|
||||||
@@ -14,18 +14,30 @@ USER redash
|
|||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
COPY --chown=redash package.json yarn.lock .yarnrc /frontend/
|
COPY --chown=redash package.json yarn.lock .yarnrc /frontend/
|
||||||
COPY --chown=redash viz-lib /frontend/viz-lib
|
COPY --chown=redash viz-lib /frontend/viz-lib
|
||||||
|
COPY --chown=redash scripts /frontend/scripts
|
||||||
|
|
||||||
# Controls whether to instrument code for coverage information
|
# Controls whether to instrument code for coverage information
|
||||||
ARG code_coverage
|
ARG code_coverage
|
||||||
ENV BABEL_ENV=${code_coverage:+test}
|
ENV BABEL_ENV=${code_coverage:+test}
|
||||||
|
|
||||||
|
# Avoid issues caused by lags in disk and network I/O speeds when working on top of QEMU emulation for multi-platform image building.
|
||||||
|
RUN yarn config set network-timeout 300000
|
||||||
|
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn --frozen-lockfile --network-concurrency 1; fi
|
RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn --frozen-lockfile --network-concurrency 1; fi
|
||||||
|
|
||||||
COPY --chown=redash client /frontend/client
|
COPY --chown=redash client /frontend/client
|
||||||
COPY --chown=redash webpack.config.js /frontend/
|
COPY --chown=redash webpack.config.js /frontend/
|
||||||
RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
|
RUN <<EOF
|
||||||
|
if [ "x$skip_frontend_build" = "x" ]; then
|
||||||
|
yarn build
|
||||||
|
else
|
||||||
|
mkdir -p /frontend/client/dist
|
||||||
|
touch /frontend/client/dist/multi_org.html
|
||||||
|
touch /frontend/client/dist/index.html
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
FROM python:3.8-slim-bookworm
|
FROM python:3.10-slim-bookworm
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
@@ -63,28 +75,34 @@ RUN apt-get update && \
|
|||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
ARG databricks_odbc_driver_url=https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc/2.6.26/SimbaSparkODBC-2.6.26.1045-Debian-64bit.zip
|
ARG databricks_odbc_driver_url=https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc/2.6.26/SimbaSparkODBC-2.6.26.1045-Debian-64bit.zip
|
||||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
RUN <<EOF
|
||||||
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then
|
||||||
&& curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \
|
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
|
||||||
&& apt-get update \
|
curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||||
&& ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql17 \
|
apt-get update
|
||||||
&& apt-get clean \
|
ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
apt-get clean
|
||||||
&& curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip \
|
rm -rf /var/lib/apt/lists/*
|
||||||
&& chmod 600 /tmp/simba_odbc.zip \
|
curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip
|
||||||
&& unzip /tmp/simba_odbc.zip -d /tmp/simba \
|
chmod 600 /tmp/simba_odbc.zip
|
||||||
&& dpkg -i /tmp/simba/*.deb \
|
unzip /tmp/simba_odbc.zip -d /tmp/simba
|
||||||
&& printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini \
|
dpkg -i /tmp/simba/*.deb
|
||||||
&& rm /tmp/simba_odbc.zip \
|
printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini
|
||||||
&& rm -rf /tmp/simba; fi
|
rm /tmp/simba_odbc.zip
|
||||||
|
rm -rf /tmp/simba
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV POETRY_VERSION=1.6.1
|
ENV POETRY_VERSION=1.8.3
|
||||||
ENV POETRY_HOME=/etc/poetry
|
ENV POETRY_HOME=/etc/poetry
|
||||||
ENV POETRY_VIRTUALENVS_CREATE=false
|
ENV POETRY_VIRTUALENVS_CREATE=false
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
|
|
||||||
|
# Avoid crashes, including corrupted cache artifacts, when building multi-platform images with GitHub Actions.
|
||||||
|
RUN /etc/poetry/bin/poetry cache clear pypi --all
|
||||||
|
|
||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml poetry.lock ./
|
||||||
|
|
||||||
ARG POETRY_OPTIONS="--no-root --no-interaction --no-ansi"
|
ARG POETRY_OPTIONS="--no-root --no-interaction --no-ansi"
|
||||||
|
|||||||
24
Makefile
24
Makefile
@@ -1,10 +1,14 @@
|
|||||||
.PHONY: compose_build up test_db create_database clean down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash
|
.PHONY: compose_build up test_db create_database clean clean-all down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash
|
||||||
|
|
||||||
compose_build: .env
|
compose_build: .env
|
||||||
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
|
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
|
||||||
|
|
||||||
up:
|
up:
|
||||||
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build
|
docker compose up -d redis postgres --remove-orphans
|
||||||
|
docker compose exec -u postgres postgres psql postgres --csv \
|
||||||
|
-1tqc "SELECT table_name FROM information_schema.tables WHERE table_name = 'organizations'" 2> /dev/null \
|
||||||
|
| grep -q "organizations" || make create_database
|
||||||
|
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build --remove-orphans
|
||||||
|
|
||||||
test_db:
|
test_db:
|
||||||
@for i in `seq 1 5`; do \
|
@for i in `seq 1 5`; do \
|
||||||
@@ -17,7 +21,21 @@ create_database: .env
|
|||||||
docker compose run server create_db
|
docker compose run server create_db
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
docker compose down && docker compose rm
|
docker compose down
|
||||||
|
docker compose --project-name cypress down
|
||||||
|
docker compose rm --stop --force
|
||||||
|
docker compose --project-name cypress rm --stop --force
|
||||||
|
docker image rm --force \
|
||||||
|
cypress-server:latest cypress-worker:latest cypress-scheduler:latest \
|
||||||
|
redash-server:latest redash-worker:latest redash-scheduler:latest
|
||||||
|
docker container prune --force
|
||||||
|
docker image prune --force
|
||||||
|
docker volume prune --force
|
||||||
|
|
||||||
|
clean-all: clean
|
||||||
|
docker image rm --force \
|
||||||
|
redash/redash:latest redis:7-alpine maildev/maildev:latest \
|
||||||
|
pgautoupgrade/pgautoupgrade:15-alpine3.8 pgautoupgrade/pgautoupgrade:latest
|
||||||
|
|
||||||
down:
|
down:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
|
|||||||
- Python
|
- Python
|
||||||
- Qubole
|
- Qubole
|
||||||
- Rockset
|
- Rockset
|
||||||
|
- RisingWave
|
||||||
- Salesforce
|
- Salesforce
|
||||||
- ScyllaDB
|
- ScyllaDB
|
||||||
- Shell Scripts
|
- Shell Scripts
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ help() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "shell -- open shell"
|
echo "shell -- open shell"
|
||||||
echo "dev_server -- start Flask development server with debugger and auto reload"
|
echo "dev_server -- start Flask development server with debugger and auto reload"
|
||||||
echo "debug -- start Flask development server with remote debugger via ptvsd"
|
echo "debug -- start Flask development server with remote debugger via debugpy"
|
||||||
echo "create_db -- create database tables"
|
echo "create_db -- create database tables"
|
||||||
echo "manage -- CLI to manage redash"
|
echo "manage -- CLI to manage redash"
|
||||||
echo "tests -- run tests"
|
echo "tests -- run tests"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
BIN
client/app/assets/images/db-logos/risingwave.png
Normal file
BIN
client/app/assets/images/db-logos/risingwave.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
@@ -22,7 +22,7 @@ function BeaconConsent() {
|
|||||||
setHide(true);
|
setHide(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmConsent = confirm => {
|
const confirmConsent = (confirm) => {
|
||||||
let message = "🙏 Thank you.";
|
let message = "🙏 Thank you.";
|
||||||
|
|
||||||
if (!confirm) {
|
if (!confirm) {
|
||||||
@@ -47,7 +47,8 @@ function BeaconConsent() {
|
|||||||
<HelpTrigger type="USAGE_DATA_SHARING" />
|
<HelpTrigger type="USAGE_DATA_SHARING" />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
bordered={false}>
|
bordered={false}
|
||||||
|
>
|
||||||
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
||||||
<div className="m-t-5">
|
<div className="m-t-5">
|
||||||
<ul>
|
<ul>
|
||||||
@@ -66,8 +67,7 @@ function BeaconConsent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="m-t-15">
|
<div className="m-t-15">
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
You can change this setting anytime from the{" "}
|
You can change this setting anytime from the <Link href="settings/general">Settings</Link> page.
|
||||||
<Link href="settings/organization">Organization Settings</Link> page.
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
|||||||
import QuerySelector from "@/components/QuerySelector";
|
import QuerySelector from "@/components/QuerySelector";
|
||||||
import { Query } from "@/services/query";
|
import { Query } from "@/services/query";
|
||||||
import { useUniqueId } from "@/lib/hooks/useUniqueId";
|
import { useUniqueId } from "@/lib/hooks/useUniqueId";
|
||||||
|
import "./EditParameterSettingsDialog.less";
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||||
@@ -26,7 +27,7 @@ function isTypeDateRange(type) {
|
|||||||
|
|
||||||
function joinExampleList(multiValuesOptions) {
|
function joinExampleList(multiValuesOptions) {
|
||||||
const { prefix, suffix } = multiValuesOptions;
|
const { prefix, suffix } = multiValuesOptions;
|
||||||
return ["value1", "value2", "value3"].map(value => `${prefix}${value}${suffix}`).join(",");
|
return ["value1", "value2", "value3"].map((value) => `${prefix}${value}${suffix}`).join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
||||||
@@ -54,7 +55,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item required label="Keyword" help={helpText} validateStatus={validateStatus} {...formItemProps}>
|
<Form.Item required label="Keyword" help={helpText} validateStatus={validateStatus} {...formItemProps}>
|
||||||
<Input onChange={e => onChange(e.target.value)} autoFocus />
|
<Input onChange={(e) => onChange(e.target.value)} autoFocus />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,8 @@ function EditParameterSettingsDialog(props) {
|
|||||||
const [param, setParam] = useState(clone(props.parameter));
|
const [param, setParam] = useState(clone(props.parameter));
|
||||||
const [isNameValid, setIsNameValid] = useState(true);
|
const [isNameValid, setIsNameValid] = useState(true);
|
||||||
const [initialQuery, setInitialQuery] = useState();
|
const [initialQuery, setInitialQuery] = useState();
|
||||||
|
const [userInput, setUserInput] = useState(param.regex || "");
|
||||||
|
const [isValidRegex, setIsValidRegex] = useState(true);
|
||||||
|
|
||||||
const isNew = !props.parameter.name;
|
const isNew = !props.parameter.name;
|
||||||
|
|
||||||
@@ -114,6 +117,17 @@ function EditParameterSettingsDialog(props) {
|
|||||||
|
|
||||||
const paramFormId = useUniqueId("paramForm");
|
const paramFormId = useUniqueId("paramForm");
|
||||||
|
|
||||||
|
const handleRegexChange = (e) => {
|
||||||
|
setUserInput(e.target.value);
|
||||||
|
try {
|
||||||
|
new RegExp(e.target.value);
|
||||||
|
setParam({ ...param, regex: e.target.value });
|
||||||
|
setIsValidRegex(true);
|
||||||
|
} catch (error) {
|
||||||
|
setIsValidRegex(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
{...props.dialog.props}
|
{...props.dialog.props}
|
||||||
@@ -129,15 +143,17 @@ function EditParameterSettingsDialog(props) {
|
|||||||
disabled={!isFulfilled()}
|
disabled={!isFulfilled()}
|
||||||
type="primary"
|
type="primary"
|
||||||
form={paramFormId}
|
form={paramFormId}
|
||||||
data-test="SaveParameterSettings">
|
data-test="SaveParameterSettings"
|
||||||
|
>
|
||||||
{isNew ? "Add Parameter" : "OK"}
|
{isNew ? "Add Parameter" : "OK"}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}>
|
]}
|
||||||
|
>
|
||||||
<Form layout="horizontal" onFinish={onConfirm} id={paramFormId}>
|
<Form layout="horizontal" onFinish={onConfirm} id={paramFormId}>
|
||||||
{isNew && (
|
{isNew && (
|
||||||
<NameInput
|
<NameInput
|
||||||
name={param.name}
|
name={param.name}
|
||||||
onChange={name => setParam({ ...param, name })}
|
onChange={(name) => setParam({ ...param, name })}
|
||||||
setValidation={setIsNameValid}
|
setValidation={setIsNameValid}
|
||||||
existingNames={props.existingParams}
|
existingNames={props.existingParams}
|
||||||
type={param.type}
|
type={param.type}
|
||||||
@@ -146,15 +162,16 @@ function EditParameterSettingsDialog(props) {
|
|||||||
<Form.Item required label="Title" {...formItemProps}>
|
<Form.Item required label="Title" {...formItemProps}>
|
||||||
<Input
|
<Input
|
||||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
onChange={(e) => setParam({ ...param, title: e.target.value })}
|
||||||
data-test="ParameterTitleInput"
|
data-test="ParameterTitleInput"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Type" {...formItemProps}>
|
<Form.Item label="Type" {...formItemProps}>
|
||||||
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect">
|
<Select value={param.type} onChange={(type) => setParam({ ...param, type })} data-test="ParameterTypeSelect">
|
||||||
<Option value="text" data-test="TextParameterTypeOption">
|
<Option value="text" data-test="TextParameterTypeOption">
|
||||||
Text
|
Text
|
||||||
</Option>
|
</Option>
|
||||||
|
<Option value="text-pattern">Text Pattern</Option>
|
||||||
<Option value="number" data-test="NumberParameterTypeOption">
|
<Option value="number" data-test="NumberParameterTypeOption">
|
||||||
Number
|
Number
|
||||||
</Option>
|
</Option>
|
||||||
@@ -180,12 +197,26 @@ function EditParameterSettingsDialog(props) {
|
|||||||
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
|
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{param.type === "text-pattern" && (
|
||||||
|
<Form.Item
|
||||||
|
label="Regex"
|
||||||
|
help={!isValidRegex ? "Invalid Regex Pattern" : "Valid Regex Pattern"}
|
||||||
|
{...formItemProps}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={userInput}
|
||||||
|
onChange={handleRegexChange}
|
||||||
|
className={!isValidRegex ? "input-error" : ""}
|
||||||
|
data-test="RegexPatternInput"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
{param.type === "enum" && (
|
{param.type === "enum" && (
|
||||||
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
|
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={param.enumOptions}
|
value={param.enumOptions}
|
||||||
onChange={e => setParam({ ...param, enumOptions: e.target.value })}
|
onChange={(e) => setParam({ ...param, enumOptions: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
@@ -193,7 +224,7 @@ function EditParameterSettingsDialog(props) {
|
|||||||
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||||
<QuerySelector
|
<QuerySelector
|
||||||
selectedQuery={initialQuery}
|
selectedQuery={initialQuery}
|
||||||
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
onChange={(q) => setParam({ ...param, queryId: q && q.id })}
|
||||||
type="select"
|
type="select"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -202,7 +233,7 @@ function EditParameterSettingsDialog(props) {
|
|||||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
defaultChecked={!!param.multiValuesOptions}
|
defaultChecked={!!param.multiValuesOptions}
|
||||||
onChange={e =>
|
onChange={(e) =>
|
||||||
setParam({
|
setParam({
|
||||||
...param,
|
...param,
|
||||||
multiValuesOptions: e.target.checked
|
multiValuesOptions: e.target.checked
|
||||||
@@ -214,7 +245,8 @@ function EditParameterSettingsDialog(props) {
|
|||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
data-test="AllowMultipleValuesCheckbox">
|
data-test="AllowMultipleValuesCheckbox"
|
||||||
|
>
|
||||||
Allow multiple values
|
Allow multiple values
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -227,10 +259,11 @@ function EditParameterSettingsDialog(props) {
|
|||||||
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
|
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
}
|
}
|
||||||
{...formItemProps}>
|
{...formItemProps}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={param.multiValuesOptions.prefix}
|
value={param.multiValuesOptions.prefix}
|
||||||
onChange={quoteOption =>
|
onChange={(quoteOption) =>
|
||||||
setParam({
|
setParam({
|
||||||
...param,
|
...param,
|
||||||
multiValuesOptions: {
|
multiValuesOptions: {
|
||||||
@@ -240,7 +273,8 @@ function EditParameterSettingsDialog(props) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
data-test="QuotationSelect">
|
data-test="QuotationSelect"
|
||||||
|
>
|
||||||
<Option value="">None (default)</Option>
|
<Option value="">None (default)</Option>
|
||||||
<Option value="'">Single Quotation Mark</Option>
|
<Option value="'">Single Quotation Mark</Option>
|
||||||
<Option value={'"'} data-test="DoubleQuotationMarkOption">
|
<Option value={'"'} data-test="DoubleQuotationMarkOption">
|
||||||
|
|||||||
3
client/app/components/EditParameterSettingsDialog.less
Normal file
3
client/app/components/EditParameterSettingsDialog.less
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.input-error {
|
||||||
|
border-color: red !important;
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
clearTimeout(this.iframeLoadingTimeout);
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadIframe = url => {
|
loadIframe = (url) => {
|
||||||
clearTimeout(this.iframeLoadingTimeout);
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
this.setState({ loading: true, error: false });
|
this.setState({ loading: true, error: false });
|
||||||
|
|
||||||
@@ -116,8 +116,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
clearTimeout(this.iframeLoadingTimeout);
|
clearTimeout(this.iframeLoadingTimeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
onPostMessageReceived = event => {
|
onPostMessageReceived = (event) => {
|
||||||
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
|
if (!some(allowedDomains, (domain) => startsWith(event.origin, domain))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
openDrawer = e => {
|
openDrawer = (e) => {
|
||||||
// keep "open in new tab" behavior
|
// keep "open in new tab" behavior
|
||||||
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -144,7 +144,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
closeDrawer = event => {
|
closeDrawer = (event) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
|
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
|
||||||
const className = cx("help-trigger", this.props.className);
|
const className = cx("help-trigger", this.props.className);
|
||||||
const url = this.state.currentUrl;
|
const url = this.state.currentUrl;
|
||||||
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain));
|
const isAllowedDomain = some(allowedDomains, (domain) => startsWith(url || targetUrl, domain));
|
||||||
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
|
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,13 +180,15 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : null
|
) : null
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={url || this.getUrl()}
|
href={url || this.getUrl()}
|
||||||
className={className}
|
className={className}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
|
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}
|
||||||
|
>
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</Link>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -197,7 +199,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
|||||||
visible={this.state.visible}
|
visible={this.state.visible}
|
||||||
className={cx("help-drawer", drawerClassName)}
|
className={cx("help-drawer", drawerClassName)}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
width={400}>
|
width={400}
|
||||||
|
>
|
||||||
<div className="drawer-wrapper">
|
<div className="drawer-wrapper">
|
||||||
<div className="drawer-menu">
|
<div className="drawer-menu">
|
||||||
{url && (
|
{url && (
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ export const MappingType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) {
|
export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) {
|
||||||
return map(mappings, mapping => {
|
return map(mappings, (mapping) => {
|
||||||
const result = extend({}, mapping);
|
const result = extend({}, mapping);
|
||||||
const alreadyExists = includes(existingParameterNames, mapping.mapTo);
|
const alreadyExists = includes(existingParameterNames, mapping.mapTo);
|
||||||
result.param = find(parameters, p => p.name === mapping.name);
|
result.param = find(parameters, (p) => p.name === mapping.name);
|
||||||
switch (mapping.type) {
|
switch (mapping.type) {
|
||||||
case ParameterMappingType.DashboardLevel:
|
case ParameterMappingType.DashboardLevel:
|
||||||
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
|
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
|
||||||
@@ -62,7 +62,7 @@ export function editableMappingsToParameterMappings(mappings) {
|
|||||||
map(
|
map(
|
||||||
// convert to map
|
// convert to map
|
||||||
mappings,
|
mappings,
|
||||||
mapping => {
|
(mapping) => {
|
||||||
const result = extend({}, mapping);
|
const result = extend({}, mapping);
|
||||||
switch (mapping.type) {
|
switch (mapping.type) {
|
||||||
case MappingType.DashboardAddNew:
|
case MappingType.DashboardAddNew:
|
||||||
@@ -95,11 +95,11 @@ export function editableMappingsToParameterMappings(mappings) {
|
|||||||
export function synchronizeWidgetTitles(sourceMappings, widgets) {
|
export function synchronizeWidgetTitles(sourceMappings, widgets) {
|
||||||
const affectedWidgets = [];
|
const affectedWidgets = [];
|
||||||
|
|
||||||
each(sourceMappings, sourceMapping => {
|
each(sourceMappings, (sourceMapping) => {
|
||||||
if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
|
if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
|
||||||
each(widgets, widget => {
|
each(widgets, (widget) => {
|
||||||
const widgetMappings = widget.options.parameterMappings;
|
const widgetMappings = widget.options.parameterMappings;
|
||||||
each(widgetMappings, widgetMapping => {
|
each(widgetMappings, (widgetMapping) => {
|
||||||
// check if mapped to the same dashboard-level parameter
|
// check if mapped to the same dashboard-level parameter
|
||||||
if (
|
if (
|
||||||
widgetMapping.type === ParameterMappingType.DashboardLevel &&
|
widgetMapping.type === ParameterMappingType.DashboardLevel &&
|
||||||
@@ -140,7 +140,7 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
className: "form-item",
|
className: "form-item",
|
||||||
};
|
};
|
||||||
|
|
||||||
updateSourceType = type => {
|
updateSourceType = (type) => {
|
||||||
let {
|
let {
|
||||||
mapping: { mapTo },
|
mapping: { mapTo },
|
||||||
} = this.props;
|
} = this.props;
|
||||||
@@ -155,7 +155,7 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
this.updateParamMapping({ type, mapTo });
|
this.updateParamMapping({ type, mapTo });
|
||||||
};
|
};
|
||||||
|
|
||||||
updateParamMapping = update => {
|
updateParamMapping = (update) => {
|
||||||
const { onChange, mapping } = this.props;
|
const { onChange, mapping } = this.props;
|
||||||
const newMapping = extend({}, mapping, update);
|
const newMapping = extend({}, mapping, update);
|
||||||
if (newMapping.value !== mapping.value) {
|
if (newMapping.value !== mapping.value) {
|
||||||
@@ -175,7 +175,7 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
renderMappingTypeSelector() {
|
renderMappingTypeSelector() {
|
||||||
const noExisting = isEmpty(this.props.existingParamNames);
|
const noExisting = isEmpty(this.props.existingParamNames);
|
||||||
return (
|
return (
|
||||||
<Radio.Group value={this.props.mapping.type} onChange={e => this.updateSourceType(e.target.value)}>
|
<Radio.Group value={this.props.mapping.type} onChange={(e) => this.updateSourceType(e.target.value)}>
|
||||||
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
|
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
|
||||||
New dashboard parameter
|
New dashboard parameter
|
||||||
</Radio>
|
</Radio>
|
||||||
@@ -205,16 +205,16 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
<Input
|
<Input
|
||||||
value={mapTo}
|
value={mapTo}
|
||||||
aria-label="Parameter name (key)"
|
aria-label="Parameter name (key)"
|
||||||
onChange={e => this.updateParamMapping({ mapTo: e.target.value })}
|
onChange={(e) => this.updateParamMapping({ mapTo: e.target.value })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderDashboardMapToExisting() {
|
renderDashboardMapToExisting() {
|
||||||
const { mapping, existingParamNames } = this.props;
|
const { mapping, existingParamNames } = this.props;
|
||||||
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
|
const options = map(existingParamNames, (paramName) => ({ label: paramName, value: paramName }));
|
||||||
|
|
||||||
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />;
|
return <Select value={mapping.mapTo} onChange={(mapTo) => this.updateParamMapping({ mapTo })} options={options} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStaticValue() {
|
renderStaticValue() {
|
||||||
@@ -226,7 +226,8 @@ export class ParameterMappingInput extends React.Component {
|
|||||||
enumOptions={mapping.param.enumOptions}
|
enumOptions={mapping.param.enumOptions}
|
||||||
queryId={mapping.param.queryId}
|
queryId={mapping.param.queryId}
|
||||||
parameter={mapping.param}
|
parameter={mapping.param}
|
||||||
onSelect={value => this.updateParamMapping({ value })}
|
onSelect={(value) => this.updateParamMapping({ value })}
|
||||||
|
regex={mapping.param.regex}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,12 +285,12 @@ class MappingEditor extends React.Component {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onVisibleChange = visible => {
|
onVisibleChange = (visible) => {
|
||||||
if (visible) this.show();
|
if (visible) this.show();
|
||||||
else this.hide();
|
else this.hide();
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange = mapping => {
|
onChange = (mapping) => {
|
||||||
let inputError = null;
|
let inputError = null;
|
||||||
|
|
||||||
if (mapping.type === MappingType.DashboardAddNew) {
|
if (mapping.type === MappingType.DashboardAddNew) {
|
||||||
@@ -351,7 +352,8 @@ class MappingEditor extends React.Component {
|
|||||||
trigger="click"
|
trigger="click"
|
||||||
content={this.renderContent()}
|
content={this.renderContent()}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onVisibleChange={this.onVisibleChange}>
|
onVisibleChange={this.onVisibleChange}
|
||||||
|
>
|
||||||
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||||
<EditOutlinedIcon />
|
<EditOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -376,14 +378,14 @@ class TitleEditor extends React.Component {
|
|||||||
title: "", // will be set on editing
|
title: "", // will be set on editing
|
||||||
};
|
};
|
||||||
|
|
||||||
onPopupVisibleChange = showPopup => {
|
onPopupVisibleChange = (showPopup) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
showPopup,
|
showPopup,
|
||||||
title: showPopup ? this.getMappingTitle() : "",
|
title: showPopup ? this.getMappingTitle() : "",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onEditingTitleChange = event => {
|
onEditingTitleChange = (event) => {
|
||||||
this.setState({ title: event.target.value });
|
this.setState({ title: event.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,7 +462,8 @@ class TitleEditor extends React.Component {
|
|||||||
trigger="click"
|
trigger="click"
|
||||||
content={this.renderPopover()}
|
content={this.renderPopover()}
|
||||||
visible={this.state.showPopup}
|
visible={this.state.showPopup}
|
||||||
onVisibleChange={this.onPopupVisibleChange}>
|
onVisibleChange={this.onPopupVisibleChange}
|
||||||
|
>
|
||||||
<Button size="small" type="dashed">
|
<Button size="small" type="dashed">
|
||||||
<EditOutlinedIcon />
|
<EditOutlinedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -508,7 +511,7 @@ export class ParameterMappingListInput extends React.Component {
|
|||||||
|
|
||||||
// just to be safe, array or object
|
// just to be safe, array or object
|
||||||
if (typeof value === "object") {
|
if (typeof value === "object") {
|
||||||
return map(value, v => this.getStringValue(v)).join(", ");
|
return map(value, (v) => this.getStringValue(v)).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// rest
|
// rest
|
||||||
@@ -574,7 +577,7 @@ export class ParameterMappingListInput extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { existingParams } = this.props; // eslint-disable-line react/prop-types
|
const { existingParams } = this.props; // eslint-disable-line react/prop-types
|
||||||
const dataSource = this.props.mappings.map(mapping => ({ mapping }));
|
const dataSource = this.props.mappings.map((mapping) => ({ mapping }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="parameters-mapping-list">
|
<div className="parameters-mapping-list">
|
||||||
@@ -583,11 +586,11 @@ export class ParameterMappingListInput extends React.Component {
|
|||||||
title="Title"
|
title="Title"
|
||||||
dataIndex="mapping"
|
dataIndex="mapping"
|
||||||
key="title"
|
key="title"
|
||||||
render={mapping => (
|
render={(mapping) => (
|
||||||
<TitleEditor
|
<TitleEditor
|
||||||
existingParams={existingParams}
|
existingParams={existingParams}
|
||||||
mapping={mapping}
|
mapping={mapping}
|
||||||
onChange={newMapping => this.updateParamMapping(mapping, newMapping)}
|
onChange={(newMapping) => this.updateParamMapping(mapping, newMapping)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -596,19 +599,19 @@ export class ParameterMappingListInput extends React.Component {
|
|||||||
dataIndex="mapping"
|
dataIndex="mapping"
|
||||||
key="keyword"
|
key="keyword"
|
||||||
className="keyword"
|
className="keyword"
|
||||||
render={mapping => <code>{`{{ ${mapping.name} }}`}</code>}
|
render={(mapping) => <code>{`{{ ${mapping.name} }}`}</code>}
|
||||||
/>
|
/>
|
||||||
<Table.Column
|
<Table.Column
|
||||||
title="Default Value"
|
title="Default Value"
|
||||||
dataIndex="mapping"
|
dataIndex="mapping"
|
||||||
key="value"
|
key="value"
|
||||||
render={mapping => this.constructor.getDefaultValue(mapping, this.props.existingParams)}
|
render={(mapping) => this.constructor.getDefaultValue(mapping, this.props.existingParams)}
|
||||||
/>
|
/>
|
||||||
<Table.Column
|
<Table.Column
|
||||||
title="Value Source"
|
title="Value Source"
|
||||||
dataIndex="mapping"
|
dataIndex="mapping"
|
||||||
key="source"
|
key="source"
|
||||||
render={mapping => {
|
render={(mapping) => {
|
||||||
const existingParamsNames = existingParams
|
const existingParamsNames = existingParams
|
||||||
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
|
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
|
||||||
.map(({ name }) => name); // keep names only
|
.map(({ name }) => name); // keep names only
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParamet
|
|||||||
import QueryBasedParameterInput from "./QueryBasedParameterInput";
|
import QueryBasedParameterInput from "./QueryBasedParameterInput";
|
||||||
|
|
||||||
import "./ParameterValueInput.less";
|
import "./ParameterValueInput.less";
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
const multipleValuesProps = {
|
const multipleValuesProps = {
|
||||||
maxTagCount: 3,
|
maxTagCount: 3,
|
||||||
maxTagTextLength: 10,
|
maxTagTextLength: 10,
|
||||||
maxTagPlaceholder: num => `+${num.length} more`,
|
maxTagPlaceholder: (num) => `+${num.length} more`,
|
||||||
};
|
};
|
||||||
|
|
||||||
class ParameterValueInput extends React.Component {
|
class ParameterValueInput extends React.Component {
|
||||||
@@ -25,6 +26,7 @@ class ParameterValueInput extends React.Component {
|
|||||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||||
onSelect: PropTypes.func,
|
onSelect: PropTypes.func,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
|
regex: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@@ -35,6 +37,7 @@ class ParameterValueInput extends React.Component {
|
|||||||
parameter: null,
|
parameter: null,
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
className: "",
|
className: "",
|
||||||
|
regex: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -45,7 +48,7 @@ class ParameterValueInput extends React.Component {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate = prevProps => {
|
componentDidUpdate = (prevProps) => {
|
||||||
const { value, parameter } = this.props;
|
const { value, parameter } = this.props;
|
||||||
// if value prop updated, reset dirty state
|
// if value prop updated, reset dirty state
|
||||||
if (prevProps.value !== value || prevProps.parameter !== parameter) {
|
if (prevProps.value !== value || prevProps.parameter !== parameter) {
|
||||||
@@ -56,7 +59,7 @@ class ParameterValueInput extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onSelect = value => {
|
onSelect = (value) => {
|
||||||
const isDirty = !isEqual(value, this.props.value);
|
const isDirty = !isEqual(value, this.props.value);
|
||||||
this.setState({ value, isDirty });
|
this.setState({ value, isDirty });
|
||||||
this.props.onSelect(value, isDirty);
|
this.props.onSelect(value, isDirty);
|
||||||
@@ -93,9 +96,9 @@ class ParameterValueInput extends React.Component {
|
|||||||
renderEnumInput() {
|
renderEnumInput() {
|
||||||
const { enumOptions, parameter } = this.props;
|
const { enumOptions, parameter } = this.props;
|
||||||
const { value } = this.state;
|
const { value } = this.state;
|
||||||
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
|
const enumOptionsArray = enumOptions.split("\n").filter((v) => v !== "");
|
||||||
// Antd Select doesn't handle null in multiple mode
|
// Antd Select doesn't handle null in multiple mode
|
||||||
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
const normalize = (val) => (parameter.multiValuesOptions && val === null ? [] : val);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectWithVirtualScroll
|
<SelectWithVirtualScroll
|
||||||
@@ -103,7 +106,7 @@ class ParameterValueInput extends React.Component {
|
|||||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||||
value={normalize(value)}
|
value={normalize(value)}
|
||||||
onChange={this.onSelect}
|
onChange={this.onSelect}
|
||||||
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
|
options={map(enumOptionsArray, (opt) => ({ label: String(opt), value: opt }))}
|
||||||
showSearch
|
showSearch
|
||||||
showArrow
|
showArrow
|
||||||
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
||||||
@@ -133,18 +136,36 @@ class ParameterValueInput extends React.Component {
|
|||||||
const { className } = this.props;
|
const { className } = this.props;
|
||||||
const { value } = this.state;
|
const { value } = this.state;
|
||||||
|
|
||||||
const normalize = val => (isNaN(val) ? undefined : val);
|
const normalize = (val) => (isNaN(val) ? undefined : val);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
className={className}
|
className={className}
|
||||||
value={normalize(value)}
|
value={normalize(value)}
|
||||||
aria-label="Parameter number value"
|
aria-label="Parameter number value"
|
||||||
onChange={val => this.onSelect(normalize(val))}
|
onChange={(val) => this.onSelect(normalize(val))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderTextPatternInput() {
|
||||||
|
const { className } = this.props;
|
||||||
|
const { value } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Tooltip title={`Regex to match: ${this.props.regex}`} placement="right">
|
||||||
|
<Input
|
||||||
|
className={className}
|
||||||
|
value={value}
|
||||||
|
aria-label="Parameter text pattern value"
|
||||||
|
onChange={(e) => this.onSelect(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
renderTextInput() {
|
renderTextInput() {
|
||||||
const { className } = this.props;
|
const { className } = this.props;
|
||||||
const { value } = this.state;
|
const { value } = this.state;
|
||||||
@@ -155,7 +176,7 @@ class ParameterValueInput extends React.Component {
|
|||||||
value={value}
|
value={value}
|
||||||
aria-label="Parameter text value"
|
aria-label="Parameter text value"
|
||||||
data-test="TextParamInput"
|
data-test="TextParamInput"
|
||||||
onChange={e => this.onSelect(e.target.value)}
|
onChange={(e) => this.onSelect(e.target.value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -177,6 +198,8 @@ class ParameterValueInput extends React.Component {
|
|||||||
return this.renderQueryBasedInput();
|
return this.renderQueryBasedInput();
|
||||||
case "number":
|
case "number":
|
||||||
return this.renderNumberInput();
|
return this.renderNumberInput();
|
||||||
|
case "text-pattern":
|
||||||
|
return this.renderTextPatternInput();
|
||||||
default:
|
default:
|
||||||
return this.renderTextInput();
|
return this.renderTextInput();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import "./Parameters.less";
|
|||||||
|
|
||||||
function updateUrl(parameters) {
|
function updateUrl(parameters) {
|
||||||
const params = extend({}, location.search);
|
const params = extend({}, location.search);
|
||||||
parameters.forEach(param => {
|
parameters.forEach((param) => {
|
||||||
extend(params, param.toUrlParams());
|
extend(params, param.toUrlParams());
|
||||||
});
|
});
|
||||||
location.setSearch(params, true);
|
location.setSearch(params, true);
|
||||||
@@ -43,7 +43,7 @@ export default class Parameters extends React.Component {
|
|||||||
appendSortableToParent: true,
|
appendSortableToParent: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
toCamelCase = str => {
|
toCamelCase = (str) => {
|
||||||
if (isEmpty(str)) {
|
if (isEmpty(str)) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -59,10 +59,10 @@ export default class Parameters extends React.Component {
|
|||||||
}
|
}
|
||||||
const hideRegex = /hide_filter=([^&]+)/g;
|
const hideRegex = /hide_filter=([^&]+)/g;
|
||||||
const matches = window.location.search.matchAll(hideRegex);
|
const matches = window.location.search.matchAll(hideRegex);
|
||||||
this.hideValues = Array.from(matches, match => match[1]);
|
this.hideValues = Array.from(matches, (match) => match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate = prevProps => {
|
componentDidUpdate = (prevProps) => {
|
||||||
const { parameters, disableUrlUpdate } = this.props;
|
const { parameters, disableUrlUpdate } = this.props;
|
||||||
const parametersChanged = prevProps.parameters !== parameters;
|
const parametersChanged = prevProps.parameters !== parameters;
|
||||||
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
|
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
|
||||||
@@ -74,7 +74,7 @@ export default class Parameters extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleKeyDown = e => {
|
handleKeyDown = (e) => {
|
||||||
// Cmd/Ctrl/Alt + Enter
|
// Cmd/Ctrl/Alt + Enter
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -109,8 +109,8 @@ export default class Parameters extends React.Component {
|
|||||||
applyChanges = () => {
|
applyChanges = () => {
|
||||||
const { onValuesChange, disableUrlUpdate } = this.props;
|
const { onValuesChange, disableUrlUpdate } = this.props;
|
||||||
this.setState(({ parameters }) => {
|
this.setState(({ parameters }) => {
|
||||||
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue);
|
const parametersWithPendingValues = parameters.filter((p) => p.hasPendingValue);
|
||||||
forEach(parameters, p => p.applyPendingValue());
|
forEach(parameters, (p) => p.applyPendingValue());
|
||||||
if (!disableUrlUpdate) {
|
if (!disableUrlUpdate) {
|
||||||
updateUrl(parameters);
|
updateUrl(parameters);
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ export default class Parameters extends React.Component {
|
|||||||
|
|
||||||
showParameterSettings = (parameter, index) => {
|
showParameterSettings = (parameter, index) => {
|
||||||
const { onParametersEdit } = this.props;
|
const { onParametersEdit } = this.props;
|
||||||
EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => {
|
EditParameterSettingsDialog.showModal({ parameter }).onClose((updated) => {
|
||||||
this.setState(({ parameters }) => {
|
this.setState(({ parameters }) => {
|
||||||
const updatedParameter = extend(parameter, updated);
|
const updatedParameter = extend(parameter, updated);
|
||||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||||
@@ -132,7 +132,7 @@ export default class Parameters extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderParameter(param, index) {
|
renderParameter(param, index) {
|
||||||
if (this.hideValues.some(value => this.toCamelCase(value) === this.toCamelCase(param.name))) {
|
if (this.hideValues.some((value) => this.toCamelCase(value) === this.toCamelCase(param.name))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { editable } = this.props;
|
const { editable } = this.props;
|
||||||
@@ -149,7 +149,8 @@ export default class Parameters extends React.Component {
|
|||||||
aria-label="Edit"
|
aria-label="Edit"
|
||||||
onClick={() => this.showParameterSettings(param, index)}
|
onClick={() => this.showParameterSettings(param, index)}
|
||||||
data-test={`ParameterSettings-${param.name}`}
|
data-test={`ParameterSettings-${param.name}`}
|
||||||
type="button">
|
type="button"
|
||||||
|
>
|
||||||
<i className="fa fa-cog" aria-hidden="true" />
|
<i className="fa fa-cog" aria-hidden="true" />
|
||||||
</PlainButton>
|
</PlainButton>
|
||||||
)}
|
)}
|
||||||
@@ -162,6 +163,7 @@ export default class Parameters extends React.Component {
|
|||||||
enumOptions={param.enumOptions}
|
enumOptions={param.enumOptions}
|
||||||
queryId={param.queryId}
|
queryId={param.queryId}
|
||||||
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
|
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
|
||||||
|
regex={param.regex}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -178,20 +180,22 @@ export default class Parameters extends React.Component {
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
lockToContainerEdges
|
lockToContainerEdges
|
||||||
helperClass="parameter-dragged"
|
helperClass="parameter-dragged"
|
||||||
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)}
|
helperContainer={(containerEl) => (appendSortableToParent ? containerEl : document.body)}
|
||||||
updateBeforeSortStart={this.onBeforeSortStart}
|
updateBeforeSortStart={this.onBeforeSortStart}
|
||||||
onSortEnd={this.moveParameter}
|
onSortEnd={this.moveParameter}
|
||||||
containerProps={{
|
containerProps={{
|
||||||
className: "parameter-container",
|
className: "parameter-container",
|
||||||
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
|
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{parameters &&
|
{parameters &&
|
||||||
parameters.map((param, index) => (
|
parameters.map((param, index) => (
|
||||||
<SortableElement key={param.name} index={index}>
|
<SortableElement key={param.name} index={index}>
|
||||||
<div
|
<div
|
||||||
className="parameter-block"
|
className="parameter-block"
|
||||||
data-editable={sortable || null}
|
data-editable={sortable || null}
|
||||||
data-test={`ParameterBlock-${param.name}`}>
|
data-test={`ParameterBlock-${param.name}`}
|
||||||
|
>
|
||||||
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||||
{this.renderParameter(param, index)}
|
{this.renderParameter(param, index)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ UserPreviewCard.defaultProps = {
|
|||||||
// DataSourcePreviewCard
|
// DataSourcePreviewCard
|
||||||
|
|
||||||
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
|
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
|
||||||
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
|
const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
|
||||||
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
|
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
|
||||||
return (
|
return (
|
||||||
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
|
||||||
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
|
||||||
background-size: calc((100% + 15px) / 6) 5px;
|
background-size: calc((100% + 15px) / 12) 5px;
|
||||||
background-position: -7px 1px;
|
background-position: -7px 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ function EmptyState({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show if `onboardingMode=false` or any requested step not completed
|
// Show if `onboardingMode=false` or any requested step not completed
|
||||||
const shouldShow = !onboardingMode || some(keys(isAvailable), step => isAvailable[step] && !isCompleted[step]);
|
const shouldShow = !onboardingMode || some(keys(isAvailable), (step) => isAvailable[step] && !isCompleted[step]);
|
||||||
|
|
||||||
if (!shouldShow) {
|
if (!shouldShow) {
|
||||||
return null;
|
return null;
|
||||||
@@ -181,7 +181,7 @@ function EmptyState({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
|
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
|
||||||
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
|
const imageSource = illustrationPath ? illustrationPath : "/static/images/illustrations/" + illustration + ".svg";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="empty-state-wrapper">
|
<div className="empty-state-wrapper">
|
||||||
@@ -196,7 +196,7 @@ function EmptyState({
|
|||||||
</div>
|
</div>
|
||||||
<div className="empty-state__steps">
|
<div className="empty-state__steps">
|
||||||
<h4>Let's get started</h4>
|
<h4>Let's get started</h4>
|
||||||
<ol>{stepsItems.map(item => item.node)}</ol>
|
<ol>{stepsItems.map((item) => item.node)}</ol>
|
||||||
{helpMessage}
|
{helpMessage}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface Controller<I, P = any> {
|
|||||||
orderByField?: string;
|
orderByField?: string;
|
||||||
orderByReverse: boolean;
|
orderByReverse: boolean;
|
||||||
toggleSorting: (orderByField: string) => void;
|
toggleSorting: (orderByField: string) => void;
|
||||||
|
setSorting: (orderByField: string, orderByReverse: boolean) => void;
|
||||||
|
|
||||||
// pagination
|
// pagination
|
||||||
page: number;
|
page: number;
|
||||||
@@ -139,10 +140,11 @@ export function wrap<I, P = any>(
|
|||||||
this.props.onError!(error);
|
this.props.onError!(error);
|
||||||
|
|
||||||
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
|
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
|
||||||
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
|
const { updatePagination, toggleSorting, setSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
|
||||||
this.state = {
|
this.state = {
|
||||||
...initialState,
|
...initialState,
|
||||||
toggleSorting, // eslint-disable-line react/no-unused-state
|
toggleSorting, // eslint-disable-line react/no-unused-state
|
||||||
|
setSorting, // eslint-disable-line react/no-unused-state
|
||||||
updateSearch: debounce(updateSearch, 200), // eslint-disable-line react/no-unused-state
|
updateSearch: debounce(updateSearch, 200), // eslint-disable-line react/no-unused-state
|
||||||
updateSelectedTags, // eslint-disable-line react/no-unused-state
|
updateSelectedTags, // eslint-disable-line react/no-unused-state
|
||||||
updatePagination, // eslint-disable-line react/no-unused-state
|
updatePagination, // eslint-disable-line react/no-unused-state
|
||||||
|
|||||||
@@ -39,14 +39,12 @@ export class ItemsSource {
|
|||||||
const customParams = {};
|
const customParams = {};
|
||||||
const context = {
|
const context = {
|
||||||
...this.getCallbackContext(),
|
...this.getCallbackContext(),
|
||||||
setCustomParams: params => {
|
setCustomParams: (params) => {
|
||||||
extend(customParams, params);
|
extend(customParams, params);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return this._beforeUpdate().then(() => {
|
return this._beforeUpdate().then(() => {
|
||||||
const fetchToken = Math.random()
|
const fetchToken = Math.random().toString(36).substr(2);
|
||||||
.toString(36)
|
|
||||||
.substr(2);
|
|
||||||
this._currentFetchToken = fetchToken;
|
this._currentFetchToken = fetchToken;
|
||||||
return this._fetcher
|
return this._fetcher
|
||||||
.fetch(changes, state, context)
|
.fetch(changes, state, context)
|
||||||
@@ -59,7 +57,7 @@ export class ItemsSource {
|
|||||||
return this._afterUpdate();
|
return this._afterUpdate();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => this.handleError(error));
|
.catch((error) => this.handleError(error));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,13 +122,20 @@ export class ItemsSource {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleSorting = orderByField => {
|
toggleSorting = (orderByField) => {
|
||||||
this._sorter.toggleField(orderByField);
|
this._sorter.toggleField(orderByField);
|
||||||
this._savedOrderByField = this._sorter.field;
|
this._savedOrderByField = this._sorter.field;
|
||||||
this._changed({ sorting: true });
|
this._changed({ sorting: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
updateSearch = searchTerm => {
|
setSorting = (orderByField, orderByReverse) => {
|
||||||
|
this._sorter.setField(orderByField);
|
||||||
|
this._sorter.setReverse(orderByReverse);
|
||||||
|
this._savedOrderByField = this._sorter.field;
|
||||||
|
this._changed({ sorting: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSearch = (searchTerm) => {
|
||||||
// here we update state directly, but later `fetchData` will update it properly
|
// here we update state directly, but later `fetchData` will update it properly
|
||||||
this._searchTerm = searchTerm;
|
this._searchTerm = searchTerm;
|
||||||
// in search mode ignore the ordering and use the ranking order
|
// in search mode ignore the ordering and use the ranking order
|
||||||
@@ -145,7 +150,7 @@ export class ItemsSource {
|
|||||||
this._changed({ search: true, pagination: { page: true } });
|
this._changed({ search: true, pagination: { page: true } });
|
||||||
};
|
};
|
||||||
|
|
||||||
updateSelectedTags = selectedTags => {
|
updateSelectedTags = (selectedTags) => {
|
||||||
this._selectedTags = selectedTags;
|
this._selectedTags = selectedTags;
|
||||||
this._paginator.setPage(1);
|
this._paginator.setPage(1);
|
||||||
this._changed({ tags: true, pagination: { page: true } });
|
this._changed({ tags: true, pagination: { page: true } });
|
||||||
@@ -153,7 +158,7 @@ export class ItemsSource {
|
|||||||
|
|
||||||
update = () => this._changed();
|
update = () => this._changed();
|
||||||
|
|
||||||
handleError = error => {
|
handleError = (error) => {
|
||||||
if (isFunction(this.onError)) {
|
if (isFunction(this.onError)) {
|
||||||
this.onError(error);
|
this.onError(error);
|
||||||
}
|
}
|
||||||
@@ -172,7 +177,7 @@ export class ResourceItemsSource extends ItemsSource {
|
|||||||
processResults: (results, context) => {
|
processResults: (results, context) => {
|
||||||
let processItem = getItemProcessor(context);
|
let processItem = getItemProcessor(context);
|
||||||
processItem = isFunction(processItem) ? processItem : identity;
|
processItem = isFunction(processItem) ? processItem : identity;
|
||||||
return map(results, item => processItem(item, context));
|
return map(results, (item) => processItem(item, context));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const Columns = {
|
|||||||
date(overrides) {
|
date(overrides) {
|
||||||
return extend(
|
return extend(
|
||||||
{
|
{
|
||||||
render: text => formatDate(text),
|
render: (text) => formatDate(text),
|
||||||
},
|
},
|
||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
@@ -52,7 +52,7 @@ export const Columns = {
|
|||||||
dateTime(overrides) {
|
dateTime(overrides) {
|
||||||
return extend(
|
return extend(
|
||||||
{
|
{
|
||||||
render: text => formatDateTime(text),
|
render: (text) => formatDateTime(text),
|
||||||
},
|
},
|
||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
@@ -62,7 +62,7 @@ export const Columns = {
|
|||||||
{
|
{
|
||||||
width: "1%",
|
width: "1%",
|
||||||
className: "text-nowrap",
|
className: "text-nowrap",
|
||||||
render: text => durationHumanize(text),
|
render: (text) => durationHumanize(text),
|
||||||
},
|
},
|
||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
@@ -70,7 +70,7 @@ export const Columns = {
|
|||||||
timeAgo(overrides, timeAgoCustomProps = undefined) {
|
timeAgo(overrides, timeAgoCustomProps = undefined) {
|
||||||
return extend(
|
return extend(
|
||||||
{
|
{
|
||||||
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
|
render: (value) => <TimeAgo date={value} {...timeAgoCustomProps} />,
|
||||||
},
|
},
|
||||||
overrides
|
overrides
|
||||||
);
|
);
|
||||||
@@ -110,6 +110,7 @@ export default class ItemsTable extends React.Component {
|
|||||||
orderByField: PropTypes.string,
|
orderByField: PropTypes.string,
|
||||||
orderByReverse: PropTypes.bool,
|
orderByReverse: PropTypes.bool,
|
||||||
toggleSorting: PropTypes.func,
|
toggleSorting: PropTypes.func,
|
||||||
|
setSorting: PropTypes.func,
|
||||||
"data-test": PropTypes.string,
|
"data-test": PropTypes.string,
|
||||||
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||||
};
|
};
|
||||||
@@ -127,18 +128,15 @@ export default class ItemsTable extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
prepareColumns() {
|
prepareColumns() {
|
||||||
const { orderByField, orderByReverse, toggleSorting } = this.props;
|
const { orderByField, orderByReverse } = this.props;
|
||||||
const orderByDirection = orderByReverse ? "descend" : "ascend";
|
const orderByDirection = orderByReverse ? "descend" : "ascend";
|
||||||
|
|
||||||
return map(
|
return map(
|
||||||
map(
|
map(
|
||||||
filter(this.props.columns, column => (isFunction(column.isAvailable) ? column.isAvailable() : true)),
|
filter(this.props.columns, (column) => (isFunction(column.isAvailable) ? column.isAvailable() : true)),
|
||||||
column => extend(column, { orderByField: column.orderByField || column.field })
|
(column) => extend(column, { orderByField: column.orderByField || column.field })
|
||||||
),
|
),
|
||||||
(column, index) => {
|
(column, index) => {
|
||||||
// Bind click events only to sortable columns
|
|
||||||
const onHeaderCell = column.sorter ? () => ({ onClick: () => toggleSorting(column.orderByField) }) : null;
|
|
||||||
|
|
||||||
// Wrap render function to pass correct arguments
|
// Wrap render function to pass correct arguments
|
||||||
const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity;
|
const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity;
|
||||||
|
|
||||||
@@ -146,14 +144,13 @@ export default class ItemsTable extends React.Component {
|
|||||||
key: "column" + index,
|
key: "column" + index,
|
||||||
dataIndex: ["item", column.field],
|
dataIndex: ["item", column.field],
|
||||||
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
|
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
|
||||||
onHeaderCell,
|
|
||||||
render,
|
render,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRowKey = record => {
|
getRowKey = (record) => {
|
||||||
const { rowKey } = this.props;
|
const { rowKey } = this.props;
|
||||||
if (rowKey) {
|
if (rowKey) {
|
||||||
if (isFunction(rowKey)) {
|
if (isFunction(rowKey)) {
|
||||||
@@ -172,22 +169,43 @@ export default class ItemsTable extends React.Component {
|
|||||||
|
|
||||||
// Bind events only if `onRowClick` specified
|
// Bind events only if `onRowClick` specified
|
||||||
const onTableRow = isFunction(this.props.onRowClick)
|
const onTableRow = isFunction(this.props.onRowClick)
|
||||||
? row => ({
|
? (row) => ({
|
||||||
onClick: event => {
|
onClick: (event) => {
|
||||||
this.props.onRowClick(event, row.item);
|
this.props.onRowClick(event, row.item);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const onChange = (pagination, filters, sorter, extra) => {
|
||||||
|
const action = extra?.action;
|
||||||
|
if (action === "sort") {
|
||||||
|
const propsColumn = this.props.columns.find((column) => column.field === sorter.field[1]);
|
||||||
|
if (!propsColumn.sorter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let orderByField = propsColumn.orderByField;
|
||||||
|
const orderByReverse = sorter.order === "descend";
|
||||||
|
|
||||||
|
if (orderByReverse === undefined) {
|
||||||
|
orderByField = null;
|
||||||
|
}
|
||||||
|
if (this.props.setSorting) {
|
||||||
|
this.props.setSorting(orderByField, orderByReverse);
|
||||||
|
} else {
|
||||||
|
this.props.toggleSorting(orderByField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { showHeader } = this.props;
|
const { showHeader } = this.props;
|
||||||
if (this.props.loading) {
|
if (this.props.loading) {
|
||||||
if (isEmpty(tableDataProps.dataSource)) {
|
if (isEmpty(tableDataProps.dataSource)) {
|
||||||
tableDataProps.columns = tableDataProps.columns.map(column => ({
|
tableDataProps.columns = tableDataProps.columns.map((column) => ({
|
||||||
...column,
|
...column,
|
||||||
sorter: false,
|
sorter: false,
|
||||||
render: () => <Skeleton active paragraph={false} />,
|
render: () => <Skeleton active paragraph={false} />,
|
||||||
}));
|
}));
|
||||||
tableDataProps.dataSource = range(10).map(key => ({ key: `${key}` }));
|
tableDataProps.dataSource = range(10).map((key) => ({ key: `${key}` }));
|
||||||
} else {
|
} else {
|
||||||
tableDataProps.loading = { indicator: null };
|
tableDataProps.loading = { indicator: null };
|
||||||
}
|
}
|
||||||
@@ -200,6 +218,7 @@ export default class ItemsTable extends React.Component {
|
|||||||
rowKey={this.getRowKey}
|
rowKey={this.getRowKey}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
onRow={onTableRow}
|
onRow={onTableRow}
|
||||||
|
onChange={onChange}
|
||||||
data-test={this.props["data-test"]}
|
data-test={this.props["data-test"]}
|
||||||
{...tableDataProps}
|
{...tableDataProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const Query = PropTypes.shape({
|
|||||||
|
|
||||||
export const AlertOptions = PropTypes.shape({
|
export const AlertOptions = PropTypes.shape({
|
||||||
column: PropTypes.string,
|
column: PropTypes.string,
|
||||||
|
selector: PropTypes.oneOf(["first", "min", "max"]),
|
||||||
op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]),
|
op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]),
|
||||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
custom_subject: PropTypes.string,
|
custom_subject: PropTypes.string,
|
||||||
@@ -83,6 +84,7 @@ export const Alert = PropTypes.shape({
|
|||||||
query: Query,
|
query: Query,
|
||||||
options: PropTypes.shape({
|
options: PropTypes.shape({
|
||||||
column: PropTypes.string,
|
column: PropTypes.string,
|
||||||
|
selector: PropTypes.string,
|
||||||
op: PropTypes.string,
|
op: PropTypes.string,
|
||||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
|
|||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
const optionsChanged = !isEqual(options, defaultState.originalOptions);
|
const optionsChanged = !isEqual(options, defaultState.originalOptions);
|
||||||
confirmDialogClose(nameChanged || optionsChanged).then(dialog.dismiss);
|
confirmDialogClose(nameChanged || optionsChanged)
|
||||||
|
.then(dialog.dismiss)
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// When editing existing visualization chart type selector is disabled, so add only existing visualization's
|
// When editing existing visualization chart type selector is disabled, so add only existing visualization's
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function wrapComponentWithSettings(WrappedComponent) {
|
|||||||
"dateTimeFormat",
|
"dateTimeFormat",
|
||||||
"integerFormat",
|
"integerFormat",
|
||||||
"floatFormat",
|
"floatFormat",
|
||||||
|
"nullValue",
|
||||||
"booleanValues",
|
"booleanValues",
|
||||||
"tableCellMaxJSONSize",
|
"tableCellMaxJSONSize",
|
||||||
"allowCustomJSVisualizations",
|
"allowCustomJSVisualizations",
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
export default {
|
export default {
|
||||||
columns: 6, // grid columns count
|
columns: 12, // grid columns count
|
||||||
rowHeight: 50, // grid row height (incl. bottom padding)
|
rowHeight: 50, // grid row height (incl. bottom padding)
|
||||||
margins: 15, // widget margins
|
margins: 15, // widget margins
|
||||||
mobileBreakPoint: 800,
|
mobileBreakPoint: 800,
|
||||||
// defaults for widgets
|
// defaults for widgets
|
||||||
defaultSizeX: 3,
|
defaultSizeX: 6,
|
||||||
defaultSizeY: 3,
|
defaultSizeY: 3,
|
||||||
minSizeX: 1,
|
minSizeX: 2,
|
||||||
maxSizeX: 6,
|
maxSizeX: 12,
|
||||||
minSizeY: 1,
|
minSizeY: 2,
|
||||||
maxSizeY: 1000,
|
maxSizeY: 1000,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" translate="no">
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<base href="{{base_href}}" />
|
<base href="{{base_href}}" />
|
||||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||||
<script src="/static/unsupportedRedirect.js" async></script>
|
<script src="<%= htmlWebpackPlugin.options.staticPath %>unsupportedRedirect.js" async></script>
|
||||||
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />
|
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import MenuButton from "./components/MenuButton";
|
|||||||
import AlertView from "./AlertView";
|
import AlertView from "./AlertView";
|
||||||
import AlertEdit from "./AlertEdit";
|
import AlertEdit from "./AlertEdit";
|
||||||
import AlertNew from "./AlertNew";
|
import AlertNew from "./AlertNew";
|
||||||
|
import notifications from "@/services/notifications";
|
||||||
|
|
||||||
const MODES = {
|
const MODES = {
|
||||||
NEW: 0,
|
NEW: 0,
|
||||||
@@ -64,6 +65,7 @@ class Alert extends React.Component {
|
|||||||
this.setState({
|
this.setState({
|
||||||
alert: {
|
alert: {
|
||||||
options: {
|
options: {
|
||||||
|
selector: "first",
|
||||||
op: ">",
|
op: ">",
|
||||||
value: 1,
|
value: 1,
|
||||||
muted: false,
|
muted: false,
|
||||||
@@ -75,7 +77,7 @@ class Alert extends React.Component {
|
|||||||
} else {
|
} else {
|
||||||
const { alertId } = this.props;
|
const { alertId } = this.props;
|
||||||
AlertService.get({ id: alertId })
|
AlertService.get({ id: alertId })
|
||||||
.then(alert => {
|
.then((alert) => {
|
||||||
if (this._isMounted) {
|
if (this._isMounted) {
|
||||||
const canEdit = currentUser.canEdit(alert);
|
const canEdit = currentUser.canEdit(alert);
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ class Alert extends React.Component {
|
|||||||
this.onQuerySelected(alert.query);
|
this.onQuerySelected(alert.query);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
if (this._isMounted) {
|
if (this._isMounted) {
|
||||||
this.props.onError(error);
|
this.props.onError(error);
|
||||||
}
|
}
|
||||||
@@ -112,7 +114,7 @@ class Alert extends React.Component {
|
|||||||
alert.rearm = pendingRearm || null;
|
alert.rearm = pendingRearm || null;
|
||||||
|
|
||||||
return AlertService.save(alert)
|
return AlertService.save(alert)
|
||||||
.then(alert => {
|
.then((alert) => {
|
||||||
notification.success("Saved.");
|
notification.success("Saved.");
|
||||||
navigateTo(`alerts/${alert.id}`, true);
|
navigateTo(`alerts/${alert.id}`, true);
|
||||||
this.setState({ alert, mode: MODES.VIEW });
|
this.setState({ alert, mode: MODES.VIEW });
|
||||||
@@ -122,7 +124,7 @@ class Alert extends React.Component {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onQuerySelected = query => {
|
onQuerySelected = (query) => {
|
||||||
this.setState(({ alert }) => ({
|
this.setState(({ alert }) => ({
|
||||||
alert: Object.assign(alert, { query }),
|
alert: Object.assign(alert, { query }),
|
||||||
queryResult: null,
|
queryResult: null,
|
||||||
@@ -130,7 +132,7 @@ class Alert extends React.Component {
|
|||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
// get cached result for column names and values
|
// get cached result for column names and values
|
||||||
new QueryService(query).getQueryResultPromise().then(queryResult => {
|
new QueryService(query).getQueryResultPromise().then((queryResult) => {
|
||||||
if (this._isMounted) {
|
if (this._isMounted) {
|
||||||
this.setState({ queryResult });
|
this.setState({ queryResult });
|
||||||
let { column } = this.state.alert.options;
|
let { column } = this.state.alert.options;
|
||||||
@@ -146,18 +148,18 @@ class Alert extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onNameChange = name => {
|
onNameChange = (name) => {
|
||||||
const { alert } = this.state;
|
const { alert } = this.state;
|
||||||
this.setState({
|
this.setState({
|
||||||
alert: Object.assign(alert, { name }),
|
alert: Object.assign(alert, { name }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onRearmChange = pendingRearm => {
|
onRearmChange = (pendingRearm) => {
|
||||||
this.setState({ pendingRearm });
|
this.setState({ pendingRearm });
|
||||||
};
|
};
|
||||||
|
|
||||||
setAlertOptions = obj => {
|
setAlertOptions = (obj) => {
|
||||||
const { alert } = this.state;
|
const { alert } = this.state;
|
||||||
const options = { ...alert.options, ...obj };
|
const options = { ...alert.options, ...obj };
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -177,6 +179,17 @@ class Alert extends React.Component {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
evaluate = () => {
|
||||||
|
const { alert } = this.state;
|
||||||
|
return AlertService.evaluate(alert)
|
||||||
|
.then(() => {
|
||||||
|
notification.success("Alert evaluated. Refresh page for updated status.");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
notifications.error("Failed to evaluate alert.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
mute = () => {
|
mute = () => {
|
||||||
const { alert } = this.state;
|
const { alert } = this.state;
|
||||||
return AlertService.mute(alert)
|
return AlertService.mute(alert)
|
||||||
@@ -223,7 +236,14 @@ class Alert extends React.Component {
|
|||||||
const { queryResult, mode, canEdit, pendingRearm } = this.state;
|
const { queryResult, mode, canEdit, pendingRearm } = this.state;
|
||||||
|
|
||||||
const menuButton = (
|
const menuButton = (
|
||||||
<MenuButton doDelete={this.delete} muted={muted} mute={this.mute} unmute={this.unmute} canEdit={canEdit} />
|
<MenuButton
|
||||||
|
doDelete={this.delete}
|
||||||
|
muted={muted}
|
||||||
|
mute={this.mute}
|
||||||
|
unmute={this.unmute}
|
||||||
|
canEdit={canEdit}
|
||||||
|
evaluate={this.evaluate}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
@@ -258,7 +278,7 @@ routes.register(
|
|||||||
routeWithUserSession({
|
routeWithUserSession({
|
||||||
path: "/alerts/new",
|
path: "/alerts/new",
|
||||||
title: "New Alert",
|
title: "New Alert",
|
||||||
render: pageProps => <Alert {...pageProps} mode={MODES.NEW} />,
|
render: (pageProps) => <Alert {...pageProps} mode={MODES.NEW} />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
routes.register(
|
routes.register(
|
||||||
@@ -266,7 +286,7 @@ routes.register(
|
|||||||
routeWithUserSession({
|
routeWithUserSession({
|
||||||
path: "/alerts/:alertId",
|
path: "/alerts/:alertId",
|
||||||
title: "Alert",
|
title: "Alert",
|
||||||
render: pageProps => <Alert {...pageProps} mode={MODES.VIEW} />,
|
render: (pageProps) => <Alert {...pageProps} mode={MODES.VIEW} />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
routes.register(
|
routes.register(
|
||||||
@@ -274,6 +294,6 @@ routes.register(
|
|||||||
routeWithUserSession({
|
routeWithUserSession({
|
||||||
path: "/alerts/:alertId/edit",
|
path: "/alerts/:alertId/edit",
|
||||||
title: "Alert",
|
title: "Alert",
|
||||||
render: pageProps => <Alert {...pageProps} mode={MODES.EDIT} />,
|
render: (pageProps) => <Alert {...pageProps} mode={MODES.EDIT} />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,13 +68,23 @@ export default class AlertView extends React.Component {
|
|||||||
<>
|
<>
|
||||||
<Title name={name} alert={alert}>
|
<Title name={name} alert={alert}>
|
||||||
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
|
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
|
||||||
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}>
|
{canEdit ? (
|
||||||
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
<>
|
||||||
<i className="fa fa-edit m-r-5" aria-hidden="true" />
|
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
||||||
Edit
|
<i className="fa fa-edit m-r-5" aria-hidden="true" />
|
||||||
</Button>
|
Edit
|
||||||
{menuButton}
|
</Button>
|
||||||
</Tooltip>
|
{menuButton}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Tooltip title="You do not have sufficient permissions to edit this alert">
|
||||||
|
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
||||||
|
<i className="fa fa-edit m-r-5" aria-hidden="true" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{menuButton}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Title>
|
</Title>
|
||||||
<div className="bg-white tiled p-20">
|
<div className="bg-white tiled p-20">
|
||||||
<Grid.Row type="flex" gutter={16}>
|
<Grid.Row type="flex" gutter={16}>
|
||||||
|
|||||||
@@ -54,23 +54,74 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
|
|||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const columnHint = (
|
let columnHint;
|
||||||
<small className="alert-criteria-hint">
|
|
||||||
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
|
if (alertOptions.selector === "first") {
|
||||||
</small>
|
columnHint = (
|
||||||
);
|
<small className="alert-criteria-hint">
|
||||||
|
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
|
||||||
|
</small>
|
||||||
|
);
|
||||||
|
} else if (alertOptions.selector === "max") {
|
||||||
|
columnHint = (
|
||||||
|
<small className="alert-criteria-hint">
|
||||||
|
Max column value is{" "}
|
||||||
|
<code className="p-0">
|
||||||
|
{toString(
|
||||||
|
Math.max(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
|
||||||
|
) || "unknown"}
|
||||||
|
</code>
|
||||||
|
</small>
|
||||||
|
);
|
||||||
|
} else if (alertOptions.selector === "min") {
|
||||||
|
columnHint = (
|
||||||
|
<small className="alert-criteria-hint">
|
||||||
|
Min column value is{" "}
|
||||||
|
<code className="p-0">
|
||||||
|
{toString(
|
||||||
|
Math.min(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
|
||||||
|
) || "unknown"}
|
||||||
|
</code>
|
||||||
|
</small>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-test="Criteria">
|
<div data-test="Criteria">
|
||||||
|
<div className="input-title">
|
||||||
|
<span className="input-label">Selector</span>
|
||||||
|
{editMode ? (
|
||||||
|
<Select
|
||||||
|
value={alertOptions.selector}
|
||||||
|
onChange={(selector) => onChange({ selector })}
|
||||||
|
optionLabelProp="label"
|
||||||
|
dropdownMatchSelectWidth={false}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
>
|
||||||
|
<Select.Option value="first" label="first">
|
||||||
|
first
|
||||||
|
</Select.Option>
|
||||||
|
<Select.Option value="min" label="min">
|
||||||
|
min
|
||||||
|
</Select.Option>
|
||||||
|
<Select.Option value="max" label="max">
|
||||||
|
max
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<DisabledInput minWidth={60}>{alertOptions.selector}</DisabledInput>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="input-title">
|
<div className="input-title">
|
||||||
<span className="input-label">Value column</span>
|
<span className="input-label">Value column</span>
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
<Select
|
<Select
|
||||||
value={alertOptions.column}
|
value={alertOptions.column}
|
||||||
onChange={column => onChange({ column })}
|
onChange={(column) => onChange({ column })}
|
||||||
dropdownMatchSelectWidth={false}
|
dropdownMatchSelectWidth={false}
|
||||||
style={{ minWidth: 100 }}>
|
style={{ minWidth: 100 }}
|
||||||
{columnNames.map(name => (
|
>
|
||||||
|
{columnNames.map((name) => (
|
||||||
<Select.Option key={name}>{name}</Select.Option>
|
<Select.Option key={name}>{name}</Select.Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -83,10 +134,11 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
|
|||||||
{editMode ? (
|
{editMode ? (
|
||||||
<Select
|
<Select
|
||||||
value={alertOptions.op}
|
value={alertOptions.op}
|
||||||
onChange={op => onChange({ op })}
|
onChange={(op) => onChange({ op })}
|
||||||
optionLabelProp="label"
|
optionLabelProp="label"
|
||||||
dropdownMatchSelectWidth={false}
|
dropdownMatchSelectWidth={false}
|
||||||
style={{ width: 55 }}>
|
style={{ width: 55 }}
|
||||||
|
>
|
||||||
<Select.Option value=">" label={CONDITIONS[">"]}>
|
<Select.Option value=">" label={CONDITIONS[">"]}>
|
||||||
{CONDITIONS[">"]} greater than
|
{CONDITIONS[">"]} greater than
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
@@ -125,7 +177,7 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
|
|||||||
id="threshold-criterion"
|
id="threshold-criterion"
|
||||||
style={{ width: 90 }}
|
style={{ width: 90 }}
|
||||||
value={alertOptions.value}
|
value={alertOptions.value}
|
||||||
onChange={e => onChange({ value: e.target.value })}
|
onChange={(e) => onChange({ value: e.target.value })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DisabledInput minWidth={50}>{alertOptions.value}</DisabledInput>
|
<DisabledInput minWidth={50}>{alertOptions.value}</DisabledInput>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
|
|||||||
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
|
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
|
||||||
import PlainButton from "@/components/PlainButton";
|
import PlainButton from "@/components/PlainButton";
|
||||||
|
|
||||||
export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
|
export default function MenuButton({ doDelete, canEdit, mute, unmute, evaluate, muted }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const execute = useCallback(action => {
|
const execute = useCallback(action => {
|
||||||
@@ -55,6 +55,9 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
|
|||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<PlainButton onClick={confirmDelete}>Delete</PlainButton>
|
<PlainButton onClick={confirmDelete}>Delete</PlainButton>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
<PlainButton onClick={() => execute(evaluate)}>Evaluate</PlainButton>
|
||||||
|
</Menu.Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
}>
|
}>
|
||||||
<Button aria-label="More actions">
|
<Button aria-label="More actions">
|
||||||
@@ -69,6 +72,7 @@ MenuButton.propTypes = {
|
|||||||
canEdit: PropTypes.bool.isRequired,
|
canEdit: PropTypes.bool.isRequired,
|
||||||
mute: PropTypes.func.isRequired,
|
mute: PropTypes.func.isRequired,
|
||||||
unmute: PropTypes.func.isRequired,
|
unmute: PropTypes.func.isRequired,
|
||||||
|
evaluate: PropTypes.func.isRequired,
|
||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -118,28 +118,9 @@ class ShareDashboardDialog extends React.Component {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{dashboard.public_url && (
|
{dashboard.public_url && (
|
||||||
<>
|
<Form.Item label="Secret address" {...this.formItemProps}>
|
||||||
<Form.Item>
|
<InputWithCopy value={dashboard.public_url} data-test="SecretAddress" />
|
||||||
<Alert
|
</Form.Item>
|
||||||
message={
|
|
||||||
<div>
|
|
||||||
Custom rule for hiding filter components when sharing links:
|
|
||||||
<br />
|
|
||||||
You can hide filter components by appending `&hide_filter={"{{"} component_name{"}}"}` to the
|
|
||||||
sharing URL.
|
|
||||||
<br />
|
|
||||||
Example: http://{"{{"}ip{"}}"}:{"{{"}port{"}}"}/public/dashboards/{"{{"}id{"}}"}
|
|
||||||
?p_country=ghana&p_site=10&hide_filter=country
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
type="warning"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="Secret address" {...this.formItemProps}>
|
|
||||||
<InputWithCopy value={dashboard.public_url} data-test="SecretAddress" />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ function DeprecatedEmbedFeatureAlert() {
|
|||||||
<Link
|
<Link
|
||||||
href="https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337"
|
href="https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
Read more
|
Read more
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
@@ -43,7 +44,7 @@ function DeprecatedEmbedFeatureAlert() {
|
|||||||
|
|
||||||
function EmailNotVerifiedAlert() {
|
function EmailNotVerifiedAlert() {
|
||||||
const verifyEmail = () => {
|
const verifyEmail = () => {
|
||||||
axios.post("verification_email/").then(data => {
|
axios.post("verification_email/").then((data) => {
|
||||||
notification.success(data.message);
|
notification.success(data.message);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -100,6 +101,6 @@ routes.register(
|
|||||||
routeWithUserSession({
|
routeWithUserSession({
|
||||||
path: "/",
|
path: "/",
|
||||||
title: "Redash",
|
title: "Redash",
|
||||||
render: pageProps => <Home {...pageProps} />,
|
render: (pageProps) => <Home {...pageProps} />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -160,14 +160,15 @@ function QueriesList({ controller }) {
|
|||||||
orderByField={controller.orderByField}
|
orderByField={controller.orderByField}
|
||||||
orderByReverse={controller.orderByReverse}
|
orderByReverse={controller.orderByReverse}
|
||||||
toggleSorting={controller.toggleSorting}
|
toggleSorting={controller.toggleSorting}
|
||||||
|
setSorting={controller.setSorting}
|
||||||
/>
|
/>
|
||||||
<Paginator
|
<Paginator
|
||||||
showPageSizeSelect
|
showPageSizeSelect
|
||||||
totalCount={controller.totalItemsCount}
|
totalCount={controller.totalItemsCount}
|
||||||
pageSize={controller.itemsPerPage}
|
pageSize={controller.itemsPerPage}
|
||||||
onPageSizeChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
|
onPageSizeChange={(itemsPerPage) => controller.updatePagination({ itemsPerPage })}
|
||||||
page={controller.page}
|
page={controller.page}
|
||||||
onChange={page => controller.updatePagination({ page })}
|
onChange={(page) => controller.updatePagination({ page })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -196,7 +197,7 @@ const QueriesListPage = itemsList(
|
|||||||
}[currentPage];
|
}[currentPage];
|
||||||
},
|
},
|
||||||
getItemProcessor() {
|
getItemProcessor() {
|
||||||
return item => new Query(item);
|
return (item) => new Query(item);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
|
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
|
||||||
@@ -207,7 +208,7 @@ routes.register(
|
|||||||
routeWithUserSession({
|
routeWithUserSession({
|
||||||
path: "/queries",
|
path: "/queries",
|
||||||
title: "Queries",
|
title: "Queries",
|
||||||
render: pageProps => <QueriesListPage {...pageProps} currentPage="all" />,
|
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="all" />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
routes.register(
|
routes.register(
|
||||||
@@ -215,7 +216,7 @@ routes.register(
|
|||||||
routeWithUserSession({
|
routeWithUserSession({
|
||||||
path: "/queries/favorites",
|
path: "/queries/favorites",
|
||||||
title: "Favorite Queries",
|
title: "Favorite Queries",
|
||||||
render: pageProps => <QueriesListPage {...pageProps} currentPage="favorites" />,
|
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
routes.register(
|
routes.register(
|
||||||
@@ -223,7 +224,7 @@ routes.register(
|
|||||||
routeWithUserSession({
|
routeWithUserSession({
|
||||||
path: "/queries/archive",
|
path: "/queries/archive",
|
||||||
title: "Archived Queries",
|
title: "Archived Queries",
|
||||||
render: pageProps => <QueriesListPage {...pageProps} currentPage="archive" />,
|
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="archive" />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
routes.register(
|
routes.register(
|
||||||
@@ -231,6 +232,6 @@ routes.register(
|
|||||||
routeWithUserSession({
|
routeWithUserSession({
|
||||||
path: "/queries/my",
|
path: "/queries/my",
|
||||||
title: "My Queries",
|
title: "My Queries",
|
||||||
render: pageProps => <QueriesListPage {...pageProps} currentPage="my" />,
|
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="my" />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import QueryControlDropdown from "@/components/EditVisualizationButton/QueryCont
|
|||||||
import EditVisualizationButton from "@/components/EditVisualizationButton";
|
import EditVisualizationButton from "@/components/EditVisualizationButton";
|
||||||
import useQueryResultData from "@/lib/useQueryResultData";
|
import useQueryResultData from "@/lib/useQueryResultData";
|
||||||
import { durationHumanize, pluralize, prettySize } from "@/lib/utils";
|
import { durationHumanize, pluralize, prettySize } from "@/lib/utils";
|
||||||
|
import { isUndefined } from "lodash";
|
||||||
|
|
||||||
import "./QueryExecutionMetadata.less";
|
import "./QueryExecutionMetadata.less";
|
||||||
|
|
||||||
@@ -51,7 +52,8 @@ export default function QueryExecutionMetadata({
|
|||||||
"Result truncated to " +
|
"Result truncated to " +
|
||||||
queryResultData.rows.length +
|
queryResultData.rows.length +
|
||||||
" rows. Databricks may truncate query results that are unstably large."
|
" rows. Databricks may truncate query results that are unstably large."
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
<WarningTwoTone twoToneColor="#FF9800" />
|
<WarningTwoTone twoToneColor="#FF9800" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
@@ -67,10 +69,9 @@ export default function QueryExecutionMetadata({
|
|||||||
)}
|
)}
|
||||||
{isQueryExecuting && <span>Running…</span>}
|
{isQueryExecuting && <span>Running…</span>}
|
||||||
</span>
|
</span>
|
||||||
{queryResultData.metadata.data_scanned && (
|
{!isUndefined(queryResultData.metadata.data_scanned) && !isQueryExecuting && (
|
||||||
<span className="m-l-5">
|
<span className="m-l-5">
|
||||||
Data Scanned
|
Data Scanned <strong>{prettySize(queryResultData.metadata.data_scanned)}</strong>
|
||||||
<strong>{prettySize(queryResultData.metadata.data_scanned)}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export function QuerySourceTypeIcon(props) {
|
export function QuerySourceTypeIcon(props) {
|
||||||
return <img src={`static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />;
|
return <img src={`/static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
QuerySourceTypeIcon.propTypes = {
|
QuerySourceTypeIcon.propTypes = {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function EmptyState({ title, message, refreshButton }) {
|
|||||||
<div className="query-results-empty-state">
|
<div className="query-results-empty-state">
|
||||||
<div className="empty-state-content">
|
<div className="empty-state-content">
|
||||||
<div>
|
<div>
|
||||||
<img src="static/images/illustrations/no-query-results.svg" alt="No Query Results Illustration" />
|
<img src="/static/images/illustrations/no-query-results.svg" alt="No Query Results Illustration" />
|
||||||
</div>
|
</div>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<div className="m-b-20">{message}</div>
|
<div className="m-b-20">{message}</div>
|
||||||
@@ -40,7 +40,7 @@ EmptyState.defaultProps = {
|
|||||||
|
|
||||||
function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) {
|
function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) {
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
e => {
|
(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: "Delete Visualization",
|
title: "Delete Visualization",
|
||||||
@@ -111,7 +111,8 @@ export default function QueryVisualizationTabs({
|
|||||||
className="add-visualization-button"
|
className="add-visualization-button"
|
||||||
data-test="NewVisualization"
|
data-test="NewVisualization"
|
||||||
type="link"
|
type="link"
|
||||||
onClick={() => onAddVisualization()}>
|
onClick={() => onAddVisualization()}
|
||||||
|
>
|
||||||
<i className="fa fa-plus" aria-hidden="true" />
|
<i className="fa fa-plus" aria-hidden="true" />
|
||||||
<span className="m-l-5 hidden-xs">Add Visualization</span>
|
<span className="m-l-5 hidden-xs">Add Visualization</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -119,7 +120,7 @@ export default function QueryVisualizationTabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orderedVisualizations = useMemo(() => orderBy(visualizations, ["id"]), [visualizations]);
|
const orderedVisualizations = useMemo(() => orderBy(visualizations, ["id"]), [visualizations]);
|
||||||
const isFirstVisualization = useCallback(visId => visId === orderedVisualizations[0].id, [orderedVisualizations]);
|
const isFirstVisualization = useCallback((visId) => visId === orderedVisualizations[0].id, [orderedVisualizations]);
|
||||||
const isMobile = useMedia({ maxWidth: 768 });
|
const isMobile = useMedia({ maxWidth: 768 });
|
||||||
|
|
||||||
const [filters, setFilters] = useState([]);
|
const [filters, setFilters] = useState([]);
|
||||||
@@ -132,9 +133,10 @@ export default function QueryVisualizationTabs({
|
|||||||
data-test="QueryPageVisualizationTabs"
|
data-test="QueryPageVisualizationTabs"
|
||||||
animated={false}
|
animated={false}
|
||||||
tabBarGutter={0}
|
tabBarGutter={0}
|
||||||
onChange={activeKey => onChangeTab(+activeKey)}
|
onChange={(activeKey) => onChangeTab(+activeKey)}
|
||||||
destroyInactiveTabPane>
|
destroyInactiveTabPane
|
||||||
{orderedVisualizations.map(visualization => (
|
>
|
||||||
|
{orderedVisualizations.map((visualization) => (
|
||||||
<TabPane
|
<TabPane
|
||||||
key={`${visualization.id}`}
|
key={`${visualization.id}`}
|
||||||
tab={
|
tab={
|
||||||
@@ -144,7 +146,8 @@ export default function QueryVisualizationTabs({
|
|||||||
visualizationName={visualization.name}
|
visualizationName={visualization.name}
|
||||||
onDelete={() => onDeleteVisualization(visualization.id)}
|
onDelete={() => onDeleteVisualization(visualization.id)}
|
||||||
/>
|
/>
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
{queryResult ? (
|
{queryResult ? (
|
||||||
<VisualizationRenderer
|
<VisualizationRenderer
|
||||||
visualization={visualization}
|
visualization={visualization}
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { reduce } from "lodash";
|
|
||||||
import localOptions from "@/lib/localOptions";
|
import localOptions from "@/lib/localOptions";
|
||||||
|
|
||||||
function calculateTokensCount(schema) {
|
|
||||||
return reduce(schema, (totalLength, table) => totalLength + table.columns.length, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function useAutocompleteFlags(schema) {
|
export default function useAutocompleteFlags(schema) {
|
||||||
const isAvailable = useMemo(() => calculateTokensCount(schema) <= 5000, [schema]);
|
const isAvailable = true;
|
||||||
const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
|
const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
|
||||||
|
|
||||||
const toggleAutocomplete = useCallback(state => {
|
const toggleAutocomplete = useCallback((state) => {
|
||||||
setIsEnabled(state);
|
setIsEnabled(state);
|
||||||
localOptions.set("liveAutocomplete", state);
|
localOptions.set("liveAutocomplete", state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -17,14 +17,16 @@ export default function BeaconConsentSettings(props) {
|
|||||||
Anonymous Usage Data Sharing
|
Anonymous Usage Data Sharing
|
||||||
<HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" />
|
<HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" />
|
||||||
</span>
|
</span>
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||||
) : (
|
) : (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="beacon_consent"
|
name="beacon_consent"
|
||||||
checked={values.beacon_consent}
|
checked={values.beacon_consent}
|
||||||
onChange={e => onChange({ beacon_consent: e.target.checked })}>
|
onChange={(e) => onChange({ beacon_consent: e.target.checked })}
|
||||||
|
>
|
||||||
Help Redash improve by automatically sending anonymous usage data
|
Help Redash improve by automatically sending anonymous usage data
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const Alert = {
|
|||||||
delete: data => axios.delete(`api/alerts/${data.id}`),
|
delete: data => axios.delete(`api/alerts/${data.id}`),
|
||||||
mute: data => axios.post(`api/alerts/${data.id}/mute`),
|
mute: data => axios.post(`api/alerts/${data.id}/mute`),
|
||||||
unmute: data => axios.delete(`api/alerts/${data.id}/mute`),
|
unmute: data => axios.delete(`api/alerts/${data.id}/mute`),
|
||||||
|
evaluate: data => axios.post(`api/alerts/${data.id}/eval`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Alert;
|
export default Alert;
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ import { fetchDataFromJob } from "@/services/query-result";
|
|||||||
|
|
||||||
export const SCHEMA_NOT_SUPPORTED = 1;
|
export const SCHEMA_NOT_SUPPORTED = 1;
|
||||||
export const SCHEMA_LOAD_ERROR = 2;
|
export const SCHEMA_LOAD_ERROR = 2;
|
||||||
export const IMG_ROOT = "static/images/db-logos";
|
export const IMG_ROOT = "/static/images/db-logos";
|
||||||
|
|
||||||
function mapSchemaColumnsToObject(columns) {
|
function mapSchemaColumnsToObject(columns) {
|
||||||
return map(columns, column => (isObject(column) ? column : { name: column }));
|
return map(columns, (column) => (isObject(column) ? column : { name: column }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataSource = {
|
const DataSource = {
|
||||||
query: () => axios.get("api/data_sources"),
|
query: () => axios.get("api/data_sources"),
|
||||||
get: ({ id }) => axios.get(`api/data_sources/${id}`),
|
get: ({ id }) => axios.get(`api/data_sources/${id}`),
|
||||||
types: () => axios.get("api/data_sources/types"),
|
types: () => axios.get("api/data_sources/types"),
|
||||||
create: data => axios.post(`api/data_sources`, data),
|
create: (data) => axios.post(`api/data_sources`, data),
|
||||||
save: data => axios.post(`api/data_sources/${data.id}`, data),
|
save: (data) => axios.post(`api/data_sources/${data.id}`, data),
|
||||||
test: data => axios.post(`api/data_sources/${data.id}/test`),
|
test: (data) => axios.post(`api/data_sources/${data.id}/test`),
|
||||||
delete: ({ id }) => axios.delete(`api/data_sources/${id}`),
|
delete: ({ id }) => axios.delete(`api/data_sources/${id}`),
|
||||||
fetchSchema: (data, refresh = false) => {
|
fetchSchema: (data, refresh = false) => {
|
||||||
const params = {};
|
const params = {};
|
||||||
@@ -27,15 +27,15 @@ const DataSource = {
|
|||||||
|
|
||||||
return axios
|
return axios
|
||||||
.get(`api/data_sources/${data.id}/schema`, { params })
|
.get(`api/data_sources/${data.id}/schema`, { params })
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (has(data, "job")) {
|
if (has(data, "job")) {
|
||||||
return fetchDataFromJob(data.job.id).catch(error =>
|
return fetchDataFromJob(data.job.id).catch((error) =>
|
||||||
error.code === SCHEMA_NOT_SUPPORTED ? [] : Promise.reject(new Error(data.job.error))
|
error.code === SCHEMA_NOT_SUPPORTED ? [] : Promise.reject(new Error(data.job.error))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return has(data, "schema") ? data.schema : Promise.reject();
|
return has(data, "schema") ? data.schema : Promise.reject();
|
||||||
})
|
})
|
||||||
.then(tables => map(tables, table => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) })));
|
.then((tables) => map(tables, (table) => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) })));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class DateParameter extends Parameter {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedValue = moment(value);
|
const normalizedValue = moment(value, moment.ISO_8601, true);
|
||||||
return normalizedValue.isValid() ? normalizedValue : null;
|
return normalizedValue.isValid() ? normalizedValue : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
client/app/services/parameters/TextPatternParameter.js
Normal file
29
client/app/services/parameters/TextPatternParameter.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { toString, isNull } from "lodash";
|
||||||
|
import Parameter from "./Parameter";
|
||||||
|
|
||||||
|
class TextPatternParameter extends Parameter {
|
||||||
|
constructor(parameter, parentQueryId) {
|
||||||
|
super(parameter, parentQueryId);
|
||||||
|
this.regex = parameter.regex;
|
||||||
|
this.setValue(parameter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
normalizeValue(value) {
|
||||||
|
const normalizedValue = toString(value);
|
||||||
|
if (isNull(normalizedValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var re = new RegExp(this.regex);
|
||||||
|
|
||||||
|
if (re !== null) {
|
||||||
|
if (re.test(normalizedValue)) {
|
||||||
|
return normalizedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextPatternParameter;
|
||||||
@@ -5,6 +5,7 @@ import EnumParameter from "./EnumParameter";
|
|||||||
import QueryBasedDropdownParameter from "./QueryBasedDropdownParameter";
|
import QueryBasedDropdownParameter from "./QueryBasedDropdownParameter";
|
||||||
import DateParameter from "./DateParameter";
|
import DateParameter from "./DateParameter";
|
||||||
import DateRangeParameter from "./DateRangeParameter";
|
import DateRangeParameter from "./DateRangeParameter";
|
||||||
|
import TextPatternParameter from "./TextPatternParameter";
|
||||||
|
|
||||||
function createParameter(param, parentQueryId) {
|
function createParameter(param, parentQueryId) {
|
||||||
switch (param.type) {
|
switch (param.type) {
|
||||||
@@ -22,6 +23,8 @@ function createParameter(param, parentQueryId) {
|
|||||||
case "datetime-range":
|
case "datetime-range":
|
||||||
case "datetime-range-with-seconds":
|
case "datetime-range-with-seconds":
|
||||||
return new DateRangeParameter(param, parentQueryId);
|
return new DateRangeParameter(param, parentQueryId);
|
||||||
|
case "text-pattern":
|
||||||
|
return new TextPatternParameter({ ...param, type: "text-pattern" }, parentQueryId);
|
||||||
default:
|
default:
|
||||||
return new TextParameter({ ...param, type: "text" }, parentQueryId);
|
return new TextParameter({ ...param, type: "text" }, parentQueryId);
|
||||||
}
|
}
|
||||||
@@ -34,6 +37,7 @@ function cloneParameter(param) {
|
|||||||
export {
|
export {
|
||||||
Parameter,
|
Parameter,
|
||||||
TextParameter,
|
TextParameter,
|
||||||
|
TextPatternParameter,
|
||||||
NumberParameter,
|
NumberParameter,
|
||||||
EnumParameter,
|
EnumParameter,
|
||||||
QueryBasedDropdownParameter,
|
QueryBasedDropdownParameter,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createParameter,
|
createParameter,
|
||||||
TextParameter,
|
TextParameter,
|
||||||
|
TextPatternParameter,
|
||||||
NumberParameter,
|
NumberParameter,
|
||||||
EnumParameter,
|
EnumParameter,
|
||||||
QueryBasedDropdownParameter,
|
QueryBasedDropdownParameter,
|
||||||
@@ -12,6 +13,7 @@ describe("Parameter", () => {
|
|||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
const parameterTypes = [
|
const parameterTypes = [
|
||||||
["text", TextParameter],
|
["text", TextParameter],
|
||||||
|
["text-pattern", TextPatternParameter],
|
||||||
["number", NumberParameter],
|
["number", NumberParameter],
|
||||||
["enum", EnumParameter],
|
["enum", EnumParameter],
|
||||||
["query", QueryBasedDropdownParameter],
|
["query", QueryBasedDropdownParameter],
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createParameter } from "..";
|
||||||
|
|
||||||
|
describe("TextPatternParameter", () => {
|
||||||
|
let param;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
param = createParameter({ name: "param", title: "Param", type: "text-pattern", regex: "a+" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("noramlizeValue", () => {
|
||||||
|
test("converts matching strings", () => {
|
||||||
|
const normalizedValue = param.normalizeValue("art");
|
||||||
|
expect(normalizedValue).toBe("art");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when string does not match pattern", () => {
|
||||||
|
const normalizedValue = param.normalizeValue("brt");
|
||||||
|
expect(normalizedValue).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ const logger = debug("redash:services:QueryResult");
|
|||||||
const filterTypes = ["filter", "multi-filter", "multiFilter"];
|
const filterTypes = ["filter", "multi-filter", "multiFilter"];
|
||||||
|
|
||||||
function defer() {
|
function defer() {
|
||||||
const result = { onStatusChange: status => {} };
|
const result = { onStatusChange: (status) => {} };
|
||||||
result.promise = new Promise((resolve, reject) => {
|
result.promise = new Promise((resolve, reject) => {
|
||||||
result.resolve = resolve;
|
result.resolve = resolve;
|
||||||
result.reject = reject;
|
result.reject = reject;
|
||||||
@@ -40,13 +40,13 @@ function getColumnNameWithoutType(column) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getColumnFriendlyName(column) {
|
function getColumnFriendlyName(column) {
|
||||||
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, a => a.toUpperCase());
|
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, (a) => a.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
const createOrSaveUrl = data => (data.id ? `api/query_results/${data.id}` : "api/query_results");
|
const createOrSaveUrl = (data) => (data.id ? `api/query_results/${data.id}` : "api/query_results");
|
||||||
const QueryResultResource = {
|
const QueryResultResource = {
|
||||||
get: ({ id }) => axios.get(`api/query_results/${id}`),
|
get: ({ id }) => axios.get(`api/query_results/${id}`),
|
||||||
post: data => axios.post(createOrSaveUrl(data), data),
|
post: (data) => axios.post(createOrSaveUrl(data), data),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ExecutionStatus = {
|
export const ExecutionStatus = {
|
||||||
@@ -97,11 +97,11 @@ function handleErrorResponse(queryResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDataFromJob(jobId, interval = 1000) {
|
export function fetchDataFromJob(jobId, interval = 1000) {
|
||||||
return axios.get(`api/jobs/${jobId}`).then(data => {
|
return axios.get(`api/jobs/${jobId}`).then((data) => {
|
||||||
const status = statuses[data.job.status];
|
const status = statuses[data.job.status];
|
||||||
if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) {
|
if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) {
|
||||||
return sleep(interval).then(() => fetchDataFromJob(data.job.id));
|
return sleep(interval).then(() => fetchDataFromJob(data.job.id));
|
||||||
@@ -114,7 +114,7 @@ export function fetchDataFromJob(jobId, interval = 1000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isDateTime(v) {
|
export function isDateTime(v) {
|
||||||
return isString(v) && moment(v).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v);
|
return isString(v) && moment(v, moment.ISO_8601, true).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
class QueryResult {
|
class QueryResult {
|
||||||
@@ -146,7 +146,7 @@ class QueryResult {
|
|||||||
// TODO: we should stop manipulating incoming data, and switch to relaying
|
// TODO: we should stop manipulating incoming data, and switch to relaying
|
||||||
// on the column type set by the backend. This logic is prone to errors,
|
// on the column type set by the backend. This logic is prone to errors,
|
||||||
// and better be removed. Kept for now, for backward compatability.
|
// and better be removed. Kept for now, for backward compatability.
|
||||||
each(this.query_result.data.rows, row => {
|
each(this.query_result.data.rows, (row) => {
|
||||||
forOwn(row, (v, k) => {
|
forOwn(row, (v, k) => {
|
||||||
let newType = null;
|
let newType = null;
|
||||||
if (isNumber(v)) {
|
if (isNumber(v)) {
|
||||||
@@ -173,7 +173,7 @@ class QueryResult {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
each(this.query_result.data.columns, column => {
|
each(this.query_result.data.columns, (column) => {
|
||||||
column.name = "" + column.name;
|
column.name = "" + column.name;
|
||||||
if (columnTypes[column.name]) {
|
if (columnTypes[column.name]) {
|
||||||
if (column.type == null || column.type === "string") {
|
if (column.type == null || column.type === "string") {
|
||||||
@@ -265,14 +265,14 @@ class QueryResult {
|
|||||||
|
|
||||||
getColumnNames() {
|
getColumnNames() {
|
||||||
if (this.columnNames === undefined && this.query_result.data) {
|
if (this.columnNames === undefined && this.query_result.data) {
|
||||||
this.columnNames = this.query_result.data.columns.map(v => v.name);
|
this.columnNames = this.query_result.data.columns.map((v) => v.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.columnNames;
|
return this.columnNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
getColumnFriendlyNames() {
|
getColumnFriendlyNames() {
|
||||||
return this.getColumnNames().map(col => getColumnFriendlyName(col));
|
return this.getColumnNames().map((col) => getColumnFriendlyName(col));
|
||||||
}
|
}
|
||||||
|
|
||||||
getTruncated() {
|
getTruncated() {
|
||||||
@@ -286,7 +286,7 @@ class QueryResult {
|
|||||||
|
|
||||||
const filters = [];
|
const filters = [];
|
||||||
|
|
||||||
this.getColumns().forEach(col => {
|
this.getColumns().forEach((col) => {
|
||||||
const name = col.name;
|
const name = col.name;
|
||||||
const type = name.split("::")[1] || name.split("__")[1];
|
const type = name.split("::")[1] || name.split("__")[1];
|
||||||
if (includes(filterTypes, type)) {
|
if (includes(filterTypes, type)) {
|
||||||
@@ -302,8 +302,8 @@ class QueryResult {
|
|||||||
}
|
}
|
||||||
}, this);
|
}, this);
|
||||||
|
|
||||||
this.getRawData().forEach(row => {
|
this.getRawData().forEach((row) => {
|
||||||
filters.forEach(filter => {
|
filters.forEach((filter) => {
|
||||||
filter.values.push(row[filter.name]);
|
filter.values.push(row[filter.name]);
|
||||||
if (filter.values.length === 1) {
|
if (filter.values.length === 1) {
|
||||||
if (filter.multiple) {
|
if (filter.multiple) {
|
||||||
@@ -315,8 +315,8 @@ class QueryResult {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
filters.forEach(filter => {
|
filters.forEach((filter) => {
|
||||||
filter.values = uniqBy(filter.values, v => {
|
filter.values = uniqBy(filter.values, (v) => {
|
||||||
if (moment.isMoment(v)) {
|
if (moment.isMoment(v)) {
|
||||||
return v.unix();
|
return v.unix();
|
||||||
}
|
}
|
||||||
@@ -345,12 +345,12 @@ class QueryResult {
|
|||||||
|
|
||||||
axios
|
axios
|
||||||
.get(`api/queries/${queryId}/results/${id}.json`)
|
.get(`api/queries/${queryId}/results/${id}.json`)
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
// Success handler
|
// Success handler
|
||||||
queryResult.isLoadingResult = false;
|
queryResult.isLoadingResult = false;
|
||||||
queryResult.update(response);
|
queryResult.update(response);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
// Error handler
|
// Error handler
|
||||||
queryResult.isLoadingResult = false;
|
queryResult.isLoadingResult = false;
|
||||||
handleErrorResponse(queryResult, error);
|
handleErrorResponse(queryResult, error);
|
||||||
@@ -362,10 +362,10 @@ class QueryResult {
|
|||||||
loadLatestCachedResult(queryId, parameters) {
|
loadLatestCachedResult(queryId, parameters) {
|
||||||
axios
|
axios
|
||||||
.post(`api/queries/${queryId}/results`, { queryId, parameters })
|
.post(`api/queries/${queryId}/results`, { queryId, parameters })
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
this.update(response);
|
this.update(response);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
handleErrorResponse(this, error);
|
handleErrorResponse(this, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -375,11 +375,11 @@ class QueryResult {
|
|||||||
this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT);
|
this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT);
|
||||||
|
|
||||||
QueryResultResource.get({ id: this.job.query_result_id })
|
QueryResultResource.get({ id: this.job.query_result_id })
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
this.update(response);
|
this.update(response);
|
||||||
this.isLoadingResult = false;
|
this.isLoadingResult = false;
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
if (tryCount === undefined) {
|
if (tryCount === undefined) {
|
||||||
tryCount = 0;
|
tryCount = 0;
|
||||||
}
|
}
|
||||||
@@ -394,9 +394,12 @@ class QueryResult {
|
|||||||
});
|
});
|
||||||
this.isLoadingResult = false;
|
this.isLoadingResult = false;
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => {
|
setTimeout(
|
||||||
this.loadResult(tryCount + 1);
|
() => {
|
||||||
}, 1000 * Math.pow(2, tryCount));
|
this.loadResult(tryCount + 1);
|
||||||
|
},
|
||||||
|
1000 * Math.pow(2, tryCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -410,19 +413,26 @@ class QueryResult {
|
|||||||
: axios.get(`api/queries/${query}/jobs/${this.job.id}`);
|
: axios.get(`api/queries/${query}/jobs/${this.job.id}`);
|
||||||
|
|
||||||
request
|
request
|
||||||
.then(jobResponse => {
|
.then((jobResponse) => {
|
||||||
this.update(jobResponse);
|
this.update(jobResponse);
|
||||||
|
|
||||||
if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") {
|
if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") {
|
||||||
loadResult();
|
loadResult();
|
||||||
} else if (this.getStatus() !== "failed") {
|
} else if (this.getStatus() !== "failed") {
|
||||||
const waitTime = tryNumber > 10 ? 3000 : 500;
|
let waitTime;
|
||||||
|
if (tryNumber <= 10) {
|
||||||
|
waitTime = 500;
|
||||||
|
} else if (tryNumber <= 50) {
|
||||||
|
waitTime = 1000;
|
||||||
|
} else {
|
||||||
|
waitTime = 3000;
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.refreshStatus(query, parameters, tryNumber + 1);
|
this.refreshStatus(query, parameters, tryNumber + 1);
|
||||||
}, waitTime);
|
}, waitTime);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
logger("Connection error", error);
|
logger("Connection error", error);
|
||||||
// TODO: use QueryResultError, or better yet: exception/reject of promise.
|
// TODO: use QueryResultError, or better yet: exception/reject of promise.
|
||||||
this.update({
|
this.update({
|
||||||
@@ -451,14 +461,14 @@ class QueryResult {
|
|||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge })
|
.post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge })
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
queryResult.update(response);
|
queryResult.update(response);
|
||||||
|
|
||||||
if ("job" in response) {
|
if ("job" in response) {
|
||||||
queryResult.refreshStatus(id, parameters);
|
queryResult.refreshStatus(id, parameters);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
handleErrorResponse(queryResult, error);
|
handleErrorResponse(queryResult, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -481,14 +491,14 @@ class QueryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QueryResultResource.post(params)
|
QueryResultResource.post(params)
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
queryResult.update(response);
|
queryResult.update(response);
|
||||||
|
|
||||||
if ("job" in response) {
|
if ("job" in response) {
|
||||||
queryResult.refreshStatus(query, parameters);
|
queryResult.refreshStatus(query, parameters);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
handleErrorResponse(queryResult, error);
|
handleErrorResponse(queryResult, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies, no-console */
|
/* eslint-disable import/no-extraneous-dependencies, no-console */
|
||||||
const { find } = require("lodash");
|
const { find } = require("lodash");
|
||||||
const atob = require("atob");
|
|
||||||
const { execSync } = require("child_process");
|
const { execSync } = require("child_process");
|
||||||
const { get, post } = require("request").defaults({ jar: true });
|
const { get, post } = require("request").defaults({ jar: true });
|
||||||
const { seedData } = require("./seed-data");
|
const { seedData } = require("./seed-data");
|
||||||
@@ -60,23 +59,11 @@ function stopServer() {
|
|||||||
|
|
||||||
function runCypressCI() {
|
function runCypressCI() {
|
||||||
const {
|
const {
|
||||||
PERCY_TOKEN_ENCODED,
|
|
||||||
CYPRESS_PROJECT_ID_ENCODED,
|
|
||||||
CYPRESS_RECORD_KEY_ENCODED,
|
|
||||||
GITHUB_REPOSITORY,
|
GITHUB_REPOSITORY,
|
||||||
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
|
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
if (GITHUB_REPOSITORY === "getredash/redash") {
|
if (GITHUB_REPOSITORY === "getredash/redash" && process.env.CYPRESS_RECORD_KEY) {
|
||||||
if (PERCY_TOKEN_ENCODED) {
|
|
||||||
process.env.PERCY_TOKEN = atob(`${PERCY_TOKEN_ENCODED}`);
|
|
||||||
}
|
|
||||||
if (CYPRESS_PROJECT_ID_ENCODED) {
|
|
||||||
process.env.CYPRESS_PROJECT_ID = atob(`${CYPRESS_PROJECT_ID_ENCODED}`);
|
|
||||||
}
|
|
||||||
if (CYPRESS_RECORD_KEY_ENCODED) {
|
|
||||||
process.env.CYPRESS_RECORD_KEY = atob(`${CYPRESS_RECORD_KEY_ENCODED}`);
|
|
||||||
}
|
|
||||||
process.env.CYPRESS_OPTIONS = "--record";
|
process.env.CYPRESS_OPTIONS = "--record";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("Dashboard", () => {
|
|||||||
cy.getByTestId("DashboardSaveButton").click();
|
cy.getByTestId("DashboardSaveButton").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.wait("@NewDashboard").then(xhr => {
|
cy.wait("@NewDashboard").then((xhr) => {
|
||||||
const id = Cypress._.get(xhr, "response.body.id");
|
const id = Cypress._.get(xhr, "response.body.id");
|
||||||
assert.isDefined(id, "Dashboard api call returns id");
|
assert.isDefined(id, "Dashboard api call returns id");
|
||||||
|
|
||||||
@@ -40,13 +40,9 @@ describe("Dashboard", () => {
|
|||||||
|
|
||||||
cy.getByTestId("DashboardMoreButton").click();
|
cy.getByTestId("DashboardMoreButton").click();
|
||||||
|
|
||||||
cy.getByTestId("DashboardMoreButtonMenu")
|
cy.getByTestId("DashboardMoreButtonMenu").contains("Archive").click();
|
||||||
.contains("Archive")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get(".ant-modal .ant-btn")
|
cy.get(".ant-modal .ant-btn").contains("Archive").click({ force: true });
|
||||||
.contains("Archive")
|
|
||||||
.click({ force: true });
|
|
||||||
cy.get(".label-tag-archived").should("exist");
|
cy.get(".label-tag-archived").should("exist");
|
||||||
|
|
||||||
cy.visit("/dashboards");
|
cy.visit("/dashboards");
|
||||||
@@ -60,7 +56,7 @@ describe("Dashboard", () => {
|
|||||||
cy.server();
|
cy.server();
|
||||||
cy.route("GET", "**/api/dashboards/*").as("LoadDashboard");
|
cy.route("GET", "**/api/dashboards/*").as("LoadDashboard");
|
||||||
cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => {
|
cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => {
|
||||||
[`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach(url => {
|
[`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach((url) => {
|
||||||
cy.visit(url);
|
cy.visit(url);
|
||||||
cy.wait("@LoadDashboard");
|
cy.wait("@LoadDashboard");
|
||||||
cy.getByTestId(`DashboardId${id}Container`).should("exist");
|
cy.getByTestId(`DashboardId${id}Container`).should("exist");
|
||||||
@@ -72,7 +68,7 @@ describe("Dashboard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
context("viewport width is at 800px", () => {
|
context("viewport width is at 800px", () => {
|
||||||
before(function() {
|
before(function () {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.createDashboard("Foo Bar")
|
cy.createDashboard("Foo Bar")
|
||||||
.then(({ id }) => {
|
.then(({ id }) => {
|
||||||
@@ -80,49 +76,42 @@ describe("Dashboard", () => {
|
|||||||
this.dashboardEditUrl = `/dashboards/${id}?edit`;
|
this.dashboardEditUrl = `/dashboards/${id}?edit`;
|
||||||
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
|
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
|
||||||
})
|
})
|
||||||
.then(elTestId => {
|
.then((elTestId) => {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
cy.getByTestId(elTestId).as("textboxEl");
|
cy.getByTestId(elTestId).as("textboxEl");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(function() {
|
beforeEach(function () {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
cy.viewport(800 + menuWidth, 800);
|
cy.viewport(800 + menuWidth, 800);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows widgets with full width", () => {
|
it("shows widgets with full width", () => {
|
||||||
cy.get("@textboxEl").should($el => {
|
cy.get("@textboxEl").should(($el) => {
|
||||||
expect($el.width()).to.eq(770);
|
expect($el.width()).to.eq(770);
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.viewport(801 + menuWidth, 800);
|
cy.viewport(801 + menuWidth, 800);
|
||||||
cy.get("@textboxEl").should($el => {
|
cy.get("@textboxEl").should(($el) => {
|
||||||
expect($el.width()).to.eq(378);
|
expect($el.width()).to.eq(182);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides edit option", () => {
|
it("hides edit option", () => {
|
||||||
cy.getByTestId("DashboardMoreButton")
|
cy.getByTestId("DashboardMoreButton").click().should("be.visible");
|
||||||
.click()
|
|
||||||
.should("be.visible");
|
|
||||||
|
|
||||||
cy.getByTestId("DashboardMoreButtonMenu")
|
cy.getByTestId("DashboardMoreButtonMenu").contains("Edit").as("editButton").should("not.be.visible");
|
||||||
.contains("Edit")
|
|
||||||
.as("editButton")
|
|
||||||
.should("not.be.visible");
|
|
||||||
|
|
||||||
cy.viewport(801 + menuWidth, 800);
|
cy.viewport(801 + menuWidth, 800);
|
||||||
cy.get("@editButton").should("be.visible");
|
cy.get("@editButton").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables edit mode", function() {
|
it("disables edit mode", function () {
|
||||||
cy.viewport(801 + menuWidth, 800);
|
cy.viewport(801 + menuWidth, 800);
|
||||||
cy.visit(this.dashboardEditUrl);
|
cy.visit(this.dashboardEditUrl);
|
||||||
cy.contains("button", "Done Editing")
|
cy.contains("button", "Done Editing").as("saveButton").should("exist");
|
||||||
.as("saveButton")
|
|
||||||
.should("exist");
|
|
||||||
|
|
||||||
cy.viewport(800 + menuWidth, 800);
|
cy.viewport(800 + menuWidth, 800);
|
||||||
cy.contains("button", "Done Editing").should("not.exist");
|
cy.contains("button", "Done Editing").should("not.exist");
|
||||||
@@ -130,14 +119,14 @@ describe("Dashboard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
context("viewport width is at 767px", () => {
|
context("viewport width is at 767px", () => {
|
||||||
before(function() {
|
before(function () {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.createDashboard("Foo Bar").then(({ id }) => {
|
cy.createDashboard("Foo Bar").then(({ id }) => {
|
||||||
this.dashboardUrl = `/dashboards/${id}`;
|
this.dashboardUrl = `/dashboards/${id}`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(function() {
|
beforeEach(function () {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
cy.viewport(767, 800);
|
cy.viewport(767, 800);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getWidgetTestId, editDashboard, resizeBy } from "../../support/dashboar
|
|||||||
const menuWidth = 80;
|
const menuWidth = 80;
|
||||||
|
|
||||||
describe("Grid compliant widgets", () => {
|
describe("Grid compliant widgets", () => {
|
||||||
beforeEach(function() {
|
beforeEach(function () {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.viewport(1215 + menuWidth, 800);
|
cy.viewport(1215 + menuWidth, 800);
|
||||||
cy.createDashboard("Foo Bar")
|
cy.createDashboard("Foo Bar")
|
||||||
@@ -13,7 +13,7 @@ describe("Grid compliant widgets", () => {
|
|||||||
this.dashboardUrl = `/dashboards/${id}`;
|
this.dashboardUrl = `/dashboards/${id}`;
|
||||||
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
|
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
|
||||||
})
|
})
|
||||||
.then(elTestId => {
|
.then((elTestId) => {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
cy.getByTestId(elTestId).as("textboxEl");
|
cy.getByTestId(elTestId).as("textboxEl");
|
||||||
});
|
});
|
||||||
@@ -27,7 +27,7 @@ describe("Grid compliant widgets", () => {
|
|||||||
|
|
||||||
it("stays put when dragged under snap threshold", () => {
|
it("stays put when dragged under snap threshold", () => {
|
||||||
cy.get("@textboxEl")
|
cy.get("@textboxEl")
|
||||||
.dragBy(90)
|
.dragBy(30)
|
||||||
.invoke("offset")
|
.invoke("offset")
|
||||||
.should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15
|
.should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15
|
||||||
});
|
});
|
||||||
@@ -36,14 +36,14 @@ describe("Grid compliant widgets", () => {
|
|||||||
cy.get("@textboxEl")
|
cy.get("@textboxEl")
|
||||||
.dragBy(110)
|
.dragBy(110)
|
||||||
.invoke("offset")
|
.invoke("offset")
|
||||||
.should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215
|
.should("have.property", "left", 115 + menuWidth); // moved by 100, 15 -> 115
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves two columns when dragged over snap threshold", () => {
|
it("moves two columns when dragged over snap threshold", () => {
|
||||||
cy.get("@textboxEl")
|
cy.get("@textboxEl")
|
||||||
.dragBy(330)
|
.dragBy(200)
|
||||||
.invoke("offset")
|
.invoke("offset")
|
||||||
.should("have.property", "left", 415 + menuWidth); // moved by 400, 15 -> 415
|
.should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ describe("Grid compliant widgets", () => {
|
|||||||
cy.route("POST", "**/api/widgets/*").as("WidgetSave");
|
cy.route("POST", "**/api/widgets/*").as("WidgetSave");
|
||||||
|
|
||||||
editDashboard();
|
editDashboard();
|
||||||
cy.get("@textboxEl").dragBy(330);
|
cy.get("@textboxEl").dragBy(100);
|
||||||
cy.wait("@WidgetSave");
|
cy.wait("@WidgetSave");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -64,24 +64,24 @@ describe("Grid compliant widgets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stays put when dragged under snap threshold", () => {
|
it("stays put when dragged under snap threshold", () => {
|
||||||
resizeBy(cy.get("@textboxEl"), 90)
|
resizeBy(cy.get("@textboxEl"), 30)
|
||||||
.then(() => cy.get("@textboxEl"))
|
.then(() => cy.get("@textboxEl"))
|
||||||
.invoke("width")
|
.invoke("width")
|
||||||
.should("eq", 585); // no change, 585 -> 585
|
.should("eq", 285); // no change, 285 -> 285
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves one column when dragged over snap threshold", () => {
|
it("moves one column when dragged over snap threshold", () => {
|
||||||
resizeBy(cy.get("@textboxEl"), 110)
|
resizeBy(cy.get("@textboxEl"), 110)
|
||||||
.then(() => cy.get("@textboxEl"))
|
.then(() => cy.get("@textboxEl"))
|
||||||
.invoke("width")
|
.invoke("width")
|
||||||
.should("eq", 785); // resized by 200, 585 -> 785
|
.should("eq", 385); // resized by 200, 185 -> 385
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves two columns when dragged over snap threshold", () => {
|
it("moves two columns when dragged over snap threshold", () => {
|
||||||
resizeBy(cy.get("@textboxEl"), 400)
|
resizeBy(cy.get("@textboxEl"), 400)
|
||||||
.then(() => cy.get("@textboxEl"))
|
.then(() => cy.get("@textboxEl"))
|
||||||
.invoke("width")
|
.invoke("width")
|
||||||
.should("eq", 985); // resized by 400, 585 -> 985
|
.should("eq", 685); // resized by 400, 285 -> 685
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,16 +101,16 @@ describe("Grid compliant widgets", () => {
|
|||||||
resizeBy(cy.get("@textboxEl"), 0, 30)
|
resizeBy(cy.get("@textboxEl"), 0, 30)
|
||||||
.then(() => cy.get("@textboxEl"))
|
.then(() => cy.get("@textboxEl"))
|
||||||
.invoke("height")
|
.invoke("height")
|
||||||
.should("eq", 185); // resized by 50, , 135 -> 185
|
.should("eq", 185);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shrinks to minimum", () => {
|
it("shrinks to minimum", () => {
|
||||||
cy.get("@textboxEl")
|
cy.get("@textboxEl")
|
||||||
.then($el => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0
|
.then(($el) => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0
|
||||||
.then(() => cy.get("@textboxEl"))
|
.then(() => cy.get("@textboxEl"))
|
||||||
.should($el => {
|
.should(($el) => {
|
||||||
expect($el.width()).to.eq(185); // min textbox width
|
expect($el.width()).to.eq(185); // min textbox width
|
||||||
expect($el.height()).to.eq(35); // min textbox height
|
expect($el.height()).to.eq(85); // min textbox height
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { getWidgetTestId, editDashboard } from "../../support/dashboard";
|
import { getWidgetTestId, editDashboard } from "../../support/dashboard";
|
||||||
|
|
||||||
describe("Textbox", () => {
|
describe("Textbox", () => {
|
||||||
beforeEach(function() {
|
beforeEach(function () {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.createDashboard("Foo Bar").then(({ id }) => {
|
cy.createDashboard("Foo Bar").then(({ id }) => {
|
||||||
this.dashboardId = id;
|
this.dashboardId = id;
|
||||||
@@ -12,12 +12,10 @@ describe("Textbox", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const confirmDeletionInModal = () => {
|
const confirmDeletionInModal = () => {
|
||||||
cy.get(".ant-modal .ant-btn")
|
cy.get(".ant-modal .ant-btn").contains("Delete").click({ force: true });
|
||||||
.contains("Delete")
|
|
||||||
.click({ force: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it("adds textbox", function() {
|
it("adds textbox", function () {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
editDashboard();
|
editDashboard();
|
||||||
cy.getByTestId("AddTextboxButton").click();
|
cy.getByTestId("AddTextboxButton").click();
|
||||||
@@ -29,10 +27,10 @@ describe("Textbox", () => {
|
|||||||
cy.get(".widget-text").should("exist");
|
cy.get(".widget-text").should("exist");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes textbox by X button", function() {
|
it("removes textbox by X button", function () {
|
||||||
cy.addTextbox(this.dashboardId, "Hello World!")
|
cy.addTextbox(this.dashboardId, "Hello World!")
|
||||||
.then(getWidgetTestId)
|
.then(getWidgetTestId)
|
||||||
.then(elTestId => {
|
.then((elTestId) => {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
editDashboard();
|
editDashboard();
|
||||||
|
|
||||||
@@ -45,32 +43,30 @@ describe("Textbox", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes textbox by menu", function() {
|
it("removes textbox by menu", function () {
|
||||||
cy.addTextbox(this.dashboardId, "Hello World!")
|
cy.addTextbox(this.dashboardId, "Hello World!")
|
||||||
.then(getWidgetTestId)
|
.then(getWidgetTestId)
|
||||||
.then(elTestId => {
|
.then((elTestId) => {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
cy.getByTestId(elTestId).within(() => {
|
cy.getByTestId(elTestId).within(() => {
|
||||||
cy.getByTestId("WidgetDropdownButton").click();
|
cy.getByTestId("WidgetDropdownButton").click();
|
||||||
});
|
});
|
||||||
cy.getByTestId("WidgetDropdownButtonMenu")
|
cy.getByTestId("WidgetDropdownButtonMenu").contains("Remove from Dashboard").click();
|
||||||
.contains("Remove from Dashboard")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
confirmDeletionInModal();
|
confirmDeletionInModal();
|
||||||
cy.getByTestId(elTestId).should("not.exist");
|
cy.getByTestId(elTestId).should("not.exist");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows opening menu after removal", function() {
|
it("allows opening menu after removal", function () {
|
||||||
let elTestId1;
|
let elTestId1;
|
||||||
cy.addTextbox(this.dashboardId, "txb 1")
|
cy.addTextbox(this.dashboardId, "txb 1")
|
||||||
.then(getWidgetTestId)
|
.then(getWidgetTestId)
|
||||||
.then(elTestId => {
|
.then((elTestId) => {
|
||||||
elTestId1 = elTestId;
|
elTestId1 = elTestId;
|
||||||
return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId);
|
return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId);
|
||||||
})
|
})
|
||||||
.then(elTestId2 => {
|
.then((elTestId2) => {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
editDashboard();
|
editDashboard();
|
||||||
|
|
||||||
@@ -97,10 +93,10 @@ describe("Textbox", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("edits textbox", function() {
|
it("edits textbox", function () {
|
||||||
cy.addTextbox(this.dashboardId, "Hello World!")
|
cy.addTextbox(this.dashboardId, "Hello World!")
|
||||||
.then(getWidgetTestId)
|
.then(getWidgetTestId)
|
||||||
.then(elTestId => {
|
.then((elTestId) => {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
cy.getByTestId(elTestId)
|
cy.getByTestId(elTestId)
|
||||||
.as("textboxEl")
|
.as("textboxEl")
|
||||||
@@ -108,17 +104,13 @@ describe("Textbox", () => {
|
|||||||
cy.getByTestId("WidgetDropdownButton").click();
|
cy.getByTestId("WidgetDropdownButton").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
cy.getByTestId("WidgetDropdownButtonMenu")
|
cy.getByTestId("WidgetDropdownButtonMenu").contains("Edit").click();
|
||||||
.contains("Edit")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
const newContent = "[edited]";
|
const newContent = "[edited]";
|
||||||
cy.getByTestId("TextboxDialog")
|
cy.getByTestId("TextboxDialog")
|
||||||
.should("exist")
|
.should("exist")
|
||||||
.within(() => {
|
.within(() => {
|
||||||
cy.get("textarea")
|
cy.get("textarea").clear().type(newContent);
|
||||||
.clear()
|
|
||||||
.type(newContent);
|
|
||||||
cy.contains("button", "Save").click();
|
cy.contains("button", "Save").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +118,7 @@ describe("Textbox", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders textbox according to position configuration", function() {
|
it("renders textbox according to position configuration", function () {
|
||||||
const id = this.dashboardId;
|
const id = this.dashboardId;
|
||||||
const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 };
|
const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 };
|
||||||
const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 };
|
const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 };
|
||||||
@@ -135,15 +127,15 @@ describe("Textbox", () => {
|
|||||||
cy.addTextbox(id, "x", { position: txb1Pos })
|
cy.addTextbox(id, "x", { position: txb1Pos })
|
||||||
.then(() => cy.addTextbox(id, "x", { position: txb2Pos }))
|
.then(() => cy.addTextbox(id, "x", { position: txb2Pos }))
|
||||||
.then(getWidgetTestId)
|
.then(getWidgetTestId)
|
||||||
.then(elTestId => {
|
.then((elTestId) => {
|
||||||
cy.visit(this.dashboardUrl);
|
cy.visit(this.dashboardUrl);
|
||||||
return cy.getByTestId(elTestId);
|
return cy.getByTestId(elTestId);
|
||||||
})
|
})
|
||||||
.should($el => {
|
.should(($el) => {
|
||||||
const { top, left } = $el.offset();
|
const { top, left } = $el.offset();
|
||||||
expect(top).to.be.oneOf([162, 162.015625]);
|
expect(top).to.be.oneOf([162, 162.015625]);
|
||||||
expect(left).to.eq(282);
|
expect(left).to.eq(188);
|
||||||
expect($el.width()).to.eq(545);
|
expect($el.width()).to.eq(265);
|
||||||
expect($el.height()).to.eq(185);
|
expect($el.height()).to.eq(185);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import { dragParam } from "../../support/parameters";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
function openAndSearchAntdDropdown(testId, paramOption) {
|
function openAndSearchAntdDropdown(testId, paramOption) {
|
||||||
cy.getByTestId(testId)
|
cy.getByTestId(testId).find(".ant-select-selection-search-input").type(paramOption, { force: true });
|
||||||
.find(".ant-select-selection-search-input")
|
|
||||||
.type(paramOption, { force: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Parameter", () => {
|
describe("Parameter", () => {
|
||||||
const expectDirtyStateChange = edit => {
|
const expectDirtyStateChange = (edit) => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter")
|
||||||
.find(".parameter-input")
|
.find(".parameter-input")
|
||||||
.should($el => {
|
.should(($el) => {
|
||||||
assert.isUndefined($el.data("dirty"));
|
assert.isUndefined($el.data("dirty"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,7 +17,7 @@ describe("Parameter", () => {
|
|||||||
|
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter")
|
||||||
.find(".parameter-input")
|
.find(".parameter-input")
|
||||||
.should($el => {
|
.should(($el) => {
|
||||||
assert.isTrue($el.data("dirty"));
|
assert.isTrue($el.data("dirty"));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -42,9 +40,7 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates the results after clicking Apply", () => {
|
it("updates the results after clicking Apply", () => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
|
||||||
.find("input")
|
|
||||||
.type("Redash");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
@@ -53,13 +49,66 @@ describe("Parameter", () => {
|
|||||||
|
|
||||||
it("sets dirty state when edited", () => {
|
it("sets dirty state when edited", () => {
|
||||||
expectDirtyStateChange(() => {
|
expectDirtyStateChange(() => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
|
||||||
.find("input")
|
|
||||||
.type("Redash");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Text Pattern Parameter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const queryData = {
|
||||||
|
name: "Text Pattern Parameter",
|
||||||
|
query: "SELECT '{{test-parameter}}' AS parameter",
|
||||||
|
options: {
|
||||||
|
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text-pattern", regex: "a.*a" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the results after clicking Apply", () => {
|
||||||
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
|
cy.getByTestId("TableVisualization").should("contain", "arta");
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arounda");
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
|
cy.getByTestId("TableVisualization").should("contain", "arounda");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error message with invalid query request", () => {
|
||||||
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}abcab");
|
||||||
|
|
||||||
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
|
cy.getByTestId("QueryExecutionStatus").should("exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets dirty state when edited", () => {
|
||||||
|
expectDirtyStateChange(() => {
|
||||||
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't let user save invalid regex", () => {
|
||||||
|
cy.get(".fa-cog").click();
|
||||||
|
cy.getByTestId("RegexPatternInput").type("{selectall}[");
|
||||||
|
cy.contains("Invalid Regex Pattern").should("exist");
|
||||||
|
cy.getByTestId("SaveParameterSettings").click();
|
||||||
|
cy.get(".fa-cog").click();
|
||||||
|
cy.getByTestId("RegexPatternInput").should("not.equal", "[");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Number Parameter", () => {
|
describe("Number Parameter", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const queryData = {
|
const queryData = {
|
||||||
@@ -74,17 +123,13 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates the results after clicking Apply", () => {
|
it("updates the results after clicking Apply", () => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
|
||||||
.find("input")
|
|
||||||
.type("{selectall}42");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
cy.getByTestId("TableVisualization").should("contain", 42);
|
cy.getByTestId("TableVisualization").should("contain", 42);
|
||||||
|
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}31415");
|
||||||
.find("input")
|
|
||||||
.type("{selectall}31415");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
@@ -93,9 +138,7 @@ describe("Parameter", () => {
|
|||||||
|
|
||||||
it("sets dirty state when edited", () => {
|
it("sets dirty state when edited", () => {
|
||||||
expectDirtyStateChange(() => {
|
expectDirtyStateChange(() => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
|
||||||
.find("input")
|
|
||||||
.type("{selectall}42");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -119,10 +162,7 @@ describe("Parameter", () => {
|
|||||||
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
||||||
|
|
||||||
// only the filtered option should be on the DOM
|
// only the filtered option should be on the DOM
|
||||||
cy.get(".ant-select-item-option")
|
cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
|
||||||
.should("have.length", 1)
|
|
||||||
.and("contain", "value2")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
// ensure that query is being executed
|
// ensure that query is being executed
|
||||||
@@ -140,12 +180,10 @@ describe("Parameter", () => {
|
|||||||
SaveParameterSettings
|
SaveParameterSettings
|
||||||
`);
|
`);
|
||||||
|
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find(".ant-select-selection-search").click();
|
||||||
.find(".ant-select-selection-search")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// select all unselected options
|
// select all unselected options
|
||||||
cy.get(".ant-select-item-option").each($option => {
|
cy.get(".ant-select-item-option").each(($option) => {
|
||||||
if (!$option.hasClass("ant-select-item-option-selected")) {
|
if (!$option.hasClass("ant-select-item-option-selected")) {
|
||||||
cy.wrap($option).click();
|
cy.wrap($option).click();
|
||||||
}
|
}
|
||||||
@@ -160,9 +198,7 @@ describe("Parameter", () => {
|
|||||||
|
|
||||||
it("sets dirty state when edited", () => {
|
it("sets dirty state when edited", () => {
|
||||||
expectDirtyStateChange(() => {
|
expectDirtyStateChange(() => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
|
||||||
.find(".ant-select")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.contains(".ant-select-item-option", "value2").click();
|
cy.contains(".ant-select-item-option", "value2").click();
|
||||||
});
|
});
|
||||||
@@ -176,7 +212,7 @@ describe("Parameter", () => {
|
|||||||
name: "Dropdown Query",
|
name: "Dropdown Query",
|
||||||
query: "",
|
query: "",
|
||||||
};
|
};
|
||||||
cy.createQuery(dropdownQueryData, true).then(dropdownQuery => {
|
cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
|
||||||
const queryData = {
|
const queryData = {
|
||||||
name: "Query Based Dropdown Parameter",
|
name: "Query Based Dropdown Parameter",
|
||||||
query: "SELECT '{{test-parameter}}' AS parameter",
|
query: "SELECT '{{test-parameter}}' AS parameter",
|
||||||
@@ -208,7 +244,7 @@ describe("Parameter", () => {
|
|||||||
SELECT 'value2' AS name, 2 AS value UNION ALL
|
SELECT 'value2' AS name, 2 AS value UNION ALL
|
||||||
SELECT 'value3' AS name, 3 AS value`,
|
SELECT 'value3' AS name, 3 AS value`,
|
||||||
};
|
};
|
||||||
cy.createQuery(dropdownQueryData, true).then(dropdownQuery => {
|
cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
|
||||||
const queryData = {
|
const queryData = {
|
||||||
name: "Query Based Dropdown Parameter",
|
name: "Query Based Dropdown Parameter",
|
||||||
query: "SELECT '{{test-parameter}}' AS parameter",
|
query: "SELECT '{{test-parameter}}' AS parameter",
|
||||||
@@ -234,10 +270,7 @@ describe("Parameter", () => {
|
|||||||
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
||||||
|
|
||||||
// only the filtered option should be on the DOM
|
// only the filtered option should be on the DOM
|
||||||
cy.get(".ant-select-item-option")
|
cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
|
||||||
.should("have.length", 1)
|
|
||||||
.and("contain", "value2")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
// ensure that query is being executed
|
// ensure that query is being executed
|
||||||
@@ -255,12 +288,10 @@ describe("Parameter", () => {
|
|||||||
SaveParameterSettings
|
SaveParameterSettings
|
||||||
`);
|
`);
|
||||||
|
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
|
||||||
.find(".ant-select")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// make sure all options are unselected and select all
|
// make sure all options are unselected and select all
|
||||||
cy.get(".ant-select-item-option").each($option => {
|
cy.get(".ant-select-item-option").each(($option) => {
|
||||||
expect($option).not.to.have.class("ant-select-dropdown-menu-item-selected");
|
expect($option).not.to.have.class("ant-select-dropdown-menu-item-selected");
|
||||||
cy.wrap($option).click();
|
cy.wrap($option).click();
|
||||||
});
|
});
|
||||||
@@ -274,14 +305,10 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectCalendarDate = date => {
|
const selectCalendarDate = (date) => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").click();
|
||||||
.find("input")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get(".ant-picker-panel")
|
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", date).click();
|
||||||
.contains(".ant-picker-cell-inner", date)
|
|
||||||
.click();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Date Parameter", () => {
|
describe("Date Parameter", () => {
|
||||||
@@ -303,10 +330,10 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cy.clock().then(clock => clock.restore());
|
cy.clock().then((clock) => clock.restore());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the results after selecting a date", function() {
|
it("updates the results after selecting a date", function () {
|
||||||
selectCalendarDate("15");
|
selectCalendarDate("15");
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
@@ -314,12 +341,10 @@ describe("Parameter", () => {
|
|||||||
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("15/MM/YY"));
|
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("15/MM/YY"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows picking a dynamic date", function() {
|
it("allows picking a dynamic date", function () {
|
||||||
cy.getByTestId("DynamicButton").click();
|
cy.getByTestId("DynamicButton").click();
|
||||||
|
|
||||||
cy.getByTestId("DynamicButtonMenu")
|
cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
|
||||||
.contains("Today/Now")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
@@ -350,14 +375,11 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cy.clock().then(clock => clock.restore());
|
cy.clock().then((clock) => clock.restore());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the results after selecting a date and clicking in ok", function() {
|
it("updates the results after selecting a date and clicking in ok", function () {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
|
||||||
.find("input")
|
|
||||||
.as("Input")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
selectCalendarDate("15");
|
selectCalendarDate("15");
|
||||||
|
|
||||||
@@ -368,27 +390,20 @@ describe("Parameter", () => {
|
|||||||
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-15 HH:mm"));
|
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-15 HH:mm"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the current datetime after clicking in Now", function() {
|
it("shows the current datetime after clicking in Now", function () {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
|
||||||
.find("input")
|
|
||||||
.as("Input")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get(".ant-picker-panel")
|
cy.get(".ant-picker-panel").contains("Now").click();
|
||||||
.contains("Now")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-DD HH:mm"));
|
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-DD HH:mm"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows picking a dynamic date", function() {
|
it("allows picking a dynamic date", function () {
|
||||||
cy.getByTestId("DynamicButton").click();
|
cy.getByTestId("DynamicButton").click();
|
||||||
|
|
||||||
cy.getByTestId("DynamicButtonMenu")
|
cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
|
||||||
.contains("Today/Now")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
@@ -397,31 +412,20 @@ describe("Parameter", () => {
|
|||||||
|
|
||||||
it("sets dirty state when edited", () => {
|
it("sets dirty state when edited", () => {
|
||||||
expectDirtyStateChange(() => {
|
expectDirtyStateChange(() => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").click();
|
||||||
.find("input")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get(".ant-picker-panel")
|
cy.get(".ant-picker-panel").contains("Now").click();
|
||||||
.contains("Now")
|
|
||||||
.click();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Date Range Parameter", () => {
|
describe("Date Range Parameter", () => {
|
||||||
const selectCalendarDateRange = (startDate, endDate) => {
|
const selectCalendarDateRange = (startDate, endDate) => {
|
||||||
cy.getByTestId("ParameterName-test-parameter")
|
cy.getByTestId("ParameterName-test-parameter").find("input").first().click();
|
||||||
.find("input")
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get(".ant-picker-panel")
|
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", startDate).click();
|
||||||
.contains(".ant-picker-cell-inner", startDate)
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get(".ant-picker-panel")
|
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", endDate).click();
|
||||||
.contains(".ant-picker-cell-inner", endDate)
|
|
||||||
.click();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -442,10 +446,10 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cy.clock().then(clock => clock.restore());
|
cy.clock().then((clock) => clock.restore());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the results after selecting a date range", function() {
|
it("updates the results after selecting a date range", function () {
|
||||||
selectCalendarDateRange("15", "20");
|
selectCalendarDateRange("15", "20");
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
@@ -457,12 +461,10 @@ describe("Parameter", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows picking a dynamic date range", function() {
|
it("allows picking a dynamic date range", function () {
|
||||||
cy.getByTestId("DynamicButton").click();
|
cy.getByTestId("DynamicButton").click();
|
||||||
|
|
||||||
cy.getByTestId("DynamicButtonMenu")
|
cy.getByTestId("DynamicButtonMenu").contains("Last month").click();
|
||||||
.contains("Last month")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").click();
|
cy.getByTestId("ParameterApplyButton").click();
|
||||||
|
|
||||||
@@ -479,15 +481,10 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Apply Changes", () => {
|
describe("Apply Changes", () => {
|
||||||
const expectAppliedChanges = apply => {
|
const expectAppliedChanges = (apply) => {
|
||||||
cy.getByTestId("ParameterName-test-parameter-1")
|
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
|
||||||
.find("input")
|
|
||||||
.as("Input")
|
|
||||||
.type("Redash");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterName-test-parameter-2")
|
cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
|
||||||
.find("input")
|
|
||||||
.type("Redash");
|
|
||||||
|
|
||||||
cy.location("search").should("not.contain", "Redash");
|
cy.location("search").should("not.contain", "Redash");
|
||||||
|
|
||||||
@@ -523,10 +520,7 @@ describe("Parameter", () => {
|
|||||||
it("shows and hides according to parameter dirty state", () => {
|
it("shows and hides according to parameter dirty state", () => {
|
||||||
cy.getByTestId("ParameterApplyButton").should("not.be", "visible");
|
cy.getByTestId("ParameterApplyButton").should("not.be", "visible");
|
||||||
|
|
||||||
cy.getByTestId("ParameterName-test-parameter-1")
|
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Param").type("Redash");
|
||||||
.find("input")
|
|
||||||
.as("Param")
|
|
||||||
.type("Redash");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton").should("be.visible");
|
cy.getByTestId("ParameterApplyButton").should("be.visible");
|
||||||
|
|
||||||
@@ -536,21 +530,13 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates dirty counter", () => {
|
it("updates dirty counter", () => {
|
||||||
cy.getByTestId("ParameterName-test-parameter-1")
|
cy.getByTestId("ParameterName-test-parameter-1").find("input").type("Redash");
|
||||||
.find("input")
|
|
||||||
.type("Redash");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton")
|
cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "1");
|
||||||
.find(".ant-badge-count p.current")
|
|
||||||
.should("contain", "1");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterName-test-parameter-2")
|
cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
|
||||||
.find("input")
|
|
||||||
.type("Redash");
|
|
||||||
|
|
||||||
cy.getByTestId("ParameterApplyButton")
|
cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "2");
|
||||||
.find(".ant-badge-count p.current")
|
|
||||||
.should("contain", "2");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies changes from "Apply Changes" button', () => {
|
it('applies changes from "Apply Changes" button', () => {
|
||||||
@@ -560,16 +546,13 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('applies changes from "alt+enter" keyboard shortcut', () => {
|
it('applies changes from "alt+enter" keyboard shortcut', () => {
|
||||||
expectAppliedChanges(input => {
|
expectAppliedChanges((input) => {
|
||||||
input.type("{alt}{enter}");
|
input.type("{alt}{enter}");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables "Execute" button', () => {
|
it('disables "Execute" button', () => {
|
||||||
cy.getByTestId("ParameterName-test-parameter-1")
|
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
|
||||||
.find("input")
|
|
||||||
.as("Input")
|
|
||||||
.type("Redash");
|
|
||||||
cy.getByTestId("ExecuteButton").should("be.disabled");
|
cy.getByTestId("ExecuteButton").should("be.disabled");
|
||||||
|
|
||||||
cy.get("@Input").clear();
|
cy.get("@Input").clear();
|
||||||
@@ -594,15 +577,12 @@ describe("Parameter", () => {
|
|||||||
|
|
||||||
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
|
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
|
||||||
|
|
||||||
cy.get(".parameter-block")
|
cy.get(".parameter-block").first().invoke("width").as("paramWidth");
|
||||||
.first()
|
|
||||||
.invoke("width")
|
|
||||||
.as("paramWidth");
|
|
||||||
|
|
||||||
cy.get("body").type("{alt}D"); // hide schema browser
|
cy.get("body").type("{alt}D"); // hide schema browser
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is possible to rearrange parameters", function() {
|
it("is possible to rearrange parameters", function () {
|
||||||
cy.server();
|
cy.server();
|
||||||
cy.route("POST", "**/api/queries/*").as("QuerySave");
|
cy.route("POST", "**/api/queries/*").as("QuerySave");
|
||||||
|
|
||||||
|
|||||||
@@ -26,33 +26,33 @@ const SQL = `
|
|||||||
describe("Chart", () => {
|
describe("Chart", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.createQuery({ name: "Chart Visualization", query: SQL })
|
cy.createQuery({ name: "Chart Visualization", query: SQL }).its("id").as("queryId");
|
||||||
.its("id")
|
|
||||||
.as("queryId");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates Bar charts", function() {
|
it("creates Bar charts", function () {
|
||||||
cy.visit(`queries/${this.queryId}/source`);
|
cy.visit(`queries/${this.queryId}/source`);
|
||||||
cy.getByTestId("ExecuteButton").click();
|
cy.getByTestId("ExecuteButton").click();
|
||||||
|
|
||||||
const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => {
|
const getBarChartAssertionFunction =
|
||||||
// checks for TabbedEditor standard tabs
|
(specificBarChartAssertionFn = () => {}) =>
|
||||||
assertTabbedEditor();
|
() => {
|
||||||
|
// checks for TabbedEditor standard tabs
|
||||||
|
assertTabbedEditor();
|
||||||
|
|
||||||
// standard chart should be bar
|
// standard chart should be bar
|
||||||
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
|
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
|
||||||
|
|
||||||
// checks the plot canvas exists and is empty
|
// checks the plot canvas exists and is empty
|
||||||
assertPlotPreview("not.exist");
|
assertPlotPreview("not.exist");
|
||||||
|
|
||||||
// creates a chart and checks it is plotted
|
// creates a chart and checks it is plotted
|
||||||
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
|
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
|
||||||
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
|
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
|
||||||
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
|
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
|
||||||
assertPlotPreview("exist");
|
assertPlotPreview("exist");
|
||||||
|
|
||||||
specificBarChartAssertionFn();
|
specificBarChartAssertionFn();
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartTests = [
|
const chartTests = [
|
||||||
{
|
{
|
||||||
@@ -95,8 +95,8 @@ describe("Chart", () => {
|
|||||||
|
|
||||||
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
|
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
|
||||||
cy.visit(dashboardUrl);
|
cy.visit(dashboardUrl);
|
||||||
widgetGetters.forEach(widgetGetter => {
|
widgetGetters.forEach((widgetGetter) => {
|
||||||
cy.get(`@${widgetGetter}`).then(widget => {
|
cy.get(`@${widgetGetter}`).then((widget) => {
|
||||||
cy.getByTestId(getWidgetTestId(widget)).within(() => {
|
cy.getByTestId(getWidgetTestId(widget)).within(() => {
|
||||||
cy.get("g.points").should("exist");
|
cy.get("g.points").should("exist");
|
||||||
});
|
});
|
||||||
@@ -107,4 +107,34 @@ describe("Chart", () => {
|
|||||||
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
|
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
|
||||||
cy.percySnapshot("Visualizations - Charts - Bar");
|
cy.percySnapshot("Visualizations - Charts - Bar");
|
||||||
});
|
});
|
||||||
|
it("colors Bar charts", function () {
|
||||||
|
cy.visit(`queries/${this.queryId}/source`);
|
||||||
|
cy.getByTestId("ExecuteButton").click();
|
||||||
|
cy.getByTestId("NewVisualization").click();
|
||||||
|
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
|
||||||
|
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
|
||||||
|
cy.getByTestId("ColorScheme").click();
|
||||||
|
cy.getByTestId("ColorOptionViridis").click();
|
||||||
|
cy.getByTestId("ColorScheme").click();
|
||||||
|
cy.getByTestId("ColorOptionTableau 10").click();
|
||||||
|
cy.getByTestId("ColorScheme").click();
|
||||||
|
cy.getByTestId("ColorOptionD3 Category 10").click();
|
||||||
|
});
|
||||||
|
it("colors Pie charts", function () {
|
||||||
|
cy.visit(`queries/${this.queryId}/source`);
|
||||||
|
cy.getByTestId("ExecuteButton").click();
|
||||||
|
cy.getByTestId("NewVisualization").click();
|
||||||
|
cy.getByTestId("Chart.GlobalSeriesType").click();
|
||||||
|
cy.getByTestId("Chart.ChartType.pie").click();
|
||||||
|
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
|
||||||
|
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
|
||||||
|
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
|
||||||
|
cy.getByTestId("ColorScheme").click();
|
||||||
|
cy.getByTestId("ColorOptionViridis").click();
|
||||||
|
cy.getByTestId("ColorScheme").click();
|
||||||
|
cy.getByTestId("ColorOptionTableau 10").click();
|
||||||
|
cy.getByTestId("ColorScheme").click();
|
||||||
|
cy.getByTestId("ColorOptionD3 Category 10").click();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ function prepareVisualization(query, type, name, options) {
|
|||||||
cy.get("body").type("{alt}D");
|
cy.get("body").type("{alt}D");
|
||||||
|
|
||||||
// do some pre-checks here to ensure that visualization was created and is visible
|
// do some pre-checks here to ensure that visualization was created and is visible
|
||||||
cy.getByTestId("TableVisualization")
|
cy.getByTestId("TableVisualization").should("exist").find("table").should("exist");
|
||||||
.should("exist")
|
|
||||||
.find("table")
|
|
||||||
.should("exist");
|
|
||||||
|
|
||||||
return cy.then(() => ({ queryId, visualizationId }));
|
return cy.then(() => ({ queryId, visualizationId }));
|
||||||
});
|
});
|
||||||
@@ -53,7 +50,7 @@ describe("Table", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Sorting data", () => {
|
describe("Sorting data", () => {
|
||||||
beforeEach(function() {
|
beforeEach(function () {
|
||||||
const { query, config } = MultiColumnSort;
|
const { query, config } = MultiColumnSort;
|
||||||
prepareVisualization(query, "TABLE", "Sort data", config).then(({ queryId, visualizationId }) => {
|
prepareVisualization(query, "TABLE", "Sort data", config).then(({ queryId, visualizationId }) => {
|
||||||
this.queryId = queryId;
|
this.queryId = queryId;
|
||||||
@@ -61,39 +58,22 @@ describe("Table", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts data by a single column", function() {
|
it("sorts data by a single column", function () {
|
||||||
cy.getByTestId("TableVisualization")
|
cy.getByTestId("TableVisualization").find("table th").contains("c").should("exist").click();
|
||||||
.find("table th")
|
|
||||||
.contains("c")
|
|
||||||
.should("exist")
|
|
||||||
.click();
|
|
||||||
cy.percySnapshot("Visualizations - Table (Single-column sort)", { widths: [viewportWidth] });
|
cy.percySnapshot("Visualizations - Table (Single-column sort)", { widths: [viewportWidth] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts data by a multiple columns", function() {
|
it("sorts data by a multiple columns", function () {
|
||||||
cy.getByTestId("TableVisualization")
|
cy.getByTestId("TableVisualization").find("table th").contains("a").should("exist").click();
|
||||||
.find("table th")
|
|
||||||
.contains("a")
|
|
||||||
.should("exist")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get("body").type("{shift}", { release: false });
|
cy.get("body").type("{shift}", { release: false });
|
||||||
cy.getByTestId("TableVisualization")
|
cy.getByTestId("TableVisualization").find("table th").contains("b").should("exist").click();
|
||||||
.find("table th")
|
|
||||||
.contains("b")
|
|
||||||
.should("exist")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.percySnapshot("Visualizations - Table (Multi-column sort)", { widths: [viewportWidth] });
|
cy.percySnapshot("Visualizations - Table (Multi-column sort)", { widths: [viewportWidth] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts data in reverse order", function() {
|
it("sorts data in reverse order", function () {
|
||||||
cy.getByTestId("TableVisualization")
|
cy.getByTestId("TableVisualization").find("table th").contains("c").should("exist").click().click();
|
||||||
.find("table th")
|
|
||||||
.contains("c")
|
|
||||||
.should("exist")
|
|
||||||
.click()
|
|
||||||
.click();
|
|
||||||
cy.percySnapshot("Visualizations - Table (Single-column reverse sort)", { widths: [viewportWidth] });
|
cy.percySnapshot("Visualizations - Table (Single-column reverse sort)", { widths: [viewportWidth] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -101,10 +81,7 @@ describe("Table", () => {
|
|||||||
it("searches in multiple columns", () => {
|
it("searches in multiple columns", () => {
|
||||||
const { query, config } = SearchInData;
|
const { query, config } = SearchInData;
|
||||||
prepareVisualization(query, "TABLE", "Search", config).then(({ visualizationId }) => {
|
prepareVisualization(query, "TABLE", "Search", config).then(({ visualizationId }) => {
|
||||||
cy.getByTestId("TableVisualization")
|
cy.getByTestId("TableVisualization").find("table input").should("exist").type("test");
|
||||||
.find("table input")
|
|
||||||
.should("exist")
|
|
||||||
.type("test");
|
|
||||||
cy.percySnapshot("Visualizations - Table (Search in data)", { widths: [viewportWidth] });
|
cy.percySnapshot("Visualizations - Table (Search in data)", { widths: [viewportWidth] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
const { extend, get, merge, find } = Cypress._;
|
const { extend, get, merge, find } = Cypress._;
|
||||||
|
|
||||||
const post = options =>
|
const post = (options) =>
|
||||||
cy
|
cy
|
||||||
.getCookie("csrf_token")
|
.getCookie("csrf_token")
|
||||||
.then(csrf => cy.request({ ...options, method: "POST", headers: { "X-CSRF-TOKEN": csrf.value } }));
|
.then((csrf) => cy.request({ ...options, method: "POST", headers: { "X-CSRF-TOKEN": csrf.value } }));
|
||||||
|
|
||||||
Cypress.Commands.add("createDashboard", name => {
|
Cypress.Commands.add("createDashboard", (name) => {
|
||||||
return post({ url: "api/dashboards", body: { name } }).then(({ body }) => body);
|
return post({ url: "api/dashboards", body: { name } }).then(({ body }) => body);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ Cypress.Commands.add("createQuery", (data, shouldPublish = true) => {
|
|||||||
// eslint-disable-next-line cypress/no-assigning-return-values
|
// eslint-disable-next-line cypress/no-assigning-return-values
|
||||||
let request = post({ url: "/api/queries", body: merged }).then(({ body }) => body);
|
let request = post({ url: "/api/queries", body: merged }).then(({ body }) => body);
|
||||||
if (shouldPublish) {
|
if (shouldPublish) {
|
||||||
request = request.then(query =>
|
request = request.then((query) =>
|
||||||
post({ url: `/api/queries/${query.id}`, body: { is_draft: false } }).then(() => query)
|
post({ url: `/api/queries/${query.id}`, body: { is_draft: false } }).then(() => query)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,7 @@ Cypress.Commands.add("addWidget", (dashboardId, visualizationId, options = {}) =
|
|||||||
Cypress.Commands.add("createAlert", (queryId, options = {}, name) => {
|
Cypress.Commands.add("createAlert", (queryId, options = {}, name) => {
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
column: "?column?",
|
column: "?column?",
|
||||||
|
selector: "first",
|
||||||
op: "greater than",
|
op: "greater than",
|
||||||
rearm: 0,
|
rearm: 0,
|
||||||
value: 1,
|
value: 1,
|
||||||
@@ -109,7 +110,7 @@ Cypress.Commands.add("createUser", ({ name, email, password }) => {
|
|||||||
url: "api/users?no_invite=yes",
|
url: "api/users?no_invite=yes",
|
||||||
body: { name, email },
|
body: { name, email },
|
||||||
failOnStatusCode: false,
|
failOnStatusCode: false,
|
||||||
}).then(xhr => {
|
}).then((xhr) => {
|
||||||
const { status, body } = xhr;
|
const { status, body } = xhr;
|
||||||
if (status < 200 || status > 400) {
|
if (status < 200 || status > 400) {
|
||||||
throw new Error(xhr);
|
throw new Error(xhr);
|
||||||
@@ -146,7 +147,7 @@ Cypress.Commands.add("getDestinations", () => {
|
|||||||
Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) => {
|
Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) => {
|
||||||
return cy
|
return cy
|
||||||
.getDestinations()
|
.getDestinations()
|
||||||
.then(destinations => {
|
.then((destinations) => {
|
||||||
const destination = find(destinations, { name: destinationName });
|
const destination = find(destinations, { name: destinationName });
|
||||||
if (!destination) {
|
if (!destination) {
|
||||||
throw new Error("Destination not found");
|
throw new Error("Destination not found");
|
||||||
@@ -166,6 +167,6 @@ Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Cypress.Commands.add("updateOrgSettings", settings => {
|
Cypress.Commands.add("updateOrgSettings", (settings) => {
|
||||||
return post({ url: "api/settings/organization", body: settings }).then(({ body }) => body);
|
return post({ url: "api/settings/organization", body: settings }).then(({ body }) => body);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,36 +3,26 @@
|
|||||||
* @param should Passed to should expression after plot points are captured
|
* @param should Passed to should expression after plot points are captured
|
||||||
*/
|
*/
|
||||||
export function assertPlotPreview(should = "exist") {
|
export function assertPlotPreview(should = "exist") {
|
||||||
cy.getByTestId("VisualizationPreview")
|
cy.getByTestId("VisualizationPreview").find("g.overplot").should("exist").find("g.points").should(should);
|
||||||
.find("g.plot")
|
|
||||||
.should("exist")
|
|
||||||
.find("g.points")
|
|
||||||
.should(should);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) {
|
export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) {
|
||||||
cy.getByTestId("NewVisualization").click();
|
cy.getByTestId("NewVisualization").click();
|
||||||
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART");
|
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART");
|
||||||
cy.getByTestId("VisualizationName")
|
cy.getByTestId("VisualizationName").clear().type(chartName);
|
||||||
.clear()
|
|
||||||
.type(chartName);
|
|
||||||
|
|
||||||
chartSpecificAssertionFn();
|
chartSpecificAssertionFn();
|
||||||
|
|
||||||
cy.server();
|
cy.server();
|
||||||
cy.route("POST", "**/api/visualizations").as("SaveVisualization");
|
cy.route("POST", "**/api/visualizations").as("SaveVisualization");
|
||||||
|
|
||||||
cy.getByTestId("EditVisualizationDialog")
|
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
|
||||||
.contains("button", "Save")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.getByTestId("QueryPageVisualizationTabs")
|
cy.getByTestId("QueryPageVisualizationTabs").contains("span", chartName).should("exist");
|
||||||
.contains("span", chartName)
|
|
||||||
.should("exist");
|
|
||||||
|
|
||||||
cy.wait("@SaveVisualization").should("have.property", "status", 200);
|
cy.wait("@SaveVisualization").should("have.property", "status", 200);
|
||||||
|
|
||||||
return cy.get("@SaveVisualization").then(xhr => {
|
return cy.get("@SaveVisualization").then((xhr) => {
|
||||||
const { id, name, options } = xhr.response.body;
|
const { id, name, options } = xhr.response.body;
|
||||||
return cy.wrap({ id, name, options });
|
return cy.wrap({ id, name, options });
|
||||||
});
|
});
|
||||||
@@ -42,19 +32,13 @@ export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () =>
|
|||||||
cy.getByTestId("Chart.GlobalSeriesType").should("exist");
|
cy.getByTestId("Chart.GlobalSeriesType").should("exist");
|
||||||
|
|
||||||
cy.getByTestId("VisualizationEditor.Tabs.Series").click();
|
cy.getByTestId("VisualizationEditor.Tabs.Series").click();
|
||||||
cy.getByTestId("VisualizationEditor")
|
cy.getByTestId("VisualizationEditor").find("table").should("exist");
|
||||||
.find("table")
|
|
||||||
.should("exist");
|
|
||||||
|
|
||||||
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
|
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
|
||||||
cy.getByTestId("VisualizationEditor")
|
cy.getByTestId("VisualizationEditor").find("table").should("exist");
|
||||||
.find("table")
|
|
||||||
.should("exist");
|
|
||||||
|
|
||||||
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
|
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
|
||||||
cy.getByTestId("VisualizationEditor")
|
cy.getByTestId("VisualizationEditor").getByTestId("Chart.DataLabels.ShowDataLabels").should("exist");
|
||||||
.getByTestId("Chart.DataLabels.ShowDataLabels")
|
|
||||||
.should("exist");
|
|
||||||
|
|
||||||
chartSpecificTabbedEditorAssertionFn();
|
chartSpecificTabbedEditorAssertionFn();
|
||||||
|
|
||||||
@@ -63,39 +47,29 @@ export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () =>
|
|||||||
|
|
||||||
export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) {
|
export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) {
|
||||||
cy.getByTestId("VisualizationEditor.Tabs.XAxis").click();
|
cy.getByTestId("VisualizationEditor.Tabs.XAxis").click();
|
||||||
cy.getByTestId("Chart.XAxis.Type")
|
cy.getByTestId("Chart.XAxis.Type").contains(".ant-select-selection-item", "Auto Detect").should("exist");
|
||||||
.contains(".ant-select-selection-item", "Auto Detect")
|
|
||||||
.should("exist");
|
|
||||||
|
|
||||||
cy.getByTestId("Chart.XAxis.Name")
|
cy.getByTestId("Chart.XAxis.Name").clear().type(xaxisLabel);
|
||||||
.clear()
|
|
||||||
.type(xaxisLabel);
|
|
||||||
|
|
||||||
cy.getByTestId("VisualizationEditor.Tabs.YAxis").click();
|
cy.getByTestId("VisualizationEditor.Tabs.YAxis").click();
|
||||||
cy.getByTestId("Chart.LeftYAxis.Type")
|
cy.getByTestId("Chart.LeftYAxis.Type").contains(".ant-select-selection-item", "Linear").should("exist");
|
||||||
.contains(".ant-select-selection-item", "Linear")
|
|
||||||
.should("exist");
|
|
||||||
|
|
||||||
cy.getByTestId("Chart.LeftYAxis.Name")
|
cy.getByTestId("Chart.LeftYAxis.Name").clear().type(yaxisLabel);
|
||||||
.clear()
|
|
||||||
.type(yaxisLabel);
|
|
||||||
|
|
||||||
cy.getByTestId("Chart.LeftYAxis.TickFormat")
|
cy.getByTestId("Chart.LeftYAxis.TickFormat").clear().type("+");
|
||||||
.clear()
|
|
||||||
.type("+");
|
|
||||||
|
|
||||||
cy.getByTestId("VisualizationEditor.Tabs.General").click();
|
cy.getByTestId("VisualizationEditor.Tabs.General").click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
|
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
|
||||||
cy.createDashboard(title).then(dashboard => {
|
cy.createDashboard(title).then((dashboard) => {
|
||||||
const dashboardUrl = `/dashboards/${dashboard.id}`;
|
const dashboardUrl = `/dashboards/${dashboard.id}`;
|
||||||
const widgetGetters = chartGetters.map(chartGetter => `${chartGetter}Widget`);
|
const widgetGetters = chartGetters.map((chartGetter) => `${chartGetter}Widget`);
|
||||||
|
|
||||||
chartGetters.forEach((chartGetter, i) => {
|
chartGetters.forEach((chartGetter, i) => {
|
||||||
const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 };
|
const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 };
|
||||||
cy.get(`@${chartGetter}`)
|
cy.get(`@${chartGetter}`)
|
||||||
.then(chart => cy.addWidget(dashboard.id, chart.id, { position }))
|
.then((chart) => cy.addWidget(dashboard.id, chart.id, { position }))
|
||||||
.as(widgetGetters[i]);
|
.as(widgetGetters[i]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
export function expectTableToHaveLength(length) {
|
export function expectTableToHaveLength(length) {
|
||||||
cy.getByTestId("TableVisualization")
|
cy.getByTestId("TableVisualization").find("tbody tr").should("have.length", length);
|
||||||
.find("tbody tr")
|
|
||||||
.should("have.length", length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expectFirstColumnToHaveMembers(values) {
|
export function expectFirstColumnToHaveMembers(values) {
|
||||||
cy.getByTestId("TableVisualization")
|
cy.getByTestId("TableVisualization")
|
||||||
.find("tbody tr td:first-child")
|
.find("tbody tr td:first-child")
|
||||||
.then($cell => Cypress.$.map($cell, item => Cypress.$(item).text()))
|
.then(($cell) => Cypress.$.map($cell, (item) => Cypress.$(item).text()))
|
||||||
.then(firstColumnCells => expect(firstColumnCells).to.have.members(values));
|
.then((firstColumnCells) => expect(firstColumnCells).to.have.members(values));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# This configuration file is for the **development** setup.
|
# This configuration file is for the **development** setup.
|
||||||
# For a production example please refer to getredash/setup repository on GitHub.
|
# For a production example please refer to getredash/setup repository on GitHub.
|
||||||
version: "2.2"
|
|
||||||
x-redash-service: &redash-service
|
x-redash-service: &redash-service
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -11,6 +10,7 @@ x-redash-service: &redash-service
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
x-redash-environment: &redash-environment
|
x-redash-environment: &redash-environment
|
||||||
|
REDASH_HOST: http://localhost:5001
|
||||||
REDASH_LOG_LEVEL: "INFO"
|
REDASH_LOG_LEVEL: "INFO"
|
||||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
postgres:
|
postgres:
|
||||||
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
|
image: pgautoupgrade/pgautoupgrade:latest
|
||||||
ports:
|
ports:
|
||||||
- "15432:5432"
|
- "15432:5432"
|
||||||
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3
|
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3
|
||||||
26
migrations/versions/1655999df5e3_default_alert_selector.py
Normal file
26
migrations/versions/1655999df5e3_default_alert_selector.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""set default alert selector
|
||||||
|
|
||||||
|
Revision ID: 1655999df5e3
|
||||||
|
Revises: 9e8c841d1a30
|
||||||
|
Create Date: 2025-07-09 14:44:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1655999df5e3'
|
||||||
|
down_revision = '9e8c841d1a30'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("""
|
||||||
|
UPDATE alerts
|
||||||
|
SET options = jsonb_set(options, '{selector}', '"first"')
|
||||||
|
WHERE options->>'selector' IS NULL;
|
||||||
|
""")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -24,62 +24,56 @@ def upgrade():
|
|||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
postgresql_using='options::jsonb',
|
postgresql_using='options::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('queries', 'schedule',
|
op.alter_column('queries', 'schedule',
|
||||||
existing_type=sa.Text(),
|
existing_type=sa.Text(),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
postgresql_using='schedule::jsonb',
|
postgresql_using='schedule::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('events', 'additional_properties',
|
op.alter_column('events', 'additional_properties',
|
||||||
existing_type=sa.Text(),
|
existing_type=sa.Text(),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
postgresql_using='additional_properties::jsonb',
|
postgresql_using='additional_properties::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('organizations', 'settings',
|
op.alter_column('organizations', 'settings',
|
||||||
existing_type=sa.Text(),
|
existing_type=sa.Text(),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
postgresql_using='settings::jsonb',
|
postgresql_using='settings::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('alerts', 'options',
|
op.alter_column('alerts', 'options',
|
||||||
existing_type=JSON(astext_type=sa.Text()),
|
existing_type=JSON(astext_type=sa.Text()),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
postgresql_using='options::jsonb',
|
postgresql_using='options::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('dashboards', 'options',
|
op.alter_column('dashboards', 'options',
|
||||||
existing_type=JSON(astext_type=sa.Text()),
|
existing_type=JSON(astext_type=sa.Text()),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
postgresql_using='options::jsonb',
|
postgresql_using='options::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('dashboards', 'layout',
|
op.alter_column('dashboards', 'layout',
|
||||||
existing_type=sa.Text(),
|
existing_type=sa.Text(),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
postgresql_using='layout::jsonb',
|
postgresql_using='layout::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('query_results', 'data',
|
|
||||||
existing_type=sa.Text(),
|
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
|
||||||
nullable=True,
|
|
||||||
postgresql_using='data::text',
|
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
|
||||||
op.alter_column('changes', 'change',
|
op.alter_column('changes', 'change',
|
||||||
existing_type=JSON(astext_type=sa.Text()),
|
existing_type=JSON(astext_type=sa.Text()),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
postgresql_using='change::jsonb',
|
postgresql_using='change::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('visualizations', 'options',
|
op.alter_column('visualizations', 'options',
|
||||||
existing_type=sa.Text(),
|
existing_type=sa.Text(),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
postgresql_using='options::jsonb',
|
postgresql_using='options::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
op.alter_column('widgets', 'options',
|
op.alter_column('widgets', 'options',
|
||||||
existing_type=sa.Text(),
|
existing_type=sa.Text(),
|
||||||
type_=JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
postgresql_using='options::jsonb',
|
postgresql_using='options::jsonb',
|
||||||
server_default=sa.text("'{}'::jsonb"))
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
@@ -89,58 +83,53 @@ def downgrade():
|
|||||||
type_=sa.Text(),
|
type_=sa.Text(),
|
||||||
postgresql_using='options::text',
|
postgresql_using='options::text',
|
||||||
existing_nullable=True,
|
existing_nullable=True,
|
||||||
server_default=sa.text("'{}'::text"))
|
)
|
||||||
op.alter_column('queries', 'schedule',
|
op.alter_column('queries', 'schedule',
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
type_=sa.Text(),
|
type_=sa.Text(),
|
||||||
postgresql_using='schedule::text',
|
postgresql_using='schedule::text',
|
||||||
existing_nullable=True,
|
existing_nullable=True,
|
||||||
server_default=sa.text("'{}'::text"))
|
)
|
||||||
op.alter_column('events', 'additional_properties',
|
op.alter_column('events', 'additional_properties',
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
type_=sa.Text(),
|
type_=sa.Text(),
|
||||||
postgresql_using='additional_properties::text',
|
postgresql_using='additional_properties::text',
|
||||||
existing_nullable=True,
|
existing_nullable=True,
|
||||||
server_default=sa.text("'{}'::text"))
|
)
|
||||||
op.alter_column('organizations', 'settings',
|
op.alter_column('organizations', 'settings',
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
type_=sa.Text(),
|
type_=sa.Text(),
|
||||||
postgresql_using='settings::text',
|
postgresql_using='settings::text',
|
||||||
existing_nullable=True,
|
existing_nullable=True,
|
||||||
server_default=sa.text("'{}'::text"))
|
)
|
||||||
op.alter_column('alerts', 'options',
|
op.alter_column('alerts', 'options',
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
type_=JSON(astext_type=sa.Text()),
|
type_=JSON(astext_type=sa.Text()),
|
||||||
postgresql_using='options::json',
|
postgresql_using='options::json',
|
||||||
existing_nullable=True,
|
existing_nullable=True,
|
||||||
server_default=sa.text("'{}'::json"))
|
)
|
||||||
op.alter_column('dashboards', 'options',
|
op.alter_column('dashboards', 'options',
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
type_=JSON(astext_type=sa.Text()),
|
type_=JSON(astext_type=sa.Text()),
|
||||||
postgresql_using='options::json',
|
postgresql_using='options::json',
|
||||||
server_default=sa.text("'{}'::json"))
|
)
|
||||||
op.alter_column('dashboards', 'layout',
|
op.alter_column('dashboards', 'layout',
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
type_=sa.Text(),
|
type_=sa.Text(),
|
||||||
postgresql_using='layout::text',
|
postgresql_using='layout::text',
|
||||||
server_default=sa.text("'{}'::text"))
|
)
|
||||||
op.alter_column('query_results', 'data',
|
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
|
||||||
type_=sa.Text(),
|
|
||||||
postgresql_using='data::text',
|
|
||||||
server_default=sa.text("'{}'::text"))
|
|
||||||
op.alter_column('changes', 'change',
|
op.alter_column('changes', 'change',
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
type_=JSON(astext_type=sa.Text()),
|
type_=JSON(astext_type=sa.Text()),
|
||||||
postgresql_using='change::json',
|
postgresql_using='change::json',
|
||||||
server_default=sa.text("'{}'::json"))
|
)
|
||||||
op.alter_column('visualizations', 'options',
|
op.alter_column('visualizations', 'options',
|
||||||
type_=sa.Text(),
|
type_=sa.Text(),
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
postgresql_using='options::text',
|
postgresql_using='options::text',
|
||||||
server_default=sa.text("'{}'::text"))
|
)
|
||||||
op.alter_column('widgets', 'options',
|
op.alter_column('widgets', 'options',
|
||||||
type_=sa.Text(),
|
type_=sa.Text(),
|
||||||
existing_type=JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
postgresql_using='options::text',
|
postgresql_using='options::text',
|
||||||
server_default=sa.text("'{}'::text"))
|
)
|
||||||
|
|||||||
64
migrations/versions/9e8c841d1a30_fix_hash.py
Normal file
64
migrations/versions/9e8c841d1a30_fix_hash.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""fix_hash
|
||||||
|
|
||||||
|
Revision ID: 9e8c841d1a30
|
||||||
|
Revises: 7205816877ec
|
||||||
|
Create Date: 2024-10-05 18:55:35.730573
|
||||||
|
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.sql import table
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from redash.query_runner import BaseQueryRunner, get_query_runner
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '9e8c841d1a30'
|
||||||
|
down_revision = '7205816877ec'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def update_query_hash(record):
|
||||||
|
should_apply_auto_limit = record['options'].get("apply_auto_limit", False) if record['options'] else False
|
||||||
|
query_runner = get_query_runner(record['type'], {}) if record['type'] else BaseQueryRunner({})
|
||||||
|
query_text = record['query']
|
||||||
|
|
||||||
|
parameters_dict = {p["name"]: p.get("value") for p in record['options'].get('parameters', [])} if record.options else {}
|
||||||
|
if any(parameters_dict):
|
||||||
|
print(f"Query {record['query_id']} has parameters. Hash might be incorrect.")
|
||||||
|
|
||||||
|
return query_runner.gen_query_hash(query_text, should_apply_auto_limit)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
metadata = sa.MetaData(bind=conn)
|
||||||
|
queries = sa.Table("queries", metadata, autoload=True)
|
||||||
|
data_sources = sa.Table("data_sources", metadata, autoload=True)
|
||||||
|
|
||||||
|
joined_table = queries.outerjoin(data_sources, queries.c.data_source_id == data_sources.c.id)
|
||||||
|
|
||||||
|
query = select([
|
||||||
|
queries.c.id.label("query_id"),
|
||||||
|
queries.c.query,
|
||||||
|
queries.c.query_hash,
|
||||||
|
queries.c.options,
|
||||||
|
data_sources.c.id.label("data_source_id"),
|
||||||
|
data_sources.c.type
|
||||||
|
]).select_from(joined_table)
|
||||||
|
|
||||||
|
for record in conn.execute(query):
|
||||||
|
new_hash = update_query_hash(record)
|
||||||
|
print(f"Updating hash for query {record['query_id']} from {record['query_hash']} to {new_hash}")
|
||||||
|
conn.execute(
|
||||||
|
queries.update()
|
||||||
|
.where(queries.c.id == record['query_id'])
|
||||||
|
.values(query_hash=new_hash))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""12-column dashboard layout
|
||||||
|
|
||||||
|
Revision ID: db0aca1ebd32
|
||||||
|
Revises: 1655999df5e3
|
||||||
|
Create Date: 2025-03-31 13:45:43.160893
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'db0aca1ebd32'
|
||||||
|
down_revision = '1655999df5e3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("""
|
||||||
|
UPDATE widgets
|
||||||
|
SET options = jsonb_set(options, '{position,col}', to_json((options->'position'->>'col')::int * 2)::jsonb);
|
||||||
|
UPDATE widgets
|
||||||
|
SET options = jsonb_set(options, '{position,sizeX}', to_json((options->'position'->>'sizeX')::int * 2)::jsonb);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.execute("""
|
||||||
|
UPDATE widgets
|
||||||
|
SET options = jsonb_set(options, '{position,col}', to_json((options->'position'->>'col')::int / 2)::jsonb);
|
||||||
|
UPDATE widgets
|
||||||
|
SET options = jsonb_set(options, '{position,sizeX}', to_json((options->'position'->>'sizeX')::int / 2)::jsonb);
|
||||||
|
""")
|
||||||
@@ -28,7 +28,7 @@ def upgrade():
|
|||||||
existing_nullable=True,
|
existing_nullable=True,
|
||||||
existing_server_default=sa.text("'{}'::jsonb"))
|
existing_server_default=sa.text("'{}'::jsonb"))
|
||||||
### end Alembic commands ###
|
### end Alembic commands ###
|
||||||
|
|
||||||
update_query = """
|
update_query = """
|
||||||
update users
|
update users
|
||||||
set details = details::jsonb || ('{"profile_image_url": "' || profile_image_url || '"}')::jsonb
|
set details = details::jsonb || ('{"profile_image_url": "' || profile_image_url || '"}')::jsonb
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
command = "cd ../ && yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 && yarn build && cd ./client"
|
command = "cd ../ && yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 && yarn build && cd ./client"
|
||||||
|
|
||||||
[build.environment]
|
[build.environment]
|
||||||
NODE_VERSION = "16.20.1"
|
NODE_VERSION = "18"
|
||||||
NETLIFY_USE_YARN = "true"
|
NETLIFY_USE_YARN = "true"
|
||||||
YARN_VERSION = "1.22.19"
|
YARN_VERSION = "1.22.19"
|
||||||
CYPRESS_INSTALL_BINARY = "0"
|
CYPRESS_INSTALL_BINARY = "0"
|
||||||
|
|||||||
26
package.json
26
package.json
@@ -1,20 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "redash-client",
|
"name": "redash-client",
|
||||||
"version": "24.01.0-dev",
|
"version": "25.07.0-dev",
|
||||||
"description": "The frontend part of Redash.",
|
"description": "The frontend part of Redash.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm-run-all --parallel watch:viz webpack-dev-server",
|
"start": "npm-run-all --parallel watch:viz webpack-dev-server",
|
||||||
"clean": "rm -rf ./client/dist/",
|
"clean": "rm -rf ./client/dist/",
|
||||||
"build:viz": "(cd viz-lib && yarn build:babel)",
|
"build:viz": "(cd viz-lib && yarn build:babel)",
|
||||||
"build": "yarn clean && yarn build:viz && NODE_ENV=production webpack",
|
"build": "yarn clean && yarn build:viz && NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack",
|
||||||
"build:old-node-version": "yarn clean && NODE_ENV=production node --max-old-space-size=4096 node_modules/.bin/webpack",
|
"watch:app": "NODE_OPTIONS=--openssl-legacy-provider webpack watch --progress",
|
||||||
"watch:app": "webpack watch --progress",
|
|
||||||
"watch:viz": "(cd viz-lib && yarn watch:babel)",
|
"watch:viz": "(cd viz-lib && yarn watch:babel)",
|
||||||
"watch": "npm-run-all --parallel watch:*",
|
"watch": "npm-run-all --parallel watch:*",
|
||||||
"webpack-dev-server": "webpack-dev-server",
|
"webpack-dev-server": "webpack-dev-server",
|
||||||
"analyze": "yarn clean && BUNDLE_ANALYZER=on webpack",
|
"analyze": "yarn clean && BUNDLE_ANALYZER=on NODE_OPTIONS=--openssl-legacy-provider webpack",
|
||||||
"analyze:build": "yarn clean && NODE_ENV=production BUNDLE_ANALYZER=on webpack",
|
"analyze:build": "yarn clean && NODE_ENV=production BUNDLE_ANALYZER=on NODE_OPTIONS=--openssl-legacy-provider webpack",
|
||||||
"lint": "yarn lint:base --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
|
"lint": "yarn lint:base --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
|
||||||
"lint:fix": "yarn lint:base --fix --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
|
"lint:fix": "yarn lint:base --fix --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
|
||||||
"lint:base": "eslint --config ./client/.eslintrc.js --ignore-path ./client/.eslintignore",
|
"lint:base": "eslint --config ./client/.eslintrc.js --ignore-path ./client/.eslintignore",
|
||||||
@@ -34,7 +33,8 @@
|
|||||||
"url": "git+https://github.com/getredash/redash.git"
|
"url": "git+https://github.com/getredash/redash.git"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">14.16.0 <17.0.0",
|
"node": ">16.0 <21.0",
|
||||||
|
"npm": "please-use-yarn",
|
||||||
"yarn": "^1.22.10"
|
"yarn": "^1.22.10"
|
||||||
},
|
},
|
||||||
"author": "Redash Contributors",
|
"author": "Redash Contributors",
|
||||||
@@ -50,11 +50,12 @@
|
|||||||
"antd": "^4.4.3",
|
"antd": "^4.4.3",
|
||||||
"axios": "0.27.2",
|
"axios": "0.27.2",
|
||||||
"axios-auth-refresh": "3.3.6",
|
"axios-auth-refresh": "3.3.6",
|
||||||
"bootstrap": "^3.3.7",
|
"bootstrap": "^3.4.1",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"d3": "^3.5.17",
|
"d3": "^3.5.17",
|
||||||
"debug": "^3.2.7",
|
"debug": "^3.2.7",
|
||||||
"dompurify": "^2.0.17",
|
"dompurify": "^2.0.17",
|
||||||
|
"elliptic": "^6.6.0",
|
||||||
"font-awesome": "^4.7.0",
|
"font-awesome": "^4.7.0",
|
||||||
"history": "^4.10.1",
|
"history": "^4.10.1",
|
||||||
"hoist-non-react-statics": "^3.3.0",
|
"hoist-non-react-statics": "^3.3.0",
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
"mousetrap": "^1.6.1",
|
"mousetrap": "^1.6.1",
|
||||||
"mustache": "^2.3.0",
|
"mustache": "^2.3.0",
|
||||||
"numeral": "^2.0.6",
|
"numeral": "^2.0.6",
|
||||||
"path-to-regexp": "^3.1.0",
|
"path-to-regexp": "^3.3.0",
|
||||||
"prop-types": "^15.6.1",
|
"prop-types": "^15.6.1",
|
||||||
"query-string": "^6.9.0",
|
"query-string": "^6.9.0",
|
||||||
"react": "16.14.0",
|
"react": "16.14.0",
|
||||||
@@ -137,11 +138,12 @@
|
|||||||
"mini-css-extract-plugin": "^1.6.2",
|
"mini-css-extract-plugin": "^1.6.2",
|
||||||
"mockdate": "^2.0.2",
|
"mockdate": "^2.0.2",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^1.19.1",
|
"prettier": "3.3.2",
|
||||||
"raw-loader": "^0.5.1",
|
"raw-loader": "^0.5.1",
|
||||||
"react-refresh": "^0.14.0",
|
"react-refresh": "^0.14.0",
|
||||||
"react-test-renderer": "^16.14.0",
|
"react-test-renderer": "^16.14.0",
|
||||||
"request-cookies": "^1.1.0",
|
"request-cookies": "^1.1.0",
|
||||||
|
"source-map-loader": "^1.1.3",
|
||||||
"style-loader": "^2.0.0",
|
"style-loader": "^2.0.0",
|
||||||
"typescript": "^4.1.2",
|
"typescript": "^4.1.2",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
@@ -179,8 +181,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"fs": false,
|
"fs": false,
|
||||||
"path": false
|
"path": false
|
||||||
},
|
},
|
||||||
"//": "browserslist set to 'Async functions' compatibility",
|
"//": "browserslist set to 'Async functions' compatibility",
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|||||||
3465
poetry.lock
generated
3465
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ force-exclude = '''
|
|||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "redash"
|
name = "redash"
|
||||||
version = "24.01.0-dev"
|
version = "25.07.0-dev"
|
||||||
description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data."
|
description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data."
|
||||||
authors = ["Arik Fraimovich <arik@redash.io>"]
|
authors = ["Arik Fraimovich <arik@redash.io>"]
|
||||||
# to be added to/removed from the mailing list, please reach out to Arik via the above email or Discord
|
# to be added to/removed from the mailing list, please reach out to Arik via the above email or Discord
|
||||||
@@ -29,7 +29,7 @@ authlib = "0.15.5"
|
|||||||
backoff = "2.2.1"
|
backoff = "2.2.1"
|
||||||
blinker = "1.6.2"
|
blinker = "1.6.2"
|
||||||
click = "8.1.3"
|
click = "8.1.3"
|
||||||
cryptography = "41.0.6"
|
cryptography = "43.0.1"
|
||||||
disposable-email-domains = ">=0.0.52"
|
disposable-email-domains = ">=0.0.52"
|
||||||
flask = "2.3.2"
|
flask = "2.3.2"
|
||||||
flask-limiter = "3.3.1"
|
flask-limiter = "3.3.1"
|
||||||
@@ -43,10 +43,10 @@ flask-wtf = "1.1.1"
|
|||||||
funcy = "1.13"
|
funcy = "1.13"
|
||||||
gevent = "23.9.1"
|
gevent = "23.9.1"
|
||||||
greenlet = "2.0.2"
|
greenlet = "2.0.2"
|
||||||
gunicorn = "20.0.4"
|
gunicorn = "22.0.0"
|
||||||
httplib2 = "0.19.0"
|
httplib2 = "0.19.0"
|
||||||
itsdangerous = "2.1.2"
|
itsdangerous = "2.1.2"
|
||||||
jinja2 = "3.1.3"
|
jinja2 = "3.1.5"
|
||||||
jsonschema = "3.1.1"
|
jsonschema = "3.1.1"
|
||||||
markupsafe = "2.1.1"
|
markupsafe = "2.1.1"
|
||||||
maxminddb-geolite2 = "2018.703"
|
maxminddb-geolite2 = "2018.703"
|
||||||
@@ -54,7 +54,7 @@ parsedatetime = "2.4"
|
|||||||
passlib = "1.7.3"
|
passlib = "1.7.3"
|
||||||
psycopg2-binary = "2.9.6"
|
psycopg2-binary = "2.9.6"
|
||||||
pyjwt = "2.4.0"
|
pyjwt = "2.4.0"
|
||||||
pyopenssl = "23.2.0"
|
pyopenssl = "24.2.1"
|
||||||
pypd = "1.1.0"
|
pypd = "1.1.0"
|
||||||
pysaml2 = "7.3.1"
|
pysaml2 = "7.3.1"
|
||||||
pystache = "0.6.0"
|
pystache = "0.6.0"
|
||||||
@@ -64,34 +64,38 @@ pytz = ">=2019.3"
|
|||||||
pyyaml = "6.0.1"
|
pyyaml = "6.0.1"
|
||||||
redis = "4.6.0"
|
redis = "4.6.0"
|
||||||
regex = "2023.8.8"
|
regex = "2023.8.8"
|
||||||
requests = "2.31.0"
|
requests = "2.32.3"
|
||||||
restrictedpython = "6.2"
|
restrictedpython = "7.3"
|
||||||
rq = "1.9.0"
|
rq = "1.16.1"
|
||||||
rq-scheduler = "0.11.0"
|
rq-scheduler = "0.13.1"
|
||||||
semver = "2.8.1"
|
semver = "2.8.1"
|
||||||
sentry-sdk = "1.28.1"
|
sentry-sdk = "1.45.1"
|
||||||
sqlalchemy = "1.3.24"
|
sqlalchemy = "1.3.24"
|
||||||
sqlalchemy-searchable = "1.2.0"
|
sqlalchemy-searchable = "1.2.0"
|
||||||
sqlalchemy-utils = "0.34.2"
|
sqlalchemy-utils = "0.38.3"
|
||||||
sqlparse = "0.4.4"
|
sqlparse = "0.5.0"
|
||||||
sshtunnel = "0.1.5"
|
sshtunnel = "0.1.5"
|
||||||
statsd = "3.3.0"
|
statsd = "3.3.0"
|
||||||
supervisor = "4.1.0"
|
supervisor = "4.1.0"
|
||||||
supervisor-checks = "0.8.1"
|
supervisor-checks = "0.8.1"
|
||||||
ua-parser = "0.18.0"
|
ua-parser = "0.18.0"
|
||||||
urllib3 = "1.26.18"
|
urllib3 = "1.26.19"
|
||||||
user-agents = "2.0"
|
user-agents = "2.0"
|
||||||
werkzeug = "2.3.8"
|
werkzeug = "2.3.8"
|
||||||
wtforms = "2.2.1"
|
wtforms = "2.2.1"
|
||||||
xlsxwriter = "1.2.2"
|
xlsxwriter = "1.2.2"
|
||||||
tzlocal = "4.3.1"
|
tzlocal = "4.3.1"
|
||||||
|
pyodbc = "5.1.0"
|
||||||
|
debugpy = "^1.8.9"
|
||||||
|
paramiko = "3.4.1"
|
||||||
|
oracledb = "2.5.1"
|
||||||
|
|
||||||
[tool.poetry.group.all_ds]
|
[tool.poetry.group.all_ds]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[tool.poetry.group.all_ds.dependencies]
|
[tool.poetry.group.all_ds.dependencies]
|
||||||
atsd-client = "3.0.5"
|
atsd-client = "3.0.5"
|
||||||
azure-kusto-data = "0.0.35"
|
azure-kusto-data = "5.0.1"
|
||||||
boto3 = "1.28.8"
|
boto3 = "1.28.8"
|
||||||
botocore = "1.31.8"
|
botocore = "1.31.8"
|
||||||
cassandra-driver = "3.21.0"
|
cassandra-driver = "3.21.0"
|
||||||
@@ -106,31 +110,30 @@ influxdb = "5.2.3"
|
|||||||
influxdb-client = "1.38.0"
|
influxdb-client = "1.38.0"
|
||||||
memsql = "3.2.0"
|
memsql = "3.2.0"
|
||||||
mysqlclient = "2.1.1"
|
mysqlclient = "2.1.1"
|
||||||
|
numpy = "1.24.4"
|
||||||
nzalchemy = "^11.0.2"
|
nzalchemy = "^11.0.2"
|
||||||
nzpy = ">=1.15"
|
nzpy = ">=1.15"
|
||||||
oauth2client = "4.1.3"
|
oauth2client = "4.1.3"
|
||||||
openpyxl = "3.0.7"
|
openpyxl = "3.0.7"
|
||||||
oracledb = "2.0.1"
|
|
||||||
pandas = "1.3.4"
|
pandas = "1.3.4"
|
||||||
phoenixdb = "0.7"
|
phoenixdb = "0.7"
|
||||||
pinotdb = ">=0.4.5"
|
pinotdb = ">=0.4.5"
|
||||||
protobuf = "3.20.2"
|
protobuf = "3.20.2"
|
||||||
pyathena = ">=1.5.0,<=1.11.5"
|
pyathena = "2.25.2"
|
||||||
pydgraph = "2.0.2"
|
pydgraph = "2.0.2"
|
||||||
pydruid = "0.5.7"
|
pydruid = "0.5.7"
|
||||||
pyexasol = "0.12.0"
|
pyexasol = "0.12.0"
|
||||||
pyhive = "0.6.1"
|
pyhive = "0.6.1"
|
||||||
pyignite = "0.6.1"
|
pyignite = "0.6.1"
|
||||||
pymongo = { version = "4.3.3", extras = ["srv", "tls"] }
|
pymongo = { version = "4.6.3", extras = ["srv", "tls"] }
|
||||||
pymssql = "2.2.8"
|
pymssql = "^2.3.1"
|
||||||
pyodbc = "4.0.28"
|
pyodbc = "5.1.0"
|
||||||
python-arango = "6.1.0"
|
python-arango = "6.1.0"
|
||||||
python-rapidjson = "1.1.0"
|
python-rapidjson = "1.20"
|
||||||
qds-sdk = ">=1.9.6"
|
|
||||||
requests-aws-sign = "0.1.5"
|
requests-aws-sign = "0.1.5"
|
||||||
sasl = ">=0.1.3"
|
sasl = ">=0.1.3"
|
||||||
simple-salesforce = "0.74.3"
|
simple-salesforce = "0.74.3"
|
||||||
snowflake-connector-python = "3.4.0"
|
snowflake-connector-python = "3.12.3"
|
||||||
td-client = "1.0.0"
|
td-client = "1.0.0"
|
||||||
thrift = ">=0.8.0"
|
thrift = ">=0.8.0"
|
||||||
thrift-sasl = ">=0.1.0"
|
thrift-sasl = ">=0.1.0"
|
||||||
@@ -152,11 +155,10 @@ optional = true
|
|||||||
pytest = "7.4.0"
|
pytest = "7.4.0"
|
||||||
coverage = "7.2.7"
|
coverage = "7.2.7"
|
||||||
freezegun = "1.2.1"
|
freezegun = "1.2.1"
|
||||||
jwcrypto = "1.5.1"
|
jwcrypto = "1.5.6"
|
||||||
mock = "5.0.2"
|
mock = "5.0.2"
|
||||||
pre-commit = "3.3.3"
|
pre-commit = "3.3.3"
|
||||||
ptpython = "3.0.23"
|
ptpython = "3.0.23"
|
||||||
ptvsd = "4.3.2"
|
|
||||||
pytest-cov = "4.1.0"
|
pytest-cov = "4.1.0"
|
||||||
watchdog = "3.0.0"
|
watchdog = "3.0.0"
|
||||||
ruff = "0.0.289"
|
ruff = "0.0.289"
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ from redash.app import create_app # noqa
|
|||||||
from redash.destinations import import_destinations
|
from redash.destinations import import_destinations
|
||||||
from redash.query_runner import import_query_runners
|
from redash.query_runner import import_query_runners
|
||||||
|
|
||||||
__version__ = "24.01.0-dev"
|
__version__ = "25.07.0-dev"
|
||||||
|
|
||||||
|
|
||||||
if os.environ.get("REMOTE_DEBUG"):
|
if os.environ.get("REMOTE_DEBUG"):
|
||||||
import ptvsd
|
import debugpy
|
||||||
|
|
||||||
ptvsd.enable_attach(address=("0.0.0.0", 5678))
|
debugpy.listen(("0.0.0.0", 5678))
|
||||||
|
debugpy.wait_for_client()
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import requests
|
|||||||
from authlib.integrations.flask_client import OAuth
|
from authlib.integrations.flask_client import OAuth
|
||||||
from flask import Blueprint, flash, redirect, request, session, url_for
|
from flask import Blueprint, flash, redirect, request, session, url_for
|
||||||
|
|
||||||
from redash import models
|
from redash import models, settings
|
||||||
from redash.authentication import (
|
from redash.authentication import (
|
||||||
create_and_login_user,
|
create_and_login_user,
|
||||||
get_next_path,
|
get_next_path,
|
||||||
@@ -29,6 +29,41 @@ def verify_profile(org, profile):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_profile(access_token, logger):
|
||||||
|
headers = {"Authorization": f"OAuth {access_token}"}
|
||||||
|
response = requests.get("https://www.googleapis.com/oauth2/v1/userinfo", headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
logger.warning("Failed getting user profile (response code 401).")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def build_redirect_uri():
|
||||||
|
scheme = settings.GOOGLE_OAUTH_SCHEME_OVERRIDE or None
|
||||||
|
return url_for(".callback", _external=True, _scheme=scheme)
|
||||||
|
|
||||||
|
|
||||||
|
def build_next_path(org_slug=None):
|
||||||
|
next_path = request.args.get("next")
|
||||||
|
if not next_path:
|
||||||
|
if org_slug is None:
|
||||||
|
org_slug = session.get("org_slug")
|
||||||
|
|
||||||
|
scheme = None
|
||||||
|
if settings.GOOGLE_OAUTH_SCHEME_OVERRIDE:
|
||||||
|
scheme = settings.GOOGLE_OAUTH_SCHEME_OVERRIDE
|
||||||
|
|
||||||
|
next_path = url_for(
|
||||||
|
"redash.index",
|
||||||
|
org_slug=org_slug,
|
||||||
|
_external=True,
|
||||||
|
_scheme=scheme,
|
||||||
|
)
|
||||||
|
return next_path
|
||||||
|
|
||||||
|
|
||||||
def create_google_oauth_blueprint(app):
|
def create_google_oauth_blueprint(app):
|
||||||
oauth = OAuth(app)
|
oauth = OAuth(app)
|
||||||
|
|
||||||
@@ -36,23 +71,12 @@ def create_google_oauth_blueprint(app):
|
|||||||
blueprint = Blueprint("google_oauth", __name__)
|
blueprint = Blueprint("google_oauth", __name__)
|
||||||
|
|
||||||
CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
|
CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
|
||||||
oauth = OAuth(app)
|
|
||||||
oauth.register(
|
oauth.register(
|
||||||
name="google",
|
name="google",
|
||||||
server_metadata_url=CONF_URL,
|
server_metadata_url=CONF_URL,
|
||||||
client_kwargs={"scope": "openid email profile"},
|
client_kwargs={"scope": "openid email profile"},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_user_profile(access_token):
|
|
||||||
headers = {"Authorization": "OAuth {}".format(access_token)}
|
|
||||||
response = requests.get("https://www.googleapis.com/oauth2/v1/userinfo", headers=headers)
|
|
||||||
|
|
||||||
if response.status_code == 401:
|
|
||||||
logger.warning("Failed getting user profile (response code 401).")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
@blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org")
|
@blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org")
|
||||||
def org_login(org_slug):
|
def org_login(org_slug):
|
||||||
session["org_slug"] = current_org.slug
|
session["org_slug"] = current_org.slug
|
||||||
@@ -60,9 +84,9 @@ def create_google_oauth_blueprint(app):
|
|||||||
|
|
||||||
@blueprint.route("/oauth/google", endpoint="authorize")
|
@blueprint.route("/oauth/google", endpoint="authorize")
|
||||||
def login():
|
def login():
|
||||||
redirect_uri = url_for(".callback", _external=True)
|
redirect_uri = build_redirect_uri()
|
||||||
|
|
||||||
next_path = request.args.get("next", url_for("redash.index", org_slug=session.get("org_slug")))
|
next_path = build_next_path()
|
||||||
logger.debug("Callback url: %s", redirect_uri)
|
logger.debug("Callback url: %s", redirect_uri)
|
||||||
logger.debug("Next is: %s", next_path)
|
logger.debug("Next is: %s", next_path)
|
||||||
|
|
||||||
@@ -86,7 +110,7 @@ def create_google_oauth_blueprint(app):
|
|||||||
flash("Validation error. Please retry.")
|
flash("Validation error. Please retry.")
|
||||||
return redirect(url_for("redash.login"))
|
return redirect(url_for("redash.login"))
|
||||||
|
|
||||||
profile = get_user_profile(access_token)
|
profile = get_user_profile(access_token, logger)
|
||||||
if profile is None:
|
if profile is None:
|
||||||
flash("Validation error. Please retry.")
|
flash("Validation error. Please retry.")
|
||||||
return redirect(url_for("redash.login"))
|
return redirect(url_for("redash.login"))
|
||||||
@@ -110,7 +134,9 @@ def create_google_oauth_blueprint(app):
|
|||||||
if user is None:
|
if user is None:
|
||||||
return logout_and_redirect_to_index()
|
return logout_and_redirect_to_index()
|
||||||
|
|
||||||
unsafe_next_path = session.get("next_url") or url_for("redash.index", org_slug=org.slug)
|
unsafe_next_path = session.get("next_url")
|
||||||
|
if not unsafe_next_path:
|
||||||
|
unsafe_next_path = build_next_path(org.slug)
|
||||||
next_path = get_next_path(unsafe_next_path)
|
next_path = get_next_path(unsafe_next_path)
|
||||||
|
|
||||||
return redirect(next_path)
|
return redirect(next_path)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from redash import settings
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from ldap3 import Connection, Server
|
from ldap3 import Connection, Server
|
||||||
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
except ImportError:
|
except ImportError:
|
||||||
if settings.LDAP_LOGIN_ENABLED:
|
if settings.LDAP_LOGIN_ENABLED:
|
||||||
sys.exit(
|
sys.exit(
|
||||||
@@ -69,6 +70,7 @@ def login(org_slug=None):
|
|||||||
|
|
||||||
|
|
||||||
def auth_ldap_user(username, password):
|
def auth_ldap_user(username, password):
|
||||||
|
clean_username = escape_filter_chars(username)
|
||||||
server = Server(settings.LDAP_HOST_URL, use_ssl=settings.LDAP_SSL)
|
server = Server(settings.LDAP_HOST_URL, use_ssl=settings.LDAP_SSL)
|
||||||
if settings.LDAP_BIND_DN is not None:
|
if settings.LDAP_BIND_DN is not None:
|
||||||
conn = Connection(
|
conn = Connection(
|
||||||
@@ -83,7 +85,7 @@ def auth_ldap_user(username, password):
|
|||||||
|
|
||||||
conn.search(
|
conn.search(
|
||||||
settings.LDAP_SEARCH_DN,
|
settings.LDAP_SEARCH_DN,
|
||||||
settings.LDAP_SEARCH_TEMPLATE % {"username": username},
|
settings.LDAP_SEARCH_TEMPLATE % {"username": clean_username},
|
||||||
attributes=[settings.LDAP_DISPLAY_NAME_KEY, settings.LDAP_EMAIL_KEY],
|
attributes=[settings.LDAP_DISPLAY_NAME_KEY, settings.LDAP_EMAIL_KEY],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ from sqlalchemy.orm.exc import NoResultFound
|
|||||||
manager = AppGroup(help="Queries management commands.")
|
manager = AppGroup(help="Queries management commands.")
|
||||||
|
|
||||||
|
|
||||||
|
@manager.command(name="rehash")
|
||||||
|
def rehash():
|
||||||
|
from redash import models
|
||||||
|
|
||||||
|
for q in models.Query.query.all():
|
||||||
|
old_hash = q.query_hash
|
||||||
|
q.update_query_hash()
|
||||||
|
new_hash = q.query_hash
|
||||||
|
|
||||||
|
if old_hash != new_hash:
|
||||||
|
print(f"Query {q.id} has changed hash from {old_hash} to {new_hash}")
|
||||||
|
models.db.session.add(q)
|
||||||
|
|
||||||
|
models.db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
@manager.command(name="add_tag")
|
@manager.command(name="add_tag")
|
||||||
@argument("query_id")
|
@argument("query_id")
|
||||||
@argument("tag")
|
@argument("tag")
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ class Discord(BaseDestination):
|
|||||||
"inline": True,
|
"inline": True,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if alert.options.get("custom_body"):
|
if alert.custom_body:
|
||||||
fields.append({"name": "Description", "value": alert.options["custom_body"]})
|
fields.append({"name": "Description", "value": alert.custom_body})
|
||||||
if new_state == Alert.TRIGGERED_STATE:
|
if new_state == Alert.TRIGGERED_STATE:
|
||||||
if alert.options.get("custom_subject"):
|
if alert.options.get("custom_subject"):
|
||||||
text = alert.options["custom_subject"]
|
text = alert.options["custom_subject"]
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ class Slack(BaseDestination):
|
|||||||
fields = [
|
fields = [
|
||||||
{
|
{
|
||||||
"title": "Query",
|
"title": "Query",
|
||||||
|
"type": "mrkdwn",
|
||||||
"value": "{host}/queries/{query_id}".format(host=host, query_id=query.id),
|
"value": "{host}/queries/{query_id}".format(host=host, query_id=query.id),
|
||||||
"short": True,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Alert",
|
"title": "Alert",
|
||||||
|
"type": "mrkdwn",
|
||||||
"value": "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id),
|
"value": "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id),
|
||||||
"short": True,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if alert.custom_body:
|
if alert.custom_body:
|
||||||
@@ -50,7 +50,7 @@ class Slack(BaseDestination):
|
|||||||
payload = {"attachments": [{"text": text, "color": color, "fields": fields}]}
|
payload = {"attachments": [{"text": text, "color": color, "fields": fields}]}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(options.get("url"), data=json_dumps(payload), timeout=5.0)
|
resp = requests.post(options.get("url"), data=json_dumps(payload).encode("utf-8"), timeout=5.0)
|
||||||
logging.warning(resp.text)
|
logging.warning(resp.text)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))
|
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import html
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
@@ -37,6 +39,129 @@ class Webex(BaseDestination):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def formatted_attachments_template(subject, description, query_link, alert_link):
|
def formatted_attachments_template(subject, description, query_link, alert_link):
|
||||||
|
# Attempt to parse the description to find a 2D array
|
||||||
|
try:
|
||||||
|
# Extract the part of the description that looks like a JSON array
|
||||||
|
start_index = description.find("[")
|
||||||
|
end_index = description.rfind("]") + 1
|
||||||
|
json_array_str = description[start_index:end_index]
|
||||||
|
|
||||||
|
# Decode HTML entities
|
||||||
|
json_array_str = html.unescape(json_array_str)
|
||||||
|
|
||||||
|
# Replace single quotes with double quotes for valid JSON
|
||||||
|
json_array_str = json_array_str.replace("'", '"')
|
||||||
|
|
||||||
|
# Load the JSON array
|
||||||
|
data_array = json.loads(json_array_str)
|
||||||
|
|
||||||
|
# Check if it's a 2D array
|
||||||
|
if isinstance(data_array, list) and all(isinstance(i, list) for i in data_array):
|
||||||
|
# Create a table for the Adaptive Card
|
||||||
|
table_rows = []
|
||||||
|
for row in data_array:
|
||||||
|
table_rows.append(
|
||||||
|
{
|
||||||
|
"type": "ColumnSet",
|
||||||
|
"columns": [
|
||||||
|
{"type": "Column", "items": [{"type": "TextBlock", "text": str(item), "wrap": True}]}
|
||||||
|
for item in row
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the body of the card with the table
|
||||||
|
body = (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"{subject}",
|
||||||
|
"weight": "bolder",
|
||||||
|
"size": "medium",
|
||||||
|
"wrap": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"{description[:start_index]}",
|
||||||
|
"isSubtle": True,
|
||||||
|
"wrap": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
+ table_rows
|
||||||
|
+ [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"Click [here]({query_link}) to check your query!",
|
||||||
|
"wrap": True,
|
||||||
|
"isSubtle": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||||
|
"wrap": True,
|
||||||
|
"isSubtle": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to the original description if no valid 2D array is found
|
||||||
|
body = [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"{subject}",
|
||||||
|
"weight": "bolder",
|
||||||
|
"size": "medium",
|
||||||
|
"wrap": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"{description}",
|
||||||
|
"isSubtle": True,
|
||||||
|
"wrap": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"Click [here]({query_link}) to check your query!",
|
||||||
|
"wrap": True,
|
||||||
|
"isSubtle": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||||
|
"wrap": True,
|
||||||
|
"isSubtle": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# If parsing fails, fallback to the original description
|
||||||
|
body = [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"{subject}",
|
||||||
|
"weight": "bolder",
|
||||||
|
"size": "medium",
|
||||||
|
"wrap": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"{description}",
|
||||||
|
"isSubtle": True,
|
||||||
|
"wrap": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"Click [here]({query_link}) to check your query!",
|
||||||
|
"wrap": True,
|
||||||
|
"isSubtle": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||||
|
"wrap": True,
|
||||||
|
"isSubtle": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||||
@@ -44,44 +169,7 @@ class Webex(BaseDestination):
|
|||||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
"type": "AdaptiveCard",
|
"type": "AdaptiveCard",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"body": [
|
"body": body,
|
||||||
{
|
|
||||||
"type": "ColumnSet",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"type": "Column",
|
|
||||||
"width": 4,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "TextBlock",
|
|
||||||
"text": {subject},
|
|
||||||
"weight": "bolder",
|
|
||||||
"size": "medium",
|
|
||||||
"wrap": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "TextBlock",
|
|
||||||
"text": {description},
|
|
||||||
"isSubtle": True,
|
|
||||||
"wrap": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "TextBlock",
|
|
||||||
"text": f"Click [here]({query_link}) to check your query!",
|
|
||||||
"wrap": True,
|
|
||||||
"isSubtle": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "TextBlock",
|
|
||||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
|
||||||
"wrap": True,
|
|
||||||
"isSubtle": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -116,6 +204,10 @@ class Webex(BaseDestination):
|
|||||||
|
|
||||||
# destinations is guaranteed to be a comma-separated string
|
# destinations is guaranteed to be a comma-separated string
|
||||||
for destination_id in destinations.split(","):
|
for destination_id in destinations.split(","):
|
||||||
|
destination_id = destination_id.strip() # Remove any leading or trailing whitespace
|
||||||
|
if not destination_id: # Check if the destination_id is empty or blank
|
||||||
|
continue # Skip to the next iteration if it's empty or blank
|
||||||
|
|
||||||
payload = deepcopy(template_payload)
|
payload = deepcopy(template_payload)
|
||||||
payload[payload_tag] = destination_id
|
payload[payload_tag] = destination_id
|
||||||
self.post_message(payload, headers)
|
self.post_message(payload, headers)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from flask import request
|
from flask import request
|
||||||
from funcy import project
|
from funcy import project
|
||||||
|
|
||||||
from redash import models
|
from redash import models, utils
|
||||||
from redash.handlers.base import (
|
from redash.handlers.base import (
|
||||||
BaseResource,
|
BaseResource,
|
||||||
get_object_or_404,
|
get_object_or_404,
|
||||||
@@ -14,6 +14,10 @@ from redash.permissions import (
|
|||||||
view_only,
|
view_only,
|
||||||
)
|
)
|
||||||
from redash.serializers import serialize_alert
|
from redash.serializers import serialize_alert
|
||||||
|
from redash.tasks.alerts import (
|
||||||
|
notify_subscriptions,
|
||||||
|
should_notify,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AlertResource(BaseResource):
|
class AlertResource(BaseResource):
|
||||||
@@ -43,6 +47,21 @@ class AlertResource(BaseResource):
|
|||||||
models.db.session.commit()
|
models.db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class AlertEvaluateResource(BaseResource):
|
||||||
|
def post(self, alert_id):
|
||||||
|
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
|
||||||
|
require_admin_or_owner(alert.user.id)
|
||||||
|
|
||||||
|
new_state = alert.evaluate()
|
||||||
|
if should_notify(alert, new_state):
|
||||||
|
alert.state = new_state
|
||||||
|
alert.last_triggered_at = utils.utcnow()
|
||||||
|
models.db.session.commit()
|
||||||
|
|
||||||
|
notify_subscriptions(alert, new_state, {})
|
||||||
|
self.record_event({"action": "evaluate", "object_id": alert.id, "object_type": "alert"})
|
||||||
|
|
||||||
|
|
||||||
class AlertMuteResource(BaseResource):
|
class AlertMuteResource(BaseResource):
|
||||||
def post(self, alert_id):
|
def post(self, alert_id):
|
||||||
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
|
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from flask_restful import Api
|
|||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
from redash.handlers.alerts import (
|
from redash.handlers.alerts import (
|
||||||
|
AlertEvaluateResource,
|
||||||
AlertListResource,
|
AlertListResource,
|
||||||
AlertMuteResource,
|
AlertMuteResource,
|
||||||
AlertResource,
|
AlertResource,
|
||||||
@@ -117,6 +118,7 @@ def json_representation(data, code, headers=None):
|
|||||||
|
|
||||||
api.add_org_resource(AlertResource, "/api/alerts/<alert_id>", endpoint="alert")
|
api.add_org_resource(AlertResource, "/api/alerts/<alert_id>", endpoint="alert")
|
||||||
api.add_org_resource(AlertMuteResource, "/api/alerts/<alert_id>/mute", endpoint="alert_mute")
|
api.add_org_resource(AlertMuteResource, "/api/alerts/<alert_id>/mute", endpoint="alert_mute")
|
||||||
|
api.add_org_resource(AlertEvaluateResource, "/api/alerts/<alert_id>/eval", endpoint="alert_eval")
|
||||||
api.add_org_resource(
|
api.add_org_resource(
|
||||||
AlertSubscriptionListResource,
|
AlertSubscriptionListResource,
|
||||||
"/api/alerts/<alert_id>/subscriptions",
|
"/api/alerts/<alert_id>/subscriptions",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def get_google_auth_url(next_path):
|
|||||||
|
|
||||||
|
|
||||||
def render_token_login_page(template, org_slug, token, invite):
|
def render_token_login_page(template, org_slug, token, invite):
|
||||||
|
error_message = None
|
||||||
try:
|
try:
|
||||||
user_id = validate_token(token)
|
user_id = validate_token(token)
|
||||||
org = current_org._get_current_object()
|
org = current_org._get_current_object()
|
||||||
@@ -40,19 +41,19 @@ def render_token_login_page(template, org_slug, token, invite):
|
|||||||
user_id,
|
user_id,
|
||||||
org_slug,
|
org_slug,
|
||||||
)
|
)
|
||||||
|
error_message = "Your invite link is invalid. Bad user id in token. Please ask for a new one."
|
||||||
|
except SignatureExpired:
|
||||||
|
logger.exception("Token signature has expired. Token: %s, org=%s", token, org_slug)
|
||||||
|
error_message = "Your invite link has expired. Please ask for a new one."
|
||||||
|
except BadSignature:
|
||||||
|
logger.exception("Bad signature for the token: %s, org=%s", token, org_slug)
|
||||||
|
error_message = "Your invite link is invalid. Bad signature. Please double-check the token."
|
||||||
|
|
||||||
|
if error_message:
|
||||||
return (
|
return (
|
||||||
render_template(
|
render_template(
|
||||||
"error.html",
|
"error.html",
|
||||||
error_message="Invalid invite link. Please ask for a new one.",
|
error_message=error_message,
|
||||||
),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
except (SignatureExpired, BadSignature):
|
|
||||||
logger.exception("Failed to verify invite token: %s, org=%s", token, org_slug)
|
|
||||||
return (
|
|
||||||
render_template(
|
|
||||||
"error.html",
|
|
||||||
error_message="Your invite link has expired. Please ask for a new one.",
|
|
||||||
),
|
),
|
||||||
400,
|
400,
|
||||||
)
|
)
|
||||||
@@ -254,6 +255,12 @@ def number_format_config():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def null_value_config():
|
||||||
|
return {
|
||||||
|
"nullValue": current_org.get_setting("null_value"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def client_config():
|
def client_config():
|
||||||
if not current_user.is_api_user() and current_user.is_authenticated:
|
if not current_user.is_api_user() and current_user.is_authenticated:
|
||||||
client_config = {
|
client_config = {
|
||||||
@@ -288,6 +295,7 @@ def client_config():
|
|||||||
client_config.update({"basePath": base_href()})
|
client_config.update({"basePath": base_href()})
|
||||||
client_config.update(date_time_format_config())
|
client_config.update(date_time_format_config())
|
||||||
client_config.update(number_format_config())
|
client_config.update(number_format_config())
|
||||||
|
client_config.update(null_value_config())
|
||||||
|
|
||||||
return client_config
|
return client_config
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ from flask_restful import Resource, abort
|
|||||||
from sqlalchemy import cast
|
from sqlalchemy import cast
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
from sqlalchemy_utils.functions import sort_query
|
|
||||||
|
|
||||||
from redash import settings
|
from redash import settings
|
||||||
from redash.authentication import current_org
|
from redash.authentication import current_org
|
||||||
from redash.models import db
|
from redash.models import db
|
||||||
from redash.tasks import record_event as record_event_task
|
from redash.tasks import record_event as record_event_task
|
||||||
from redash.utils import json_dumps
|
from redash.utils import json_dumps
|
||||||
|
from redash.utils.query_order import sort_query
|
||||||
|
|
||||||
routes = Blueprint("redash", __name__, template_folder=settings.fix_assets_path("templates"))
|
routes = Blueprint("redash", __name__, template_folder=settings.fix_assets_path("templates"))
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class WidgetListResource(BaseResource):
|
|||||||
|
|
||||||
widget = models.Widget(**widget_properties)
|
widget = models.Widget(**widget_properties)
|
||||||
models.db.session.add(widget)
|
models.db.session.add(widget)
|
||||||
models.db.session.commit()
|
|
||||||
|
|
||||||
models.db.session.commit()
|
models.db.session.commit()
|
||||||
return serialize_widget(widget)
|
return serialize_widget(widget)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from flask import g, has_request_context
|
|||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.event import listens_for
|
from sqlalchemy.event import listens_for
|
||||||
from sqlalchemy.orm.util import _ORMJoin
|
from sqlalchemy.orm.util import _ORMJoin
|
||||||
from sqlalchemy.sql.selectable import Alias
|
from sqlalchemy.sql.selectable import Alias, Join
|
||||||
|
|
||||||
from redash import statsd_client
|
from redash import statsd_client
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ def _table_name_from_select_element(elt):
|
|||||||
if isinstance(t, Alias):
|
if isinstance(t, Alias):
|
||||||
t = t.original.froms[0]
|
t = t.original.froms[0]
|
||||||
|
|
||||||
while isinstance(t, _ORMJoin):
|
while isinstance(t, _ORMJoin) or isinstance(t, Join):
|
||||||
t = t.left
|
t = t.left
|
||||||
|
|
||||||
return t.name
|
return t.name
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from redash.models.parameterized_query import (
|
|||||||
from redash.models.types import (
|
from redash.models.types import (
|
||||||
Configuration,
|
Configuration,
|
||||||
EncryptedConfiguration,
|
EncryptedConfiguration,
|
||||||
|
JSONText,
|
||||||
MutableDict,
|
MutableDict,
|
||||||
MutableList,
|
MutableList,
|
||||||
json_cast_property,
|
json_cast_property,
|
||||||
@@ -315,7 +316,7 @@ class QueryResult(db.Model, BelongsToOrgMixin):
|
|||||||
data_source = db.relationship(DataSource, backref=backref("query_results"))
|
data_source = db.relationship(DataSource, backref=backref("query_results"))
|
||||||
query_hash = Column(db.String(32), index=True)
|
query_hash = Column(db.String(32), index=True)
|
||||||
query_text = Column("query", db.Text)
|
query_text = Column("query", db.Text)
|
||||||
data = Column(MutableDict.as_mutable(JSONB), nullable=True)
|
data = Column(JSONText, nullable=True)
|
||||||
runtime = Column(DOUBLE_PRECISION)
|
runtime = Column(DOUBLE_PRECISION)
|
||||||
retrieved_at = Column(db.DateTime(True))
|
retrieved_at = Column(db.DateTime(True))
|
||||||
|
|
||||||
@@ -386,6 +387,10 @@ class QueryResult(db.Model, BelongsToOrgMixin):
|
|||||||
|
|
||||||
|
|
||||||
def should_schedule_next(previous_iteration, now, interval, time=None, day_of_week=None, failures=0):
|
def should_schedule_next(previous_iteration, now, interval, time=None, day_of_week=None, failures=0):
|
||||||
|
# if previous_iteration is None, it means the query has never been run before
|
||||||
|
# so we should schedule it immediately
|
||||||
|
if previous_iteration is None:
|
||||||
|
return True
|
||||||
# if time exists then interval > 23 hours (82800s)
|
# if time exists then interval > 23 hours (82800s)
|
||||||
# if day_of_week exists then interval > 6 days (518400s)
|
# if day_of_week exists then interval > 6 days (518400s)
|
||||||
if time is None:
|
if time is None:
|
||||||
@@ -559,7 +564,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
|||||||
db.session.query(tag_column, usage_count)
|
db.session.query(tag_column, usage_count)
|
||||||
.group_by(tag_column)
|
.group_by(tag_column)
|
||||||
.filter(Query.id.in_(queries.options(load_only("id"))))
|
.filter(Query.id.in_(queries.options(load_only("id"))))
|
||||||
.order_by(usage_count.desc())
|
.order_by(tag_column)
|
||||||
)
|
)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
@@ -578,7 +583,8 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
|||||||
return [
|
return [
|
||||||
query
|
query
|
||||||
for query in queries
|
for query in queries
|
||||||
if query.schedule["until"] is not None
|
if "until" in query.schedule
|
||||||
|
and query.schedule["until"] is not None
|
||||||
and pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d")) <= now
|
and pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d")) <= now
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -600,6 +606,11 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
|||||||
if query.schedule.get("disabled"):
|
if query.schedule.get("disabled"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip queries that have None for all schedule values. It's unclear whether this
|
||||||
|
# something that can happen in practice, but we have a test case for it.
|
||||||
|
if all(value is None for value in query.schedule.values()):
|
||||||
|
continue
|
||||||
|
|
||||||
if query.schedule["until"]:
|
if query.schedule["until"]:
|
||||||
schedule_until = pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d"))
|
schedule_until = pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d"))
|
||||||
|
|
||||||
@@ -611,7 +622,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if should_schedule_next(
|
if should_schedule_next(
|
||||||
retrieved_at or now,
|
retrieved_at,
|
||||||
now,
|
now,
|
||||||
query.schedule["interval"],
|
query.schedule["interval"],
|
||||||
query.schedule["time"],
|
query.schedule["time"],
|
||||||
@@ -897,6 +908,7 @@ def next_state(op, value, threshold):
|
|||||||
# boolean value is Python specific and most likely will be confusing to
|
# boolean value is Python specific and most likely will be confusing to
|
||||||
# users.
|
# users.
|
||||||
value = str(value).lower()
|
value = str(value).lower()
|
||||||
|
value_is_number = False
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
value = float(value)
|
value = float(value)
|
||||||
@@ -914,6 +926,8 @@ def next_state(op, value, threshold):
|
|||||||
|
|
||||||
if op(value, threshold):
|
if op(value, threshold):
|
||||||
new_state = Alert.TRIGGERED_STATE
|
new_state = Alert.TRIGGERED_STATE
|
||||||
|
elif not value_is_number and op not in [OPERATORS.get("!="), OPERATORS.get("=="), OPERATORS.get("equals")]:
|
||||||
|
new_state = Alert.UNKNOWN_STATE
|
||||||
else:
|
else:
|
||||||
new_state = Alert.OK_STATE
|
new_state = Alert.OK_STATE
|
||||||
|
|
||||||
@@ -925,6 +939,7 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
|
|||||||
UNKNOWN_STATE = "unknown"
|
UNKNOWN_STATE = "unknown"
|
||||||
OK_STATE = "ok"
|
OK_STATE = "ok"
|
||||||
TRIGGERED_STATE = "triggered"
|
TRIGGERED_STATE = "triggered"
|
||||||
|
TEST_STATE = "test"
|
||||||
|
|
||||||
id = primary_key("Alert")
|
id = primary_key("Alert")
|
||||||
name = Column(db.String(255))
|
name = Column(db.String(255))
|
||||||
@@ -954,17 +969,38 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
|
|||||||
return super(Alert, cls).get_by_id_and_org(object_id, org, Query)
|
return super(Alert, cls).get_by_id_and_org(object_id, org, Query)
|
||||||
|
|
||||||
def evaluate(self):
|
def evaluate(self):
|
||||||
data = self.query_rel.latest_query_data.data
|
data = self.query_rel.latest_query_data.data if self.query_rel.latest_query_data else None
|
||||||
|
new_state = self.UNKNOWN_STATE
|
||||||
|
|
||||||
if data["rows"] and self.options["column"] in data["rows"][0]:
|
if data and data["rows"] and self.options["column"] in data["rows"][0]:
|
||||||
op = OPERATORS.get(self.options["op"], lambda v, t: False)
|
op = OPERATORS.get(self.options["op"], lambda v, t: False)
|
||||||
|
|
||||||
value = data["rows"][0][self.options["column"]]
|
if "selector" not in self.options:
|
||||||
|
selector = "first"
|
||||||
|
else:
|
||||||
|
selector = self.options["selector"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if selector == "max":
|
||||||
|
max_val = float("-inf")
|
||||||
|
for i in range(len(data["rows"])):
|
||||||
|
max_val = max(max_val, float(data["rows"][i][self.options["column"]]))
|
||||||
|
value = max_val
|
||||||
|
elif selector == "min":
|
||||||
|
min_val = float("inf")
|
||||||
|
for i in range(len(data["rows"])):
|
||||||
|
min_val = min(min_val, float(data["rows"][i][self.options["column"]]))
|
||||||
|
value = min_val
|
||||||
|
else:
|
||||||
|
value = data["rows"][0][self.options["column"]]
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return self.UNKNOWN_STATE
|
||||||
|
|
||||||
threshold = self.options["value"]
|
threshold = self.options["value"]
|
||||||
|
|
||||||
new_state = next_state(op, value, threshold)
|
if value is not None:
|
||||||
else:
|
new_state = next_state(op, value, threshold)
|
||||||
new_state = self.UNKNOWN_STATE
|
|
||||||
|
|
||||||
return new_state
|
return new_state
|
||||||
|
|
||||||
@@ -987,11 +1023,11 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
|
|||||||
result_table = [] # A two-dimensional array which can rendered as a table in Mustache
|
result_table = [] # A two-dimensional array which can rendered as a table in Mustache
|
||||||
for row in data["rows"]:
|
for row in data["rows"]:
|
||||||
result_table.append([row[col["name"]] for col in data["columns"]])
|
result_table.append([row[col["name"]] for col in data["columns"]])
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"ALERT_NAME": self.name,
|
"ALERT_NAME": self.name,
|
||||||
"ALERT_URL": "{host}/alerts/{alert_id}".format(host=host, alert_id=self.id),
|
"ALERT_URL": "{host}/alerts/{alert_id}".format(host=host, alert_id=self.id),
|
||||||
"ALERT_STATUS": self.state.upper(),
|
"ALERT_STATUS": self.state.upper(),
|
||||||
|
"ALERT_SELECTOR": self.options["selector"],
|
||||||
"ALERT_CONDITION": self.options["op"],
|
"ALERT_CONDITION": self.options["op"],
|
||||||
"ALERT_THRESHOLD": self.options["value"],
|
"ALERT_THRESHOLD": self.options["value"],
|
||||||
"QUERY_NAME": self.query_rel.name,
|
"QUERY_NAME": self.query_rel.name,
|
||||||
@@ -1101,7 +1137,7 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
|
|||||||
db.session.query(tag_column, usage_count)
|
db.session.query(tag_column, usage_count)
|
||||||
.group_by(tag_column)
|
.group_by(tag_column)
|
||||||
.filter(Dashboard.id.in_(dashboards.options(load_only("id"))))
|
.filter(Dashboard.id.in_(dashboards.options(load_only("id"))))
|
||||||
.order_by(usage_count.desc())
|
.order_by(tag_column)
|
||||||
)
|
)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from numbers import Number
|
from numbers import Number
|
||||||
|
|
||||||
@@ -88,6 +89,16 @@ def _is_number(string):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_regex_pattern(value, regex):
|
||||||
|
try:
|
||||||
|
if re.compile(regex).fullmatch(value):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_date(string):
|
def _is_date(string):
|
||||||
parse(string)
|
parse(string)
|
||||||
return True
|
return True
|
||||||
@@ -135,6 +146,7 @@ class ParameterizedQuery:
|
|||||||
|
|
||||||
enum_options = definition.get("enumOptions")
|
enum_options = definition.get("enumOptions")
|
||||||
query_id = definition.get("queryId")
|
query_id = definition.get("queryId")
|
||||||
|
regex = definition.get("regex")
|
||||||
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
|
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
|
||||||
|
|
||||||
if isinstance(enum_options, str):
|
if isinstance(enum_options, str):
|
||||||
@@ -142,6 +154,7 @@ class ParameterizedQuery:
|
|||||||
|
|
||||||
validators = {
|
validators = {
|
||||||
"text": lambda value: isinstance(value, str),
|
"text": lambda value: isinstance(value, str),
|
||||||
|
"text-pattern": lambda value: _is_regex_pattern(value, regex),
|
||||||
"number": _is_number,
|
"number": _is_number,
|
||||||
"enum": lambda value: _is_value_within_options(value, enum_options, allow_multiple_values),
|
"enum": lambda value: _is_value_within_options(value, enum_options, allow_multiple_values),
|
||||||
"query": lambda value: _is_value_within_options(
|
"query": lambda value: _is_value_within_options(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from sqlalchemy.ext.mutable import Mutable
|
|||||||
from sqlalchemy.types import TypeDecorator
|
from sqlalchemy.types import TypeDecorator
|
||||||
from sqlalchemy_utils import EncryptedType
|
from sqlalchemy_utils import EncryptedType
|
||||||
|
|
||||||
|
from redash.utils import json_dumps, json_loads
|
||||||
from redash.utils.configuration import ConfigurationContainer
|
from redash.utils.configuration import ConfigurationContainer
|
||||||
|
|
||||||
from .base import db
|
from .base import db
|
||||||
@@ -28,6 +29,22 @@ class EncryptedConfiguration(EncryptedType):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Utilized for cases when JSON size is bigger than JSONB (255MB) or JSON (10MB) limit
|
||||||
|
class JSONText(TypeDecorator):
|
||||||
|
impl = db.Text
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return json_dumps(value)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
return json_loads(value)
|
||||||
|
|
||||||
|
|
||||||
class MutableDict(Mutable, dict):
|
class MutableDict(Mutable, dict):
|
||||||
@classmethod
|
@classmethod
|
||||||
def coerce(cls, key, value):
|
def coerce(cls, key, value):
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class User(TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCh
|
|||||||
if self._profile_image_url:
|
if self._profile_image_url:
|
||||||
return self._profile_image_url
|
return self._profile_image_url
|
||||||
|
|
||||||
email_md5 = hashlib.md5(self.email.lower().encode()).hexdigest()
|
email_md5 = hashlib.md5(self.email.lower().encode(), usedforsecurity=False).hexdigest()
|
||||||
return "https://www.gravatar.com/avatar/{}?s=40&d=identicon".format(email_md5)
|
return "https://www.gravatar.com/avatar/{}?s=40&d=identicon".format(email_md5)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -233,7 +233,9 @@ class User(TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCh
|
|||||||
return AccessPermission.exists(obj, access_type, grantee=self)
|
return AccessPermission.exists(obj, access_type, grantee=self)
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
identity = hashlib.md5("{},{}".format(self.email, self.password_hash).encode()).hexdigest()
|
identity = hashlib.md5(
|
||||||
|
"{},{}".format(self.email, self.password_hash).encode(), usedforsecurity=False
|
||||||
|
).hexdigest()
|
||||||
return "{0}-{1}".format(self.id, identity)
|
return "{0}-{1}".format(self.id, identity)
|
||||||
|
|
||||||
def get_actual_user(self):
|
def get_actual_user(self):
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def get_status():
|
|||||||
|
|
||||||
|
|
||||||
def rq_job_ids():
|
def rq_job_ids():
|
||||||
queues = Queue.all(connection=redis_connection)
|
queues = Queue.all(connection=rq_redis_connection)
|
||||||
|
|
||||||
started_jobs = [StartedJobRegistry(queue=q).get_job_ids() for q in queues]
|
started_jobs = [StartedJobRegistry(queue=q).get_job_ids() for q in queues]
|
||||||
queued_jobs = [q.job_ids for q in queues]
|
queued_jobs = [q.job_ids for q in queues]
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ class BaseQueryRunner:
|
|||||||
noop_query = None
|
noop_query = None
|
||||||
limit_query = " LIMIT 1000"
|
limit_query = " LIMIT 1000"
|
||||||
limit_keywords = ["LIMIT", "OFFSET"]
|
limit_keywords = ["LIMIT", "OFFSET"]
|
||||||
|
limit_after_select = False
|
||||||
|
|
||||||
def __init__(self, configuration):
|
def __init__(self, configuration):
|
||||||
self.syntax = "sql"
|
self.syntax = "sql"
|
||||||
@@ -287,7 +288,10 @@ class BaseSQLQueryRunner(BaseQueryRunner):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def query_is_select_no_limit(self, query):
|
def query_is_select_no_limit(self, query):
|
||||||
parsed_query = sqlparse.parse(query)[0]
|
parsed_query_list = sqlparse.parse(query)
|
||||||
|
if len(parsed_query_list) == 0:
|
||||||
|
return False
|
||||||
|
parsed_query = parsed_query_list[0]
|
||||||
last_keyword_idx = find_last_keyword_idx(parsed_query)
|
last_keyword_idx = find_last_keyword_idx(parsed_query)
|
||||||
# Either invalid query or query that is not select
|
# Either invalid query or query that is not select
|
||||||
if last_keyword_idx == -1 or parsed_query.tokens[0].value.upper() != "SELECT":
|
if last_keyword_idx == -1 or parsed_query.tokens[0].value.upper() != "SELECT":
|
||||||
@@ -301,10 +305,19 @@ class BaseSQLQueryRunner(BaseQueryRunner):
|
|||||||
parsed_query = sqlparse.parse(query)[0]
|
parsed_query = sqlparse.parse(query)[0]
|
||||||
limit_tokens = sqlparse.parse(self.limit_query)[0].tokens
|
limit_tokens = sqlparse.parse(self.limit_query)[0].tokens
|
||||||
length = len(parsed_query.tokens)
|
length = len(parsed_query.tokens)
|
||||||
if parsed_query.tokens[length - 1].ttype == sqlparse.tokens.Punctuation:
|
if not self.limit_after_select:
|
||||||
parsed_query.tokens[length - 1 : length - 1] = limit_tokens
|
if parsed_query.tokens[length - 1].ttype == sqlparse.tokens.Punctuation:
|
||||||
|
parsed_query.tokens[length - 1 : length - 1] = limit_tokens
|
||||||
|
else:
|
||||||
|
parsed_query.tokens += limit_tokens
|
||||||
else:
|
else:
|
||||||
parsed_query.tokens += limit_tokens
|
for i in range(length - 1, -1, -1):
|
||||||
|
if parsed_query[i].value.upper() == "SELECT":
|
||||||
|
index = parsed_query.token_index(parsed_query[i + 1])
|
||||||
|
parsed_query = sqlparse.sql.Statement(
|
||||||
|
parsed_query.tokens[:index] + limit_tokens + parsed_query.tokens[index:]
|
||||||
|
)
|
||||||
|
break
|
||||||
return str(parsed_query)
|
return str(parsed_query)
|
||||||
|
|
||||||
def apply_auto_limit(self, query_text, should_apply_auto_limit):
|
def apply_auto_limit(self, query_text, should_apply_auto_limit):
|
||||||
|
|||||||
@@ -63,5 +63,8 @@ class AmazonElasticsearchService(ElasticSearch2):
|
|||||||
|
|
||||||
self.auth = AWSV4Sign(cred, region, "es")
|
self.auth = AWSV4Sign(cred, region, "es")
|
||||||
|
|
||||||
|
def get_auth(self):
|
||||||
|
return self.auth
|
||||||
|
|
||||||
|
|
||||||
register(AmazonElasticsearchService)
|
register(AmazonElasticsearchService)
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ class Athena(BaseQueryRunner):
|
|||||||
"default": "default",
|
"default": "default",
|
||||||
},
|
},
|
||||||
"glue": {"type": "boolean", "title": "Use Glue Data Catalog"},
|
"glue": {"type": "boolean", "title": "Use Glue Data Catalog"},
|
||||||
|
"catalog_ids": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Enter Glue Data Catalog IDs, separated by commas (leave blank for default catalog)",
|
||||||
|
},
|
||||||
"work_group": {
|
"work_group": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Athena Work Group",
|
"title": "Athena Work Group",
|
||||||
@@ -86,15 +90,26 @@ class Athena(BaseQueryRunner):
|
|||||||
"title": "Athena cost per Tb scanned (USD)",
|
"title": "Athena cost per Tb scanned (USD)",
|
||||||
"default": 5,
|
"default": 5,
|
||||||
},
|
},
|
||||||
|
"result_reuse_enable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Reuse Athena query results",
|
||||||
|
},
|
||||||
|
"result_reuse_minutes": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "Minutes to reuse Athena query results",
|
||||||
|
"default": 60,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["region", "s3_staging_dir"],
|
"required": ["region", "s3_staging_dir"],
|
||||||
"extra_options": ["glue", "cost_per_tb"],
|
"extra_options": ["glue", "catalog_ids", "cost_per_tb", "result_reuse_enable", "result_reuse_minutes"],
|
||||||
"order": [
|
"order": [
|
||||||
"region",
|
"region",
|
||||||
"s3_staging_dir",
|
"s3_staging_dir",
|
||||||
"schema",
|
"schema",
|
||||||
"work_group",
|
"work_group",
|
||||||
"cost_per_tb",
|
"cost_per_tb",
|
||||||
|
"result_reuse_enable",
|
||||||
|
"result_reuse_minutes",
|
||||||
],
|
],
|
||||||
"secret": ["aws_secret_key"],
|
"secret": ["aws_secret_key"],
|
||||||
}
|
}
|
||||||
@@ -172,35 +187,53 @@ class Athena(BaseQueryRunner):
|
|||||||
"region_name": self.configuration["region"],
|
"region_name": self.configuration["region"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def __get_schema_from_glue(self):
|
def __get_schema_from_glue(self, catalog_id=""):
|
||||||
client = boto3.client("glue", **self._get_iam_credentials())
|
client = boto3.client("glue", **self._get_iam_credentials())
|
||||||
schema = {}
|
schema = {}
|
||||||
|
|
||||||
database_paginator = client.get_paginator("get_databases")
|
database_paginator = client.get_paginator("get_databases")
|
||||||
table_paginator = client.get_paginator("get_tables")
|
table_paginator = client.get_paginator("get_tables")
|
||||||
|
|
||||||
for databases in database_paginator.paginate():
|
databases_iterator = database_paginator.paginate(
|
||||||
|
**({"CatalogId": catalog_id} if catalog_id != "" else {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for databases in databases_iterator:
|
||||||
for database in databases["DatabaseList"]:
|
for database in databases["DatabaseList"]:
|
||||||
iterator = table_paginator.paginate(DatabaseName=database["Name"])
|
iterator = table_paginator.paginate(
|
||||||
|
DatabaseName=database["Name"],
|
||||||
|
**({"CatalogId": catalog_id} if catalog_id != "" else {}),
|
||||||
|
)
|
||||||
for table in iterator.search("TableList[]"):
|
for table in iterator.search("TableList[]"):
|
||||||
table_name = "%s.%s" % (database["Name"], table["Name"])
|
table_name = "%s.%s" % (database["Name"], table["Name"])
|
||||||
if "StorageDescriptor" not in table:
|
if "StorageDescriptor" not in table:
|
||||||
logger.warning("Glue table doesn't have StorageDescriptor: %s", table_name)
|
logger.warning("Glue table doesn't have StorageDescriptor: %s", table_name)
|
||||||
continue
|
continue
|
||||||
if table_name not in schema:
|
if table_name not in schema:
|
||||||
column = [columns["Name"] for columns in table["StorageDescriptor"]["Columns"]]
|
schema[table_name] = {"name": table_name, "columns": []}
|
||||||
schema[table_name] = {"name": table_name, "columns": column}
|
|
||||||
for partition in table.get("PartitionKeys", []):
|
for column_data in table["StorageDescriptor"]["Columns"]:
|
||||||
schema[table_name]["columns"].append(partition["Name"])
|
column = {
|
||||||
|
"name": column_data["Name"],
|
||||||
|
"type": column_data["Type"] if "Type" in column_data else None,
|
||||||
|
}
|
||||||
|
schema[table_name]["columns"].append(column)
|
||||||
|
for partition in table.get("PartitionKeys", []):
|
||||||
|
partition_column = {
|
||||||
|
"name": partition["Name"],
|
||||||
|
"type": partition["Type"] if "Type" in partition else None,
|
||||||
|
}
|
||||||
|
schema[table_name]["columns"].append(partition_column)
|
||||||
return list(schema.values())
|
return list(schema.values())
|
||||||
|
|
||||||
def get_schema(self, get_stats=False):
|
def get_schema(self, get_stats=False):
|
||||||
if self.configuration.get("glue", False):
|
if self.configuration.get("glue", False):
|
||||||
return self.__get_schema_from_glue()
|
catalog_ids = [id.strip() for id in self.configuration.get("catalog_ids", "").split(",")]
|
||||||
|
return sum([self.__get_schema_from_glue(catalog_id) for catalog_id in catalog_ids], [])
|
||||||
|
|
||||||
schema = {}
|
schema = {}
|
||||||
query = """
|
query = """
|
||||||
SELECT table_schema, table_name, column_name
|
SELECT table_schema, table_name, column_name, data_type
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema NOT IN ('information_schema')
|
WHERE table_schema NOT IN ('information_schema')
|
||||||
"""
|
"""
|
||||||
@@ -213,7 +246,7 @@ class Athena(BaseQueryRunner):
|
|||||||
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
|
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
|
||||||
if table_name not in schema:
|
if table_name not in schema:
|
||||||
schema[table_name] = {"name": table_name, "columns": []}
|
schema[table_name] = {"name": table_name, "columns": []}
|
||||||
schema[table_name]["columns"].append(row["column_name"])
|
schema[table_name]["columns"].append({"name": row["column_name"], "type": row["data_type"]})
|
||||||
|
|
||||||
return list(schema.values())
|
return list(schema.values())
|
||||||
|
|
||||||
@@ -225,6 +258,8 @@ class Athena(BaseQueryRunner):
|
|||||||
kms_key=self.configuration.get("kms_key", None),
|
kms_key=self.configuration.get("kms_key", None),
|
||||||
work_group=self.configuration.get("work_group", "primary"),
|
work_group=self.configuration.get("work_group", "primary"),
|
||||||
formatter=SimpleFormatter(),
|
formatter=SimpleFormatter(),
|
||||||
|
result_reuse_enable=self.configuration.get("result_reuse_enable", False),
|
||||||
|
result_reuse_minutes=self.configuration.get("result_reuse_minutes", 60),
|
||||||
**self._get_iam_credentials(user=user),
|
**self._get_iam_credentials(user=user),
|
||||||
).cursor()
|
).cursor()
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ from redash.query_runner import (
|
|||||||
from redash.utils import json_loads
|
from redash.utils import json_loads
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from azure.kusto.data.exceptions import KustoServiceError
|
from azure.kusto.data import (
|
||||||
from azure.kusto.data.request import (
|
|
||||||
ClientRequestProperties,
|
ClientRequestProperties,
|
||||||
KustoClient,
|
KustoClient,
|
||||||
KustoConnectionStringBuilder,
|
KustoConnectionStringBuilder,
|
||||||
)
|
)
|
||||||
|
from azure.kusto.data.exceptions import KustoServiceError
|
||||||
|
|
||||||
enabled = True
|
enabled = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -37,6 +37,34 @@ TYPES_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_data_scanned(kusto_response):
|
||||||
|
try:
|
||||||
|
metadata_table = next(
|
||||||
|
(table for table in kusto_response.tables if table.table_name == "QueryCompletionInformation"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if metadata_table:
|
||||||
|
resource_usage_json = next(
|
||||||
|
(row["Payload"] for row in metadata_table.rows if row["EventTypeName"] == "QueryResourceConsumption"),
|
||||||
|
"{}",
|
||||||
|
)
|
||||||
|
resource_usage = json_loads(resource_usage_json).get("resource_usage", {})
|
||||||
|
|
||||||
|
data_scanned = (
|
||||||
|
resource_usage["cache"]["shards"]["cold"]["hitbytes"]
|
||||||
|
+ resource_usage["cache"]["shards"]["cold"]["missbytes"]
|
||||||
|
+ resource_usage["cache"]["shards"]["hot"]["hitbytes"]
|
||||||
|
+ resource_usage["cache"]["shards"]["hot"]["missbytes"]
|
||||||
|
+ resource_usage["cache"]["shards"]["bypassbytes"]
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
data_scanned = 0
|
||||||
|
|
||||||
|
return int(data_scanned)
|
||||||
|
|
||||||
|
|
||||||
class AzureKusto(BaseQueryRunner):
|
class AzureKusto(BaseQueryRunner):
|
||||||
should_annotate_query = False
|
should_annotate_query = False
|
||||||
noop_query = "let noop = datatable (Noop:string)[1]; noop"
|
noop_query = "let noop = datatable (Noop:string)[1]; noop"
|
||||||
@@ -44,8 +72,6 @@ class AzureKusto(BaseQueryRunner):
|
|||||||
def __init__(self, configuration):
|
def __init__(self, configuration):
|
||||||
super(AzureKusto, self).__init__(configuration)
|
super(AzureKusto, self).__init__(configuration)
|
||||||
self.syntax = "custom"
|
self.syntax = "custom"
|
||||||
self.client_request_properties = ClientRequestProperties()
|
|
||||||
self.client_request_properties.application = "redash"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def configuration_schema(cls):
|
def configuration_schema(cls):
|
||||||
@@ -60,12 +86,14 @@ class AzureKusto(BaseQueryRunner):
|
|||||||
},
|
},
|
||||||
"azure_ad_tenant_id": {"type": "string", "title": "Azure AD Tenant Id"},
|
"azure_ad_tenant_id": {"type": "string", "title": "Azure AD Tenant Id"},
|
||||||
"database": {"type": "string"},
|
"database": {"type": "string"},
|
||||||
|
"msi": {"type": "boolean", "title": "Use Managed Service Identity"},
|
||||||
|
"user_msi": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "User-assigned managed identity client ID",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"cluster",
|
"cluster",
|
||||||
"azure_ad_client_id",
|
|
||||||
"azure_ad_client_secret",
|
|
||||||
"azure_ad_tenant_id",
|
|
||||||
"database",
|
"database",
|
||||||
],
|
],
|
||||||
"order": [
|
"order": [
|
||||||
@@ -91,18 +119,48 @@ class AzureKusto(BaseQueryRunner):
|
|||||||
return "Azure Data Explorer (Kusto)"
|
return "Azure Data Explorer (Kusto)"
|
||||||
|
|
||||||
def run_query(self, query, user):
|
def run_query(self, query, user):
|
||||||
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
|
cluster = self.configuration["cluster"]
|
||||||
connection_string=self.configuration["cluster"],
|
msi = self.configuration.get("msi", False)
|
||||||
aad_app_id=self.configuration["azure_ad_client_id"],
|
# Managed Service Identity(MSI)
|
||||||
app_key=self.configuration["azure_ad_client_secret"],
|
if msi:
|
||||||
authority_id=self.configuration["azure_ad_tenant_id"],
|
# If user-assigned managed identity is used, the client ID must be provided
|
||||||
)
|
if self.configuration.get("user_msi"):
|
||||||
|
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(
|
||||||
|
cluster,
|
||||||
|
client_id=self.configuration["user_msi"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(cluster)
|
||||||
|
# Service Principal auth
|
||||||
|
else:
|
||||||
|
aad_app_id = self.configuration.get("azure_ad_client_id")
|
||||||
|
app_key = self.configuration.get("azure_ad_client_secret")
|
||||||
|
authority_id = self.configuration.get("azure_ad_tenant_id")
|
||||||
|
|
||||||
|
if not (aad_app_id and app_key and authority_id):
|
||||||
|
raise ValueError(
|
||||||
|
"Azure AD Client ID, Client Secret, and Tenant ID are required for Service Principal authentication."
|
||||||
|
)
|
||||||
|
|
||||||
|
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
|
||||||
|
connection_string=cluster,
|
||||||
|
aad_app_id=aad_app_id,
|
||||||
|
app_key=app_key,
|
||||||
|
authority_id=authority_id,
|
||||||
|
)
|
||||||
|
|
||||||
client = KustoClient(kcsb)
|
client = KustoClient(kcsb)
|
||||||
|
|
||||||
|
request_properties = ClientRequestProperties()
|
||||||
|
request_properties.application = "redash"
|
||||||
|
|
||||||
|
if user:
|
||||||
|
request_properties.user = user.email
|
||||||
|
request_properties.set_option("request_description", user.email)
|
||||||
|
|
||||||
db = self.configuration["database"]
|
db = self.configuration["database"]
|
||||||
try:
|
try:
|
||||||
response = client.execute(db, query, self.client_request_properties)
|
response = client.execute(db, query, request_properties)
|
||||||
|
|
||||||
result_cols = response.primary_results[0].columns
|
result_cols = response.primary_results[0].columns
|
||||||
result_rows = response.primary_results[0].rows
|
result_rows = response.primary_results[0].rows
|
||||||
@@ -123,14 +181,15 @@ class AzureKusto(BaseQueryRunner):
|
|||||||
rows.append(row.to_dict())
|
rows.append(row.to_dict())
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
data = {"columns": columns, "rows": rows}
|
data = {
|
||||||
|
"columns": columns,
|
||||||
|
"rows": rows,
|
||||||
|
"metadata": {"data_scanned": _get_data_scanned(response)},
|
||||||
|
}
|
||||||
|
|
||||||
except KustoServiceError as err:
|
except KustoServiceError as err:
|
||||||
data = None
|
data = None
|
||||||
try:
|
error = str(err)
|
||||||
error = err.args[1][0]["error"]["@message"]
|
|
||||||
except (IndexError, KeyError):
|
|
||||||
error = err.args[1]
|
|
||||||
|
|
||||||
return data, error
|
return data, error
|
||||||
|
|
||||||
@@ -143,7 +202,10 @@ class AzureKusto(BaseQueryRunner):
|
|||||||
self._handle_run_query_error(error)
|
self._handle_run_query_error(error)
|
||||||
|
|
||||||
schema_as_json = json_loads(results["rows"][0]["DatabaseSchema"])
|
schema_as_json = json_loads(results["rows"][0]["DatabaseSchema"])
|
||||||
tables_list = schema_as_json["Databases"][self.configuration["database"]]["Tables"].values()
|
tables_list = [
|
||||||
|
*(schema_as_json["Databases"][self.configuration["database"]]["Tables"].values()),
|
||||||
|
*(schema_as_json["Databases"][self.configuration["database"]]["MaterializedViews"].values()),
|
||||||
|
]
|
||||||
|
|
||||||
schema = {}
|
schema = {}
|
||||||
|
|
||||||
@@ -154,7 +216,9 @@ class AzureKusto(BaseQueryRunner):
|
|||||||
schema[table_name] = {"name": table_name, "columns": []}
|
schema[table_name] = {"name": table_name, "columns": []}
|
||||||
|
|
||||||
for column in table["OrderedColumns"]:
|
for column in table["OrderedColumns"]:
|
||||||
schema[table_name]["columns"].append(column["Name"])
|
schema[table_name]["columns"].append(
|
||||||
|
{"name": column["Name"], "type": TYPES_MAP.get(column["CslType"], None)}
|
||||||
|
)
|
||||||
|
|
||||||
return list(schema.values())
|
return list(schema.values())
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ from base64 import b64decode
|
|||||||
from redash import settings
|
from redash import settings
|
||||||
from redash.query_runner import (
|
from redash.query_runner import (
|
||||||
TYPE_BOOLEAN,
|
TYPE_BOOLEAN,
|
||||||
|
TYPE_DATE,
|
||||||
TYPE_DATETIME,
|
TYPE_DATETIME,
|
||||||
TYPE_FLOAT,
|
TYPE_FLOAT,
|
||||||
TYPE_INTEGER,
|
TYPE_INTEGER,
|
||||||
TYPE_STRING,
|
TYPE_STRING,
|
||||||
BaseQueryRunner,
|
BaseSQLQueryRunner,
|
||||||
InterruptException,
|
InterruptException,
|
||||||
JobTimeoutException,
|
JobTimeoutException,
|
||||||
register,
|
register,
|
||||||
@@ -37,6 +38,8 @@ types_map = {
|
|||||||
"BOOLEAN": TYPE_BOOLEAN,
|
"BOOLEAN": TYPE_BOOLEAN,
|
||||||
"STRING": TYPE_STRING,
|
"STRING": TYPE_STRING,
|
||||||
"TIMESTAMP": TYPE_DATETIME,
|
"TIMESTAMP": TYPE_DATETIME,
|
||||||
|
"DATETIME": TYPE_DATETIME,
|
||||||
|
"DATE": TYPE_DATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +86,7 @@ def _get_query_results(jobs, project_id, location, job_id, start_index):
|
|||||||
).execute()
|
).execute()
|
||||||
logging.debug("query_reply %s", query_reply)
|
logging.debug("query_reply %s", query_reply)
|
||||||
if not query_reply["jobComplete"]:
|
if not query_reply["jobComplete"]:
|
||||||
time.sleep(10)
|
time.sleep(1)
|
||||||
return _get_query_results(jobs, project_id, location, job_id, start_index)
|
return _get_query_results(jobs, project_id, location, job_id, start_index)
|
||||||
|
|
||||||
return query_reply
|
return query_reply
|
||||||
@@ -95,12 +98,12 @@ def _get_total_bytes_processed_for_resp(bq_response):
|
|||||||
return int(bq_response.get("totalBytesProcessed", "0"))
|
return int(bq_response.get("totalBytesProcessed", "0"))
|
||||||
|
|
||||||
|
|
||||||
class BigQuery(BaseQueryRunner):
|
class BigQuery(BaseSQLQueryRunner):
|
||||||
noop_query = "SELECT 1"
|
noop_query = "SELECT 1"
|
||||||
|
|
||||||
def __init__(self, configuration):
|
def __init__(self, configuration):
|
||||||
super().__init__(configuration)
|
super().__init__(configuration)
|
||||||
self.should_annotate_query = configuration["useQueryAnnotation"]
|
self.should_annotate_query = configuration.get("useQueryAnnotation", False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled(cls):
|
def enabled(cls):
|
||||||
@@ -301,7 +304,7 @@ class BigQuery(BaseQueryRunner):
|
|||||||
datasets = self._get_project_datasets(project_id)
|
datasets = self._get_project_datasets(project_id)
|
||||||
|
|
||||||
query_base = """
|
query_base = """
|
||||||
SELECT table_schema, table_name, field_path
|
SELECT table_schema, table_name, field_path, data_type
|
||||||
FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
|
FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
|
||||||
WHERE table_schema NOT IN ('information_schema')
|
WHERE table_schema NOT IN ('information_schema')
|
||||||
"""
|
"""
|
||||||
@@ -310,6 +313,10 @@ class BigQuery(BaseQueryRunner):
|
|||||||
queries = []
|
queries = []
|
||||||
for dataset in datasets:
|
for dataset in datasets:
|
||||||
dataset_id = dataset["datasetReference"]["datasetId"]
|
dataset_id = dataset["datasetReference"]["datasetId"]
|
||||||
|
location = dataset["location"]
|
||||||
|
if self._get_location() and location != self._get_location():
|
||||||
|
logger.debug("dataset location is different: %s", location)
|
||||||
|
continue
|
||||||
query = query_base.format(dataset_id=dataset_id)
|
query = query_base.format(dataset_id=dataset_id)
|
||||||
queries.append(query)
|
queries.append(query)
|
||||||
|
|
||||||
@@ -322,7 +329,7 @@ class BigQuery(BaseQueryRunner):
|
|||||||
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
|
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
|
||||||
if table_name not in schema:
|
if table_name not in schema:
|
||||||
schema[table_name] = {"name": table_name, "columns": []}
|
schema[table_name] = {"name": table_name, "columns": []}
|
||||||
schema[table_name]["columns"].append(row["field_path"])
|
schema[table_name]["columns"].append({"name": row["field_path"], "type": row["data_type"]})
|
||||||
|
|
||||||
return list(schema.values())
|
return list(schema.values())
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user