mirror of
https://github.com/getredash/redash.git
synced 2025-12-22 02:45:44 -05:00
Compare commits
266 Commits
23.10.0-de
...
redis-lock
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e0e128244 | ||
|
|
34e932556b | ||
|
|
594e2f24ef | ||
|
|
3275a9e459 | ||
|
|
3bad8c8e8c | ||
|
|
d0af4499d6 | ||
|
|
4357ea56ae | ||
|
|
5df5ca87a2 | ||
|
|
8387fe6fcb | ||
|
|
e95de2ee4c | ||
|
|
71902e5933 | ||
|
|
53eab14cef | ||
|
|
925bb91d8e | ||
|
|
a50ea05b19 | ||
|
|
ec2ca6f986 | ||
|
|
96ea0194e8 | ||
|
|
2776992101 | ||
|
|
5cfa6bc217 | ||
|
|
85f001982e | ||
|
|
06c9a2b21a | ||
|
|
f841b217e8 | ||
|
|
af496fe5e3 | ||
|
|
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 | ||
|
|
4c47bef582 | ||
|
|
ec1c4d07de | ||
|
|
4d5103978b | ||
|
|
3c2c2786ed | ||
|
|
cd482e780a | ||
|
|
4d81c3148d | ||
|
|
1b1b9bd98d | ||
|
|
473cf29c9f | ||
|
|
cbde237b12 | ||
|
|
998dc31eb0 | ||
|
|
2505e8ab3b | ||
|
|
858fc4d78f | ||
|
|
3e500ea18e | ||
|
|
58bf96c298 | ||
|
|
66ef942572 | ||
|
|
9bbdb4b765 | ||
|
|
2b4b1cf7e3 | ||
|
|
9b29f26217 | ||
|
|
392b930f2d | ||
|
|
9df6f80bb7 | ||
|
|
f7b47c0436 | ||
|
|
09addaadc3 | ||
|
|
a07b8a6bd3 | ||
|
|
8bfc57430d | ||
|
|
a8c6dd0043 | ||
|
|
2d879510e4 | ||
|
|
13e61fc3a0 | ||
|
|
de1958e995 | ||
|
|
198b422eaf | ||
|
|
63cef6632e | ||
|
|
2611dcc0f1 | ||
|
|
55193fbf66 | ||
|
|
8b8dd4f68c | ||
|
|
ae77e72821 | ||
|
|
39e4ea155c | ||
|
|
a5b01bf8ee | ||
|
|
5516b427d8 | ||
|
|
de84c40868 | ||
|
|
39766a2d97 | ||
|
|
593b6ae6ed | ||
|
|
8bb1767c69 | ||
|
|
7b03e60f9d | ||
|
|
ac9f24a781 | ||
|
|
54c4a4249a | ||
|
|
36dd3e9609 | ||
|
|
69d1e03e60 | ||
|
|
a2c0c488eb | ||
|
|
ddbe0f6ce5 | ||
|
|
42108089ed | ||
|
|
d4ade51fba | ||
|
|
84d1693419 | ||
|
|
12f1050000 | ||
|
|
6b981972f0 | ||
|
|
eafe30d52c | ||
|
|
abbd4d3146 | ||
|
|
1d350853bd |
@@ -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/
|
||||||
|
|||||||
162
.github/workflows/ci.yml
vendored
162
.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,92 +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=ghq
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
env:
|
|
||||||
DOCKER_CONTENT_TRUST: true
|
|
||||||
|
|
||||||
- name: "Failure: output container logs to console"
|
|
||||||
if: failure()
|
|
||||||
run: docker compose logs
|
|
||||||
|
|||||||
84
.github/workflows/periodic-snapshot.yml
vendored
84
.github/workflows/periodic-snapshot.yml
vendored
@@ -1,27 +1,85 @@
|
|||||||
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 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
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- 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 push origin
|
git tag ${TAG_NAME}
|
||||||
git tag $date
|
git push --atomic origin master refs/tags/${TAG_NAME}
|
||||||
git push origin $date
|
|
||||||
|
# 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
|
||||||
|
|||||||
182
.github/workflows/preview-image.yml
vendored
Normal file
182
.github/workflows/preview-image.yml
vendored
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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"
|
||||||
|
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_USER }}/redash
|
||||||
|
${{ vars.DOCKER_USER }}/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_USER }}/redash:${{ steps.version.outputs.VERSION_TAG }}
|
||||||
|
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: "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_USER }}/redash:preview \
|
||||||
|
$(printf '${{ vars.DOCKER_USER }}/redash:preview@sha256:%s ' *)
|
||||||
|
docker buildx imagetools create -t ${{ vars.DOCKER_USER }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
|
||||||
|
$(printf '${{ vars.DOCKER_USER }}/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_USER }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
|
||||||
|
$(printf '${{ vars.DOCKER_USER }}/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 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-buster
|
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 | apt-key add - \
|
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then
|
||||||
&& curl https://packages.microsoft.com/config/debian/10/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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://redash.io/help/)
|
[](https://redash.io/help/)
|
||||||
[](https://datree.io/?src=badge)
|
|
||||||
[](https://github.com/getredash/redash/actions)
|
[](https://github.com/getredash/redash/actions)
|
||||||
|
|
||||||
Redash is designed to enable anyone, regardless of the level of technical sophistication, to harness the power of data big and small. SQL users leverage Redash to explore, query, visualize, and share data from any data sources. Their work in turn enables anybody in their organization to use the data. Every day, millions of users at thousands of organizations around the world use Redash to develop insights and make data-driven decisions.
|
Redash is designed to enable anyone, regardless of the level of technical sophistication, to harness the power of data big and small. SQL users leverage Redash to explore, query, visualize, and share data from any data sources. Their work in turn enables anybody in their organization to use the data. Every day, millions of users at thousands of organizations around the world use Redash to develop insights and make data-driven decisions.
|
||||||
@@ -47,6 +46,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
|
|||||||
- Dgraph
|
- Dgraph
|
||||||
- Apache Drill
|
- Apache Drill
|
||||||
- Apache Druid
|
- Apache Druid
|
||||||
|
- e6data
|
||||||
- Eccenca Corporate Memory
|
- Eccenca Corporate Memory
|
||||||
- Elasticsearch
|
- Elasticsearch
|
||||||
- Exasol
|
- Exasol
|
||||||
@@ -61,6 +61,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
|
|||||||
- Apache Hive
|
- Apache Hive
|
||||||
- Apache Impala
|
- Apache Impala
|
||||||
- InfluxDB
|
- InfluxDB
|
||||||
|
- InfluxDBv2
|
||||||
- IBM Netezza Performance Server
|
- IBM Netezza Performance Server
|
||||||
- JIRA (JQL)
|
- JIRA (JQL)
|
||||||
- JSON
|
- JSON
|
||||||
@@ -83,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"
|
||||||
|
|||||||
BIN
client/app/assets/images/db-logos/e6data.png
Normal file
BIN
client/app/assets/images/db-logos/e6data.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
client/app/assets/images/db-logos/influxdbv2.png
Normal file
BIN
client/app/assets/images/db-logos/influxdbv2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
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 |
BIN
client/app/assets/images/db-logos/yandex_disk.png
Normal file
BIN
client/app/assets/images/db-logos/yandex_disk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -223,6 +223,7 @@ body.fixed-layout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor__left__schema {
|
.editor__left__schema {
|
||||||
|
min-height: 120px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@
|
|||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 15px;
|
bottom: 15px;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{menuButton}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Tooltip title="You do not have sufficient permissions to edit this alert">
|
||||||
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
||||||
<i className="fa fa-edit m-r-5" aria-hidden="true" />
|
<i className="fa fa-edit m-r-5" aria-hidden="true" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
{menuButton}
|
{menuButton}
|
||||||
</Tooltip>
|
</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;
|
||||||
|
|
||||||
|
if (alertOptions.selector === "first") {
|
||||||
|
columnHint = (
|
||||||
<small className="alert-criteria-hint">
|
<small className="alert-criteria-hint">
|
||||||
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
|
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
|
||||||
</small>
|
</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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
|
|||||||
managePermissions,
|
managePermissions,
|
||||||
gridDisabled,
|
gridDisabled,
|
||||||
isDashboardOwnerOrAdmin,
|
isDashboardOwnerOrAdmin,
|
||||||
|
isDuplicating,
|
||||||
|
duplicateDashboard,
|
||||||
} = dashboardConfiguration;
|
} = dashboardConfiguration;
|
||||||
|
|
||||||
const archive = () => {
|
const archive = () => {
|
||||||
@@ -142,6 +144,14 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
|
|||||||
<Menu.Item className={cx({ hidden: gridDisabled })}>
|
<Menu.Item className={cx({ hidden: gridDisabled })}>
|
||||||
<PlainButton onClick={() => setEditingLayout(true)}>Edit</PlainButton>
|
<PlainButton onClick={() => setEditingLayout(true)}>Edit</PlainButton>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
{!isDuplicating && dashboard.canEdit() && (
|
||||||
|
<Menu.Item>
|
||||||
|
<PlainButton onClick={duplicateDashboard}>
|
||||||
|
Fork <i className="fa fa-external-link m-l-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">(opens in a new tab)</span>
|
||||||
|
</PlainButton>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
{clientConfig.showPermissionsControl && isDashboardOwnerOrAdmin && (
|
{clientConfig.showPermissionsControl && isDashboardOwnerOrAdmin && (
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<PlainButton onClick={managePermissions}>Manage Permissions</PlainButton>
|
<PlainButton onClick={managePermissions}>Manage Permissions</PlainButton>
|
||||||
|
|||||||
@@ -118,28 +118,9 @@ class ShareDashboardDialog extends React.Component {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{dashboard.public_url && (
|
{dashboard.public_url && (
|
||||||
<>
|
|
||||||
<Form.Item>
|
|
||||||
<Alert
|
|
||||||
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}>
|
<Form.Item label="Secret address" {...this.formItemProps}>
|
||||||
<InputWithCopy value={dashboard.public_url} data-test="SecretAddress" />
|
<InputWithCopy value={dashboard.public_url} data-test="SecretAddress" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import ShareDashboardDialog from "../components/ShareDashboardDialog";
|
|||||||
import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler";
|
import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler";
|
||||||
import useRefreshRateHandler from "./useRefreshRateHandler";
|
import useRefreshRateHandler from "./useRefreshRateHandler";
|
||||||
import useEditModeHandler from "./useEditModeHandler";
|
import useEditModeHandler from "./useEditModeHandler";
|
||||||
|
import useDuplicateDashboard from "./useDuplicateDashboard";
|
||||||
import { policy } from "@/services/policy";
|
import { policy } from "@/services/policy";
|
||||||
|
|
||||||
export { DashboardStatusEnum } from "./useEditModeHandler";
|
export { DashboardStatusEnum } from "./useEditModeHandler";
|
||||||
@@ -53,6 +54,8 @@ function useDashboard(dashboardData) {
|
|||||||
[dashboard]
|
[dashboard]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isDuplicating, duplicateDashboard] = useDuplicateDashboard(dashboard);
|
||||||
|
|
||||||
const managePermissions = useCallback(() => {
|
const managePermissions = useCallback(() => {
|
||||||
const aclUrl = `api/dashboards/${dashboard.id}/acl`;
|
const aclUrl = `api/dashboards/${dashboard.id}/acl`;
|
||||||
PermissionsEditorDialog.showModal({
|
PermissionsEditorDialog.showModal({
|
||||||
@@ -243,6 +246,8 @@ function useDashboard(dashboardData) {
|
|||||||
showAddTextboxDialog,
|
showAddTextboxDialog,
|
||||||
showAddWidgetDialog,
|
showAddWidgetDialog,
|
||||||
managePermissions,
|
managePermissions,
|
||||||
|
isDuplicating,
|
||||||
|
duplicateDashboard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
client/app/pages/dashboards/hooks/useDuplicateDashboard.js
Normal file
40
client/app/pages/dashboards/hooks/useDuplicateDashboard.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { noop, extend, pick } from "lodash";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import url from "url";
|
||||||
|
import qs from "query-string";
|
||||||
|
import { Dashboard } from "@/services/dashboard";
|
||||||
|
|
||||||
|
function keepCurrentUrlParams(targetUrl) {
|
||||||
|
const currentUrlParams = qs.parse(window.location.search);
|
||||||
|
targetUrl = url.parse(targetUrl);
|
||||||
|
const targetUrlParams = qs.parse(targetUrl.search);
|
||||||
|
return url.format(
|
||||||
|
extend(pick(targetUrl, ["protocol", "auth", "host", "pathname"]), {
|
||||||
|
search: qs.stringify(extend(currentUrlParams, targetUrlParams)),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useDuplicateDashboard(dashboard) {
|
||||||
|
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||||
|
|
||||||
|
const duplicateDashboard = useCallback(() => {
|
||||||
|
// To prevent opening the same tab, name must be unique for each browser
|
||||||
|
const tabName = `duplicatedDashboardTab/${Math.random().toString()}`;
|
||||||
|
|
||||||
|
// We should open tab here because this moment is a part of user interaction;
|
||||||
|
// later browser will block such attempts
|
||||||
|
const tab = window.open("", tabName);
|
||||||
|
|
||||||
|
setIsDuplicating(true);
|
||||||
|
Dashboard.fork({ id: dashboard.id })
|
||||||
|
.then(newDashboard => {
|
||||||
|
tab.location = keepCurrentUrlParams(newDashboard.getUrl());
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsDuplicating(false);
|
||||||
|
});
|
||||||
|
}, [dashboard.id]);
|
||||||
|
|
||||||
|
return [isDuplicating, isDuplicating ? noop : duplicateDashboard];
|
||||||
|
}
|
||||||
@@ -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} />,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,9 +37,10 @@
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
max-height: unset !important;
|
||||||
.ant-input {
|
.ant-input {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
max-height: 150px - 15px * 2;
|
height: 30vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ const DashboardService = {
|
|||||||
favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
|
favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
|
||||||
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
|
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
|
||||||
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
|
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
|
||||||
|
fork: ({ id }) => axios.post(`api/dashboards/${id}/fork`, { id }).then(transformResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
_.extend(Dashboard, DashboardService);
|
_.extend(Dashboard, DashboardService);
|
||||||
@@ -265,3 +266,7 @@ Dashboard.prototype.favorite = function favorite() {
|
|||||||
Dashboard.prototype.unfavorite = function unfavorite() {
|
Dashboard.prototype.unfavorite = function unfavorite() {
|
||||||
return Dashboard.unfavorite(this);
|
return Dashboard.unfavorite(this);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Dashboard.prototype.getUrl = function getUrl() {
|
||||||
|
return urlForDashboard(this);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +330,7 @@ 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 () {
|
||||||
@@ -317,9 +344,7 @@ describe("Parameter", () => {
|
|||||||
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");
|
||||||
|
|
||||||
@@ -369,14 +391,9 @@ describe("Parameter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
@@ -386,9 +403,7 @@ describe("Parameter", () => {
|
|||||||
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,7 +446,7 @@ 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 () {
|
||||||
@@ -460,9 +464,7 @@ 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,10 +577,7 @@ 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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ 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 =
|
||||||
|
(specificBarChartAssertionFn = () => {}) =>
|
||||||
|
() => {
|
||||||
// checks for TabbedEditor standard tabs
|
// checks for TabbedEditor standard tabs
|
||||||
assertTabbedEditor();
|
assertTabbedEditor();
|
||||||
|
|
||||||
@@ -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 }));
|
||||||
});
|
});
|
||||||
@@ -62,38 +59,21 @@ 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
|
||||||
@@ -7,7 +7,7 @@ Create Date: 2020-12-23 21:35:32.766354
|
|||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects.postgresql import JSON
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '0ec979123ba4'
|
revision = '0ec979123ba4'
|
||||||
@@ -18,7 +18,7 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.add_column('dashboards', sa.Column('options', postgresql.JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
|
op.add_column('dashboards', sa.Column('options', JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import json
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.sql import table
|
from sqlalchemy.sql import table
|
||||||
|
from redash.models import MutableDict
|
||||||
from redash.models import MutableDict, PseudoJSON
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
@@ -41,7 +40,7 @@ def upgrade():
|
|||||||
"queries",
|
"queries",
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"schedule",
|
"schedule",
|
||||||
MutableDict.as_mutable(PseudoJSON),
|
sa.Text(),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=json.dumps({}),
|
server_default=json.dumps({}),
|
||||||
),
|
),
|
||||||
@@ -51,7 +50,7 @@ def upgrade():
|
|||||||
queries = table(
|
queries = table(
|
||||||
"queries",
|
"queries",
|
||||||
sa.Column("id", sa.Integer, primary_key=True),
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
|
sa.Column("schedule", sa.Text()),
|
||||||
sa.Column("old_schedule", sa.String(length=10)),
|
sa.Column("old_schedule", sa.String(length=10)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ def downgrade():
|
|||||||
"queries",
|
"queries",
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"old_schedule",
|
"old_schedule",
|
||||||
MutableDict.as_mutable(PseudoJSON),
|
sa.Text(),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=json.dumps({}),
|
server_default=json.dumps({}),
|
||||||
),
|
),
|
||||||
@@ -93,8 +92,8 @@ def downgrade():
|
|||||||
|
|
||||||
queries = table(
|
queries = table(
|
||||||
"queries",
|
"queries",
|
||||||
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
|
sa.Column("schedule", sa.Text()),
|
||||||
sa.Column("old_schedule", MutableDict.as_mutable(PseudoJSON)),
|
sa.Column("old_schedule", sa.Text()),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.execute(queries.update().values({"old_schedule": queries.c.schedule}))
|
op.execute(queries.update().values({"old_schedule": queries.c.schedule}))
|
||||||
@@ -106,7 +105,7 @@ def downgrade():
|
|||||||
"queries",
|
"queries",
|
||||||
sa.Column("id", sa.Integer, primary_key=True),
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
sa.Column("schedule", sa.String(length=10)),
|
sa.Column("schedule", sa.String(length=10)),
|
||||||
sa.Column("old_schedule", MutableDict.as_mutable(PseudoJSON)),
|
sa.Column("old_schedule", sa.Text()),
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""change type of json fields from varchar to json
|
||||||
|
|
||||||
|
Revision ID: 7205816877ec
|
||||||
|
Revises: 7ce5925f832b
|
||||||
|
Create Date: 2024-01-03 13:55:18.885021
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, JSON
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7205816877ec'
|
||||||
|
down_revision = '7ce5925f832b'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
connection = op.get_bind()
|
||||||
|
op.alter_column('queries', 'options',
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
postgresql_using='options::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('queries', 'schedule',
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
postgresql_using='schedule::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('events', 'additional_properties',
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
postgresql_using='additional_properties::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('organizations', 'settings',
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
postgresql_using='settings::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('alerts', 'options',
|
||||||
|
existing_type=JSON(astext_type=sa.Text()),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
postgresql_using='options::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('dashboards', 'options',
|
||||||
|
existing_type=JSON(astext_type=sa.Text()),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
postgresql_using='options::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('dashboards', 'layout',
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
postgresql_using='layout::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('changes', 'change',
|
||||||
|
existing_type=JSON(astext_type=sa.Text()),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
postgresql_using='change::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('visualizations', 'options',
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
postgresql_using='options::jsonb',
|
||||||
|
)
|
||||||
|
op.alter_column('widgets', 'options',
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
|
postgresql_using='options::jsonb',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
connection = op.get_bind()
|
||||||
|
op.alter_column('queries', 'options',
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
type_=sa.Text(),
|
||||||
|
postgresql_using='options::text',
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
op.alter_column('queries', 'schedule',
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
type_=sa.Text(),
|
||||||
|
postgresql_using='schedule::text',
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
op.alter_column('events', 'additional_properties',
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
type_=sa.Text(),
|
||||||
|
postgresql_using='additional_properties::text',
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
op.alter_column('organizations', 'settings',
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
type_=sa.Text(),
|
||||||
|
postgresql_using='settings::text',
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
op.alter_column('alerts', 'options',
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
type_=JSON(astext_type=sa.Text()),
|
||||||
|
postgresql_using='options::json',
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
op.alter_column('dashboards', 'options',
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
type_=JSON(astext_type=sa.Text()),
|
||||||
|
postgresql_using='options::json',
|
||||||
|
)
|
||||||
|
op.alter_column('dashboards', 'layout',
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
type_=sa.Text(),
|
||||||
|
postgresql_using='layout::text',
|
||||||
|
)
|
||||||
|
op.alter_column('changes', 'change',
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
type_=JSON(astext_type=sa.Text()),
|
||||||
|
postgresql_using='change::json',
|
||||||
|
)
|
||||||
|
op.alter_column('visualizations', 'options',
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
postgresql_using='options::text',
|
||||||
|
)
|
||||||
|
op.alter_column('widgets', 'options',
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
|
postgresql_using='options::text',
|
||||||
|
)
|
||||||
@@ -7,10 +7,9 @@ Create Date: 2019-01-17 13:22:21.729334
|
|||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
from sqlalchemy.sql import table
|
from sqlalchemy.sql import table
|
||||||
|
|
||||||
from redash.models import MutableDict, PseudoJSON
|
from redash.models import MutableDict
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = "73beceabb948"
|
revision = "73beceabb948"
|
||||||
@@ -43,7 +42,7 @@ def upgrade():
|
|||||||
queries = table(
|
queries = table(
|
||||||
"queries",
|
"queries",
|
||||||
sa.Column("id", sa.Integer, primary_key=True),
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
|
sa.Column("schedule", sa.Text()),
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""create sqlalchemy_searchable expressions
|
||||||
|
|
||||||
|
Revision ID: 7ce5925f832b
|
||||||
|
Revises: 1038c2174f5d
|
||||||
|
Create Date: 2023-09-29 16:48:29.517762
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy_searchable import sql_expressions
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7ce5925f832b'
|
||||||
|
down_revision = '1038c2174f5d'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute(sql_expressions)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -6,7 +6,7 @@ Create Date: 2018-01-31 15:20:30.396533
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import simplejson
|
import json
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ def upgrade():
|
|||||||
dashboard_result = db.session.execute("SELECT id, layout FROM dashboards")
|
dashboard_result = db.session.execute("SELECT id, layout FROM dashboards")
|
||||||
for dashboard in dashboard_result:
|
for dashboard in dashboard_result:
|
||||||
print(" Updating dashboard: {}".format(dashboard["id"]))
|
print(" Updating dashboard: {}".format(dashboard["id"]))
|
||||||
layout = simplejson.loads(dashboard["layout"])
|
layout = json.loads(dashboard["layout"])
|
||||||
|
|
||||||
print(" Building widgets map:")
|
print(" Building widgets map:")
|
||||||
widgets = {}
|
widgets = {}
|
||||||
@@ -53,7 +53,7 @@ def upgrade():
|
|||||||
if widget is None:
|
if widget is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
options = simplejson.loads(widget["options"]) or {}
|
options = json.loads(widget["options"]) or {}
|
||||||
options["position"] = {
|
options["position"] = {
|
||||||
"row": row_index,
|
"row": row_index,
|
||||||
"col": column_index * column_size,
|
"col": column_index * column_size,
|
||||||
@@ -62,7 +62,7 @@ def upgrade():
|
|||||||
|
|
||||||
db.session.execute(
|
db.session.execute(
|
||||||
"UPDATE widgets SET options=:options WHERE id=:id",
|
"UPDATE widgets SET options=:options WHERE id=:id",
|
||||||
{"options": simplejson.dumps(options), "id": widget_id},
|
{"options": json.dumps(options), "id": widget_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
dashboard_result.close()
|
dashboard_result.close()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Create Date: 2019-01-31 09:21:31.517265
|
|||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects.postgresql import BYTEA
|
||||||
from sqlalchemy.sql import table
|
from sqlalchemy.sql import table
|
||||||
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ from redash.models.types import (
|
|||||||
Configuration,
|
Configuration,
|
||||||
MutableDict,
|
MutableDict,
|
||||||
MutableList,
|
MutableList,
|
||||||
PseudoJSON,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
@@ -31,7 +30,7 @@ depends_on = None
|
|||||||
def upgrade():
|
def upgrade():
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"data_sources",
|
"data_sources",
|
||||||
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True),
|
sa.Column("encrypted_options", BYTEA(), nullable=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
# copy values
|
# copy values
|
||||||
|
|||||||
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
|
||||||
@@ -9,7 +9,7 @@ import re
|
|||||||
from funcy import flatten, compact
|
from funcy import flatten, compact
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
from redash import models
|
from redash import models
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
@@ -21,10 +21,10 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"dashboards", sa.Column("tags", postgresql.ARRAY(sa.Unicode()), nullable=True)
|
"dashboards", sa.Column("tags", ARRAY(sa.Unicode()), nullable=True)
|
||||||
)
|
)
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"queries", sa.Column("tags", postgresql.ARRAY(sa.Unicode()), nullable=True)
|
"queries", sa.Column("tags", ARRAY(sa.Unicode()), nullable=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Create Date: 2020-12-14 21:42:48.661684
|
|||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects.postgresql import BYTEA
|
||||||
from sqlalchemy.sql import table
|
from sqlalchemy.sql import table
|
||||||
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ depends_on = None
|
|||||||
def upgrade():
|
def upgrade():
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"notification_destinations",
|
"notification_destinations",
|
||||||
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True)
|
sa.Column("encrypted_options", BYTEA(), nullable=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
# copy values
|
# copy values
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Create Date: 2018-11-08 16:12:17.023569
|
|||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects.postgresql import JSON
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = "e7f8a917aa8e"
|
revision = "e7f8a917aa8e"
|
||||||
@@ -21,7 +21,7 @@ def upgrade():
|
|||||||
"users",
|
"users",
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"details",
|
"details",
|
||||||
postgresql.JSON(astext_type=sa.Text()),
|
JSON(astext_type=sa.Text()),
|
||||||
server_default="{}",
|
server_default="{}",
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Create Date: 2022-01-31 15:24:16.507888
|
|||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects.postgresql import JSON, JSONB
|
||||||
|
|
||||||
from redash.models import db
|
from redash.models import db
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ def upgrade():
|
|||||||
|
|
||||||
### commands auto generated by Alembic - please adjust! ###
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
op.alter_column('users', 'details',
|
op.alter_column('users', 'details',
|
||||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
existing_type=JSON(astext_type=sa.Text()),
|
||||||
type_=postgresql.JSONB(astext_type=sa.Text()),
|
type_=JSONB(astext_type=sa.Text()),
|
||||||
existing_nullable=True,
|
existing_nullable=True,
|
||||||
existing_server_default=sa.text("'{}'::jsonb"))
|
existing_server_default=sa.text("'{}'::jsonb"))
|
||||||
### end Alembic commands ###
|
### end Alembic commands ###
|
||||||
@@ -52,8 +52,8 @@ def downgrade():
|
|||||||
connection.execute(update_query)
|
connection.execute(update_query)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
op.alter_column('users', 'details',
|
op.alter_column('users', 'details',
|
||||||
existing_type=postgresql.JSONB(astext_type=sa.Text()),
|
existing_type=JSONB(astext_type=sa.Text()),
|
||||||
type_=postgresql.JSON(astext_type=sa.Text()),
|
type_=JSON(astext_type=sa.Text()),
|
||||||
existing_nullable=True,
|
existing_nullable=True,
|
||||||
existing_server_default=sa.text("'{}'::json"))
|
existing_server_default=sa.text("'{}'::json"))
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
25
package.json
25
package.json
@@ -1,20 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "redash-client",
|
"name": "redash-client",
|
||||||
"version": "23.10.0-dev",
|
"version": "25.03.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",
|
||||||
@@ -62,8 +63,8 @@
|
|||||||
"material-design-iconic-font": "^2.2.0",
|
"material-design-iconic-font": "^2.2.0",
|
||||||
"mousetrap": "^1.6.1",
|
"mousetrap": "^1.6.1",
|
||||||
"mustache": "^2.3.0",
|
"mustache": "^2.3.0",
|
||||||
"numbro": "^2.3.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",
|
||||||
@@ -178,6 +179,10 @@
|
|||||||
"viz-lib/**"
|
"viz-lib/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"browser": {
|
||||||
|
"fs": false,
|
||||||
|
"path": false
|
||||||
|
},
|
||||||
"//": "browserslist set to 'Async functions' compatibility",
|
"//": "browserslist set to 'Async functions' compatibility",
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"Edge >= 15",
|
"Edge >= 15",
|
||||||
|
|||||||
2996
poetry.lock
generated
2996
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 = "23.10.0-dev"
|
version = "25.03.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
|
||||||
@@ -26,9 +26,10 @@ python = ">=3.8,<3.11"
|
|||||||
advocate = "1.0.0"
|
advocate = "1.0.0"
|
||||||
aniso8601 = "8.0.0"
|
aniso8601 = "8.0.0"
|
||||||
authlib = "0.15.5"
|
authlib = "0.15.5"
|
||||||
|
backoff = "2.2.1"
|
||||||
blinker = "1.6.2"
|
blinker = "1.6.2"
|
||||||
click = "8.1.3"
|
click = "8.1.3"
|
||||||
cryptography = "41.0.4"
|
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"
|
||||||
@@ -42,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.2"
|
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"
|
||||||
@@ -53,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"
|
||||||
@@ -63,27 +64,31 @@ 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"
|
||||||
simplejson = "3.16.0"
|
|
||||||
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.17"
|
urllib3 = "1.26.19"
|
||||||
user-agents = "2.0"
|
user-agents = "2.0"
|
||||||
werkzeug = "2.3.6"
|
werkzeug = "2.3.8"
|
||||||
wtforms = "2.2.1"
|
wtforms = "2.2.1"
|
||||||
xlsxwriter = "1.2.2"
|
xlsxwriter = "1.2.2"
|
||||||
|
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
|
||||||
@@ -102,40 +107,39 @@ google-api-python-client = "1.7.11"
|
|||||||
gspread = "5.11.2"
|
gspread = "5.11.2"
|
||||||
impyla = "0.16.0"
|
impyla = "0.16.0"
|
||||||
influxdb = "5.2.3"
|
influxdb = "5.2.3"
|
||||||
|
influxdb-client = "1.38.0"
|
||||||
memsql = "3.2.0"
|
memsql = "3.2.0"
|
||||||
mysqlclient = "2.1.1"
|
mysqlclient = "2.1.1"
|
||||||
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 = "1.4.0"
|
|
||||||
pandas = "1.3.4"
|
pandas = "1.3.4"
|
||||||
phoenixdb = "0.7"
|
phoenixdb = "0.7"
|
||||||
pinotdb = ">=0.4.5"
|
pinotdb = ">=0.4.5"
|
||||||
protobuf = "3.18.3"
|
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"
|
||||||
pygridgain = "1.4.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.1.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"
|
||||||
trino = ">=0.305,<1.0"
|
trino = ">=0.305,<1.0"
|
||||||
vertica-python = "1.1.1"
|
vertica-python = "1.1.1"
|
||||||
xlrd = "2.0.1"
|
xlrd = "2.0.1"
|
||||||
|
e6data-python-connector = "1.1.9"
|
||||||
|
|
||||||
[tool.poetry.group.ldap3]
|
[tool.poetry.group.ldap3]
|
||||||
optional = true
|
optional = true
|
||||||
@@ -150,11 +154,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.0"
|
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"
|
||||||
@@ -166,7 +169,7 @@ build-backend = "poetry.core.masonry.api"
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
exclude = [".git", "viz-lib", "node_modules", "migrations"]
|
exclude = [".git", "viz-lib", "node_modules", "migrations"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
select = ["C9", "E", "F", "W", "I001"]
|
select = ["C9", "E", "F", "W", "I001", "UP004"]
|
||||||
|
|
||||||
[tool.ruff.mccabe]
|
[tool.ruff.mccabe]
|
||||||
max-complexity = 15
|
max-complexity = 15
|
||||||
|
|||||||
@@ -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__ = "23.10.0-dev"
|
__version__ = "25.03.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():
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import requests
|
import requests
|
||||||
import simplejson
|
|
||||||
|
|
||||||
logger = logging.getLogger("jwt_auth")
|
logger = logging.getLogger("jwt_auth")
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ def get_public_key_from_net(url):
|
|||||||
if "keys" in data:
|
if "keys" in data:
|
||||||
public_keys = []
|
public_keys = []
|
||||||
for key_dict in data["keys"]:
|
for key_dict in data["keys"]:
|
||||||
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(simplejson.dumps(key_dict))
|
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_dict))
|
||||||
public_keys.append(public_key)
|
public_keys.append(public_key)
|
||||||
|
|
||||||
get_public_keys.key_cache[url] = public_keys
|
get_public_keys.key_cache[url] = public_keys
|
||||||
|
|||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ def get_saml_client(org):
|
|||||||
|
|
||||||
saml_settings["metadata"] = {"inline": [metadata_inline]}
|
saml_settings["metadata"] = {"inline": [metadata_inline]}
|
||||||
|
|
||||||
if acs_url is not None and acs_url != "":
|
if entity_id is not None and entity_id != "":
|
||||||
saml_settings["entityid"] = acs_url
|
saml_settings["entityid"] = entity_id
|
||||||
|
|
||||||
if sp_settings:
|
if sp_settings:
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import simplejson
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask.cli import FlaskGroup, run_command, with_appcontext
|
from flask.cli import FlaskGroup, run_command, with_appcontext
|
||||||
from rq import Connection
|
from rq import Connection
|
||||||
@@ -53,7 +54,7 @@ def version():
|
|||||||
@manager.command()
|
@manager.command()
|
||||||
def status():
|
def status():
|
||||||
with Connection(rq_redis_connection):
|
with Connection(rq_redis_connection):
|
||||||
print(simplejson.dumps(get_status(), indent=2))
|
print(json.dumps(get_status(), indent=2))
|
||||||
|
|
||||||
|
|
||||||
@manager.command()
|
@manager.command()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ logger = logging.getLogger(__name__)
|
|||||||
__all__ = ["BaseDestination", "register", "get_destination", "import_destinations"]
|
__all__ = ["BaseDestination", "register", "get_destination", "import_destinations"]
|
||||||
|
|
||||||
|
|
||||||
class BaseDestination(object):
|
class BaseDestination:
|
||||||
deprecated = False
|
deprecated = False
|
||||||
|
|
||||||
def __init__(self, configuration):
|
def __init__(self, configuration):
|
||||||
|
|||||||
@@ -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,31 +39,83 @@ 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):
|
||||||
return [
|
# Attempt to parse the description to find a 2D array
|
||||||
{
|
try:
|
||||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
# Extract the part of the description that looks like a JSON array
|
||||||
"content": {
|
start_index = description.find("[")
|
||||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
end_index = description.rfind("]") + 1
|
||||||
"type": "AdaptiveCard",
|
json_array_str = description[start_index:end_index]
|
||||||
"version": "1.0",
|
|
||||||
"body": [
|
# 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",
|
"type": "ColumnSet",
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{"type": "Column", "items": [{"type": "TextBlock", "text": str(item), "wrap": True}]}
|
||||||
"type": "Column",
|
for item in row
|
||||||
"width": 4,
|
],
|
||||||
"items": [
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the body of the card with the table
|
||||||
|
body = (
|
||||||
|
[
|
||||||
{
|
{
|
||||||
"type": "TextBlock",
|
"type": "TextBlock",
|
||||||
"text": {subject},
|
"text": f"{subject}",
|
||||||
"weight": "bolder",
|
"weight": "bolder",
|
||||||
"size": "medium",
|
"size": "medium",
|
||||||
"wrap": True,
|
"wrap": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "TextBlock",
|
"type": "TextBlock",
|
||||||
"text": {description},
|
"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,
|
"isSubtle": True,
|
||||||
"wrap": True,
|
"wrap": True,
|
||||||
},
|
},
|
||||||
@@ -77,11 +131,45 @@ class Webex(BaseDestination):
|
|||||||
"wrap": True,
|
"wrap": True,
|
||||||
"isSubtle": 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 [
|
||||||
|
{
|
||||||
|
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||||
|
"content": {
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.0",
|
||||||
|
"body": body,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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,
|
||||||
@@ -12,6 +13,7 @@ from redash.handlers.alerts import (
|
|||||||
from redash.handlers.base import org_scoped_rule
|
from redash.handlers.base import org_scoped_rule
|
||||||
from redash.handlers.dashboards import (
|
from redash.handlers.dashboards import (
|
||||||
DashboardFavoriteListResource,
|
DashboardFavoriteListResource,
|
||||||
|
DashboardForkResource,
|
||||||
DashboardListResource,
|
DashboardListResource,
|
||||||
DashboardResource,
|
DashboardResource,
|
||||||
DashboardShareResource,
|
DashboardShareResource,
|
||||||
@@ -116,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",
|
||||||
@@ -190,6 +193,7 @@ api.add_org_resource(
|
|||||||
"/api/dashboards/<object_id>/favorite",
|
"/api/dashboards/<object_id>/favorite",
|
||||||
endpoint="dashboard_favorite",
|
endpoint="dashboard_favorite",
|
||||||
)
|
)
|
||||||
|
api.add_org_resource(DashboardForkResource, "/api/dashboards/<dashboard_id>/fork", endpoint="dashboard_fork")
|
||||||
|
|
||||||
api.add_org_resource(MyDashboardsResource, "/api/dashboards/my", endpoint="my_dashboards")
|
api.add_org_resource(MyDashboardsResource, "/api/dashboards/my", endpoint="my_dashboards")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ from flask import Blueprint, current_app, request
|
|||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from flask_restful import Resource, abort
|
from flask_restful import Resource, abort
|
||||||
from sqlalchemy import cast
|
from sqlalchemy import cast
|
||||||
from sqlalchemy.dialects import postgresql
|
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"))
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ def json_response(response):
|
|||||||
def filter_by_tags(result_set, column):
|
def filter_by_tags(result_set, column):
|
||||||
if request.args.getlist("tags"):
|
if request.args.getlist("tags"):
|
||||||
tags = request.args.getlist("tags")
|
tags = request.args.getlist("tags")
|
||||||
result_set = result_set.filter(cast(column, postgresql.ARRAY(db.Text)).contains(tags))
|
result_set = result_set.filter(cast(column, ARRAY(db.Text)).contains(tags))
|
||||||
return result_set
|
return result_set
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class DashboardListResource(BaseResource):
|
|||||||
org=self.current_org,
|
org=self.current_org,
|
||||||
user=self.current_user,
|
user=self.current_user,
|
||||||
is_draft=True,
|
is_draft=True,
|
||||||
layout="[]",
|
layout=[],
|
||||||
)
|
)
|
||||||
models.db.session.add(dashboard)
|
models.db.session.add(dashboard)
|
||||||
models.db.session.commit()
|
models.db.session.commit()
|
||||||
@@ -398,3 +398,16 @@ class DashboardFavoriteListResource(BaseResource):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardForkResource(BaseResource):
|
||||||
|
@require_permission("edit_dashboard")
|
||||||
|
def post(self, dashboard_id):
|
||||||
|
dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org)
|
||||||
|
|
||||||
|
fork_dashboard = dashboard.fork(self.current_user)
|
||||||
|
models.db.session.commit()
|
||||||
|
|
||||||
|
self.record_event({"action": "fork", "object_id": dashboard_id, "object_type": "dashboard"})
|
||||||
|
|
||||||
|
return DashboardSerializer(fork_dashboard, with_widgets=True).serialize()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ def organization_status(org_slug=None):
|
|||||||
"data_sources": models.DataSource.all(current_org, group_ids=current_user.group_ids).count(),
|
"data_sources": models.DataSource.all(current_org, group_ids=current_user.group_ids).count(),
|
||||||
"queries": models.Query.all_queries(current_user.group_ids, current_user.id, include_drafts=True).count(),
|
"queries": models.Query.all_queries(current_user.group_ids, current_user.id, include_drafts=True).count(),
|
||||||
"dashboards": models.Dashboard.query.filter(
|
"dashboards": models.Dashboard.query.filter(
|
||||||
models.Dashboard.org == current_org, models.Dashboard.is_archived is False
|
models.Dashboard.org == current_org, models.Dashboard.is_archived.is_(False)
|
||||||
).count(),
|
).count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from redash.permissions import (
|
|||||||
require_permission,
|
require_permission,
|
||||||
)
|
)
|
||||||
from redash.serializers import serialize_visualization
|
from redash.serializers import serialize_visualization
|
||||||
from redash.utils import json_dumps
|
|
||||||
|
|
||||||
|
|
||||||
class VisualizationListResource(BaseResource):
|
class VisualizationListResource(BaseResource):
|
||||||
@@ -18,7 +17,6 @@ class VisualizationListResource(BaseResource):
|
|||||||
query = get_object_or_404(models.Query.get_by_id_and_org, kwargs.pop("query_id"), self.current_org)
|
query = get_object_or_404(models.Query.get_by_id_and_org, kwargs.pop("query_id"), self.current_org)
|
||||||
require_object_modify_permission(query, self.current_user)
|
require_object_modify_permission(query, self.current_user)
|
||||||
|
|
||||||
kwargs["options"] = json_dumps(kwargs["options"])
|
|
||||||
kwargs["query_rel"] = query
|
kwargs["query_rel"] = query
|
||||||
|
|
||||||
vis = models.Visualization(**kwargs)
|
vis = models.Visualization(**kwargs)
|
||||||
@@ -34,8 +32,6 @@ class VisualizationResource(BaseResource):
|
|||||||
require_object_modify_permission(vis.query_rel, self.current_user)
|
require_object_modify_permission(vis.query_rel, self.current_user)
|
||||||
|
|
||||||
kwargs = request.get_json(force=True)
|
kwargs = request.get_json(force=True)
|
||||||
if "options" in kwargs:
|
|
||||||
kwargs["options"] = json_dumps(kwargs["options"])
|
|
||||||
|
|
||||||
kwargs.pop("id", None)
|
kwargs.pop("id", None)
|
||||||
kwargs.pop("query_id", None)
|
kwargs.pop("query_id", None)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user