mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 10:00:45 -05:00
Compare commits
145 Commits
24.05.0-de
...
25.07.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ddd2a7d6 | ||
|
|
6b1e910126 | ||
|
|
14550a9a6c | ||
|
|
b80c5f6a7c | ||
|
|
e46d44f208 | ||
|
|
a1a4bc9d3e | ||
|
|
0900178d24 | ||
|
|
5d31429ca8 | ||
|
|
2f35ceb803 | ||
|
|
8e6c02ecde | ||
|
|
231fd36d46 | ||
|
|
0b6a53a079 | ||
|
|
6167edf97c | ||
|
|
4ed0ad3c9c | ||
|
|
2375f0b05f | ||
|
|
eced377ae4 | ||
|
|
84262fe143 | ||
|
|
612eb8c630 | ||
|
|
866fb48afb | ||
|
|
353776e8e1 | ||
|
|
594e2f24ef | ||
|
|
3275a9e459 | ||
|
|
3bad8c8e8c | ||
|
|
d0af4499d6 | ||
|
|
4357ea56ae | ||
|
|
5df5ca87a2 | ||
|
|
8387fe6fcb | ||
|
|
e95de2ee4c | ||
|
|
71902e5933 | ||
|
|
53eab14cef | ||
|
|
925bb91d8e | ||
|
|
ec2ca6f986 | ||
|
|
96ea0194e8 | ||
|
|
2776992101 | ||
|
|
85f001982e | ||
|
|
d03a2c4096 | ||
|
|
8c5890482a | ||
|
|
10ce280a96 | ||
|
|
0dd7ac3d2e | ||
|
|
4ee53a9445 | ||
|
|
c08292d90e | ||
|
|
3142131cdd | ||
|
|
530c1a0734 | ||
|
|
52dc1769a1 | ||
|
|
b9583c0b48 | ||
|
|
89d7f54e90 | ||
|
|
d884da2b0b | ||
|
|
f7d485082c | ||
|
|
130ab1fe1a | ||
|
|
2ff83679fe | ||
|
|
de49b73855 | ||
|
|
c12e68f5d1 | ||
|
|
baa9bbd505 | ||
|
|
349cd5d031 | ||
|
|
49277d27f8 | ||
|
|
2aae5705c9 | ||
|
|
38d0579660 | ||
|
|
673ba769c7 | ||
|
|
b922730482 | ||
|
|
ba973eb1fe | ||
|
|
d8dde6c544 | ||
|
|
d359a716a7 | ||
|
|
ba4293912b | ||
|
|
ee359120ee | ||
|
|
04a25f4327 | ||
|
|
7c22756e66 | ||
|
|
a03668f5b2 | ||
|
|
e4a841a0c5 | ||
|
|
38dc31a49b | ||
|
|
c42b15125c | ||
|
|
590d39bc8d | ||
|
|
79bbb248bb | ||
|
|
5cf0b7b038 | ||
|
|
fb1a056561 | ||
|
|
75e1ce4c9c | ||
|
|
d6c6e3bb7a | ||
|
|
821c1a9488 | ||
|
|
76eeea1f64 | ||
|
|
2ab07f9fc3 | ||
|
|
a85b9d7801 | ||
|
|
3330815081 | ||
|
|
c25c65bc04 | ||
|
|
79a4c4c9c9 | ||
|
|
58a7438cc8 | ||
|
|
c073c1e154 | ||
|
|
159a329e26 | ||
|
|
9de135c0bd | ||
|
|
285c2b6e56 | ||
|
|
b1fe2d4162 | ||
|
|
a4f92a8fb5 | ||
|
|
51ef625a30 | ||
|
|
a2611b89a3 | ||
|
|
a531597016 | ||
|
|
e59c02f497 | ||
|
|
c1a60bf6d2 | ||
|
|
72203655ec | ||
|
|
5257e39282 | ||
|
|
ec70ff4408 | ||
|
|
ed8c05f634 | ||
|
|
86b75db82e | ||
|
|
660d04b0f1 | ||
|
|
fc1e1f7a01 | ||
|
|
8725fa4737 | ||
|
|
ea0b3cbe3a | ||
|
|
714b950fde | ||
|
|
a9c9f085af | ||
|
|
a69f7fb2fe | ||
|
|
c244e75352 | ||
|
|
80f7ba1b91 | ||
|
|
d2745e5acc | ||
|
|
4114227471 | ||
|
|
8fc4ce1494 | ||
|
|
ebb0e2c9ad | ||
|
|
57a79bc96b | ||
|
|
77f108dd09 | ||
|
|
dd1a9b96da | ||
|
|
d9282b2688 | ||
|
|
28c39219af | ||
|
|
a37ef3b235 | ||
|
|
0056aa68f8 | ||
|
|
76b5a30fd9 | ||
|
|
db4fdd003e | ||
|
|
4cb32fc1c3 | ||
|
|
a6c728b99c | ||
|
|
01e036d0a9 | ||
|
|
17fe69f551 | ||
|
|
bceaab0496 | ||
|
|
70dd05916f | ||
|
|
60a12e906e | ||
|
|
ec051a8939 | ||
|
|
60d3c66a8b | ||
|
|
bd4ba96c43 | ||
|
|
10a46fd33c | ||
|
|
c874eb6b11 | ||
|
|
f3a323695f | ||
|
|
408ba78bd0 | ||
|
|
58cc49bc88 | ||
|
|
753ea846ff | ||
|
|
1b946b59ec | ||
|
|
4569191113 | ||
|
|
62890c3ec4 | ||
|
|
bd115e7f5f | ||
|
|
bd17662005 | ||
|
|
b7f22b1896 | ||
|
|
897c683980 |
25
.ci/compose.ci.yaml
Normal file
25
.ci/compose.ci.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
redash:
|
||||
build: ../
|
||||
command: manage version
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
PYTHONUNBUFFERED: 0
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
POSTGRES_PASSWORD: "FmTKs5vX52ufKR1rd8tn4MoSP7zvCJwb"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres:FmTKs5vX52ufKR1rd8tn4MoSP7zvCJwb@postgres/postgres"
|
||||
REDASH_COOKIE_SECRET: "2H9gNG9obnAQ9qnR9BDTQUph6CbXKCzF"
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
postgres:
|
||||
image: pgautoupgrade/pgautoupgrade:latest
|
||||
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: "trust"
|
||||
73
.ci/compose.cypress.yaml
Normal file
73
.ci/compose.cypress.yaml
Normal file
@@ -0,0 +1,73 @@
|
||||
x-redash-service: &redash-service
|
||||
build:
|
||||
context: ../
|
||||
args:
|
||||
install_groups: "main"
|
||||
code_coverage: ${CODE_COVERAGE}
|
||||
x-redash-environment: &redash-environment
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
POSTGRES_PASSWORD: "FmTKs5vX52ufKR1rd8tn4MoSP7zvCJwb"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres:FmTKs5vX52ufKR1rd8tn4MoSP7zvCJwb@postgres/postgres"
|
||||
REDASH_RATELIMIT_ENABLED: "false"
|
||||
REDASH_ENFORCE_CSRF: "true"
|
||||
REDASH_COOKIE_SECRET: "2H9gNG9obnAQ9qnR9BDTQUph6CbXKCzF"
|
||||
services:
|
||||
server:
|
||||
<<: *redash-service
|
||||
command: server
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
<<: *redash-environment
|
||||
PYTHONUNBUFFERED: 0
|
||||
scheduler:
|
||||
<<: *redash-service
|
||||
command: scheduler
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
<<: *redash-environment
|
||||
worker:
|
||||
<<: *redash-service
|
||||
command: worker
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
<<: *redash-environment
|
||||
PYTHONUNBUFFERED: 0
|
||||
cypress:
|
||||
ipc: host
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: .ci/Dockerfile.cypress
|
||||
depends_on:
|
||||
- server
|
||||
- worker
|
||||
- scheduler
|
||||
environment:
|
||||
CYPRESS_baseUrl: "http://server:5000"
|
||||
CYPRESS_coverage: ${CODE_COVERAGE}
|
||||
PERCY_TOKEN: ${PERCY_TOKEN}
|
||||
PERCY_BRANCH: ${CIRCLE_BRANCH}
|
||||
PERCY_COMMIT: ${CIRCLE_SHA1}
|
||||
PERCY_PULL_REQUEST: ${CIRCLE_PR_NUMBER}
|
||||
COMMIT_INFO_BRANCH: ${CIRCLE_BRANCH}
|
||||
COMMIT_INFO_MESSAGE: ${COMMIT_INFO_MESSAGE}
|
||||
COMMIT_INFO_AUTHOR: ${CIRCLE_USERNAME}
|
||||
COMMIT_INFO_SHA: ${CIRCLE_SHA1}
|
||||
COMMIT_INFO_REMOTE: ${CIRCLE_REPOSITORY_URL}
|
||||
CYPRESS_PROJECT_ID: ${CYPRESS_PROJECT_ID}
|
||||
CYPRESS_RECORD_KEY: ${CYPRESS_RECORD_KEY}
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
postgres:
|
||||
image: pgautoupgrade/pgautoupgrade:latest
|
||||
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: "trust"
|
||||
39
.ci/docker_build
Executable file
39
.ci/docker_build
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script only needs to run on the main Redash repo
|
||||
|
||||
if [ "${GITHUB_REPOSITORY}" != "getredash/redash" ]; then
|
||||
echo "Skipping image build for Docker Hub, as this isn't the main Redash repository"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${GITHUB_REF_NAME}" != "master" ] && [ "${GITHUB_REF_NAME}" != "preview-image" ]; then
|
||||
echo "Skipping image build for Docker Hub, as this isn't the 'master' nor 'preview-image' branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "x${DOCKER_USER}" = "x" ] || [ "x${DOCKER_PASS}" = "x" ]; then
|
||||
echo "Skipping image build for Docker Hub, as the login details aren't available"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
VERSION=$(jq -r .version package.json)
|
||||
VERSION_TAG="$VERSION.b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}"
|
||||
|
||||
export DOCKER_BUILDKIT=1
|
||||
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||
|
||||
docker login -u "${DOCKER_USER}" -p "${DOCKER_PASS}"
|
||||
|
||||
DOCKERHUB_REPO="redash/redash"
|
||||
DOCKER_TAGS="-t redash/redash:preview -t redash/preview:${VERSION_TAG}"
|
||||
|
||||
# Build the docker container
|
||||
docker build --build-arg install_groups="main,all_ds,dev" ${DOCKER_TAGS} .
|
||||
|
||||
# Push the container to the preview build locations
|
||||
docker push "${DOCKERHUB_REPO}:preview"
|
||||
docker push "redash/preview:${VERSION_TAG}"
|
||||
|
||||
echo "Built: ${VERSION_TAG}"
|
||||
9
.ci/pack
Executable file
9
.ci/pack
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
NAME=redash
|
||||
VERSION=$(jq -r .version package.json)
|
||||
FULL_VERSION=$VERSION+b$CIRCLE_BUILD_NUM
|
||||
FILENAME=$NAME.$FULL_VERSION.tar.gz
|
||||
|
||||
mkdir -p /tmp/artifacts/
|
||||
|
||||
tar -zcv -f /tmp/artifacts/$FILENAME --exclude=".git" --exclude="optipng*" --exclude="cypress" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" *
|
||||
6
.ci/update_version
Executable file
6
.ci/update_version
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
VERSION=$(jq -r .version package.json)
|
||||
FULL_VERSION=${VERSION}+b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}
|
||||
|
||||
sed -ri "s/^__version__ = '([A-Za-z0-9.-]*)'/__version__ = '${FULL_VERSION}'/" redash/__init__.py
|
||||
sed -i "s/dev/${GITHUB_SHA}/" client/app/version.json
|
||||
81
.github/workflows/ci.yml
vendored
81
.github/workflows/ci.yml
vendored
@@ -3,29 +3,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- '*'
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
env:
|
||||
CYPRESS_COVERAGE: "true"
|
||||
NODE_VERSION: 18
|
||||
YARN_VERSION: 1.22.22
|
||||
REDASH_COOKIE_SECRET: 2H9gNG9obnAQ9qnR9BDTQUph6CbXKCzF
|
||||
REDASH_SECRET_KEY: 2H9gNG9obnAQ9qnR9BDTQUph6CbXKCzF
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
DOCKER_BUILDKIT: 1
|
||||
FRONTEND_BUILD_MODE: 1
|
||||
INSTALL_GROUPS: main,all_ds,dev
|
||||
PERCY_BRANCH: ${{github.head_ref || github.ref_name}}
|
||||
PERCY_COMMIT: ${{github.sha}}
|
||||
PERCY_PULL_REQUEST: ${{github.event.number}}
|
||||
COMMIT_INFO_BRANCH: ${{github.head_ref || github.ref_name}}
|
||||
COMMIT_INFO_MESSAGE: ${{github.event.head_commit.message}}
|
||||
COMMIT_INFO_AUTHOR: ${{github.event.pull_request.user.login}}
|
||||
COMMIT_INFO_SHA: ${{github.sha}}
|
||||
COMMIT_INFO_REMOTE: ${{github.server_url}}/${{github.repository}}
|
||||
jobs:
|
||||
backend-lint:
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -40,7 +23,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- run: sudo pip install black==24.3.0 ruff==0.1.9
|
||||
- run: sudo pip install black==23.1.0 ruff==0.0.287
|
||||
- run: ruff check .
|
||||
- run: black --check .
|
||||
|
||||
@@ -48,7 +31,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: backend-lint
|
||||
env:
|
||||
FRONTEND_BUILD_MODE: 0
|
||||
COMPOSE_FILE: .ci/compose.ci.yaml
|
||||
COMPOSE_PROJECT_NAME: redash
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
DOCKER_BUILDKIT: 1
|
||||
steps:
|
||||
- if: github.event.pull_request.mergeable == 'false'
|
||||
name: Exit if PR is not mergeable
|
||||
@@ -60,25 +46,24 @@ jobs:
|
||||
- name: Build Docker Images
|
||||
run: |
|
||||
set -x
|
||||
touch .env
|
||||
docker compose build
|
||||
docker compose build --build-arg install_groups="main,all_ds,dev" --build-arg skip_frontend_build=true
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
- name: Create Test Database
|
||||
run: docker compose run --rm postgres psql -h postgres -U postgres -c "create database tests;"
|
||||
run: docker compose -p redash run --rm postgres psql -h postgres -U postgres -c "create database tests;"
|
||||
- name: List Enabled Query Runners
|
||||
run: docker compose run --rm server manage ds list_types
|
||||
run: docker compose -p redash run --rm redash manage ds list_types
|
||||
- name: Run Tests
|
||||
run: docker compose run --name tests server tests --junitxml=junit.xml --cov-report=xml --cov=redash --cov-config=.coveragerc tests/
|
||||
run: docker compose -p redash run --name tests redash tests --junitxml=junit.xml --cov-report=xml --cov=redash --cov-config=.coveragerc tests/
|
||||
- name: Copy Test Results
|
||||
run: |
|
||||
mkdir -p /tmp/test-results/unit-tests
|
||||
docker cp tests:/app/coverage.xml ./coverage.xml
|
||||
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
# - name: Upload coverage reports to Codecov
|
||||
# uses: codecov/codecov-action@v3
|
||||
# with:
|
||||
# token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Store Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -87,7 +72,7 @@ jobs:
|
||||
- name: Store Coverage Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage
|
||||
name: coverage
|
||||
path: coverage.xml
|
||||
|
||||
frontend-lint:
|
||||
@@ -107,8 +92,7 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
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
|
||||
run: yarn lint:ci
|
||||
- name: Store Test Results
|
||||
@@ -135,27 +119,24 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
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
|
||||
run: yarn test
|
||||
- name: Run Visualizations Tests
|
||||
run: |
|
||||
cd viz-lib
|
||||
yarn test
|
||||
run: cd viz-lib && yarn test
|
||||
- run: yarn lint
|
||||
|
||||
frontend-e2e-tests:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: frontend-lint
|
||||
env:
|
||||
COMPOSE_FILE: .ci/compose.cypress.yaml
|
||||
COMPOSE_PROJECT_NAME: cypress
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||
INSTALL_GROUPS: main
|
||||
COMPOSE_PROFILES: e2e
|
||||
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
|
||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
# PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
|
||||
# CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
# CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
steps:
|
||||
- if: github.event.pull_request.mergeable == 'false'
|
||||
name: Exit if PR is not mergeable
|
||||
@@ -168,16 +149,17 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'yarn'
|
||||
- name: Enable Code Coverage Report For Master Branch
|
||||
if: endsWith(github.ref, '/master')
|
||||
run: |
|
||||
echo "CODE_COVERAGE=true" >> "$GITHUB_ENV"
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
set -x
|
||||
touch .env
|
||||
yarn build
|
||||
yarn cypress build
|
||||
yarn cypress start -- --skip-db-seed
|
||||
docker compose run cypress yarn cypress db-seed
|
||||
@@ -191,10 +173,5 @@ jobs:
|
||||
- name: Store Coverage Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-coverage
|
||||
name: coverage
|
||||
path: coverage
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend
|
||||
path: client/dist
|
||||
retention-days: 1
|
||||
|
||||
82
.github/workflows/periodic-snapshot.yml
vendored
82
.github/workflows/periodic-snapshot.yml
vendored
@@ -1,28 +1,86 @@
|
||||
name: Periodic Snapshot
|
||||
|
||||
# 10 minutes after midnight on the first of every month
|
||||
on:
|
||||
schedule:
|
||||
- cron: "10 0 1 * *"
|
||||
- cron: '10 0 1 * *' # 10 minutes after midnight on the first day of every month
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Bump the last digit of the version'
|
||||
required: false
|
||||
type: boolean
|
||||
version:
|
||||
description: 'Specific version to set'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump-version-and-tag:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_name == github.event.repository.default_branch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ssh-key: ${{secrets.ACTION_PUSH_KEY}}
|
||||
ssh-key: ${{ secrets.ACTION_PUSH_KEY }}
|
||||
|
||||
- run: |
|
||||
date="$(date +%y.%m).0-dev"
|
||||
gawk -i inplace -F: -v q=\" -v tag=$date '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json
|
||||
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
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
# Function to bump the version
|
||||
bump_version() {
|
||||
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 commit -m "Snapshot: ${date}"
|
||||
git tag $date
|
||||
git push --atomic origin master refs/tags/$date
|
||||
git commit -m "Snapshot: ${TAG_NAME}"
|
||||
git tag ${TAG_NAME}
|
||||
git push --atomic origin master refs/tags/${TAG_NAME}
|
||||
|
||||
# Run the 'preview-image' workflow if run this workflow manually
|
||||
# For more information, please see the: https://docs.github.com/en/actions/security-guides/automatic-token-authentication
|
||||
if [ "$BUMP_INPUT" = "true" ] || [ -n "$SPECIFIC_VERSION" ]; then
|
||||
gh workflow run preview-image.yml --ref $TAG_NAME
|
||||
fi
|
||||
|
||||
201
.github/workflows/preview-image.yml
vendored
201
.github/workflows/preview-image.yml
vendored
@@ -1,20 +1,25 @@
|
||||
name: Preview Image
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Tests
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
tags:
|
||||
- '*-dev'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dockerRepository:
|
||||
description: 'Docker repository'
|
||||
required: true
|
||||
default: 'preview'
|
||||
type: choice
|
||||
options:
|
||||
- preview
|
||||
- redash
|
||||
|
||||
env:
|
||||
DOCKER_REPO: redash
|
||||
NODE_VERSION: 18
|
||||
|
||||
jobs:
|
||||
build-skip-check:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
outputs:
|
||||
skip: ${{ steps.skip-check.outputs.skip }}
|
||||
steps:
|
||||
@@ -27,6 +32,9 @@ jobs:
|
||||
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
|
||||
echo 'Docker password is empty. Skipping build+push'
|
||||
echo skip=true >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ vars.DOCKER_REPOSITORY }}" == '' ]]; then
|
||||
echo 'Docker repository is empty. Skipping build+push'
|
||||
echo skip=true >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'Docker user and password are set and branch is `master`.'
|
||||
echo 'Building + pushing `preview` image.'
|
||||
@@ -34,121 +42,144 @@ jobs:
|
||||
fi
|
||||
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- build-skip-check
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION_TAG }}
|
||||
repo: ${{ steps.version.outputs.DOCKER_REPO }}
|
||||
if: needs.build-skip-check.outputs.skip == 'false'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
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: dawidd6/action-download-artifact@v3
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
name: frontend
|
||||
workflow: ci.yml
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: client/dist
|
||||
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
|
||||
VERSION=$(jq -r .version package.json)
|
||||
FULL_VERSION=${VERSION}-b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}
|
||||
sed -ri "s/^__version__ = ([A-Za-z0-9.-]*)'/__version__ = '${FULL_VERSION}'/" redash/__init__.py
|
||||
sed -i "s/dev/${GITHUB_SHA}/" client/app/version.json
|
||||
echo "VERSION_TAG=$FULL_VERSION" >> "$GITHUB_OUTPUT"
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
echo "SCOPE=${platform//\//-}" >> $GITHUB_ENV
|
||||
if [[ "${{ vars.DOCKER_REPO }}" != "" ]]; then
|
||||
echo "DOCKER_REPO=${{ vars.DOCKER_REPO }}" >> $GITHUB_ENV
|
||||
echo "DOCKER_REPO=${{ vars.DOCKER_REPO }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "DOCKER_REPO=${DOCKER_REPO}" >> $GITHUB_ENV
|
||||
echo "DOCKER_REPO=${DOCKER_REPO}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
.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
|
||||
uses: docker/build-push-action@v5
|
||||
id: build
|
||||
id: build-preview
|
||||
uses: docker/build-push-action@v4
|
||||
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.DOCKER_REPOSITORY }}/redash
|
||||
${{ vars.DOCKER_REPOSITORY }}/preview
|
||||
context: .
|
||||
cache-from: type=gha,scope=${{ env.SCOPE }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.SCOPE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.DOCKER_REPO }}/redash,push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
FRONTEND_BUILD_MODE=1
|
||||
test_all_deps=true
|
||||
outputs: type=image,push-by-digest=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: true
|
||||
|
||||
- name: Build and push release image to Docker Hub
|
||||
id: build-release
|
||||
uses: docker/build-push-action@v4
|
||||
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
|
||||
with:
|
||||
tags: |
|
||||
${{ vars.DOCKER_REPOSITORY }}/redash:${{ steps.version.outputs.VERSION_TAG }}
|
||||
context: .
|
||||
build-args: |
|
||||
test_all_deps=true
|
||||
outputs: type=image,push-by-digest=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 /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
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-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish-docker-manifest:
|
||||
merge-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- build-skip-check
|
||||
- build-docker-image
|
||||
if: needs.build-skip-check.outputs.skip == 'false'
|
||||
needs: build-docker-image
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: digests-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
- name: Setup Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ needs.build-docker-image.outputs.repo }}/redash
|
||||
tags: preview
|
||||
- name: Login to Docker Hub
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASS }}
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
|
||||
- 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 $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ needs.build-docker-image.outputs.repo }}/redash@sha256:%s ' *)
|
||||
- name: Inspect image
|
||||
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:preview \
|
||||
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:preview@sha256:%s ' *)
|
||||
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
|
||||
$(printf '${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
|
||||
|
||||
- name: Create and push manifest for the release image
|
||||
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
REDASH_IMAGE="${{ needs.build-docker-image.outputs.repo }}/redash:${{ steps.meta.outputs.version }}"
|
||||
docker buildx imagetools inspect $REDASH_IMAGE
|
||||
- name: Push image ${{ needs.build-docker-image.outputs.repo }}/preview image
|
||||
run: |
|
||||
REDASH_IMAGE="${{ needs.build-docker-image.outputs.repo }}/redash:preview"
|
||||
PREVIEW_IMAGE="${{ needs.build-docker-image.outputs.repo }}/preview:${{ needs.build-docker-image.outputs.version }}"
|
||||
docker buildx imagetools create --tag $PREVIEW_IMAGE $REDASH_IMAGE
|
||||
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
|
||||
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
|
||||
|
||||
36
.github/workflows/restyled.yml
vendored
Normal file
36
.github/workflows/restyled.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Restyled
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
restyled:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: restyled-io/actions/setup@v4
|
||||
- id: restyler
|
||||
uses: restyled-io/actions/run@v4
|
||||
with:
|
||||
fail-on-differences: true
|
||||
|
||||
- if: |
|
||||
!cancelled() &&
|
||||
steps.restyler.outputs.success == 'true' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
base: ${{ steps.restyler.outputs.restyled-base }}
|
||||
branch: ${{ steps.restyler.outputs.restyled-head }}
|
||||
title: ${{ steps.restyler.outputs.restyled-title }}
|
||||
body: ${{ steps.restyler.outputs.restyled-body }}
|
||||
labels: "restyled"
|
||||
reviewers: ${{ github.event.pull_request.user.login }}
|
||||
delete-branch: true
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ client/dist
|
||||
_build
|
||||
.vscode
|
||||
.env
|
||||
.tool-versions
|
||||
|
||||
dump.rdb
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ request_review: author
|
||||
#
|
||||
# These can be used to tell other automation to avoid our PRs.
|
||||
#
|
||||
labels: ["Skip CI"]
|
||||
labels:
|
||||
- restyled
|
||||
- "Skip CI"
|
||||
|
||||
# Labels to ignore
|
||||
#
|
||||
@@ -50,13 +52,13 @@ labels: ["Skip CI"]
|
||||
# Restylers to run, and how
|
||||
restylers:
|
||||
- name: black
|
||||
image: restyled/restyler-black:v19.10b0
|
||||
image: restyled/restyler-black:v24.4.2
|
||||
include:
|
||||
- redash
|
||||
- tests
|
||||
- migrations/versions
|
||||
- name: prettier
|
||||
image: restyled/restyler-prettier:v1.19.1-2
|
||||
image: restyled/restyler-prettier:v3.3.2-2
|
||||
command:
|
||||
- prettier
|
||||
- --write
|
||||
|
||||
87
Dockerfile
87
Dockerfile
@@ -1,37 +1,43 @@
|
||||
# Controls whether to build the frontend assets
|
||||
ARG FRONTEND_BUILD_MODE=0
|
||||
FROM node:18-bookworm AS frontend-builder
|
||||
|
||||
# MODE 0: create empty files. useful for backend tests
|
||||
FROM alpine:3.19 as frontend-builder-0
|
||||
RUN \
|
||||
mkdir -p /frontend/client/dist && \
|
||||
touch /frontend/client/dist/multi_org.html && \
|
||||
touch /frontend/client/dist/index.html
|
||||
|
||||
# MODE 1: copy static frontend from host, useful for CI to ignore building static content multiple times
|
||||
FROM alpine:3.19 as frontend-builder-1
|
||||
COPY client/dist /frontend/client/dist
|
||||
|
||||
# MODE 2: build static content in docker, can be used for a local development
|
||||
FROM node:18-bookworm as frontend-builder-2
|
||||
RUN npm install --global --force yarn@1.22.22
|
||||
|
||||
# Controls whether to build the frontend assets
|
||||
ARG skip_frontend_build
|
||||
|
||||
ENV CYPRESS_INSTALL_BINARY=0
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
|
||||
|
||||
RUN useradd -m -d /frontend redash
|
||||
USER redash
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY --chown=redash package.json yarn.lock .yarnrc /frontend/
|
||||
COPY --chown=redash viz-lib /frontend/viz-lib
|
||||
COPY --chown=redash scripts /frontend/scripts
|
||||
|
||||
RUN yarn --frozen-lockfile --network-concurrency 1;
|
||||
# Controls whether to instrument code for coverage information
|
||||
ARG code_coverage
|
||||
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
|
||||
|
||||
COPY --chown=redash client /frontend/client
|
||||
COPY --chown=redash webpack.config.js /frontend/
|
||||
RUN yarn build
|
||||
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 frontend-builder-${FRONTEND_BUILD_MODE} as frontend-builder
|
||||
|
||||
FROM python:3.8-slim-bookworm
|
||||
FROM python:3.10-slim-bookworm
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
@@ -66,39 +72,44 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN \
|
||||
curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list && \
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg && \
|
||||
apt update && \
|
||||
ACCEPT_EULA=Y apt install -y --no-install-recommends msodbcsql18 && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
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
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip \
|
||||
&& chmod 600 /tmp/simba_odbc.zip \
|
||||
&& unzip /tmp/simba_odbc.zip -d /tmp/simba \
|
||||
&& dpkg -i /tmp/simba/*.deb \
|
||||
&& printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini \
|
||||
&& rm /tmp/simba_odbc.zip \
|
||||
&& rm -rf /tmp/simba; fi
|
||||
RUN <<EOF
|
||||
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
|
||||
curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
apt-get update
|
||||
ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip
|
||||
chmod 600 /tmp/simba_odbc.zip
|
||||
unzip /tmp/simba_odbc.zip -d /tmp/simba
|
||||
dpkg -i /tmp/simba/*.deb
|
||||
printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini
|
||||
rm /tmp/simba_odbc.zip
|
||||
rm -rf /tmp/simba
|
||||
fi
|
||||
EOF
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV POETRY_VERSION=1.6.1
|
||||
ENV POETRY_VERSION=1.8.3
|
||||
ENV POETRY_HOME=/etc/poetry
|
||||
ENV POETRY_VIRTUALENVS_CREATE=false
|
||||
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 ./
|
||||
|
||||
ARG POETRY_OPTIONS="--no-root --no-interaction --no-ansi"
|
||||
# for LDAP authentication, install with `ldap3` group
|
||||
# disabled by default due to GPL license conflict
|
||||
ARG INSTALL_GROUPS="main,all_ds,dev"
|
||||
RUN /etc/poetry/bin/poetry install --only $INSTALL_GROUPS $POETRY_OPTIONS
|
||||
ARG install_groups="main,all_ds,dev"
|
||||
RUN /etc/poetry/bin/poetry install --only $install_groups $POETRY_OPTIONS
|
||||
|
||||
COPY --chown=redash . /app
|
||||
COPY --from=frontend-builder --chown=redash /frontend/client/dist /app/client/dist
|
||||
|
||||
24
Makefile
24
Makefile
@@ -1,18 +1,14 @@
|
||||
.PHONY: compose_build up test_db create_database create_db clean clean-all down tests lint backend-unit-tests frontend-unit-tests pydeps test build watch start redis-cli bash
|
||||
|
||||
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||
export DOCKER_BUILDKIT=1
|
||||
export COMPOSE_PROFILES=local
|
||||
.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
|
||||
docker compose build
|
||||
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
|
||||
|
||||
up:
|
||||
docker compose up -d redis postgres
|
||||
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
|
||||
docker compose up -d --build
|
||||
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build --remove-orphans
|
||||
|
||||
test_db:
|
||||
@for i in `seq 1 5`; do \
|
||||
@@ -21,11 +17,9 @@ test_db:
|
||||
done
|
||||
docker compose exec postgres sh -c 'psql -U postgres -c "drop database if exists tests;" && psql -U postgres -c "create database tests;"'
|
||||
|
||||
create_db: .env
|
||||
create_database: .env
|
||||
docker compose run server create_db
|
||||
|
||||
create_database: create_db
|
||||
|
||||
clean:
|
||||
docker compose down
|
||||
docker compose --project-name cypress down
|
||||
@@ -40,7 +34,7 @@ clean:
|
||||
|
||||
clean-all: clean
|
||||
docker image rm --force \
|
||||
redash/redash:10.1.0.b50633 redis:7-alpine maildev/maildev:latest \
|
||||
redash/redash:latest redis:7-alpine maildev/maildev:latest \
|
||||
pgautoupgrade/pgautoupgrade:15-alpine3.8 pgautoupgrade/pgautoupgrade:latest
|
||||
|
||||
down:
|
||||
@@ -54,12 +48,6 @@ env: .env
|
||||
format:
|
||||
pre-commit run --all-files
|
||||
|
||||
pydeps:
|
||||
pip3 install wheel
|
||||
pip3 install --upgrade black ruff launchpadlib pip setuptools
|
||||
pip3 install poetry
|
||||
poetry install --only main,all_ds,dev
|
||||
|
||||
tests:
|
||||
docker compose run server tests
|
||||
|
||||
|
||||
@@ -1,48 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z $REDASH_REDIS_URL ]; then
|
||||
export REDASH_REDIS_URL=redis://:${REDASH_REDIS_PASSWORD}@${REDASH_REDIS_HOSTNAME}:${REDASH_REDIS_PORT}/${REDASH_REDIS_NAME}
|
||||
fi
|
||||
|
||||
if [ -z $REDASH_DATABASE_URL ]; then
|
||||
export REDASH_DATABASE_URL=postgresql://${REDASH_DATABASE_USER}:${REDASH_DATABASE_PASSWORD}@${REDASH_DATABASE_HOSTNAME}:${REDASH_DATABASE_PORT}/${REDASH_DATABASE_NAME}
|
||||
fi
|
||||
|
||||
scheduler() {
|
||||
echo "Starting RQ scheduler..."
|
||||
|
||||
case $REDASH_PRODUCTION in
|
||||
true)
|
||||
echo "Starting RQ scheduler in production mode"
|
||||
exec ./manage.py rq scheduler
|
||||
;;
|
||||
*)
|
||||
echo "Starting RQ scheduler in dev mode"
|
||||
exec watchmedo auto-restart \
|
||||
--directory=./redash/ \
|
||||
--pattern=*.py \
|
||||
--recursive -- ./manage.py rq scheduler $QUEUES
|
||||
;;
|
||||
esac
|
||||
exec /app/manage.py rq scheduler
|
||||
}
|
||||
|
||||
dev_scheduler() {
|
||||
echo "Starting dev RQ scheduler..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq scheduler
|
||||
}
|
||||
|
||||
worker() {
|
||||
echo "Starting RQ worker..."
|
||||
|
||||
export WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
export QUEUES=${QUEUES:-}
|
||||
case $REDASH_PRODUCTION in
|
||||
true)
|
||||
echo "Starting RQ worker in production mode"
|
||||
exec supervisord -c worker.conf
|
||||
;;
|
||||
*)
|
||||
echo "Starting RQ worker in dev mode"
|
||||
exec watchmedo auto-restart \
|
||||
--directory=./redash/ \
|
||||
--pattern=*.py \
|
||||
--recursive -- ./manage.py rq worker $QUEUES
|
||||
;;
|
||||
esac
|
||||
|
||||
exec supervisord -c worker.conf
|
||||
}
|
||||
|
||||
workers_healthcheck() {
|
||||
@@ -58,63 +35,22 @@ workers_healthcheck() {
|
||||
fi
|
||||
}
|
||||
|
||||
dev_worker() {
|
||||
echo "Starting dev RQ worker..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq worker $QUEUES
|
||||
}
|
||||
|
||||
server() {
|
||||
# Recycle gunicorn workers every n-th request. See http://docs.gunicorn.org/en/stable/settings.html#max-requests for more details.
|
||||
case $REDASH_PRODUCTION in
|
||||
true)
|
||||
echo "Starting Redash Server in production mode"
|
||||
MAX_REQUESTS=${MAX_REQUESTS:-1000}
|
||||
MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100}
|
||||
TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60}
|
||||
exec /usr/local/bin/gunicorn \
|
||||
-b 0.0.0.0:5000 \
|
||||
--name redash \
|
||||
-w${REDASH_WEB_WORKERS:-4} redash.wsgi:app \
|
||||
--max-requests $MAX_REQUESTS \
|
||||
--max-requests-jitter $MAX_REQUESTS_JITTER \
|
||||
--timeout $TIMEOUT
|
||||
;;
|
||||
*)
|
||||
echo "Starting Redash Server in a dev mode"
|
||||
export FLASK_DEBUG=1
|
||||
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
;;
|
||||
esac
|
||||
MAX_REQUESTS=${MAX_REQUESTS:-1000}
|
||||
MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100}
|
||||
TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60}
|
||||
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT
|
||||
}
|
||||
|
||||
create_db() {
|
||||
REDASH_DATABASE_MIGRATE_TIMEOUT=${REDASH_DATABASE_UPGRADE_TIMEOUT:-600}
|
||||
REDASH_DATABASE_MIGRATE_MAX_ATTEMPTS=${REDASH_DATABASE_MIGRATE_MAX_ATTEMPTS:-5}
|
||||
REDASH_DATABASE_MIGRATE_RETRY_WAIT=${REDASH_DATABASE_MIGRATE_RETRY_WAIT:-10}
|
||||
ATTEMPTS=1
|
||||
while ((ATTEMPTS <= REDASH_DATABASE_MIGRATE_MAX_ATTEMPTS)); do
|
||||
echo "Creating or updating Redash database, attempt ${ATTEMPTS} of ${REDASH_DATABASE_MIGRATE_MAX_ATTEMPTS}"
|
||||
ATTEMPTS=$((ATTEMPTS+1))
|
||||
timeout $REDASH_DATABASE_MIGRATE_TIMEOUT /app/manage.py database create_tables
|
||||
timeout $REDASH_DATABASE_MIGRATE_TIMEOUT /app/manage.py db upgrade
|
||||
STATUS=$(timeout $REDASH_DATABASE_MIGRATE_TIMEOUT /app/manage.py status 2>&1)
|
||||
RETCODE=$?
|
||||
case "$RETCODE" in
|
||||
0)
|
||||
exit 0
|
||||
;;
|
||||
124)
|
||||
echo "Status command timed out after ${REDASH_DATABASE_MIGRATE_TIMEOUT} seconds."
|
||||
;;
|
||||
esac
|
||||
case "$STATUS" in
|
||||
*sqlalchemy.exc.OperationalError*)
|
||||
echo "Database not yet functional, waiting."
|
||||
;;
|
||||
*sqlalchemy.exc.ProgrammingError*)
|
||||
echo "Database does not appear to be installed."
|
||||
;;
|
||||
esac
|
||||
echo "Waiting ${REDASH_DATABASE_MIGRATE_RETRY_WAIT} seconds before retrying."
|
||||
sleep ${REDASH_DATABASE_MIGRATE_RETRY_WAIT}
|
||||
done
|
||||
echo "Reached ${REDASH_DATABASE_MIGRATE_MAX_ATTEMPTS} attempts, giving up."
|
||||
exit 1
|
||||
exec /app/manage.py database create_tables
|
||||
}
|
||||
|
||||
help() {
|
||||
@@ -125,16 +61,21 @@ help() {
|
||||
|
||||
echo "server -- start Redash server (with gunicorn)"
|
||||
echo "worker -- start a single RQ worker"
|
||||
echo "dev_worker -- start a single RQ worker with code reloading"
|
||||
echo "scheduler -- start an rq-scheduler instance"
|
||||
echo "dev_scheduler -- start an rq-scheduler instance with code reloading"
|
||||
echo ""
|
||||
echo "shell -- open shell"
|
||||
echo "debug -- start Flask development server with remote debugger via ptvsd"
|
||||
echo "create_db -- create database tables and run migrations"
|
||||
echo "dev_server -- start Flask development server with debugger and auto reload"
|
||||
echo "debug -- start Flask development server with remote debugger via debugpy"
|
||||
echo "create_db -- create database tables"
|
||||
echo "manage -- CLI to manage redash"
|
||||
echo "tests -- run tests"
|
||||
}
|
||||
|
||||
tests() {
|
||||
export REDASH_DATABASE_URL="postgresql://postgres@postgres/tests"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
TEST_ARGS=tests/
|
||||
else
|
||||
@@ -160,10 +101,22 @@ case "$1" in
|
||||
shift
|
||||
scheduler
|
||||
;;
|
||||
dev_scheduler)
|
||||
shift
|
||||
dev_scheduler
|
||||
;;
|
||||
dev_worker)
|
||||
shift
|
||||
dev_worker
|
||||
;;
|
||||
celery_healthcheck)
|
||||
shift
|
||||
echo "DEPRECATED: Celery has been replaced with RQ and now performs healthchecks autonomously as part of the 'worker' entrypoint."
|
||||
;;
|
||||
dev_server)
|
||||
export FLASK_DEBUG=1
|
||||
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
;;
|
||||
debug)
|
||||
export FLASK_DEBUG=1
|
||||
export REMOTE_DEBUG=1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import Link from "@/components/Link";
|
||||
import { clientConfig, currentUser } from "@/services/auth";
|
||||
import frontendVersion from "@/version.json";
|
||||
|
||||
export default function VersionInfo() {
|
||||
@@ -9,6 +10,15 @@ export default function VersionInfo() {
|
||||
Version: {clientConfig.version}
|
||||
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
|
||||
</div>
|
||||
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
|
||||
<div className="m-t-10">
|
||||
{/* eslint-disable react/jsx-no-target-blank */}
|
||||
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
|
||||
Update Available <i className="fa fa-external-link m-l-5" aria-hidden="true" />
|
||||
<span className="sr-only">(opens in a new tab)</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
79
client/app/components/BeaconConsent.jsx
Normal file
79
client/app/components/BeaconConsent.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from "react";
|
||||
import Card from "antd/lib/card";
|
||||
import Button from "antd/lib/button";
|
||||
import Typography from "antd/lib/typography";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import OrgSettings from "@/services/organizationSettings";
|
||||
|
||||
const Text = Typography.Text;
|
||||
|
||||
function BeaconConsent() {
|
||||
const [hide, setHide] = useState(false);
|
||||
|
||||
if (!clientConfig.showBeaconConsentMessage || hide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hideConsentCard = () => {
|
||||
clientConfig.showBeaconConsentMessage = false;
|
||||
setHide(true);
|
||||
};
|
||||
|
||||
const confirmConsent = (confirm) => {
|
||||
let message = "🙏 Thank you.";
|
||||
|
||||
if (!confirm) {
|
||||
message = "Settings Saved.";
|
||||
}
|
||||
|
||||
OrgSettings.save({ beacon_consent: confirm }, message)
|
||||
// .then(() => {
|
||||
// // const settings = get(response, 'settings');
|
||||
// // this.setState({ settings, formValues: { ...settings } });
|
||||
// })
|
||||
.finally(hideConsentCard);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicComponent name="BeaconConsent">
|
||||
<div className="m-t-10 tiled">
|
||||
<Card
|
||||
title={
|
||||
<>
|
||||
Would you be ok with sharing anonymous usage data with the Redash team?{" "}
|
||||
<HelpTrigger type="USAGE_DATA_SHARING" />
|
||||
</>
|
||||
}
|
||||
bordered={false}
|
||||
>
|
||||
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
||||
<div className="m-t-5">
|
||||
<ul>
|
||||
<li> Number of users, queries, dashboards, alerts, widgets and visualizations.</li>
|
||||
<li> Types of data sources, alert destinations and visualizations.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Text>All data is aggregated and will never include any sensitive or private data.</Text>
|
||||
<div className="m-t-5">
|
||||
<Button type="primary" className="m-r-5" onClick={() => confirmConsent(true)}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="default" onClick={() => confirmConsent(false)}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
<div className="m-t-15">
|
||||
<Text type="secondary">
|
||||
You can change this setting anytime from the <Link href="settings/general">Settings</Link> page.
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
export default BeaconConsent;
|
||||
@@ -12,6 +12,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import QuerySelector from "@/components/QuerySelector";
|
||||
import { Query } from "@/services/query";
|
||||
import { useUniqueId } from "@/lib/hooks/useUniqueId";
|
||||
import "./EditParameterSettingsDialog.less";
|
||||
|
||||
const { Option } = Select;
|
||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||
@@ -26,7 +27,7 @@ function isTypeDateRange(type) {
|
||||
|
||||
function joinExampleList(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 }) {
|
||||
@@ -54,7 +55,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +72,8 @@ function EditParameterSettingsDialog(props) {
|
||||
const [param, setParam] = useState(clone(props.parameter));
|
||||
const [isNameValid, setIsNameValid] = useState(true);
|
||||
const [initialQuery, setInitialQuery] = useState();
|
||||
const [userInput, setUserInput] = useState(param.regex || "");
|
||||
const [isValidRegex, setIsValidRegex] = useState(true);
|
||||
|
||||
const isNew = !props.parameter.name;
|
||||
|
||||
@@ -114,6 +117,17 @@ function EditParameterSettingsDialog(props) {
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
{...props.dialog.props}
|
||||
@@ -129,15 +143,17 @@ function EditParameterSettingsDialog(props) {
|
||||
disabled={!isFulfilled()}
|
||||
type="primary"
|
||||
form={paramFormId}
|
||||
data-test="SaveParameterSettings">
|
||||
data-test="SaveParameterSettings"
|
||||
>
|
||||
{isNew ? "Add Parameter" : "OK"}
|
||||
</Button>,
|
||||
]}>
|
||||
]}
|
||||
>
|
||||
<Form layout="horizontal" onFinish={onConfirm} id={paramFormId}>
|
||||
{isNew && (
|
||||
<NameInput
|
||||
name={param.name}
|
||||
onChange={name => setParam({ ...param, name })}
|
||||
onChange={(name) => setParam({ ...param, name })}
|
||||
setValidation={setIsNameValid}
|
||||
existingNames={props.existingParams}
|
||||
type={param.type}
|
||||
@@ -146,15 +162,16 @@ function EditParameterSettingsDialog(props) {
|
||||
<Form.Item required label="Title" {...formItemProps}>
|
||||
<Input
|
||||
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"
|
||||
/>
|
||||
</Form.Item>
|
||||
<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">
|
||||
Text
|
||||
</Option>
|
||||
<Option value="text-pattern">Text Pattern</Option>
|
||||
<Option value="number" data-test="NumberParameterTypeOption">
|
||||
Number
|
||||
</Option>
|
||||
@@ -180,12 +197,26 @@ function EditParameterSettingsDialog(props) {
|
||||
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
|
||||
</Select>
|
||||
</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" && (
|
||||
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={param.enumOptions}
|
||||
onChange={e => setParam({ ...param, enumOptions: e.target.value })}
|
||||
onChange={(e) => setParam({ ...param, enumOptions: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -193,7 +224,7 @@ function EditParameterSettingsDialog(props) {
|
||||
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||
<QuerySelector
|
||||
selectedQuery={initialQuery}
|
||||
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
||||
onChange={(q) => setParam({ ...param, queryId: q && q.id })}
|
||||
type="select"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -202,7 +233,7 @@ function EditParameterSettingsDialog(props) {
|
||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||
<Checkbox
|
||||
defaultChecked={!!param.multiValuesOptions}
|
||||
onChange={e =>
|
||||
onChange={(e) =>
|
||||
setParam({
|
||||
...param,
|
||||
multiValuesOptions: e.target.checked
|
||||
@@ -214,7 +245,8 @@ function EditParameterSettingsDialog(props) {
|
||||
: null,
|
||||
})
|
||||
}
|
||||
data-test="AllowMultipleValuesCheckbox">
|
||||
data-test="AllowMultipleValuesCheckbox"
|
||||
>
|
||||
Allow multiple values
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
@@ -227,10 +259,11 @@ function EditParameterSettingsDialog(props) {
|
||||
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
|
||||
</React.Fragment>
|
||||
}
|
||||
{...formItemProps}>
|
||||
{...formItemProps}
|
||||
>
|
||||
<Select
|
||||
value={param.multiValuesOptions.prefix}
|
||||
onChange={quoteOption =>
|
||||
onChange={(quoteOption) =>
|
||||
setParam({
|
||||
...param,
|
||||
multiValuesOptions: {
|
||||
@@ -240,7 +273,8 @@ function EditParameterSettingsDialog(props) {
|
||||
},
|
||||
})
|
||||
}
|
||||
data-test="QuotationSelect">
|
||||
data-test="QuotationSelect"
|
||||
>
|
||||
<Option value="">None (default)</Option>
|
||||
<Option value="'">Single Quotation Mark</Option>
|
||||
<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;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const TYPES = mapValues(
|
||||
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
|
||||
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
|
||||
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
|
||||
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
|
||||
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
|
||||
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
|
||||
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
|
||||
@@ -100,7 +101,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
}
|
||||
|
||||
loadIframe = url => {
|
||||
loadIframe = (url) => {
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
this.setState({ loading: true, error: false });
|
||||
|
||||
@@ -115,8 +116,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
};
|
||||
|
||||
onPostMessageReceived = event => {
|
||||
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
|
||||
onPostMessageReceived = (event) => {
|
||||
if (!some(allowedDomains, (domain) => startsWith(event.origin, domain))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,7 +134,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
||||
return helpTriggerType ? helpTriggerType[0] : this.props.href;
|
||||
};
|
||||
|
||||
openDrawer = e => {
|
||||
openDrawer = (e) => {
|
||||
// keep "open in new tab" behavior
|
||||
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
@@ -143,7 +144,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
||||
}
|
||||
};
|
||||
|
||||
closeDrawer = event => {
|
||||
closeDrawer = (event) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -160,7 +161,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
||||
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
|
||||
const className = cx("help-trigger", this.props.className);
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -179,13 +180,15 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}>
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={url || this.getUrl()}
|
||||
className={className}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
|
||||
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}
|
||||
>
|
||||
{this.props.children}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
@@ -196,7 +199,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
|
||||
visible={this.state.visible}
|
||||
className={cx("help-drawer", drawerClassName)}
|
||||
destroyOnClose
|
||||
width={400}>
|
||||
width={400}
|
||||
>
|
||||
<div className="drawer-wrapper">
|
||||
<div className="drawer-menu">
|
||||
{url && (
|
||||
|
||||
@@ -33,10 +33,10 @@ export const MappingType = {
|
||||
};
|
||||
|
||||
export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) {
|
||||
return map(mappings, mapping => {
|
||||
return map(mappings, (mapping) => {
|
||||
const result = extend({}, mapping);
|
||||
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) {
|
||||
case ParameterMappingType.DashboardLevel:
|
||||
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
|
||||
@@ -62,7 +62,7 @@ export function editableMappingsToParameterMappings(mappings) {
|
||||
map(
|
||||
// convert to map
|
||||
mappings,
|
||||
mapping => {
|
||||
(mapping) => {
|
||||
const result = extend({}, mapping);
|
||||
switch (mapping.type) {
|
||||
case MappingType.DashboardAddNew:
|
||||
@@ -95,11 +95,11 @@ export function editableMappingsToParameterMappings(mappings) {
|
||||
export function synchronizeWidgetTitles(sourceMappings, widgets) {
|
||||
const affectedWidgets = [];
|
||||
|
||||
each(sourceMappings, sourceMapping => {
|
||||
each(sourceMappings, (sourceMapping) => {
|
||||
if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
|
||||
each(widgets, widget => {
|
||||
each(widgets, (widget) => {
|
||||
const widgetMappings = widget.options.parameterMappings;
|
||||
each(widgetMappings, widgetMapping => {
|
||||
each(widgetMappings, (widgetMapping) => {
|
||||
// check if mapped to the same dashboard-level parameter
|
||||
if (
|
||||
widgetMapping.type === ParameterMappingType.DashboardLevel &&
|
||||
@@ -140,7 +140,7 @@ export class ParameterMappingInput extends React.Component {
|
||||
className: "form-item",
|
||||
};
|
||||
|
||||
updateSourceType = type => {
|
||||
updateSourceType = (type) => {
|
||||
let {
|
||||
mapping: { mapTo },
|
||||
} = this.props;
|
||||
@@ -155,7 +155,7 @@ export class ParameterMappingInput extends React.Component {
|
||||
this.updateParamMapping({ type, mapTo });
|
||||
};
|
||||
|
||||
updateParamMapping = update => {
|
||||
updateParamMapping = (update) => {
|
||||
const { onChange, mapping } = this.props;
|
||||
const newMapping = extend({}, mapping, update);
|
||||
if (newMapping.value !== mapping.value) {
|
||||
@@ -175,7 +175,7 @@ export class ParameterMappingInput extends React.Component {
|
||||
renderMappingTypeSelector() {
|
||||
const noExisting = isEmpty(this.props.existingParamNames);
|
||||
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">
|
||||
New dashboard parameter
|
||||
</Radio>
|
||||
@@ -205,16 +205,16 @@ export class ParameterMappingInput extends React.Component {
|
||||
<Input
|
||||
value={mapTo}
|
||||
aria-label="Parameter name (key)"
|
||||
onChange={e => this.updateParamMapping({ mapTo: e.target.value })}
|
||||
onChange={(e) => this.updateParamMapping({ mapTo: e.target.value })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDashboardMapToExisting() {
|
||||
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() {
|
||||
@@ -226,7 +226,8 @@ export class ParameterMappingInput extends React.Component {
|
||||
enumOptions={mapping.param.enumOptions}
|
||||
queryId={mapping.param.queryId}
|
||||
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();
|
||||
else this.hide();
|
||||
};
|
||||
|
||||
onChange = mapping => {
|
||||
onChange = (mapping) => {
|
||||
let inputError = null;
|
||||
|
||||
if (mapping.type === MappingType.DashboardAddNew) {
|
||||
@@ -351,7 +352,8 @@ class MappingEditor extends React.Component {
|
||||
trigger="click"
|
||||
content={this.renderContent()}
|
||||
visible={visible}
|
||||
onVisibleChange={this.onVisibleChange}>
|
||||
onVisibleChange={this.onVisibleChange}
|
||||
>
|
||||
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
@@ -376,14 +378,14 @@ class TitleEditor extends React.Component {
|
||||
title: "", // will be set on editing
|
||||
};
|
||||
|
||||
onPopupVisibleChange = showPopup => {
|
||||
onPopupVisibleChange = (showPopup) => {
|
||||
this.setState({
|
||||
showPopup,
|
||||
title: showPopup ? this.getMappingTitle() : "",
|
||||
});
|
||||
};
|
||||
|
||||
onEditingTitleChange = event => {
|
||||
onEditingTitleChange = (event) => {
|
||||
this.setState({ title: event.target.value });
|
||||
};
|
||||
|
||||
@@ -460,7 +462,8 @@ class TitleEditor extends React.Component {
|
||||
trigger="click"
|
||||
content={this.renderPopover()}
|
||||
visible={this.state.showPopup}
|
||||
onVisibleChange={this.onPopupVisibleChange}>
|
||||
onVisibleChange={this.onPopupVisibleChange}
|
||||
>
|
||||
<Button size="small" type="dashed">
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
@@ -508,7 +511,7 @@ export class ParameterMappingListInput extends React.Component {
|
||||
|
||||
// just to be safe, array or object
|
||||
if (typeof value === "object") {
|
||||
return map(value, v => this.getStringValue(v)).join(", ");
|
||||
return map(value, (v) => this.getStringValue(v)).join(", ");
|
||||
}
|
||||
|
||||
// rest
|
||||
@@ -574,7 +577,7 @@ export class ParameterMappingListInput extends React.Component {
|
||||
|
||||
render() {
|
||||
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 (
|
||||
<div className="parameters-mapping-list">
|
||||
@@ -583,11 +586,11 @@ export class ParameterMappingListInput extends React.Component {
|
||||
title="Title"
|
||||
dataIndex="mapping"
|
||||
key="title"
|
||||
render={mapping => (
|
||||
render={(mapping) => (
|
||||
<TitleEditor
|
||||
existingParams={existingParams}
|
||||
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"
|
||||
key="keyword"
|
||||
className="keyword"
|
||||
render={mapping => <code>{`{{ ${mapping.name} }}`}</code>}
|
||||
render={(mapping) => <code>{`{{ ${mapping.name} }}`}</code>}
|
||||
/>
|
||||
<Table.Column
|
||||
title="Default Value"
|
||||
dataIndex="mapping"
|
||||
key="value"
|
||||
render={mapping => this.constructor.getDefaultValue(mapping, this.props.existingParams)}
|
||||
render={(mapping) => this.constructor.getDefaultValue(mapping, this.props.existingParams)}
|
||||
/>
|
||||
<Table.Column
|
||||
title="Value Source"
|
||||
dataIndex="mapping"
|
||||
key="source"
|
||||
render={mapping => {
|
||||
render={(mapping) => {
|
||||
const existingParamsNames = existingParams
|
||||
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
|
||||
.map(({ name }) => name); // keep names only
|
||||
|
||||
@@ -9,11 +9,12 @@ import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParamet
|
||||
import QueryBasedParameterInput from "./QueryBasedParameterInput";
|
||||
|
||||
import "./ParameterValueInput.less";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
const multipleValuesProps = {
|
||||
maxTagCount: 3,
|
||||
maxTagTextLength: 10,
|
||||
maxTagPlaceholder: num => `+${num.length} more`,
|
||||
maxTagPlaceholder: (num) => `+${num.length} more`,
|
||||
};
|
||||
|
||||
class ParameterValueInput extends React.Component {
|
||||
@@ -25,6 +26,7 @@ class ParameterValueInput extends React.Component {
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
regex: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -35,6 +37,7 @@ class ParameterValueInput extends React.Component {
|
||||
parameter: null,
|
||||
onSelect: () => {},
|
||||
className: "",
|
||||
regex: "",
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@@ -45,7 +48,7 @@ class ParameterValueInput extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate = prevProps => {
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const { value, parameter } = this.props;
|
||||
// if value prop updated, reset dirty state
|
||||
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);
|
||||
this.setState({ value, isDirty });
|
||||
this.props.onSelect(value, isDirty);
|
||||
@@ -93,9 +96,9 @@ class ParameterValueInput extends React.Component {
|
||||
renderEnumInput() {
|
||||
const { enumOptions, parameter } = this.props;
|
||||
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
|
||||
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
|
||||
const normalize = (val) => (parameter.multiValuesOptions && val === null ? [] : val);
|
||||
|
||||
return (
|
||||
<SelectWithVirtualScroll
|
||||
@@ -103,7 +106,7 @@ class ParameterValueInput extends React.Component {
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
value={normalize(value)}
|
||||
onChange={this.onSelect}
|
||||
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
|
||||
options={map(enumOptionsArray, (opt) => ({ label: String(opt), value: opt }))}
|
||||
showSearch
|
||||
showArrow
|
||||
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
|
||||
@@ -133,18 +136,36 @@ class ParameterValueInput extends React.Component {
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
const normalize = val => (isNaN(val) ? undefined : val);
|
||||
const normalize = (val) => (isNaN(val) ? undefined : val);
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
className={className}
|
||||
value={normalize(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() {
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
@@ -155,7 +176,7 @@ class ParameterValueInput extends React.Component {
|
||||
value={value}
|
||||
aria-label="Parameter text value"
|
||||
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();
|
||||
case "number":
|
||||
return this.renderNumberInput();
|
||||
case "text-pattern":
|
||||
return this.renderTextPatternInput();
|
||||
default:
|
||||
return this.renderTextInput();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import "./Parameters.less";
|
||||
|
||||
function updateUrl(parameters) {
|
||||
const params = extend({}, location.search);
|
||||
parameters.forEach(param => {
|
||||
parameters.forEach((param) => {
|
||||
extend(params, param.toUrlParams());
|
||||
});
|
||||
location.setSearch(params, true);
|
||||
@@ -43,7 +43,7 @@ export default class Parameters extends React.Component {
|
||||
appendSortableToParent: true,
|
||||
};
|
||||
|
||||
toCamelCase = str => {
|
||||
toCamelCase = (str) => {
|
||||
if (isEmpty(str)) {
|
||||
return "";
|
||||
}
|
||||
@@ -59,10 +59,10 @@ export default class Parameters extends React.Component {
|
||||
}
|
||||
const hideRegex = /hide_filter=([^&]+)/g;
|
||||
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 parametersChanged = prevProps.parameters !== parameters;
|
||||
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
|
||||
@@ -74,7 +74,7 @@ export default class Parameters extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
handleKeyDown = (e) => {
|
||||
// Cmd/Ctrl/Alt + Enter
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
||||
e.stopPropagation();
|
||||
@@ -109,8 +109,8 @@ export default class Parameters extends React.Component {
|
||||
applyChanges = () => {
|
||||
const { onValuesChange, disableUrlUpdate } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue);
|
||||
forEach(parameters, p => p.applyPendingValue());
|
||||
const parametersWithPendingValues = parameters.filter((p) => p.hasPendingValue);
|
||||
forEach(parameters, (p) => p.applyPendingValue());
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export default class Parameters extends React.Component {
|
||||
|
||||
showParameterSettings = (parameter, index) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => {
|
||||
EditParameterSettingsDialog.showModal({ parameter }).onClose((updated) => {
|
||||
this.setState(({ parameters }) => {
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
|
||||
@@ -132,7 +132,7 @@ export default class Parameters extends React.Component {
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
const { editable } = this.props;
|
||||
@@ -149,7 +149,8 @@ export default class Parameters extends React.Component {
|
||||
aria-label="Edit"
|
||||
onClick={() => this.showParameterSettings(param, index)}
|
||||
data-test={`ParameterSettings-${param.name}`}
|
||||
type="button">
|
||||
type="button"
|
||||
>
|
||||
<i className="fa fa-cog" aria-hidden="true" />
|
||||
</PlainButton>
|
||||
)}
|
||||
@@ -162,6 +163,7 @@ export default class Parameters extends React.Component {
|
||||
enumOptions={param.enumOptions}
|
||||
queryId={param.queryId}
|
||||
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
|
||||
regex={param.regex}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -178,20 +180,22 @@ export default class Parameters extends React.Component {
|
||||
useDragHandle
|
||||
lockToContainerEdges
|
||||
helperClass="parameter-dragged"
|
||||
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)}
|
||||
helperContainer={(containerEl) => (appendSortableToParent ? containerEl : document.body)}
|
||||
updateBeforeSortStart={this.onBeforeSortStart}
|
||||
onSortEnd={this.moveParameter}
|
||||
containerProps={{
|
||||
className: "parameter-container",
|
||||
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{parameters &&
|
||||
parameters.map((param, index) => (
|
||||
<SortableElement key={param.name} index={index}>
|
||||
<div
|
||||
className="parameter-block"
|
||||
data-editable={sortable || null}
|
||||
data-test={`ParameterBlock-${param.name}`}>
|
||||
data-test={`ParameterBlock-${param.name}`}
|
||||
>
|
||||
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
|
||||
{this.renderParameter(param, index)}
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ UserPreviewCard.defaultProps = {
|
||||
// DataSourcePreviewCard
|
||||
|
||||
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;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
||||
|
||||
@@ -19,7 +19,6 @@ import PlainButton from "@/components/PlainButton";
|
||||
import ExpandedWidgetDialog from "@/components/dashboards/ExpandedWidgetDialog";
|
||||
import EditParameterMappingsDialog from "@/components/dashboards/EditParameterMappingsDialog";
|
||||
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
|
||||
import { ExecutionStatus } from "@/services/query-result";
|
||||
|
||||
import Widget from "./Widget";
|
||||
|
||||
@@ -279,7 +278,7 @@ class VisualizationWidget extends React.Component {
|
||||
const widgetQueryResult = widget.getQueryResult();
|
||||
const widgetStatus = widgetQueryResult && widgetQueryResult.getStatus();
|
||||
switch (widgetStatus) {
|
||||
case ExecutionStatus.FAILED:
|
||||
case "failed":
|
||||
return (
|
||||
<div className="body-row-auto scrollbox">
|
||||
{widgetQueryResult.getError() && (
|
||||
@@ -289,7 +288,7 @@ class VisualizationWidget extends React.Component {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case ExecutionStatus.FINISHED:
|
||||
case "done":
|
||||
return (
|
||||
<div className="body-row-auto scrollbox">
|
||||
<VisualizationRenderer
|
||||
|
||||
@@ -96,7 +96,7 @@ function EmptyState({
|
||||
}, []);
|
||||
|
||||
// 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) {
|
||||
return null;
|
||||
@@ -181,7 +181,7 @@ function EmptyState({
|
||||
];
|
||||
|
||||
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 (
|
||||
<div className="empty-state-wrapper">
|
||||
@@ -196,7 +196,7 @@ function EmptyState({
|
||||
</div>
|
||||
<div className="empty-state__steps">
|
||||
<h4>Let's get started</h4>
|
||||
<ol>{stepsItems.map(item => item.node)}</ol>
|
||||
<ol>{stepsItems.map((item) => item.node)}</ol>
|
||||
{helpMessage}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Controller<I, P = any> {
|
||||
orderByField?: string;
|
||||
orderByReverse: boolean;
|
||||
toggleSorting: (orderByField: string) => void;
|
||||
setSorting: (orderByField: string, orderByReverse: boolean) => void;
|
||||
|
||||
// pagination
|
||||
page: number;
|
||||
@@ -139,10 +140,11 @@ export function wrap<I, P = any>(
|
||||
this.props.onError!(error);
|
||||
|
||||
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
|
||||
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
|
||||
const { updatePagination, toggleSorting, setSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
|
||||
this.state = {
|
||||
...initialState,
|
||||
toggleSorting, // eslint-disable-line react/no-unused-state
|
||||
setSorting, // eslint-disable-line react/no-unused-state
|
||||
updateSearch: debounce(updateSearch, 200), // eslint-disable-line react/no-unused-state
|
||||
updateSelectedTags, // eslint-disable-line react/no-unused-state
|
||||
updatePagination, // eslint-disable-line react/no-unused-state
|
||||
|
||||
@@ -39,14 +39,12 @@ export class ItemsSource {
|
||||
const customParams = {};
|
||||
const context = {
|
||||
...this.getCallbackContext(),
|
||||
setCustomParams: params => {
|
||||
setCustomParams: (params) => {
|
||||
extend(customParams, params);
|
||||
},
|
||||
};
|
||||
return this._beforeUpdate().then(() => {
|
||||
const fetchToken = Math.random()
|
||||
.toString(36)
|
||||
.substr(2);
|
||||
const fetchToken = Math.random().toString(36).substr(2);
|
||||
this._currentFetchToken = fetchToken;
|
||||
return this._fetcher
|
||||
.fetch(changes, state, context)
|
||||
@@ -59,7 +57,7 @@ export class ItemsSource {
|
||||
return this._afterUpdate();
|
||||
}
|
||||
})
|
||||
.catch(error => this.handleError(error));
|
||||
.catch((error) => this.handleError(error));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,13 +122,20 @@ export class ItemsSource {
|
||||
});
|
||||
};
|
||||
|
||||
toggleSorting = orderByField => {
|
||||
toggleSorting = (orderByField) => {
|
||||
this._sorter.toggleField(orderByField);
|
||||
this._savedOrderByField = this._sorter.field;
|
||||
this._changed({ sorting: true });
|
||||
};
|
||||
|
||||
updateSearch = searchTerm => {
|
||||
setSorting = (orderByField, orderByReverse) => {
|
||||
this._sorter.setField(orderByField);
|
||||
this._sorter.setReverse(orderByReverse);
|
||||
this._savedOrderByField = this._sorter.field;
|
||||
this._changed({ sorting: true });
|
||||
};
|
||||
|
||||
updateSearch = (searchTerm) => {
|
||||
// here we update state directly, but later `fetchData` will update it properly
|
||||
this._searchTerm = searchTerm;
|
||||
// in search mode ignore the ordering and use the ranking order
|
||||
@@ -145,7 +150,7 @@ export class ItemsSource {
|
||||
this._changed({ search: true, pagination: { page: true } });
|
||||
};
|
||||
|
||||
updateSelectedTags = selectedTags => {
|
||||
updateSelectedTags = (selectedTags) => {
|
||||
this._selectedTags = selectedTags;
|
||||
this._paginator.setPage(1);
|
||||
this._changed({ tags: true, pagination: { page: true } });
|
||||
@@ -153,7 +158,7 @@ export class ItemsSource {
|
||||
|
||||
update = () => this._changed();
|
||||
|
||||
handleError = error => {
|
||||
handleError = (error) => {
|
||||
if (isFunction(this.onError)) {
|
||||
this.onError(error);
|
||||
}
|
||||
@@ -172,7 +177,7 @@ export class ResourceItemsSource extends ItemsSource {
|
||||
processResults: (results, context) => {
|
||||
let processItem = getItemProcessor(context);
|
||||
processItem = isFunction(processItem) ? processItem : identity;
|
||||
return map(results, item => processItem(item, context));
|
||||
return map(results, (item) => processItem(item, context));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const Columns = {
|
||||
date(overrides) {
|
||||
return extend(
|
||||
{
|
||||
render: text => formatDate(text),
|
||||
render: (text) => formatDate(text),
|
||||
},
|
||||
overrides
|
||||
);
|
||||
@@ -52,7 +52,7 @@ export const Columns = {
|
||||
dateTime(overrides) {
|
||||
return extend(
|
||||
{
|
||||
render: text => formatDateTime(text),
|
||||
render: (text) => formatDateTime(text),
|
||||
},
|
||||
overrides
|
||||
);
|
||||
@@ -62,7 +62,7 @@ export const Columns = {
|
||||
{
|
||||
width: "1%",
|
||||
className: "text-nowrap",
|
||||
render: text => durationHumanize(text),
|
||||
render: (text) => durationHumanize(text),
|
||||
},
|
||||
overrides
|
||||
);
|
||||
@@ -70,7 +70,7 @@ export const Columns = {
|
||||
timeAgo(overrides, timeAgoCustomProps = undefined) {
|
||||
return extend(
|
||||
{
|
||||
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
|
||||
render: (value) => <TimeAgo date={value} {...timeAgoCustomProps} />,
|
||||
},
|
||||
overrides
|
||||
);
|
||||
@@ -110,6 +110,7 @@ export default class ItemsTable extends React.Component {
|
||||
orderByField: PropTypes.string,
|
||||
orderByReverse: PropTypes.bool,
|
||||
toggleSorting: PropTypes.func,
|
||||
setSorting: PropTypes.func,
|
||||
"data-test": PropTypes.string,
|
||||
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
};
|
||||
@@ -127,18 +128,15 @@ export default class ItemsTable extends React.Component {
|
||||
};
|
||||
|
||||
prepareColumns() {
|
||||
const { orderByField, orderByReverse, toggleSorting } = this.props;
|
||||
const { orderByField, orderByReverse } = this.props;
|
||||
const orderByDirection = orderByReverse ? "descend" : "ascend";
|
||||
|
||||
return map(
|
||||
map(
|
||||
filter(this.props.columns, column => (isFunction(column.isAvailable) ? column.isAvailable() : true)),
|
||||
column => extend(column, { orderByField: column.orderByField || column.field })
|
||||
filter(this.props.columns, (column) => (isFunction(column.isAvailable) ? column.isAvailable() : true)),
|
||||
(column) => extend(column, { orderByField: column.orderByField || column.field })
|
||||
),
|
||||
(column, index) => {
|
||||
// Bind click events only to sortable columns
|
||||
const onHeaderCell = column.sorter ? () => ({ onClick: () => toggleSorting(column.orderByField) }) : null;
|
||||
|
||||
// Wrap render function to pass correct arguments
|
||||
const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity;
|
||||
|
||||
@@ -146,14 +144,13 @@ export default class ItemsTable extends React.Component {
|
||||
key: "column" + index,
|
||||
dataIndex: ["item", column.field],
|
||||
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
|
||||
onHeaderCell,
|
||||
render,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getRowKey = record => {
|
||||
getRowKey = (record) => {
|
||||
const { rowKey } = this.props;
|
||||
if (rowKey) {
|
||||
if (isFunction(rowKey)) {
|
||||
@@ -172,22 +169,43 @@ export default class ItemsTable extends React.Component {
|
||||
|
||||
// Bind events only if `onRowClick` specified
|
||||
const onTableRow = isFunction(this.props.onRowClick)
|
||||
? row => ({
|
||||
onClick: event => {
|
||||
? (row) => ({
|
||||
onClick: (event) => {
|
||||
this.props.onRowClick(event, row.item);
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const onChange = (pagination, filters, sorter, extra) => {
|
||||
const action = extra?.action;
|
||||
if (action === "sort") {
|
||||
const propsColumn = this.props.columns.find((column) => column.field === sorter.field[1]);
|
||||
if (!propsColumn.sorter) {
|
||||
return;
|
||||
}
|
||||
let orderByField = propsColumn.orderByField;
|
||||
const orderByReverse = sorter.order === "descend";
|
||||
|
||||
if (orderByReverse === undefined) {
|
||||
orderByField = null;
|
||||
}
|
||||
if (this.props.setSorting) {
|
||||
this.props.setSorting(orderByField, orderByReverse);
|
||||
} else {
|
||||
this.props.toggleSorting(orderByField);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { showHeader } = this.props;
|
||||
if (this.props.loading) {
|
||||
if (isEmpty(tableDataProps.dataSource)) {
|
||||
tableDataProps.columns = tableDataProps.columns.map(column => ({
|
||||
tableDataProps.columns = tableDataProps.columns.map((column) => ({
|
||||
...column,
|
||||
sorter: false,
|
||||
render: () => <Skeleton active paragraph={false} />,
|
||||
}));
|
||||
tableDataProps.dataSource = range(10).map(key => ({ key: `${key}` }));
|
||||
tableDataProps.dataSource = range(10).map((key) => ({ key: `${key}` }));
|
||||
} else {
|
||||
tableDataProps.loading = { indicator: null };
|
||||
}
|
||||
@@ -200,6 +218,7 @@ export default class ItemsTable extends React.Component {
|
||||
rowKey={this.getRowKey}
|
||||
pagination={false}
|
||||
onRow={onTableRow}
|
||||
onChange={onChange}
|
||||
data-test={this.props["data-test"]}
|
||||
{...tableDataProps}
|
||||
/>
|
||||
|
||||
@@ -65,6 +65,7 @@ export const Query = PropTypes.shape({
|
||||
|
||||
export const AlertOptions = PropTypes.shape({
|
||||
column: PropTypes.string,
|
||||
selector: PropTypes.oneOf(["first", "min", "max"]),
|
||||
op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]),
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
custom_subject: PropTypes.string,
|
||||
@@ -83,6 +84,7 @@ export const Alert = PropTypes.shape({
|
||||
query: Query,
|
||||
options: PropTypes.shape({
|
||||
column: PropTypes.string,
|
||||
selector: PropTypes.string,
|
||||
op: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
}).isRequired,
|
||||
|
||||
@@ -16,7 +16,6 @@ import LoadingState from "../items-list/components/LoadingState";
|
||||
const SchemaItemColumnType = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
comment: PropTypes.string,
|
||||
});
|
||||
|
||||
export const SchemaItemType = PropTypes.shape({
|
||||
@@ -48,30 +47,13 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
|
||||
return (
|
||||
<div {...props}>
|
||||
<div className="schema-list-item">
|
||||
{item.description ? (
|
||||
<Tooltip
|
||||
title={item.description}
|
||||
mouseEnterDelay={0}
|
||||
mouseLeaveDelay={0}
|
||||
placement="right"
|
||||
arrowPointAtCenter>
|
||||
<PlainButton className="table-name" onClick={onToggle}>
|
||||
<i className="fa fa-table m-r-5" aria-hidden="true" />
|
||||
<strong>
|
||||
<span title={item.name}>{tableDisplayName}</span>
|
||||
{!isNil(item.size) && <span> ({item.size})</span>}
|
||||
</strong>
|
||||
</PlainButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<PlainButton className="table-name" onClick={onToggle}>
|
||||
<i className="fa fa-table m-r-5" aria-hidden="true" />
|
||||
<strong>
|
||||
<span title={item.name}>{tableDisplayName}</span>
|
||||
{!isNil(item.size) && <span> ({item.size})</span>}
|
||||
</strong>
|
||||
</PlainButton>
|
||||
)}
|
||||
<PlainButton className="table-name" onClick={onToggle}>
|
||||
<i className="fa fa-table m-r-5" aria-hidden="true" />
|
||||
<strong>
|
||||
<span title={item.name}>{tableDisplayName}</span>
|
||||
{!isNil(item.size) && <span> ({item.size})</span>}
|
||||
</strong>
|
||||
</PlainButton>
|
||||
<Tooltip
|
||||
title="Insert table name into query text"
|
||||
mouseEnterDelay={0}
|
||||
@@ -91,34 +73,22 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
|
||||
map(item.columns, column => {
|
||||
const columnName = get(column, "name");
|
||||
const columnType = get(column, "type");
|
||||
const columnComment = get(column, "comment");
|
||||
if (columnComment) {
|
||||
return (
|
||||
<Tooltip title={columnComment} mouseEnterDelay={0} mouseLeaveDelay={0} placement="rightTop">
|
||||
<PlainButton
|
||||
key={columnName}
|
||||
className="table-open-item"
|
||||
onClick={e => handleSelect(e, columnName)}>
|
||||
<div>
|
||||
{columnName} {columnType && <span className="column-type">{columnType}</span>}
|
||||
</div>
|
||||
|
||||
<div className="copy-to-editor">
|
||||
<i className="fa fa-angle-double-right" aria-hidden="true" />
|
||||
</div>
|
||||
</PlainButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PlainButton key={columnName} className="table-open-item" onClick={e => handleSelect(e, columnName)}>
|
||||
<div>
|
||||
{columnName} {columnType && <span className="column-type">{columnType}</span>}
|
||||
</div>
|
||||
<div className="copy-to-editor">
|
||||
<i className="fa fa-angle-double-right" aria-hidden="true" />
|
||||
</div>
|
||||
</PlainButton>
|
||||
<Tooltip
|
||||
title="Insert column name into query text"
|
||||
mouseEnterDelay={0}
|
||||
mouseLeaveDelay={0}
|
||||
placement="rightTop">
|
||||
<PlainButton key={columnName} className="table-open-item" onClick={e => handleSelect(e, columnName)}>
|
||||
<div>
|
||||
{columnName} {columnType && <span className="column-type">{columnType}</span>}
|
||||
</div>
|
||||
|
||||
<div className="copy-to-editor">
|
||||
<i className="fa fa-angle-double-right" aria-hidden="true" />
|
||||
</div>
|
||||
</PlainButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" translate="no">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -16,6 +16,7 @@ import MenuButton from "./components/MenuButton";
|
||||
import AlertView from "./AlertView";
|
||||
import AlertEdit from "./AlertEdit";
|
||||
import AlertNew from "./AlertNew";
|
||||
import notifications from "@/services/notifications";
|
||||
|
||||
const MODES = {
|
||||
NEW: 0,
|
||||
@@ -64,6 +65,7 @@ class Alert extends React.Component {
|
||||
this.setState({
|
||||
alert: {
|
||||
options: {
|
||||
selector: "first",
|
||||
op: ">",
|
||||
value: 1,
|
||||
muted: false,
|
||||
@@ -75,7 +77,7 @@ class Alert extends React.Component {
|
||||
} else {
|
||||
const { alertId } = this.props;
|
||||
AlertService.get({ id: alertId })
|
||||
.then(alert => {
|
||||
.then((alert) => {
|
||||
if (this._isMounted) {
|
||||
const canEdit = currentUser.canEdit(alert);
|
||||
|
||||
@@ -93,7 +95,7 @@ class Alert extends React.Component {
|
||||
this.onQuerySelected(alert.query);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
if (this._isMounted) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
@@ -112,7 +114,7 @@ class Alert extends React.Component {
|
||||
alert.rearm = pendingRearm || null;
|
||||
|
||||
return AlertService.save(alert)
|
||||
.then(alert => {
|
||||
.then((alert) => {
|
||||
notification.success("Saved.");
|
||||
navigateTo(`alerts/${alert.id}`, true);
|
||||
this.setState({ alert, mode: MODES.VIEW });
|
||||
@@ -122,7 +124,7 @@ class Alert extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
onQuerySelected = query => {
|
||||
onQuerySelected = (query) => {
|
||||
this.setState(({ alert }) => ({
|
||||
alert: Object.assign(alert, { query }),
|
||||
queryResult: null,
|
||||
@@ -130,7 +132,7 @@ class Alert extends React.Component {
|
||||
|
||||
if (query) {
|
||||
// get cached result for column names and values
|
||||
new QueryService(query).getQueryResultPromise().then(queryResult => {
|
||||
new QueryService(query).getQueryResultPromise().then((queryResult) => {
|
||||
if (this._isMounted) {
|
||||
this.setState({ queryResult });
|
||||
let { column } = this.state.alert.options;
|
||||
@@ -146,18 +148,18 @@ class Alert extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
onNameChange = name => {
|
||||
onNameChange = (name) => {
|
||||
const { alert } = this.state;
|
||||
this.setState({
|
||||
alert: Object.assign(alert, { name }),
|
||||
});
|
||||
};
|
||||
|
||||
onRearmChange = pendingRearm => {
|
||||
onRearmChange = (pendingRearm) => {
|
||||
this.setState({ pendingRearm });
|
||||
};
|
||||
|
||||
setAlertOptions = obj => {
|
||||
setAlertOptions = (obj) => {
|
||||
const { alert } = this.state;
|
||||
const options = { ...alert.options, ...obj };
|
||||
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 = () => {
|
||||
const { alert } = this.state;
|
||||
return AlertService.mute(alert)
|
||||
@@ -223,7 +236,14 @@ class Alert extends React.Component {
|
||||
const { queryResult, mode, canEdit, pendingRearm } = this.state;
|
||||
|
||||
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 = {
|
||||
@@ -258,7 +278,7 @@ routes.register(
|
||||
routeWithUserSession({
|
||||
path: "/alerts/new",
|
||||
title: "New Alert",
|
||||
render: pageProps => <Alert {...pageProps} mode={MODES.NEW} />,
|
||||
render: (pageProps) => <Alert {...pageProps} mode={MODES.NEW} />,
|
||||
})
|
||||
);
|
||||
routes.register(
|
||||
@@ -266,7 +286,7 @@ routes.register(
|
||||
routeWithUserSession({
|
||||
path: "/alerts/:alertId",
|
||||
title: "Alert",
|
||||
render: pageProps => <Alert {...pageProps} mode={MODES.VIEW} />,
|
||||
render: (pageProps) => <Alert {...pageProps} mode={MODES.VIEW} />,
|
||||
})
|
||||
);
|
||||
routes.register(
|
||||
@@ -274,6 +294,6 @@ routes.register(
|
||||
routeWithUserSession({
|
||||
path: "/alerts/:alertId/edit",
|
||||
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}>
|
||||
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
|
||||
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}>
|
||||
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
|
||||
<i className="fa fa-edit m-r-5" aria-hidden="true" />
|
||||
Edit
|
||||
</Button>
|
||||
{menuButton}
|
||||
</Tooltip>
|
||||
{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 })}>
|
||||
<i className="fa fa-edit m-r-5" aria-hidden="true" />
|
||||
Edit
|
||||
</Button>
|
||||
{menuButton}
|
||||
</Tooltip>
|
||||
)}
|
||||
</Title>
|
||||
<div className="bg-white tiled p-20">
|
||||
<Grid.Row type="flex" gutter={16}>
|
||||
|
||||
@@ -54,23 +54,74 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
|
||||
return null;
|
||||
})();
|
||||
|
||||
const columnHint = (
|
||||
<small className="alert-criteria-hint">
|
||||
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
|
||||
</small>
|
||||
);
|
||||
let columnHint;
|
||||
|
||||
if (alertOptions.selector === "first") {
|
||||
columnHint = (
|
||||
<small className="alert-criteria-hint">
|
||||
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
|
||||
</small>
|
||||
);
|
||||
} else if (alertOptions.selector === "max") {
|
||||
columnHint = (
|
||||
<small className="alert-criteria-hint">
|
||||
Max column value is{" "}
|
||||
<code className="p-0">
|
||||
{toString(
|
||||
Math.max(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
|
||||
) || "unknown"}
|
||||
</code>
|
||||
</small>
|
||||
);
|
||||
} else if (alertOptions.selector === "min") {
|
||||
columnHint = (
|
||||
<small className="alert-criteria-hint">
|
||||
Min column value is{" "}
|
||||
<code className="p-0">
|
||||
{toString(
|
||||
Math.min(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
|
||||
) || "unknown"}
|
||||
</code>
|
||||
</small>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="input-label">Value column</span>
|
||||
{editMode ? (
|
||||
<Select
|
||||
value={alertOptions.column}
|
||||
onChange={column => onChange({ column })}
|
||||
onChange={(column) => onChange({ column })}
|
||||
dropdownMatchSelectWidth={false}
|
||||
style={{ minWidth: 100 }}>
|
||||
{columnNames.map(name => (
|
||||
style={{ minWidth: 100 }}
|
||||
>
|
||||
{columnNames.map((name) => (
|
||||
<Select.Option key={name}>{name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -83,10 +134,11 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
|
||||
{editMode ? (
|
||||
<Select
|
||||
value={alertOptions.op}
|
||||
onChange={op => onChange({ op })}
|
||||
onChange={(op) => onChange({ op })}
|
||||
optionLabelProp="label"
|
||||
dropdownMatchSelectWidth={false}
|
||||
style={{ width: 55 }}>
|
||||
style={{ width: 55 }}
|
||||
>
|
||||
<Select.Option value=">" label={CONDITIONS[">"]}>
|
||||
{CONDITIONS[">"]} greater than
|
||||
</Select.Option>
|
||||
@@ -125,7 +177,7 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
|
||||
id="threshold-criterion"
|
||||
style={{ width: 90 }}
|
||||
value={alertOptions.value}
|
||||
onChange={e => onChange({ value: e.target.value })}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<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 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 execute = useCallback(action => {
|
||||
@@ -55,6 +55,9 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
|
||||
<Menu.Item>
|
||||
<PlainButton onClick={confirmDelete}>Delete</PlainButton>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<PlainButton onClick={() => execute(evaluate)}>Evaluate</PlainButton>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}>
|
||||
<Button aria-label="More actions">
|
||||
@@ -69,6 +72,7 @@ MenuButton.propTypes = {
|
||||
canEdit: PropTypes.bool.isRequired,
|
||||
mute: PropTypes.func.isRequired,
|
||||
unmute: PropTypes.func.isRequired,
|
||||
evaluate: PropTypes.func.isRequired,
|
||||
muted: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "@/components/Link";
|
||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import BeaconConsent from "@/components/BeaconConsent";
|
||||
import PlainButton from "@/components/PlainButton";
|
||||
|
||||
import { axios } from "@/services/axios";
|
||||
@@ -30,7 +31,8 @@ function DeprecatedEmbedFeatureAlert() {
|
||||
<Link
|
||||
href="https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read more
|
||||
</Link>
|
||||
.
|
||||
@@ -42,7 +44,7 @@ function DeprecatedEmbedFeatureAlert() {
|
||||
|
||||
function EmailNotVerifiedAlert() {
|
||||
const verifyEmail = () => {
|
||||
axios.post("verification_email/").then(data => {
|
||||
axios.post("verification_email/").then((data) => {
|
||||
notification.success(data.message);
|
||||
});
|
||||
};
|
||||
@@ -88,6 +90,7 @@ export default function Home() {
|
||||
</DynamicComponent>
|
||||
<DynamicComponent name="HomeExtra" />
|
||||
<DashboardAndQueryFavoritesList />
|
||||
<BeaconConsent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -98,6 +101,6 @@ routes.register(
|
||||
routeWithUserSession({
|
||||
path: "/",
|
||||
title: "Redash",
|
||||
render: pageProps => <Home {...pageProps} />,
|
||||
render: (pageProps) => <Home {...pageProps} />,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -160,14 +160,15 @@ function QueriesList({ controller }) {
|
||||
orderByField={controller.orderByField}
|
||||
orderByReverse={controller.orderByReverse}
|
||||
toggleSorting={controller.toggleSorting}
|
||||
setSorting={controller.setSorting}
|
||||
/>
|
||||
<Paginator
|
||||
showPageSizeSelect
|
||||
totalCount={controller.totalItemsCount}
|
||||
pageSize={controller.itemsPerPage}
|
||||
onPageSizeChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
|
||||
onPageSizeChange={(itemsPerPage) => controller.updatePagination({ itemsPerPage })}
|
||||
page={controller.page}
|
||||
onChange={page => controller.updatePagination({ page })}
|
||||
onChange={(page) => controller.updatePagination({ page })}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
@@ -196,7 +197,7 @@ const QueriesListPage = itemsList(
|
||||
}[currentPage];
|
||||
},
|
||||
getItemProcessor() {
|
||||
return item => new Query(item);
|
||||
return (item) => new Query(item);
|
||||
},
|
||||
}),
|
||||
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
|
||||
@@ -207,7 +208,7 @@ routes.register(
|
||||
routeWithUserSession({
|
||||
path: "/queries",
|
||||
title: "Queries",
|
||||
render: pageProps => <QueriesListPage {...pageProps} currentPage="all" />,
|
||||
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="all" />,
|
||||
})
|
||||
);
|
||||
routes.register(
|
||||
@@ -215,7 +216,7 @@ routes.register(
|
||||
routeWithUserSession({
|
||||
path: "/queries/favorites",
|
||||
title: "Favorite Queries",
|
||||
render: pageProps => <QueriesListPage {...pageProps} currentPage="favorites" />,
|
||||
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" />,
|
||||
})
|
||||
);
|
||||
routes.register(
|
||||
@@ -223,7 +224,7 @@ routes.register(
|
||||
routeWithUserSession({
|
||||
path: "/queries/archive",
|
||||
title: "Archived Queries",
|
||||
render: pageProps => <QueriesListPage {...pageProps} currentPage="archive" />,
|
||||
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="archive" />,
|
||||
})
|
||||
);
|
||||
routes.register(
|
||||
@@ -231,6 +232,6 @@ routes.register(
|
||||
routeWithUserSession({
|
||||
path: "/queries/my",
|
||||
title: "My Queries",
|
||||
render: pageProps => <QueriesListPage {...pageProps} currentPage="my" />,
|
||||
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="my" />,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -380,9 +380,7 @@ function QuerySource(props) {
|
||||
<QueryVisualizationTabs
|
||||
queryResult={queryResult}
|
||||
visualizations={query.visualizations}
|
||||
showNewVisualizationButton={
|
||||
queryFlags.canEdit && queryResultData.status === ExecutionStatus.FINISHED
|
||||
}
|
||||
showNewVisualizationButton={queryFlags.canEdit && queryResultData.status === ExecutionStatus.DONE}
|
||||
canDeleteVisualizations={queryFlags.canEdit}
|
||||
selectedTab={selectedVisualization}
|
||||
onChangeTab={setSelectedVisualization}
|
||||
|
||||
@@ -165,7 +165,7 @@ function QueryView(props) {
|
||||
<QueryVisualizationTabs
|
||||
queryResult={queryResult}
|
||||
visualizations={query.visualizations}
|
||||
showNewVisualizationButton={queryFlags.canEdit && queryResultData.status === ExecutionStatus.FINISHED}
|
||||
showNewVisualizationButton={queryFlags.canEdit && queryResultData.status === ExecutionStatus.DONE}
|
||||
canDeleteVisualizations={queryFlags.canEdit}
|
||||
selectedTab={selectedVisualization}
|
||||
onChangeTab={setSelectedVisualization}
|
||||
|
||||
@@ -9,6 +9,7 @@ import QueryControlDropdown from "@/components/EditVisualizationButton/QueryCont
|
||||
import EditVisualizationButton from "@/components/EditVisualizationButton";
|
||||
import useQueryResultData from "@/lib/useQueryResultData";
|
||||
import { durationHumanize, pluralize, prettySize } from "@/lib/utils";
|
||||
import { isUndefined } from "lodash";
|
||||
|
||||
import "./QueryExecutionMetadata.less";
|
||||
|
||||
@@ -51,7 +52,8 @@ export default function QueryExecutionMetadata({
|
||||
"Result truncated to " +
|
||||
queryResultData.rows.length +
|
||||
" rows. Databricks may truncate query results that are unstably large."
|
||||
}>
|
||||
}
|
||||
>
|
||||
<WarningTwoTone twoToneColor="#FF9800" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
@@ -67,10 +69,9 @@ export default function QueryExecutionMetadata({
|
||||
)}
|
||||
{isQueryExecuting && <span>Running…</span>}
|
||||
</span>
|
||||
{queryResultData.metadata.data_scanned && (
|
||||
{!isUndefined(queryResultData.metadata.data_scanned) && !isQueryExecuting && (
|
||||
<span className="m-l-5">
|
||||
Data Scanned
|
||||
<strong>{prettySize(queryResultData.metadata.data_scanned)}</strong>
|
||||
Data Scanned <strong>{prettySize(queryResultData.metadata.data_scanned)}</strong>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
import { includes } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Alert from "antd/lib/alert";
|
||||
import Button from "antd/lib/button";
|
||||
import Timer from "@/components/Timer";
|
||||
import { ExecutionStatus } from "@/services/query-result";
|
||||
|
||||
export default function QueryExecutionStatus({ status, updatedAt, error, isCancelling, onCancel }) {
|
||||
const alertType = status === ExecutionStatus.FAILED ? "error" : "info";
|
||||
const showTimer = status !== ExecutionStatus.FAILED && updatedAt;
|
||||
const isCancelButtonAvailable = [
|
||||
ExecutionStatus.SCHEDULED,
|
||||
ExecutionStatus.QUEUED,
|
||||
ExecutionStatus.STARTED,
|
||||
ExecutionStatus.DEFERRED,
|
||||
].includes(status);
|
||||
const alertType = status === "failed" ? "error" : "info";
|
||||
const showTimer = status !== "failed" && updatedAt;
|
||||
const isCancelButtonAvailable = includes(["waiting", "processing"], status);
|
||||
let message = isCancelling ? <React.Fragment>Cancelling…</React.Fragment> : null;
|
||||
|
||||
switch (status) {
|
||||
case ExecutionStatus.QUEUED:
|
||||
case "waiting":
|
||||
if (!isCancelling) {
|
||||
message = <React.Fragment>Query in queue…</React.Fragment>;
|
||||
}
|
||||
break;
|
||||
case ExecutionStatus.STARTED:
|
||||
case "processing":
|
||||
if (!isCancelling) {
|
||||
message = <React.Fragment>Executing query…</React.Fragment>;
|
||||
}
|
||||
break;
|
||||
case ExecutionStatus.LOADING_RESULT:
|
||||
case "loading-result":
|
||||
message = <React.Fragment>Loading results…</React.Fragment>;
|
||||
break;
|
||||
case ExecutionStatus.FAILED:
|
||||
case "failed":
|
||||
message = (
|
||||
<React.Fragment>
|
||||
Error running query: <strong>{error}</strong>
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
case ExecutionStatus.CANCELED:
|
||||
message = <React.Fragment>Query was canceled</React.Fragment>;
|
||||
break;
|
||||
// no default
|
||||
}
|
||||
|
||||
@@ -74,7 +66,7 @@ QueryExecutionStatus.propTypes = {
|
||||
};
|
||||
|
||||
QueryExecutionStatus.defaultProps = {
|
||||
status: ExecutionStatus.QUEUED,
|
||||
status: "waiting",
|
||||
updatedAt: null,
|
||||
error: null,
|
||||
isCancelling: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
||||
import React from "react";
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -18,7 +18,7 @@ function EmptyState({ title, message, refreshButton }) {
|
||||
<div className="query-results-empty-state">
|
||||
<div className="empty-state-content">
|
||||
<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>
|
||||
<h3>{title}</h3>
|
||||
<div className="m-b-20">{message}</div>
|
||||
@@ -40,7 +40,7 @@ EmptyState.defaultProps = {
|
||||
|
||||
function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) {
|
||||
const handleDelete = useCallback(
|
||||
e => {
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: "Delete Visualization",
|
||||
@@ -111,7 +111,8 @@ export default function QueryVisualizationTabs({
|
||||
className="add-visualization-button"
|
||||
data-test="NewVisualization"
|
||||
type="link"
|
||||
onClick={() => onAddVisualization()}>
|
||||
onClick={() => onAddVisualization()}
|
||||
>
|
||||
<i className="fa fa-plus" aria-hidden="true" />
|
||||
<span className="m-l-5 hidden-xs">Add Visualization</span>
|
||||
</Button>
|
||||
@@ -119,7 +120,7 @@ export default function QueryVisualizationTabs({
|
||||
}
|
||||
|
||||
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 [filters, setFilters] = useState([]);
|
||||
@@ -132,9 +133,10 @@ export default function QueryVisualizationTabs({
|
||||
data-test="QueryPageVisualizationTabs"
|
||||
animated={false}
|
||||
tabBarGutter={0}
|
||||
onChange={activeKey => onChangeTab(+activeKey)}
|
||||
destroyInactiveTabPane>
|
||||
{orderedVisualizations.map(visualization => (
|
||||
onChange={(activeKey) => onChangeTab(+activeKey)}
|
||||
destroyInactiveTabPane
|
||||
>
|
||||
{orderedVisualizations.map((visualization) => (
|
||||
<TabPane
|
||||
key={`${visualization.id}`}
|
||||
tab={
|
||||
@@ -144,7 +146,8 @@ export default function QueryVisualizationTabs({
|
||||
visualizationName={visualization.name}
|
||||
onDelete={() => onDeleteVisualization(visualization.id)}
|
||||
/>
|
||||
}>
|
||||
}
|
||||
>
|
||||
{queryResult ? (
|
||||
<VisualizationRenderer
|
||||
visualization={visualization}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { reduce } from "lodash";
|
||||
import localOptions from "@/lib/localOptions";
|
||||
|
||||
function calculateTokensCount(schema) {
|
||||
return reduce(schema, (totalLength, table) => totalLength + table.columns.length, 0);
|
||||
}
|
||||
|
||||
export default function useAutocompleteFlags(schema) {
|
||||
const isAvailable = useMemo(() => calculateTokensCount(schema) <= 5000, [schema]);
|
||||
const isAvailable = true;
|
||||
const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
|
||||
|
||||
const toggleAutocomplete = useCallback(state => {
|
||||
const toggleAutocomplete = useCallback((state) => {
|
||||
setIsEnabled(state);
|
||||
localOptions.set("liveAutocomplete", state);
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import Form from "antd/lib/form";
|
||||
import Checkbox from "antd/lib/checkbox";
|
||||
import Skeleton from "antd/lib/skeleton";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
|
||||
|
||||
export default function BeaconConsentSettings(props) {
|
||||
const { values, onChange, loading } = props;
|
||||
|
||||
return (
|
||||
<DynamicComponent name="OrganizationSettings.BeaconConsentSettings" {...props}>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Anonymous Usage Data Sharing
|
||||
<HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Skeleton title={{ width: 300 }} paragraph={false} active />
|
||||
) : (
|
||||
<Checkbox
|
||||
name="beacon_consent"
|
||||
checked={values.beacon_consent}
|
||||
onChange={(e) => onChange({ beacon_consent: e.target.checked })}
|
||||
>
|
||||
Help Redash improve by automatically sending anonymous usage data
|
||||
</Checkbox>
|
||||
)}
|
||||
</Form.Item>
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
BeaconConsentSettings.propTypes = SettingsEditorPropTypes;
|
||||
|
||||
BeaconConsentSettings.defaultProps = SettingsEditorDefaultProps;
|
||||
@@ -4,6 +4,7 @@ import DynamicComponent from "@/components/DynamicComponent";
|
||||
import FormatSettings from "./FormatSettings";
|
||||
import PlotlySettings from "./PlotlySettings";
|
||||
import FeatureFlagsSettings from "./FeatureFlagsSettings";
|
||||
import BeaconConsentSettings from "./BeaconConsentSettings";
|
||||
|
||||
export default function GeneralSettings(props) {
|
||||
return (
|
||||
@@ -13,6 +14,7 @@ export default function GeneralSettings(props) {
|
||||
<FormatSettings {...props} />
|
||||
<PlotlySettings {...props} />
|
||||
<FeatureFlagsSettings {...props} />
|
||||
<BeaconConsentSettings {...props} />
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const Alert = {
|
||||
delete: data => axios.delete(`api/alerts/${data.id}`),
|
||||
mute: data => axios.post(`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;
|
||||
|
||||
@@ -4,19 +4,19 @@ import { fetchDataFromJob } from "@/services/query-result";
|
||||
|
||||
export const SCHEMA_NOT_SUPPORTED = 1;
|
||||
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) {
|
||||
return map(columns, column => (isObject(column) ? column : { name: column }));
|
||||
return map(columns, (column) => (isObject(column) ? column : { name: column }));
|
||||
}
|
||||
|
||||
const DataSource = {
|
||||
query: () => axios.get("api/data_sources"),
|
||||
get: ({ id }) => axios.get(`api/data_sources/${id}`),
|
||||
types: () => axios.get("api/data_sources/types"),
|
||||
create: data => axios.post(`api/data_sources`, data),
|
||||
save: data => axios.post(`api/data_sources/${data.id}`, data),
|
||||
test: data => axios.post(`api/data_sources/${data.id}/test`),
|
||||
create: (data) => axios.post(`api/data_sources`, data),
|
||||
save: (data) => axios.post(`api/data_sources/${data.id}`, data),
|
||||
test: (data) => axios.post(`api/data_sources/${data.id}/test`),
|
||||
delete: ({ id }) => axios.delete(`api/data_sources/${id}`),
|
||||
fetchSchema: (data, refresh = false) => {
|
||||
const params = {};
|
||||
@@ -27,15 +27,15 @@ const DataSource = {
|
||||
|
||||
return axios
|
||||
.get(`api/data_sources/${data.id}/schema`, { params })
|
||||
.then(data => {
|
||||
.then((data) => {
|
||||
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))
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const normalizedValue = moment(value);
|
||||
const normalizedValue = moment(value, moment.ISO_8601, true);
|
||||
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 DateParameter from "./DateParameter";
|
||||
import DateRangeParameter from "./DateRangeParameter";
|
||||
import TextPatternParameter from "./TextPatternParameter";
|
||||
|
||||
function createParameter(param, parentQueryId) {
|
||||
switch (param.type) {
|
||||
@@ -22,6 +23,8 @@ function createParameter(param, parentQueryId) {
|
||||
case "datetime-range":
|
||||
case "datetime-range-with-seconds":
|
||||
return new DateRangeParameter(param, parentQueryId);
|
||||
case "text-pattern":
|
||||
return new TextPatternParameter({ ...param, type: "text-pattern" }, parentQueryId);
|
||||
default:
|
||||
return new TextParameter({ ...param, type: "text" }, parentQueryId);
|
||||
}
|
||||
@@ -34,6 +37,7 @@ function cloneParameter(param) {
|
||||
export {
|
||||
Parameter,
|
||||
TextParameter,
|
||||
TextPatternParameter,
|
||||
NumberParameter,
|
||||
EnumParameter,
|
||||
QueryBasedDropdownParameter,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createParameter,
|
||||
TextParameter,
|
||||
TextPatternParameter,
|
||||
NumberParameter,
|
||||
EnumParameter,
|
||||
QueryBasedDropdownParameter,
|
||||
@@ -12,6 +13,7 @@ describe("Parameter", () => {
|
||||
describe("create", () => {
|
||||
const parameterTypes = [
|
||||
["text", TextParameter],
|
||||
["text-pattern", TextPatternParameter],
|
||||
["number", NumberParameter],
|
||||
["enum", EnumParameter],
|
||||
["query", QueryBasedDropdownParameter],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createParameter } from "..";
|
||||
|
||||
describe("TextPatternParameter", () => {
|
||||
let param;
|
||||
|
||||
beforeEach(() => {
|
||||
param = createParameter({ name: "param", title: "Param", type: "text-pattern", regex: "a+" });
|
||||
});
|
||||
|
||||
describe("noramlizeValue", () => {
|
||||
test("converts matching strings", () => {
|
||||
const normalizedValue = param.normalizeValue("art");
|
||||
expect(normalizedValue).toBe("art");
|
||||
});
|
||||
|
||||
test("returns null when string does not match pattern", () => {
|
||||
const normalizedValue = param.normalizeValue("brt");
|
||||
expect(normalizedValue).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ const logger = debug("redash:services:QueryResult");
|
||||
const filterTypes = ["filter", "multi-filter", "multiFilter"];
|
||||
|
||||
function defer() {
|
||||
const result = { onStatusChange: status => {} };
|
||||
const result = { onStatusChange: (status) => {} };
|
||||
result.promise = new Promise((resolve, reject) => {
|
||||
result.resolve = resolve;
|
||||
result.reject = reject;
|
||||
@@ -40,25 +40,28 @@ function getColumnNameWithoutType(column) {
|
||||
}
|
||||
|
||||
function getColumnFriendlyName(column) {
|
||||
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, a => a.toUpperCase());
|
||||
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, (a) => a.toUpperCase());
|
||||
}
|
||||
|
||||
const createOrSaveUrl = data => (data.id ? `api/query_results/${data.id}` : "api/query_results");
|
||||
const createOrSaveUrl = (data) => (data.id ? `api/query_results/${data.id}` : "api/query_results");
|
||||
const QueryResultResource = {
|
||||
get: ({ id }) => axios.get(`api/query_results/${id}`),
|
||||
post: data => axios.post(createOrSaveUrl(data), data),
|
||||
post: (data) => axios.post(createOrSaveUrl(data), data),
|
||||
};
|
||||
|
||||
export const ExecutionStatus = {
|
||||
QUEUED: "queued",
|
||||
STARTED: "started",
|
||||
FINISHED: "finished",
|
||||
WAITING: "waiting",
|
||||
PROCESSING: "processing",
|
||||
DONE: "done",
|
||||
FAILED: "failed",
|
||||
LOADING_RESULT: "loading-result",
|
||||
CANCELED: "canceled",
|
||||
DEFERRED: "deferred",
|
||||
SCHEDULED: "scheduled",
|
||||
STOPPED: "stopped",
|
||||
};
|
||||
|
||||
const statuses = {
|
||||
1: ExecutionStatus.WAITING,
|
||||
2: ExecutionStatus.PROCESSING,
|
||||
3: ExecutionStatus.DONE,
|
||||
4: ExecutionStatus.FAILED,
|
||||
};
|
||||
|
||||
function handleErrorResponse(queryResult, error) {
|
||||
@@ -77,7 +80,7 @@ function handleErrorResponse(queryResult, error) {
|
||||
queryResult.update({
|
||||
job: {
|
||||
error: "cached query result unavailable, please execute again.",
|
||||
status: ExecutionStatus.FAILED,
|
||||
status: 4,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -88,30 +91,22 @@ function handleErrorResponse(queryResult, error) {
|
||||
queryResult.update({
|
||||
job: {
|
||||
error: get(error, "response.data.message", "Unknown error occurred. Please try again later."),
|
||||
status: ExecutionStatus.FAILED,
|
||||
status: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function fetchDataFromJob(jobId, interval = 1000) {
|
||||
return axios.get(`api/jobs/${jobId}`).then(data => {
|
||||
const status = data.job.status;
|
||||
if (
|
||||
[ExecutionStatus.QUEUED, ExecutionStatus.STARTED, ExecutionStatus.SCHEDULED, ExecutionStatus.DEFERRED].includes(
|
||||
status
|
||||
)
|
||||
) {
|
||||
return axios.get(`api/jobs/${jobId}`).then((data) => {
|
||||
const status = statuses[data.job.status];
|
||||
if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) {
|
||||
return sleep(interval).then(() => fetchDataFromJob(data.job.id));
|
||||
} else if (status === ExecutionStatus.FINISHED) {
|
||||
return data.job.result_id;
|
||||
} else if (status === ExecutionStatus.CANCELED) {
|
||||
return Promise.reject("Job was canceled");
|
||||
} else if (status === ExecutionStatus.STOPPED) {
|
||||
return Promise.reject("Job was stopped");
|
||||
} else if (status === ExecutionStatus.DONE) {
|
||||
return data.job.result;
|
||||
} else if (status === ExecutionStatus.FAILED) {
|
||||
return Promise.reject(data.job.error);
|
||||
}
|
||||
@@ -119,7 +114,7 @@ export function fetchDataFromJob(jobId, interval = 1000) {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -127,7 +122,7 @@ class QueryResult {
|
||||
this.deferred = defer();
|
||||
this.job = {};
|
||||
this.query_result = {};
|
||||
this.status = ExecutionStatus.QUEUED;
|
||||
this.status = "waiting";
|
||||
|
||||
this.updatedAt = moment();
|
||||
|
||||
@@ -143,15 +138,15 @@ class QueryResult {
|
||||
extend(this, props);
|
||||
|
||||
if ("query_result" in props) {
|
||||
this.status = ExecutionStatus.FINISHED;
|
||||
this.deferred.onStatusChange(ExecutionStatus.FINISHED);
|
||||
this.status = ExecutionStatus.DONE;
|
||||
this.deferred.onStatusChange(ExecutionStatus.DONE);
|
||||
|
||||
const columnTypes = {};
|
||||
|
||||
// TODO: we should stop manipulating incoming data, and switch to relaying
|
||||
// on the column type set by the backend. This logic is prone to errors,
|
||||
// and better be removed. Kept for now, for backward compatability.
|
||||
each(this.query_result.data.rows, row => {
|
||||
each(this.query_result.data.rows, (row) => {
|
||||
forOwn(row, (v, k) => {
|
||||
let newType = null;
|
||||
if (isNumber(v)) {
|
||||
@@ -178,7 +173,7 @@ class QueryResult {
|
||||
});
|
||||
});
|
||||
|
||||
each(this.query_result.data.columns, column => {
|
||||
each(this.query_result.data.columns, (column) => {
|
||||
column.name = "" + column.name;
|
||||
if (columnTypes[column.name]) {
|
||||
if (column.type == null || column.type === "string") {
|
||||
@@ -188,10 +183,11 @@ class QueryResult {
|
||||
});
|
||||
|
||||
this.deferred.resolve(this);
|
||||
} else if (this.job.status === ExecutionStatus.STARTED || this.job.status === ExecutionStatus.FINISHED) {
|
||||
this.status = ExecutionStatus.STARTED;
|
||||
} else if (this.job.status === ExecutionStatus.FAILED) {
|
||||
this.status = this.job.status;
|
||||
} else if (this.job.status === 3 || this.job.status === 2) {
|
||||
this.deferred.onStatusChange(ExecutionStatus.PROCESSING);
|
||||
this.status = "processing";
|
||||
} else if (this.job.status === 4) {
|
||||
this.status = statuses[this.job.status];
|
||||
this.deferred.reject(new QueryResultError(this.job.error));
|
||||
} else {
|
||||
this.deferred.onStatusChange(undefined);
|
||||
@@ -215,7 +211,7 @@ class QueryResult {
|
||||
if (this.isLoadingResult) {
|
||||
return ExecutionStatus.LOADING_RESULT;
|
||||
}
|
||||
return this.status || this.job.status;
|
||||
return this.status || statuses[this.job.status];
|
||||
}
|
||||
|
||||
getError() {
|
||||
@@ -269,14 +265,14 @@ class QueryResult {
|
||||
|
||||
getColumnNames() {
|
||||
if (this.columnNames === undefined && this.query_result.data) {
|
||||
this.columnNames = this.query_result.data.columns.map(v => v.name);
|
||||
this.columnNames = this.query_result.data.columns.map((v) => v.name);
|
||||
}
|
||||
|
||||
return this.columnNames;
|
||||
}
|
||||
|
||||
getColumnFriendlyNames() {
|
||||
return this.getColumnNames().map(col => getColumnFriendlyName(col));
|
||||
return this.getColumnNames().map((col) => getColumnFriendlyName(col));
|
||||
}
|
||||
|
||||
getTruncated() {
|
||||
@@ -290,7 +286,7 @@ class QueryResult {
|
||||
|
||||
const filters = [];
|
||||
|
||||
this.getColumns().forEach(col => {
|
||||
this.getColumns().forEach((col) => {
|
||||
const name = col.name;
|
||||
const type = name.split("::")[1] || name.split("__")[1];
|
||||
if (includes(filterTypes, type)) {
|
||||
@@ -306,8 +302,8 @@ class QueryResult {
|
||||
}
|
||||
}, this);
|
||||
|
||||
this.getRawData().forEach(row => {
|
||||
filters.forEach(filter => {
|
||||
this.getRawData().forEach((row) => {
|
||||
filters.forEach((filter) => {
|
||||
filter.values.push(row[filter.name]);
|
||||
if (filter.values.length === 1) {
|
||||
if (filter.multiple) {
|
||||
@@ -319,8 +315,8 @@ class QueryResult {
|
||||
});
|
||||
});
|
||||
|
||||
filters.forEach(filter => {
|
||||
filter.values = uniqBy(filter.values, v => {
|
||||
filters.forEach((filter) => {
|
||||
filter.values = uniqBy(filter.values, (v) => {
|
||||
if (moment.isMoment(v)) {
|
||||
return v.unix();
|
||||
}
|
||||
@@ -349,12 +345,12 @@ class QueryResult {
|
||||
|
||||
axios
|
||||
.get(`api/queries/${queryId}/results/${id}.json`)
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
// Success handler
|
||||
queryResult.isLoadingResult = false;
|
||||
queryResult.update(response);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
// Error handler
|
||||
queryResult.isLoadingResult = false;
|
||||
handleErrorResponse(queryResult, error);
|
||||
@@ -366,10 +362,10 @@ class QueryResult {
|
||||
loadLatestCachedResult(queryId, parameters) {
|
||||
axios
|
||||
.post(`api/queries/${queryId}/results`, { queryId, parameters })
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
this.update(response);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
handleErrorResponse(this, error);
|
||||
});
|
||||
}
|
||||
@@ -378,12 +374,12 @@ class QueryResult {
|
||||
this.isLoadingResult = true;
|
||||
this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT);
|
||||
|
||||
QueryResultResource.get({ id: this.job.result_id })
|
||||
.then(response => {
|
||||
QueryResultResource.get({ id: this.job.query_result_id })
|
||||
.then((response) => {
|
||||
this.update(response);
|
||||
this.isLoadingResult = false;
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
if (tryCount === undefined) {
|
||||
tryCount = 0;
|
||||
}
|
||||
@@ -393,14 +389,17 @@ class QueryResult {
|
||||
this.update({
|
||||
job: {
|
||||
error: "failed communicating with server. Please check your Internet connection and try again.",
|
||||
status: ExecutionStatus.FAILED,
|
||||
status: 4,
|
||||
},
|
||||
});
|
||||
this.isLoadingResult = false;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.loadResult(tryCount + 1);
|
||||
}, 1000 * Math.pow(2, tryCount));
|
||||
setTimeout(
|
||||
() => {
|
||||
this.loadResult(tryCount + 1);
|
||||
},
|
||||
1000 * Math.pow(2, tryCount)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -414,25 +413,32 @@ class QueryResult {
|
||||
: axios.get(`api/queries/${query}/jobs/${this.job.id}`);
|
||||
|
||||
request
|
||||
.then(jobResponse => {
|
||||
.then((jobResponse) => {
|
||||
this.update(jobResponse);
|
||||
|
||||
if (this.getStatus() === ExecutionStatus.STARTED && this.job.result_id && this.job.result_id !== "None") {
|
||||
if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") {
|
||||
loadResult();
|
||||
} else if (this.getStatus() !== ExecutionStatus.FAILED) {
|
||||
const waitTime = tryNumber > 10 ? 3000 : 500;
|
||||
} else if (this.getStatus() !== "failed") {
|
||||
let waitTime;
|
||||
if (tryNumber <= 10) {
|
||||
waitTime = 500;
|
||||
} else if (tryNumber <= 50) {
|
||||
waitTime = 1000;
|
||||
} else {
|
||||
waitTime = 3000;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.refreshStatus(query, parameters, tryNumber + 1);
|
||||
}, waitTime);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
logger("Connection error", error);
|
||||
// TODO: use QueryResultError, or better yet: exception/reject of promise.
|
||||
this.update({
|
||||
job: {
|
||||
error: "failed communicating with server. Please check your Internet connection and try again.",
|
||||
status: ExecutionStatus.FAILED,
|
||||
status: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -455,14 +461,14 @@ class QueryResult {
|
||||
|
||||
axios
|
||||
.post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge })
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
queryResult.update(response);
|
||||
|
||||
if ("job" in response) {
|
||||
queryResult.refreshStatus(id, parameters);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
handleErrorResponse(queryResult, error);
|
||||
});
|
||||
|
||||
@@ -485,14 +491,14 @@ class QueryResult {
|
||||
}
|
||||
|
||||
QueryResultResource.post(params)
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
queryResult.update(response);
|
||||
|
||||
if ("job" in response) {
|
||||
queryResult.refreshStatus(query, parameters);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
handleErrorResponse(queryResult, error);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import moment from "moment";
|
||||
import debug from "debug";
|
||||
import Mustache from "mustache";
|
||||
import { axios } from "@/services/axios";
|
||||
import { ExecutionStatus } from "@/services/query-result";
|
||||
import {
|
||||
zipObject,
|
||||
isEmpty,
|
||||
@@ -104,7 +103,7 @@ export class Query {
|
||||
return new QueryResult({
|
||||
job: {
|
||||
error: `missing ${valuesWord} for ${missingParams.join(", ")} ${paramsWord}.`,
|
||||
status: ExecutionStatus.FAILED,
|
||||
status: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -361,7 +360,7 @@ export class QueryResultError {
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
getStatus() {
|
||||
return ExecutionStatus.FAILED;
|
||||
return "failed";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
|
||||
@@ -43,18 +43,18 @@ function seedDatabase(seedValues) {
|
||||
|
||||
function buildServer() {
|
||||
console.log("Building the server...");
|
||||
execSync("docker compose build", { stdio: "inherit" });
|
||||
execSync("docker compose -p cypress build", { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
console.log("Starting the server...");
|
||||
execSync("docker compose up -d", { stdio: "inherit" });
|
||||
execSync("docker compose run server create_db", { stdio: "inherit" });
|
||||
execSync("docker compose -p cypress up -d", { stdio: "inherit" });
|
||||
execSync("docker compose -p cypress run server create_db", { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
console.log("Stopping the server...");
|
||||
execSync("docker compose down", { stdio: "inherit" });
|
||||
execSync("docker compose -p cypress down", { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function runCypressCI() {
|
||||
@@ -63,12 +63,12 @@ function runCypressCI() {
|
||||
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
|
||||
} = process.env;
|
||||
|
||||
if (GITHUB_REPOSITORY === "getredash/redash") {
|
||||
if (GITHUB_REPOSITORY === "getredash/redash" && process.env.CYPRESS_RECORD_KEY) {
|
||||
process.env.CYPRESS_OPTIONS = "--record";
|
||||
}
|
||||
|
||||
execSync(
|
||||
"docker compose run --name cypress cypress ./node_modules/.bin/percy exec -t 300 -- ./node_modules/.bin/cypress run $CYPRESS_OPTIONS",
|
||||
"COMMIT_INFO_MESSAGE=$(git show -s --format=%s) docker compose run --name cypress cypress ./node_modules/.bin/percy exec -t 300 -- ./node_modules/.bin/cypress run $CYPRESS_OPTIONS",
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,12 +53,11 @@ describe("Dashboard Sharing", () => {
|
||||
};
|
||||
|
||||
const dashboardUrl = this.dashboardUrl;
|
||||
cy.createQuery({ options }).then(({ id: queryId, name: queryName }) => {
|
||||
cy.createQuery({ options }).then(({ id: queryId }) => {
|
||||
cy.visit(dashboardUrl);
|
||||
editDashboard();
|
||||
cy.getByTestId("AddWidgetButton").click();
|
||||
cy.getByTestId("AddWidgetDialog").within(() => {
|
||||
cy.get("input").type(queryName);
|
||||
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
|
||||
});
|
||||
cy.contains("button", "Add to Dashboard").click();
|
||||
@@ -179,12 +178,11 @@ describe("Dashboard Sharing", () => {
|
||||
};
|
||||
|
||||
const dashboardUrl = this.dashboardUrl;
|
||||
cy.createQuery({ options }).then(({ id: queryId, name: queryName }) => {
|
||||
cy.createQuery({ options }).then(({ id: queryId }) => {
|
||||
cy.visit(dashboardUrl);
|
||||
editDashboard();
|
||||
cy.getByTestId("AddWidgetButton").click();
|
||||
cy.getByTestId("AddWidgetDialog").within(() => {
|
||||
cy.get("input").type(queryName);
|
||||
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
|
||||
});
|
||||
cy.contains("button", "Add to Dashboard").click();
|
||||
|
||||
@@ -18,12 +18,11 @@ describe("Widget", () => {
|
||||
};
|
||||
|
||||
it("adds widget", function() {
|
||||
cy.createQuery().then(({ id: queryId, name: queryName }) => {
|
||||
cy.createQuery().then(({ id: queryId }) => {
|
||||
cy.visit(this.dashboardUrl);
|
||||
editDashboard();
|
||||
cy.getByTestId("AddWidgetButton").click();
|
||||
cy.getByTestId("AddWidgetDialog").within(() => {
|
||||
cy.get("input").type(queryName);
|
||||
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
|
||||
});
|
||||
cy.contains("button", "Add to Dashboard").click();
|
||||
|
||||
@@ -2,16 +2,14 @@ import { dragParam } from "../../support/parameters";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
function openAndSearchAntdDropdown(testId, paramOption) {
|
||||
cy.getByTestId(testId)
|
||||
.find(".ant-select-selection-search-input")
|
||||
.type(paramOption, { force: true });
|
||||
cy.getByTestId(testId).find(".ant-select-selection-search-input").type(paramOption, { force: true });
|
||||
}
|
||||
|
||||
describe("Parameter", () => {
|
||||
const expectDirtyStateChange = edit => {
|
||||
const expectDirtyStateChange = (edit) => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find(".parameter-input")
|
||||
.should($el => {
|
||||
.should(($el) => {
|
||||
assert.isUndefined($el.data("dirty"));
|
||||
});
|
||||
|
||||
@@ -19,7 +17,7 @@ describe("Parameter", () => {
|
||||
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find(".parameter-input")
|
||||
.should($el => {
|
||||
.should(($el) => {
|
||||
assert.isTrue($el.data("dirty"));
|
||||
});
|
||||
};
|
||||
@@ -42,9 +40,7 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
it("updates the results after clicking Apply", () => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.type("Redash");
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
@@ -53,13 +49,66 @@ describe("Parameter", () => {
|
||||
|
||||
it("sets dirty state when edited", () => {
|
||||
expectDirtyStateChange(() => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.type("Redash");
|
||||
cy.getByTestId("ParameterName-test-parameter").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", () => {
|
||||
beforeEach(() => {
|
||||
const queryData = {
|
||||
@@ -74,17 +123,13 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
it("updates the results after clicking Apply", () => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.type("{selectall}42");
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
cy.getByTestId("TableVisualization").should("contain", 42);
|
||||
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.type("{selectall}31415");
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}31415");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
@@ -93,9 +138,7 @@ describe("Parameter", () => {
|
||||
|
||||
it("sets dirty state when edited", () => {
|
||||
expectDirtyStateChange(() => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.type("{selectall}42");
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -119,10 +162,7 @@ describe("Parameter", () => {
|
||||
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
||||
|
||||
// only the filtered option should be on the DOM
|
||||
cy.get(".ant-select-item-option")
|
||||
.should("have.length", 1)
|
||||
.and("contain", "value2")
|
||||
.click();
|
||||
cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
// ensure that query is being executed
|
||||
@@ -140,12 +180,10 @@ describe("Parameter", () => {
|
||||
SaveParameterSettings
|
||||
`);
|
||||
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find(".ant-select-selection-search")
|
||||
.click();
|
||||
cy.getByTestId("ParameterName-test-parameter").find(".ant-select-selection-search").click();
|
||||
|
||||
// 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")) {
|
||||
cy.wrap($option).click();
|
||||
}
|
||||
@@ -160,9 +198,7 @@ describe("Parameter", () => {
|
||||
|
||||
it("sets dirty state when edited", () => {
|
||||
expectDirtyStateChange(() => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find(".ant-select")
|
||||
.click();
|
||||
cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
|
||||
|
||||
cy.contains(".ant-select-item-option", "value2").click();
|
||||
});
|
||||
@@ -176,7 +212,7 @@ describe("Parameter", () => {
|
||||
name: "Dropdown Query",
|
||||
query: "",
|
||||
};
|
||||
cy.createQuery(dropdownQueryData, true).then(dropdownQuery => {
|
||||
cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
|
||||
const queryData = {
|
||||
name: "Query Based Dropdown Parameter",
|
||||
query: "SELECT '{{test-parameter}}' AS parameter",
|
||||
@@ -208,7 +244,7 @@ describe("Parameter", () => {
|
||||
SELECT 'value2' AS name, 2 AS value UNION ALL
|
||||
SELECT 'value3' AS name, 3 AS value`,
|
||||
};
|
||||
cy.createQuery(dropdownQueryData, true).then(dropdownQuery => {
|
||||
cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
|
||||
const queryData = {
|
||||
name: "Query Based Dropdown Parameter",
|
||||
query: "SELECT '{{test-parameter}}' AS parameter",
|
||||
@@ -234,10 +270,7 @@ describe("Parameter", () => {
|
||||
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
|
||||
|
||||
// only the filtered option should be on the DOM
|
||||
cy.get(".ant-select-item-option")
|
||||
.should("have.length", 1)
|
||||
.and("contain", "value2")
|
||||
.click();
|
||||
cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
// ensure that query is being executed
|
||||
@@ -255,12 +288,10 @@ describe("Parameter", () => {
|
||||
SaveParameterSettings
|
||||
`);
|
||||
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find(".ant-select")
|
||||
.click();
|
||||
cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
|
||||
|
||||
// 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");
|
||||
cy.wrap($option).click();
|
||||
});
|
||||
@@ -274,14 +305,10 @@ describe("Parameter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const selectCalendarDate = date => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.click();
|
||||
const selectCalendarDate = (date) => {
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").click();
|
||||
|
||||
cy.get(".ant-picker-panel")
|
||||
.contains(".ant-picker-cell-inner", date)
|
||||
.click();
|
||||
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", date).click();
|
||||
};
|
||||
|
||||
describe("Date Parameter", () => {
|
||||
@@ -303,10 +330,10 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cy.clock().then(clock => clock.restore());
|
||||
cy.clock().then((clock) => clock.restore());
|
||||
});
|
||||
|
||||
it("updates the results after selecting a date", function() {
|
||||
it("updates the results after selecting a date", function () {
|
||||
selectCalendarDate("15");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
@@ -314,12 +341,10 @@ describe("Parameter", () => {
|
||||
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("15/MM/YY"));
|
||||
});
|
||||
|
||||
it("allows picking a dynamic date", function() {
|
||||
it("allows picking a dynamic date", function () {
|
||||
cy.getByTestId("DynamicButton").click();
|
||||
|
||||
cy.getByTestId("DynamicButtonMenu")
|
||||
.contains("Today/Now")
|
||||
.click();
|
||||
cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
@@ -350,14 +375,11 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
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() {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.as("Input")
|
||||
.click();
|
||||
it("updates the results after selecting a date and clicking in ok", function () {
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
|
||||
|
||||
selectCalendarDate("15");
|
||||
|
||||
@@ -368,27 +390,20 @@ describe("Parameter", () => {
|
||||
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-15 HH:mm"));
|
||||
});
|
||||
|
||||
it("shows the current datetime after clicking in Now", function() {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.as("Input")
|
||||
.click();
|
||||
it("shows the current datetime after clicking in Now", function () {
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
|
||||
|
||||
cy.get(".ant-picker-panel")
|
||||
.contains("Now")
|
||||
.click();
|
||||
cy.get(".ant-picker-panel").contains("Now").click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-DD HH:mm"));
|
||||
});
|
||||
|
||||
it("allows picking a dynamic date", function() {
|
||||
it("allows picking a dynamic date", function () {
|
||||
cy.getByTestId("DynamicButton").click();
|
||||
|
||||
cy.getByTestId("DynamicButtonMenu")
|
||||
.contains("Today/Now")
|
||||
.click();
|
||||
cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
@@ -397,31 +412,20 @@ describe("Parameter", () => {
|
||||
|
||||
it("sets dirty state when edited", () => {
|
||||
expectDirtyStateChange(() => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.click();
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").click();
|
||||
|
||||
cy.get(".ant-picker-panel")
|
||||
.contains("Now")
|
||||
.click();
|
||||
cy.get(".ant-picker-panel").contains("Now").click();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date Range Parameter", () => {
|
||||
const selectCalendarDateRange = (startDate, endDate) => {
|
||||
cy.getByTestId("ParameterName-test-parameter")
|
||||
.find("input")
|
||||
.first()
|
||||
.click();
|
||||
cy.getByTestId("ParameterName-test-parameter").find("input").first().click();
|
||||
|
||||
cy.get(".ant-picker-panel")
|
||||
.contains(".ant-picker-cell-inner", startDate)
|
||||
.click();
|
||||
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", startDate).click();
|
||||
|
||||
cy.get(".ant-picker-panel")
|
||||
.contains(".ant-picker-cell-inner", endDate)
|
||||
.click();
|
||||
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", endDate).click();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -442,10 +446,10 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cy.clock().then(clock => clock.restore());
|
||||
cy.clock().then((clock) => clock.restore());
|
||||
});
|
||||
|
||||
it("updates the results after selecting a date range", function() {
|
||||
it("updates the results after selecting a date range", function () {
|
||||
selectCalendarDateRange("15", "20");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
@@ -457,12 +461,10 @@ describe("Parameter", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows picking a dynamic date range", function() {
|
||||
it("allows picking a dynamic date range", function () {
|
||||
cy.getByTestId("DynamicButton").click();
|
||||
|
||||
cy.getByTestId("DynamicButtonMenu")
|
||||
.contains("Last month")
|
||||
.click();
|
||||
cy.getByTestId("DynamicButtonMenu").contains("Last month").click();
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").click();
|
||||
|
||||
@@ -479,15 +481,10 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
describe("Apply Changes", () => {
|
||||
const expectAppliedChanges = apply => {
|
||||
cy.getByTestId("ParameterName-test-parameter-1")
|
||||
.find("input")
|
||||
.as("Input")
|
||||
.type("Redash");
|
||||
const expectAppliedChanges = (apply) => {
|
||||
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
|
||||
|
||||
cy.getByTestId("ParameterName-test-parameter-2")
|
||||
.find("input")
|
||||
.type("Redash");
|
||||
cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
|
||||
|
||||
cy.location("search").should("not.contain", "Redash");
|
||||
|
||||
@@ -523,10 +520,7 @@ describe("Parameter", () => {
|
||||
it("shows and hides according to parameter dirty state", () => {
|
||||
cy.getByTestId("ParameterApplyButton").should("not.be", "visible");
|
||||
|
||||
cy.getByTestId("ParameterName-test-parameter-1")
|
||||
.find("input")
|
||||
.as("Param")
|
||||
.type("Redash");
|
||||
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Param").type("Redash");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton").should("be.visible");
|
||||
|
||||
@@ -536,21 +530,13 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
it("updates dirty counter", () => {
|
||||
cy.getByTestId("ParameterName-test-parameter-1")
|
||||
.find("input")
|
||||
.type("Redash");
|
||||
cy.getByTestId("ParameterName-test-parameter-1").find("input").type("Redash");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton")
|
||||
.find(".ant-badge-count p.current")
|
||||
.should("contain", "1");
|
||||
cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "1");
|
||||
|
||||
cy.getByTestId("ParameterName-test-parameter-2")
|
||||
.find("input")
|
||||
.type("Redash");
|
||||
cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
|
||||
|
||||
cy.getByTestId("ParameterApplyButton")
|
||||
.find(".ant-badge-count p.current")
|
||||
.should("contain", "2");
|
||||
cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "2");
|
||||
});
|
||||
|
||||
it('applies changes from "Apply Changes" button', () => {
|
||||
@@ -560,16 +546,13 @@ describe("Parameter", () => {
|
||||
});
|
||||
|
||||
it('applies changes from "alt+enter" keyboard shortcut', () => {
|
||||
expectAppliedChanges(input => {
|
||||
expectAppliedChanges((input) => {
|
||||
input.type("{alt}{enter}");
|
||||
});
|
||||
});
|
||||
|
||||
it('disables "Execute" button', () => {
|
||||
cy.getByTestId("ParameterName-test-parameter-1")
|
||||
.find("input")
|
||||
.as("Input")
|
||||
.type("Redash");
|
||||
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
|
||||
cy.getByTestId("ExecuteButton").should("be.disabled");
|
||||
|
||||
cy.get("@Input").clear();
|
||||
@@ -594,15 +577,12 @@ describe("Parameter", () => {
|
||||
|
||||
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
|
||||
|
||||
cy.get(".parameter-block")
|
||||
.first()
|
||||
.invoke("width")
|
||||
.as("paramWidth");
|
||||
cy.get(".parameter-block").first().invoke("width").as("paramWidth");
|
||||
|
||||
cy.get("body").type("{alt}D"); // hide schema browser
|
||||
});
|
||||
|
||||
it("is possible to rearrange parameters", function() {
|
||||
it("is possible to rearrange parameters", function () {
|
||||
cy.server();
|
||||
cy.route("POST", "**/api/queries/*").as("QuerySave");
|
||||
|
||||
|
||||
@@ -26,33 +26,33 @@ const SQL = `
|
||||
describe("Chart", () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
cy.createQuery({ name: "Chart Visualization", query: SQL })
|
||||
.its("id")
|
||||
.as("queryId");
|
||||
cy.createQuery({ name: "Chart Visualization", query: SQL }).its("id").as("queryId");
|
||||
});
|
||||
|
||||
it("creates Bar charts", function() {
|
||||
it("creates Bar charts", function () {
|
||||
cy.visit(`queries/${this.queryId}/source`);
|
||||
cy.getByTestId("ExecuteButton").click();
|
||||
|
||||
const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => {
|
||||
// checks for TabbedEditor standard tabs
|
||||
assertTabbedEditor();
|
||||
const getBarChartAssertionFunction =
|
||||
(specificBarChartAssertionFn = () => {}) =>
|
||||
() => {
|
||||
// checks for TabbedEditor standard tabs
|
||||
assertTabbedEditor();
|
||||
|
||||
// standard chart should be bar
|
||||
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
|
||||
// standard chart should be bar
|
||||
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
|
||||
|
||||
// checks the plot canvas exists and is empty
|
||||
assertPlotPreview("not.exist");
|
||||
// checks the plot canvas exists and is empty
|
||||
assertPlotPreview("not.exist");
|
||||
|
||||
// creates a chart and checks it is plotted
|
||||
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
|
||||
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
|
||||
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
|
||||
assertPlotPreview("exist");
|
||||
// creates a chart and checks it is plotted
|
||||
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
|
||||
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
|
||||
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
|
||||
assertPlotPreview("exist");
|
||||
|
||||
specificBarChartAssertionFn();
|
||||
};
|
||||
specificBarChartAssertionFn();
|
||||
};
|
||||
|
||||
const chartTests = [
|
||||
{
|
||||
@@ -95,8 +95,8 @@ describe("Chart", () => {
|
||||
|
||||
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
|
||||
cy.visit(dashboardUrl);
|
||||
widgetGetters.forEach(widgetGetter => {
|
||||
cy.get(`@${widgetGetter}`).then(widget => {
|
||||
widgetGetters.forEach((widgetGetter) => {
|
||||
cy.get(`@${widgetGetter}`).then((widget) => {
|
||||
cy.getByTestId(getWidgetTestId(widget)).within(() => {
|
||||
cy.get("g.points").should("exist");
|
||||
});
|
||||
@@ -107,4 +107,34 @@ describe("Chart", () => {
|
||||
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
|
||||
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");
|
||||
|
||||
// do some pre-checks here to ensure that visualization was created and is visible
|
||||
cy.getByTestId("TableVisualization")
|
||||
.should("exist")
|
||||
.find("table")
|
||||
.should("exist");
|
||||
cy.getByTestId("TableVisualization").should("exist").find("table").should("exist");
|
||||
|
||||
return cy.then(() => ({ queryId, visualizationId }));
|
||||
});
|
||||
@@ -53,7 +50,7 @@ describe("Table", () => {
|
||||
});
|
||||
|
||||
describe("Sorting data", () => {
|
||||
beforeEach(function() {
|
||||
beforeEach(function () {
|
||||
const { query, config } = MultiColumnSort;
|
||||
prepareVisualization(query, "TABLE", "Sort data", config).then(({ queryId, visualizationId }) => {
|
||||
this.queryId = queryId;
|
||||
@@ -61,39 +58,22 @@ describe("Table", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts data by a single column", function() {
|
||||
cy.getByTestId("TableVisualization")
|
||||
.find("table th")
|
||||
.contains("c")
|
||||
.should("exist")
|
||||
.click();
|
||||
it("sorts data by a single column", function () {
|
||||
cy.getByTestId("TableVisualization").find("table th").contains("c").should("exist").click();
|
||||
cy.percySnapshot("Visualizations - Table (Single-column sort)", { widths: [viewportWidth] });
|
||||
});
|
||||
|
||||
it("sorts data by a multiple columns", function() {
|
||||
cy.getByTestId("TableVisualization")
|
||||
.find("table th")
|
||||
.contains("a")
|
||||
.should("exist")
|
||||
.click();
|
||||
it("sorts data by a multiple columns", function () {
|
||||
cy.getByTestId("TableVisualization").find("table th").contains("a").should("exist").click();
|
||||
|
||||
cy.get("body").type("{shift}", { release: false });
|
||||
cy.getByTestId("TableVisualization")
|
||||
.find("table th")
|
||||
.contains("b")
|
||||
.should("exist")
|
||||
.click();
|
||||
cy.getByTestId("TableVisualization").find("table th").contains("b").should("exist").click();
|
||||
|
||||
cy.percySnapshot("Visualizations - Table (Multi-column sort)", { widths: [viewportWidth] });
|
||||
});
|
||||
|
||||
it("sorts data in reverse order", function() {
|
||||
cy.getByTestId("TableVisualization")
|
||||
.find("table th")
|
||||
.contains("c")
|
||||
.should("exist")
|
||||
.click()
|
||||
.click();
|
||||
it("sorts data in reverse order", function () {
|
||||
cy.getByTestId("TableVisualization").find("table th").contains("c").should("exist").click().click();
|
||||
cy.percySnapshot("Visualizations - Table (Single-column reverse sort)", { widths: [viewportWidth] });
|
||||
});
|
||||
});
|
||||
@@ -101,10 +81,7 @@ describe("Table", () => {
|
||||
it("searches in multiple columns", () => {
|
||||
const { query, config } = SearchInData;
|
||||
prepareVisualization(query, "TABLE", "Search", config).then(({ visualizationId }) => {
|
||||
cy.getByTestId("TableVisualization")
|
||||
.find("table input")
|
||||
.should("exist")
|
||||
.type("test");
|
||||
cy.getByTestId("TableVisualization").find("table input").should("exist").type("test");
|
||||
cy.percySnapshot("Visualizations - Table (Search in data)", { widths: [viewportWidth] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
const { extend, get, merge, find } = Cypress._;
|
||||
|
||||
const post = options =>
|
||||
const post = (options) =>
|
||||
cy
|
||||
.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);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ Cypress.Commands.add("createQuery", (data, shouldPublish = true) => {
|
||||
// eslint-disable-next-line cypress/no-assigning-return-values
|
||||
let request = post({ url: "/api/queries", body: merged }).then(({ body }) => body);
|
||||
if (shouldPublish) {
|
||||
request = request.then(query =>
|
||||
request = request.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) => {
|
||||
const defaultOptions = {
|
||||
column: "?column?",
|
||||
selector: "first",
|
||||
op: "greater than",
|
||||
rearm: 0,
|
||||
value: 1,
|
||||
@@ -109,7 +110,7 @@ Cypress.Commands.add("createUser", ({ name, email, password }) => {
|
||||
url: "api/users?no_invite=yes",
|
||||
body: { name, email },
|
||||
failOnStatusCode: false,
|
||||
}).then(xhr => {
|
||||
}).then((xhr) => {
|
||||
const { status, body } = xhr;
|
||||
if (status < 200 || status > 400) {
|
||||
throw new Error(xhr);
|
||||
@@ -146,7 +147,7 @@ Cypress.Commands.add("getDestinations", () => {
|
||||
Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) => {
|
||||
return cy
|
||||
.getDestinations()
|
||||
.then(destinations => {
|
||||
.then((destinations) => {
|
||||
const destination = find(destinations, { name: destinationName });
|
||||
if (!destination) {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -3,36 +3,26 @@
|
||||
* @param should Passed to should expression after plot points are captured
|
||||
*/
|
||||
export function assertPlotPreview(should = "exist") {
|
||||
cy.getByTestId("VisualizationPreview")
|
||||
.find("g.plot")
|
||||
.should("exist")
|
||||
.find("g.points")
|
||||
.should(should);
|
||||
cy.getByTestId("VisualizationPreview").find("g.overplot").should("exist").find("g.points").should(should);
|
||||
}
|
||||
|
||||
export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) {
|
||||
cy.getByTestId("NewVisualization").click();
|
||||
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART");
|
||||
cy.getByTestId("VisualizationName")
|
||||
.clear()
|
||||
.type(chartName);
|
||||
cy.getByTestId("VisualizationName").clear().type(chartName);
|
||||
|
||||
chartSpecificAssertionFn();
|
||||
|
||||
cy.server();
|
||||
cy.route("POST", "**/api/visualizations").as("SaveVisualization");
|
||||
|
||||
cy.getByTestId("EditVisualizationDialog")
|
||||
.contains("button", "Save")
|
||||
.click();
|
||||
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
|
||||
|
||||
cy.getByTestId("QueryPageVisualizationTabs")
|
||||
.contains("span", chartName)
|
||||
.should("exist");
|
||||
cy.getByTestId("QueryPageVisualizationTabs").contains("span", chartName).should("exist");
|
||||
|
||||
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;
|
||||
return cy.wrap({ id, name, options });
|
||||
});
|
||||
@@ -42,19 +32,13 @@ export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () =>
|
||||
cy.getByTestId("Chart.GlobalSeriesType").should("exist");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.Series").click();
|
||||
cy.getByTestId("VisualizationEditor")
|
||||
.find("table")
|
||||
.should("exist");
|
||||
cy.getByTestId("VisualizationEditor").find("table").should("exist");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
|
||||
cy.getByTestId("VisualizationEditor")
|
||||
.find("table")
|
||||
.should("exist");
|
||||
cy.getByTestId("VisualizationEditor").find("table").should("exist");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
|
||||
cy.getByTestId("VisualizationEditor")
|
||||
.getByTestId("Chart.DataLabels.ShowDataLabels")
|
||||
.should("exist");
|
||||
cy.getByTestId("VisualizationEditor").getByTestId("Chart.DataLabels.ShowDataLabels").should("exist");
|
||||
|
||||
chartSpecificTabbedEditorAssertionFn();
|
||||
|
||||
@@ -63,39 +47,29 @@ export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () =>
|
||||
|
||||
export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) {
|
||||
cy.getByTestId("VisualizationEditor.Tabs.XAxis").click();
|
||||
cy.getByTestId("Chart.XAxis.Type")
|
||||
.contains(".ant-select-selection-item", "Auto Detect")
|
||||
.should("exist");
|
||||
cy.getByTestId("Chart.XAxis.Type").contains(".ant-select-selection-item", "Auto Detect").should("exist");
|
||||
|
||||
cy.getByTestId("Chart.XAxis.Name")
|
||||
.clear()
|
||||
.type(xaxisLabel);
|
||||
cy.getByTestId("Chart.XAxis.Name").clear().type(xaxisLabel);
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.YAxis").click();
|
||||
cy.getByTestId("Chart.LeftYAxis.Type")
|
||||
.contains(".ant-select-selection-item", "Linear")
|
||||
.should("exist");
|
||||
cy.getByTestId("Chart.LeftYAxis.Type").contains(".ant-select-selection-item", "Linear").should("exist");
|
||||
|
||||
cy.getByTestId("Chart.LeftYAxis.Name")
|
||||
.clear()
|
||||
.type(yaxisLabel);
|
||||
cy.getByTestId("Chart.LeftYAxis.Name").clear().type(yaxisLabel);
|
||||
|
||||
cy.getByTestId("Chart.LeftYAxis.TickFormat")
|
||||
.clear()
|
||||
.type("+");
|
||||
cy.getByTestId("Chart.LeftYAxis.TickFormat").clear().type("+");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.General").click();
|
||||
}
|
||||
|
||||
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
|
||||
cy.createDashboard(title).then(dashboard => {
|
||||
cy.createDashboard(title).then((dashboard) => {
|
||||
const dashboardUrl = `/dashboards/${dashboard.id}`;
|
||||
const widgetGetters = chartGetters.map(chartGetter => `${chartGetter}Widget`);
|
||||
const widgetGetters = chartGetters.map((chartGetter) => `${chartGetter}Widget`);
|
||||
|
||||
chartGetters.forEach((chartGetter, i) => {
|
||||
const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 };
|
||||
cy.get(`@${chartGetter}`)
|
||||
.then(chart => cy.addWidget(dashboard.id, chart.id, { position }))
|
||||
.then((chart) => cy.addWidget(dashboard.id, chart.id, { position }))
|
||||
.as(widgetGetters[i]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
export function expectTableToHaveLength(length) {
|
||||
cy.getByTestId("TableVisualization")
|
||||
.find("tbody tr")
|
||||
.should("have.length", length);
|
||||
cy.getByTestId("TableVisualization").find("tbody tr").should("have.length", length);
|
||||
}
|
||||
|
||||
export function expectFirstColumnToHaveMembers(values) {
|
||||
cy.getByTestId("TableVisualization")
|
||||
.find("tbody tr td:first-child")
|
||||
.then($cell => Cypress.$.map($cell, item => Cypress.$(item).text()))
|
||||
.then(firstColumnCells => expect(firstColumnCells).to.have.members(values));
|
||||
.then(($cell) => Cypress.$.map($cell, (item) => Cypress.$(item).text()))
|
||||
.then((firstColumnCells) => expect(firstColumnCells).to.have.members(values));
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
services:
|
||||
.redash:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
FRONTEND_BUILD_MODE: ${FRONTEND_BUILD_MODE:-2}
|
||||
INSTALL_GROUPS: ${INSTALL_GROUPS:-main,all_ds,dev}
|
||||
volumes:
|
||||
- $PWD:${SERVER_MOUNT:-/ignore}
|
||||
command: manage version
|
||||
environment:
|
||||
REDASH_LOG_LEVEL: INFO
|
||||
REDASH_REDIS_URL: redis://redis:6379/0
|
||||
REDASH_DATABASE_URL: postgresql://postgres@postgres/postgres
|
||||
REDASH_RATELIMIT_ENABLED: false
|
||||
REDASH_MAIL_DEFAULT_SENDER: redash@example.com
|
||||
REDASH_MAIL_SERVER: email
|
||||
REDASH_MAIL_PORT: 1025
|
||||
REDASH_ENFORCE_CSRF: true
|
||||
REDASH_COOKIE_SECRET: ${REDASH_COOKIE_SECRET}
|
||||
REDASH_SECRET_KEY: ${REDASH_SECRET_KEY}
|
||||
REDASH_PRODUCTION: ${REDASH_PRODUCTION:-true}
|
||||
env_file:
|
||||
- .env
|
||||
92
compose.yaml
92
compose.yaml
@@ -1,81 +1,71 @@
|
||||
# This configuration file is for the **development** setup.
|
||||
# For a production example please refer to getredash/setup repository on GitHub.
|
||||
x-redash-service: &redash-service
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
skip_frontend_build: "true" # set to empty string to build
|
||||
volumes:
|
||||
- .:/app
|
||||
env_file:
|
||||
- .env
|
||||
x-redash-environment: &redash-environment
|
||||
REDASH_HOST: http://localhost:5001
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
REDASH_RATELIMIT_ENABLED: "false"
|
||||
REDASH_MAIL_DEFAULT_SENDER: "redash@example.com"
|
||||
REDASH_MAIL_SERVER: "email"
|
||||
REDASH_MAIL_PORT: 1025
|
||||
REDASH_ENFORCE_CSRF: "true"
|
||||
REDASH_GUNICORN_TIMEOUT: 60
|
||||
# Set secret keys in the .env file
|
||||
services:
|
||||
server:
|
||||
extends:
|
||||
file: compose.base.yaml
|
||||
service: .redash
|
||||
command: server
|
||||
<<: *redash-service
|
||||
command: dev_server
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
ports:
|
||||
- "${REDASH_PORT:-5001}:5000"
|
||||
- "5001:5000"
|
||||
- "5678:5678"
|
||||
environment:
|
||||
<<: *redash-environment
|
||||
PYTHONUNBUFFERED: 0
|
||||
scheduler:
|
||||
extends:
|
||||
file: compose.base.yaml
|
||||
service: .redash
|
||||
profiles:
|
||||
- e2e
|
||||
- local
|
||||
command: scheduler
|
||||
depends_on:
|
||||
- server
|
||||
worker:
|
||||
extends:
|
||||
file: compose.base.yaml
|
||||
service: .redash
|
||||
profiles:
|
||||
- e2e
|
||||
- local
|
||||
command: worker
|
||||
<<: *redash-service
|
||||
command: dev_scheduler
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
<<: *redash-environment
|
||||
worker:
|
||||
<<: *redash-service
|
||||
command: dev_worker
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
<<: *redash-environment
|
||||
PYTHONUNBUFFERED: 0
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: pgautoupgrade/pgautoupgrade:latest
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-15432}:5432"
|
||||
- "15432:5432"
|
||||
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3
|
||||
# improvement on my personal machine). We should consider moving this into a dedicated Docker Compose configuration for
|
||||
# tests.
|
||||
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
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
POSTGRES_HOST_AUTH_METHOD: "trust"
|
||||
email:
|
||||
image: maildev/maildev
|
||||
ports:
|
||||
- "1080:1080"
|
||||
- "1025:1025"
|
||||
restart: unless-stopped
|
||||
cypress:
|
||||
ipc: host
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.cypress
|
||||
profiles:
|
||||
- e2e
|
||||
depends_on:
|
||||
- server
|
||||
- worker
|
||||
- scheduler
|
||||
environment:
|
||||
CYPRESS_baseUrl: http://server:5000
|
||||
PERCY_TOKEN: ${PERCY_TOKEN:-""}
|
||||
PERCY_BRANCH: ${PERCY_BRANCH:-""}
|
||||
PERCY_COMMIT: ${PERCY_COMMIT:-""}
|
||||
PERCY_PULL_REQUEST: ${PERCY_PULL_REQUEST:-}
|
||||
COMMIT_INFO_BRANCH: ${COMMIT_INFO_BRANCH:-""}
|
||||
COMMIT_INFO_MESSAGE: ${COMMIT_INFO_MESSAGE:-""}
|
||||
COMMIT_INFO_AUTHOR: ${COMMIT_INFO_AUTHOR:-""}
|
||||
COMMIT_INFO_SHA: ${COMMIT_INFO_SHA:-""}
|
||||
COMMIT_INFO_REMOTE: ${COMMIT_INFO_REMOTE:-""}
|
||||
CYPRESS_PROJECT_ID: ${CYPRESS_PROJECT_ID:-""}
|
||||
CYPRESS_RECORD_KEY: ${CYPRESS_RECORD_KEY:-""}
|
||||
CYPRESS_COVERAGE: ${CYPRESS_COVERAGE:-true}
|
||||
|
||||
@@ -24,56 +24,56 @@ def upgrade():
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
postgresql_using='options::jsonb',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
op.alter_column('queries', 'schedule',
|
||||
existing_type=sa.Text(),
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
postgresql_using='schedule::jsonb',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
op.alter_column('events', 'additional_properties',
|
||||
existing_type=sa.Text(),
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
postgresql_using='additional_properties::jsonb',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
op.alter_column('organizations', 'settings',
|
||||
existing_type=sa.Text(),
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
postgresql_using='settings::jsonb',
|
||||
server_default=sa.text("'{}'::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',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
op.alter_column('dashboards', 'options',
|
||||
existing_type=JSON(astext_type=sa.Text()),
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
postgresql_using='options::jsonb',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
op.alter_column('dashboards', 'layout',
|
||||
existing_type=sa.Text(),
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
postgresql_using='layout::jsonb',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
op.alter_column('changes', 'change',
|
||||
existing_type=JSON(astext_type=sa.Text()),
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
postgresql_using='change::jsonb',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
op.alter_column('visualizations', 'options',
|
||||
existing_type=sa.Text(),
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
postgresql_using='options::jsonb',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
op.alter_column('widgets', 'options',
|
||||
existing_type=sa.Text(),
|
||||
type_=JSONB(astext_type=sa.Text()),
|
||||
postgresql_using='options::jsonb',
|
||||
server_default=sa.text("'{}'::jsonb"))
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
@@ -83,53 +83,53 @@ def downgrade():
|
||||
type_=sa.Text(),
|
||||
postgresql_using='options::text',
|
||||
existing_nullable=True,
|
||||
server_default=sa.text("'{}'::text"))
|
||||
)
|
||||
op.alter_column('queries', 'schedule',
|
||||
existing_type=JSONB(astext_type=sa.Text()),
|
||||
type_=sa.Text(),
|
||||
postgresql_using='schedule::text',
|
||||
existing_nullable=True,
|
||||
server_default=sa.text("'{}'::text"))
|
||||
)
|
||||
op.alter_column('events', 'additional_properties',
|
||||
existing_type=JSONB(astext_type=sa.Text()),
|
||||
type_=sa.Text(),
|
||||
postgresql_using='additional_properties::text',
|
||||
existing_nullable=True,
|
||||
server_default=sa.text("'{}'::text"))
|
||||
)
|
||||
op.alter_column('organizations', 'settings',
|
||||
existing_type=JSONB(astext_type=sa.Text()),
|
||||
type_=sa.Text(),
|
||||
postgresql_using='settings::text',
|
||||
existing_nullable=True,
|
||||
server_default=sa.text("'{}'::text"))
|
||||
)
|
||||
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,
|
||||
server_default=sa.text("'{}'::json"))
|
||||
)
|
||||
op.alter_column('dashboards', 'options',
|
||||
existing_type=JSONB(astext_type=sa.Text()),
|
||||
type_=JSON(astext_type=sa.Text()),
|
||||
postgresql_using='options::json',
|
||||
server_default=sa.text("'{}'::json"))
|
||||
)
|
||||
op.alter_column('dashboards', 'layout',
|
||||
existing_type=JSONB(astext_type=sa.Text()),
|
||||
type_=sa.Text(),
|
||||
postgresql_using='layout::text',
|
||||
server_default=sa.text("'{}'::text"))
|
||||
)
|
||||
op.alter_column('changes', 'change',
|
||||
existing_type=JSONB(astext_type=sa.Text()),
|
||||
type_=JSON(astext_type=sa.Text()),
|
||||
postgresql_using='change::json',
|
||||
server_default=sa.text("'{}'::json"))
|
||||
)
|
||||
op.alter_column('visualizations', 'options',
|
||||
type_=sa.Text(),
|
||||
existing_type=JSONB(astext_type=sa.Text()),
|
||||
postgresql_using='options::text',
|
||||
server_default=sa.text("'{}'::text"))
|
||||
)
|
||||
op.alter_column('widgets', 'options',
|
||||
type_=sa.Text(),
|
||||
existing_type=JSONB(astext_type=sa.Text()),
|
||||
postgresql_using='options::text',
|
||||
server_default=sa.text("'{}'::text"))
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from redash import settings
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
from redash.models.types import (
|
||||
EncryptedConfiguration,
|
||||
Configuration,
|
||||
MutableDict,
|
||||
MutableList,
|
||||
)
|
||||
@@ -44,14 +45,7 @@ def upgrade():
|
||||
)
|
||||
),
|
||||
),
|
||||
sa.Column(
|
||||
"options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(
|
||||
sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
|
||||
)
|
||||
),
|
||||
),
|
||||
sa.Column("options", ConfigurationContainer.as_mutable(Configuration)),
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
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
|
||||
@@ -14,7 +14,10 @@ from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
|
||||
from redash import settings
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
from redash.models.base import key_type
|
||||
from redash.models.types import EncryptedConfiguration
|
||||
from redash.models.types import (
|
||||
EncryptedConfiguration,
|
||||
Configuration,
|
||||
)
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
@@ -42,14 +45,7 @@ def upgrade():
|
||||
)
|
||||
),
|
||||
),
|
||||
sa.Column(
|
||||
"options",
|
||||
ConfigurationContainer.as_mutable(
|
||||
EncryptedConfiguration(
|
||||
sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
|
||||
)
|
||||
),
|
||||
),
|
||||
sa.Column("options", ConfigurationContainer.as_mutable(Configuration)),
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
@@ -28,7 +28,7 @@ def upgrade():
|
||||
existing_nullable=True,
|
||||
existing_server_default=sa.text("'{}'::jsonb"))
|
||||
### end Alembic commands ###
|
||||
|
||||
|
||||
update_query = """
|
||||
update users
|
||||
set details = details::jsonb || ('{"profile_image_url": "' || profile_image_url || '"}')::jsonb
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "redash-client",
|
||||
"version": "24.05.0-dev",
|
||||
"version": "25.07.0-dev",
|
||||
"description": "The frontend part of Redash.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"jest": "TZ=Africa/Khartoum jest",
|
||||
"test": "run-s type-check jest",
|
||||
"test:watch": "jest --watch",
|
||||
"cypress": "COMPOSE_PROFILES=local node client/cypress/cypress.js",
|
||||
"cypress": "node client/cypress/cypress.js",
|
||||
"preinstall": "cd viz-lib && yarn link --link-folder ../.yarn",
|
||||
"postinstall": "(cd viz-lib && yarn --frozen-lockfile && yarn build:babel) && yarn link --link-folder ./.yarn @redash/viz"
|
||||
},
|
||||
@@ -50,11 +50,12 @@
|
||||
"antd": "^4.4.3",
|
||||
"axios": "0.27.2",
|
||||
"axios-auth-refresh": "3.3.6",
|
||||
"bootstrap": "^3.3.7",
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.6",
|
||||
"d3": "^3.5.17",
|
||||
"debug": "^3.2.7",
|
||||
"dompurify": "^2.0.17",
|
||||
"elliptic": "^6.6.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
"history": "^4.10.1",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
@@ -63,7 +64,7 @@
|
||||
"mousetrap": "^1.6.1",
|
||||
"mustache": "^2.3.0",
|
||||
"numeral": "^2.0.6",
|
||||
"path-to-regexp": "^3.1.0",
|
||||
"path-to-regexp": "^3.3.0",
|
||||
"prop-types": "^15.6.1",
|
||||
"query-string": "^6.9.0",
|
||||
"react": "16.14.0",
|
||||
@@ -142,6 +143,7 @@
|
||||
"react-refresh": "^0.14.0",
|
||||
"react-test-renderer": "^16.14.0",
|
||||
"request-cookies": "^1.1.0",
|
||||
"source-map-loader": "^1.1.3",
|
||||
"style-loader": "^2.0.0",
|
||||
"typescript": "^4.1.2",
|
||||
"url-loader": "^4.1.1",
|
||||
@@ -179,8 +181,8 @@
|
||||
]
|
||||
},
|
||||
"browser": {
|
||||
"fs": false,
|
||||
"path": false
|
||||
"fs": false,
|
||||
"path": false
|
||||
},
|
||||
"//": "browserslist set to 'Async functions' compatibility",
|
||||
"browserslist": [
|
||||
|
||||
3170
poetry.lock
generated
3170
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ force-exclude = '''
|
||||
|
||||
[tool.poetry]
|
||||
name = "redash"
|
||||
version = "24.05.0-dev"
|
||||
version = "25.07.0-dev"
|
||||
description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data."
|
||||
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
|
||||
@@ -29,7 +29,7 @@ authlib = "0.15.5"
|
||||
backoff = "2.2.1"
|
||||
blinker = "1.6.2"
|
||||
click = "8.1.3"
|
||||
cryptography = "41.0.6"
|
||||
cryptography = "43.0.1"
|
||||
disposable-email-domains = ">=0.0.52"
|
||||
flask = "2.3.2"
|
||||
flask-limiter = "3.3.1"
|
||||
@@ -46,7 +46,7 @@ greenlet = "2.0.2"
|
||||
gunicorn = "22.0.0"
|
||||
httplib2 = "0.19.0"
|
||||
itsdangerous = "2.1.2"
|
||||
jinja2 = "3.1.3"
|
||||
jinja2 = "3.1.5"
|
||||
jsonschema = "3.1.1"
|
||||
markupsafe = "2.1.1"
|
||||
maxminddb-geolite2 = "2018.703"
|
||||
@@ -54,7 +54,7 @@ parsedatetime = "2.4"
|
||||
passlib = "1.7.3"
|
||||
psycopg2-binary = "2.9.6"
|
||||
pyjwt = "2.4.0"
|
||||
pyopenssl = "23.2.0"
|
||||
pyopenssl = "24.2.1"
|
||||
pypd = "1.1.0"
|
||||
pysaml2 = "7.3.1"
|
||||
pystache = "0.6.0"
|
||||
@@ -64,34 +64,38 @@ pytz = ">=2019.3"
|
||||
pyyaml = "6.0.1"
|
||||
redis = "4.6.0"
|
||||
regex = "2023.8.8"
|
||||
requests = "2.31.0"
|
||||
restrictedpython = "6.2"
|
||||
requests = "2.32.3"
|
||||
restrictedpython = "7.3"
|
||||
rq = "1.16.1"
|
||||
rq-scheduler = "0.13.1"
|
||||
semver = "2.8.1"
|
||||
sentry-sdk = "1.28.1"
|
||||
sentry-sdk = "1.45.1"
|
||||
sqlalchemy = "1.3.24"
|
||||
sqlalchemy-searchable = "1.2.0"
|
||||
sqlalchemy-utils = "0.34.2"
|
||||
sqlalchemy-utils = "0.38.3"
|
||||
sqlparse = "0.5.0"
|
||||
sshtunnel = "0.1.5"
|
||||
statsd = "3.3.0"
|
||||
supervisor = "4.1.0"
|
||||
supervisor-checks = "0.8.1"
|
||||
ua-parser = "0.18.0"
|
||||
urllib3 = "1.26.18"
|
||||
urllib3 = "1.26.19"
|
||||
user-agents = "2.0"
|
||||
werkzeug = "2.3.8"
|
||||
wtforms = "2.2.1"
|
||||
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]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.all_ds.dependencies]
|
||||
atsd-client = "3.0.5"
|
||||
azure-kusto-data = "0.0.35"
|
||||
azure-kusto-data = "5.0.1"
|
||||
boto3 = "1.28.8"
|
||||
botocore = "1.31.8"
|
||||
cassandra-driver = "3.21.0"
|
||||
@@ -106,30 +110,30 @@ influxdb = "5.2.3"
|
||||
influxdb-client = "1.38.0"
|
||||
memsql = "3.2.0"
|
||||
mysqlclient = "2.1.1"
|
||||
numpy = "1.24.4"
|
||||
nzalchemy = "^11.0.2"
|
||||
nzpy = ">=1.15"
|
||||
oauth2client = "4.1.3"
|
||||
openpyxl = "3.0.7"
|
||||
oracledb = "2.1.2"
|
||||
pandas = "1.3.4"
|
||||
phoenixdb = "0.7"
|
||||
pinotdb = ">=0.4.5"
|
||||
protobuf = "3.20.2"
|
||||
pyathena = ">=1.5.0,<=1.11.5"
|
||||
pyathena = "2.25.2"
|
||||
pydgraph = "2.0.2"
|
||||
pydruid = "0.5.7"
|
||||
pyexasol = "0.12.0"
|
||||
pyhive = "0.6.1"
|
||||
pyignite = "0.6.1"
|
||||
pymongo = { version = "4.6.3", extras = ["srv", "tls"] }
|
||||
pymssql = "2.2.8"
|
||||
pyodbc = "4.0.28"
|
||||
pymssql = "^2.3.1"
|
||||
pyodbc = "5.1.0"
|
||||
python-arango = "6.1.0"
|
||||
python-rapidjson = "1.1.0"
|
||||
python-rapidjson = "1.20"
|
||||
requests-aws-sign = "0.1.5"
|
||||
sasl = ">=0.1.3"
|
||||
simple-salesforce = "0.74.3"
|
||||
snowflake-connector-python = "3.4.0"
|
||||
snowflake-connector-python = "3.12.3"
|
||||
td-client = "1.0.0"
|
||||
thrift = ">=0.8.0"
|
||||
thrift-sasl = ">=0.1.0"
|
||||
@@ -155,7 +159,6 @@ jwcrypto = "1.5.6"
|
||||
mock = "5.0.2"
|
||||
pre-commit = "3.3.3"
|
||||
ptpython = "3.0.23"
|
||||
ptvsd = "4.3.2"
|
||||
pytest-cov = "4.1.0"
|
||||
watchdog = "3.0.0"
|
||||
ruff = "0.0.289"
|
||||
|
||||
@@ -14,13 +14,14 @@ from redash.app import create_app # noqa
|
||||
from redash.destinations import import_destinations
|
||||
from redash.query_runner import import_query_runners
|
||||
|
||||
__version__ = "24.05.0-dev"
|
||||
__version__ = "25.07.0-dev"
|
||||
|
||||
|
||||
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():
|
||||
|
||||
@@ -36,10 +36,14 @@ def create_app():
|
||||
from .metrics import request as request_metrics
|
||||
from .models import db, users
|
||||
from .utils import sentry
|
||||
from .version_check import reset_new_version_status
|
||||
|
||||
sentry.init()
|
||||
app = Redash()
|
||||
|
||||
# Check and update the cached version for use by the client
|
||||
reset_new_version_status()
|
||||
|
||||
security.init_app(app)
|
||||
request_metrics.init_app(app)
|
||||
db.init_app(app)
|
||||
|
||||
@@ -8,6 +8,7 @@ from redash import settings
|
||||
|
||||
try:
|
||||
from ldap3 import Connection, Server
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
except ImportError:
|
||||
if settings.LDAP_LOGIN_ENABLED:
|
||||
sys.exit(
|
||||
@@ -69,6 +70,7 @@ def login(org_slug=None):
|
||||
|
||||
|
||||
def auth_ldap_user(username, password):
|
||||
clean_username = escape_filter_chars(username)
|
||||
server = Server(settings.LDAP_HOST_URL, use_ssl=settings.LDAP_SSL)
|
||||
if settings.LDAP_BIND_DN is not None:
|
||||
conn = Connection(
|
||||
@@ -83,7 +85,7 @@ def auth_ldap_user(username, password):
|
||||
|
||||
conn.search(
|
||||
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],
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,22 @@ from sqlalchemy.orm.exc import NoResultFound
|
||||
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")
|
||||
@argument("query_id")
|
||||
@argument("tag")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
@@ -37,6 +39,129 @@ class Webex(BaseDestination):
|
||||
|
||||
@staticmethod
|
||||
def formatted_attachments_template(subject, description, query_link, alert_link):
|
||||
# Attempt to parse the description to find a 2D array
|
||||
try:
|
||||
# Extract the part of the description that looks like a JSON array
|
||||
start_index = description.find("[")
|
||||
end_index = description.rfind("]") + 1
|
||||
json_array_str = description[start_index:end_index]
|
||||
|
||||
# Decode HTML entities
|
||||
json_array_str = html.unescape(json_array_str)
|
||||
|
||||
# Replace single quotes with double quotes for valid JSON
|
||||
json_array_str = json_array_str.replace("'", '"')
|
||||
|
||||
# Load the JSON array
|
||||
data_array = json.loads(json_array_str)
|
||||
|
||||
# Check if it's a 2D array
|
||||
if isinstance(data_array, list) and all(isinstance(i, list) for i in data_array):
|
||||
# Create a table for the Adaptive Card
|
||||
table_rows = []
|
||||
for row in data_array:
|
||||
table_rows.append(
|
||||
{
|
||||
"type": "ColumnSet",
|
||||
"columns": [
|
||||
{"type": "Column", "items": [{"type": "TextBlock", "text": str(item), "wrap": True}]}
|
||||
for item in row
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Create the body of the card with the table
|
||||
body = (
|
||||
[
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{subject}",
|
||||
"weight": "bolder",
|
||||
"size": "medium",
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{description[:start_index]}",
|
||||
"isSubtle": True,
|
||||
"wrap": True,
|
||||
},
|
||||
]
|
||||
+ table_rows
|
||||
+ [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({query_link}) to check your query!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Fallback to the original description if no valid 2D array is found
|
||||
body = [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{subject}",
|
||||
"weight": "bolder",
|
||||
"size": "medium",
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{description}",
|
||||
"isSubtle": True,
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({query_link}) to check your query!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
]
|
||||
except json.JSONDecodeError:
|
||||
# If parsing fails, fallback to the original description
|
||||
body = [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{subject}",
|
||||
"weight": "bolder",
|
||||
"size": "medium",
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{description}",
|
||||
"isSubtle": True,
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({query_link}) to check your query!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
@@ -44,44 +169,7 @@ class Webex(BaseDestination):
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.0",
|
||||
"body": [
|
||||
{
|
||||
"type": "ColumnSet",
|
||||
"columns": [
|
||||
{
|
||||
"type": "Column",
|
||||
"width": 4,
|
||||
"items": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": {subject},
|
||||
"weight": "bolder",
|
||||
"size": "medium",
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": {description},
|
||||
"isSubtle": True,
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({query_link}) to check your query!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"body": body,
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -116,6 +204,10 @@ class Webex(BaseDestination):
|
||||
|
||||
# destinations is guaranteed to be a comma-separated string
|
||||
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[payload_tag] = destination_id
|
||||
self.post_message(payload, headers)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from flask import request
|
||||
from funcy import project
|
||||
|
||||
from redash import models
|
||||
from redash import models, utils
|
||||
from redash.handlers.base import (
|
||||
BaseResource,
|
||||
get_object_or_404,
|
||||
@@ -14,6 +14,10 @@ from redash.permissions import (
|
||||
view_only,
|
||||
)
|
||||
from redash.serializers import serialize_alert
|
||||
from redash.tasks.alerts import (
|
||||
notify_subscriptions,
|
||||
should_notify,
|
||||
)
|
||||
|
||||
|
||||
class AlertResource(BaseResource):
|
||||
@@ -43,6 +47,21 @@ class AlertResource(BaseResource):
|
||||
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):
|
||||
def post(self, alert_id):
|
||||
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 redash.handlers.alerts import (
|
||||
AlertEvaluateResource,
|
||||
AlertListResource,
|
||||
AlertMuteResource,
|
||||
AlertResource,
|
||||
@@ -117,6 +118,7 @@ def json_representation(data, code, headers=None):
|
||||
|
||||
api.add_org_resource(AlertResource, "/api/alerts/<alert_id>", endpoint="alert")
|
||||
api.add_org_resource(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(
|
||||
AlertSubscriptionListResource,
|
||||
"/api/alerts/<alert_id>/subscriptions",
|
||||
@@ -236,11 +238,11 @@ api.add_org_resource(
|
||||
)
|
||||
api.add_org_resource(
|
||||
QueryResultResource,
|
||||
"/api/query_results/<result_id>.<filetype>",
|
||||
"/api/query_results/<result_id>",
|
||||
"/api/query_results/<query_result_id>.<filetype>",
|
||||
"/api/query_results/<query_result_id>",
|
||||
"/api/queries/<query_id>/results",
|
||||
"/api/queries/<query_id>/results.<filetype>",
|
||||
"/api/queries/<query_id>/results/<result_id>.<filetype>",
|
||||
"/api/queries/<query_id>/results/<query_result_id>.<filetype>",
|
||||
endpoint="query_result",
|
||||
)
|
||||
api.add_org_resource(
|
||||
|
||||
@@ -15,6 +15,7 @@ from redash.authentication.account import (
|
||||
)
|
||||
from redash.handlers import routes
|
||||
from redash.handlers.base import json_response, org_scoped_rule
|
||||
from redash.version_check import get_latest_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,6 +29,7 @@ def get_google_auth_url(next_path):
|
||||
|
||||
|
||||
def render_token_login_page(template, org_slug, token, invite):
|
||||
error_message = None
|
||||
try:
|
||||
user_id = validate_token(token)
|
||||
org = current_org._get_current_object()
|
||||
@@ -39,19 +41,19 @@ def render_token_login_page(template, org_slug, token, invite):
|
||||
user_id,
|
||||
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 (
|
||||
render_template(
|
||||
"error.html",
|
||||
error_message="Invalid invite link. Please ask for a new one.",
|
||||
),
|
||||
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.",
|
||||
error_message=error_message,
|
||||
),
|
||||
400,
|
||||
)
|
||||
@@ -255,11 +257,15 @@ def number_format_config():
|
||||
|
||||
def client_config():
|
||||
if not current_user.is_api_user() and current_user.is_authenticated:
|
||||
client_config_inner = {
|
||||
client_config = {
|
||||
"newVersionAvailable": bool(get_latest_version()),
|
||||
"version": __version__,
|
||||
}
|
||||
else:
|
||||
client_config_inner = {}
|
||||
client_config = {}
|
||||
|
||||
if current_user.has_permission("admin") and current_org.get_setting("beacon_consent") is None:
|
||||
client_config["showBeaconConsentMessage"] = True
|
||||
|
||||
defaults = {
|
||||
"allowScriptsInUserInput": settings.ALLOW_SCRIPTS_IN_USER_INPUT,
|
||||
@@ -279,12 +285,12 @@ def client_config():
|
||||
"tableCellMaxJSONSize": settings.TABLE_CELL_MAX_JSON_SIZE,
|
||||
}
|
||||
|
||||
client_config_inner.update(defaults)
|
||||
client_config_inner.update({"basePath": base_href()})
|
||||
client_config_inner.update(date_time_format_config())
|
||||
client_config_inner.update(number_format_config())
|
||||
client_config.update(defaults)
|
||||
client_config.update({"basePath": base_href()})
|
||||
client_config.update(date_time_format_config())
|
||||
client_config.update(number_format_config())
|
||||
|
||||
return client_config_inner
|
||||
return client_config
|
||||
|
||||
|
||||
def messages():
|
||||
|
||||
@@ -7,13 +7,13 @@ from flask_restful import Resource, abort
|
||||
from sqlalchemy import cast
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy_utils.functions import sort_query
|
||||
|
||||
from redash import settings
|
||||
from redash.authentication import current_org
|
||||
from redash.models import db
|
||||
from redash.tasks import record_event as record_event_task
|
||||
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"))
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import regex
|
||||
from flask import make_response, request
|
||||
from flask_login import current_user
|
||||
from flask_restful import abort
|
||||
from rq.job import JobStatus
|
||||
|
||||
from redash import models, settings
|
||||
from redash.handlers.base import BaseResource, get_object_or_404, record_event
|
||||
@@ -39,7 +38,7 @@ from redash.utils import (
|
||||
|
||||
|
||||
def error_response(message, http_status=400):
|
||||
return {"job": {"status": JobStatus.FAILED, "error": message}}, http_status
|
||||
return {"job": {"status": 4, "error": message}}, http_status
|
||||
|
||||
|
||||
error_messages = {
|
||||
@@ -226,7 +225,7 @@ class QueryResultResource(BaseResource):
|
||||
headers["Access-Control-Allow-Credentials"] = str(settings.ACCESS_CONTROL_ALLOW_CREDENTIALS).lower()
|
||||
|
||||
@require_any_of_permission(("view_query", "execute_query"))
|
||||
def options(self, query_id=None, result_id=None, filetype="json"):
|
||||
def options(self, query_id=None, query_result_id=None, filetype="json"):
|
||||
headers = {}
|
||||
self.add_cors_headers(headers)
|
||||
|
||||
@@ -286,12 +285,12 @@ class QueryResultResource(BaseResource):
|
||||
return error_messages["no_permission"]
|
||||
|
||||
@require_any_of_permission(("view_query", "execute_query"))
|
||||
def get(self, query_id=None, result_id=None, filetype="json"):
|
||||
def get(self, query_id=None, query_result_id=None, filetype="json"):
|
||||
"""
|
||||
Retrieve query results.
|
||||
|
||||
:param number query_id: The ID of the query whose results should be fetched
|
||||
:param number result_id: the ID of the query result to fetch
|
||||
:param number query_result_id: the ID of the query result to fetch
|
||||
:param string filetype: Format to return. One of 'json', 'xlsx', or 'csv'. Defaults to 'json'.
|
||||
|
||||
:<json number id: Query result ID
|
||||
@@ -306,13 +305,13 @@ class QueryResultResource(BaseResource):
|
||||
# This method handles two cases: retrieving result by id & retrieving result by query id.
|
||||
# They need to be split, as they have different logic (for example, retrieving by query id
|
||||
# should check for query parameters and shouldn't cache the result).
|
||||
should_cache = result_id is not None
|
||||
should_cache = query_result_id is not None
|
||||
|
||||
query_result = None
|
||||
query = None
|
||||
|
||||
if result_id:
|
||||
query_result = get_object_or_404(models.QueryResult.get_by_id_and_org, result_id, self.current_org)
|
||||
if query_result_id:
|
||||
query_result = get_object_or_404(models.QueryResult.get_by_id_and_org, query_result_id, self.current_org)
|
||||
|
||||
if query_id is not None:
|
||||
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
|
||||
@@ -347,7 +346,7 @@ class QueryResultResource(BaseResource):
|
||||
event["object_id"] = query_id
|
||||
else:
|
||||
event["object_type"] = "query_result"
|
||||
event["object_id"] = result_id
|
||||
event["object_id"] = query_result_id
|
||||
|
||||
self.record_event(event)
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from flask import g, redirect, render_template, request, url_for
|
||||
from flask_login import login_user
|
||||
from wtforms import Form, PasswordField, StringField, validators
|
||||
from wtforms import BooleanField, Form, PasswordField, StringField, validators
|
||||
from wtforms.fields.html5 import EmailField
|
||||
|
||||
from redash import settings
|
||||
from redash.authentication.org_resolving import current_org
|
||||
from redash.handlers.base import routes
|
||||
from redash.models import Group, Organization, User, db
|
||||
from redash.tasks.general import subscribe
|
||||
|
||||
|
||||
class SetupForm(Form):
|
||||
@@ -14,6 +15,8 @@ class SetupForm(Form):
|
||||
email = EmailField("Email Address", validators=[validators.Email()])
|
||||
password = PasswordField("Password", validators=[validators.Length(6)])
|
||||
org_name = StringField("Organization Name", validators=[validators.InputRequired()])
|
||||
security_notifications = BooleanField()
|
||||
newsletter = BooleanField()
|
||||
|
||||
|
||||
def create_org(org_name, user_name, email, password):
|
||||
@@ -54,6 +57,8 @@ def setup():
|
||||
return redirect("/")
|
||||
|
||||
form = SetupForm(request.form)
|
||||
form.newsletter.data = True
|
||||
form.security_notifications.data = True
|
||||
|
||||
if request.method == "POST" and form.validate():
|
||||
default_org, user = create_org(form.org_name.data, form.name.data, form.email.data, form.password.data)
|
||||
@@ -61,6 +66,10 @@ def setup():
|
||||
g.org = default_org
|
||||
login_user(user)
|
||||
|
||||
# signup to newsletter if needed
|
||||
if form.newsletter.data or form.security_notifications:
|
||||
subscribe.delay(form.data)
|
||||
|
||||
return redirect(url_for("redash.index", org_slug=None))
|
||||
|
||||
return render_template("setup.html", form=form)
|
||||
|
||||
@@ -5,7 +5,7 @@ from flask import g, has_request_context
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.event import listens_for
|
||||
from sqlalchemy.orm.util import _ORMJoin
|
||||
from sqlalchemy.sql.selectable import Alias
|
||||
from sqlalchemy.sql.selectable import Alias, Join
|
||||
|
||||
from redash import statsd_client
|
||||
|
||||
@@ -18,7 +18,7 @@ def _table_name_from_select_element(elt):
|
||||
if isinstance(t, Alias):
|
||||
t = t.original.froms[0]
|
||||
|
||||
while isinstance(t, _ORMJoin):
|
||||
while isinstance(t, _ORMJoin) or isinstance(t, Join):
|
||||
t = t.left
|
||||
|
||||
return t.name
|
||||
|
||||
@@ -46,6 +46,7 @@ from redash.models.parameterized_query import (
|
||||
QueryDetachedFromDataSourceError,
|
||||
)
|
||||
from redash.models.types import (
|
||||
Configuration,
|
||||
EncryptedConfiguration,
|
||||
JSONText,
|
||||
MutableDict,
|
||||
@@ -227,16 +228,7 @@ class DataSource(BelongsToOrgMixin, db.Model):
|
||||
|
||||
def _sort_schema(self, schema):
|
||||
return [
|
||||
{
|
||||
"name": i["name"],
|
||||
"description": i.get("description"),
|
||||
"columns": sorted(
|
||||
i["columns"],
|
||||
key=lambda col: (
|
||||
("partition" in col["type"], col.get("idx", 0), col["name"]) if isinstance(col, dict) else col
|
||||
),
|
||||
),
|
||||
}
|
||||
{"name": i["name"], "columns": sorted(i["columns"], key=lambda x: x["name"] if isinstance(x, dict) else x)}
|
||||
for i in sorted(schema, key=lambda x: x["name"])
|
||||
]
|
||||
|
||||
@@ -395,6 +387,10 @@ class QueryResult(db.Model, BelongsToOrgMixin):
|
||||
|
||||
|
||||
def should_schedule_next(previous_iteration, now, interval, time=None, day_of_week=None, failures=0):
|
||||
# if previous_iteration is None, it means the query has never been run before
|
||||
# so we should schedule it immediately
|
||||
if previous_iteration is None:
|
||||
return True
|
||||
# if time exists then interval > 23 hours (82800s)
|
||||
# if day_of_week exists then interval > 6 days (518400s)
|
||||
if time is None:
|
||||
@@ -610,6 +606,11 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
if query.schedule.get("disabled"):
|
||||
continue
|
||||
|
||||
# Skip queries that have None for all schedule values. It's unclear whether this
|
||||
# something that can happen in practice, but we have a test case for it.
|
||||
if all(value is None for value in query.schedule.values()):
|
||||
continue
|
||||
|
||||
if query.schedule["until"]:
|
||||
schedule_until = pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d"))
|
||||
|
||||
@@ -621,7 +622,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
)
|
||||
|
||||
if should_schedule_next(
|
||||
retrieved_at or now,
|
||||
retrieved_at,
|
||||
now,
|
||||
query.schedule["interval"],
|
||||
query.schedule["time"],
|
||||
@@ -907,6 +908,7 @@ def next_state(op, value, threshold):
|
||||
# boolean value is Python specific and most likely will be confusing to
|
||||
# users.
|
||||
value = str(value).lower()
|
||||
value_is_number = False
|
||||
else:
|
||||
try:
|
||||
value = float(value)
|
||||
@@ -924,6 +926,8 @@ def next_state(op, value, threshold):
|
||||
|
||||
if op(value, threshold):
|
||||
new_state = Alert.TRIGGERED_STATE
|
||||
elif not value_is_number and op not in [OPERATORS.get("!="), OPERATORS.get("=="), OPERATORS.get("equals")]:
|
||||
new_state = Alert.UNKNOWN_STATE
|
||||
else:
|
||||
new_state = Alert.OK_STATE
|
||||
|
||||
@@ -935,6 +939,7 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
UNKNOWN_STATE = "unknown"
|
||||
OK_STATE = "ok"
|
||||
TRIGGERED_STATE = "triggered"
|
||||
TEST_STATE = "test"
|
||||
|
||||
id = primary_key("Alert")
|
||||
name = Column(db.String(255))
|
||||
@@ -964,17 +969,38 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
return super(Alert, cls).get_by_id_and_org(object_id, org, Query)
|
||||
|
||||
def evaluate(self):
|
||||
data = self.query_rel.latest_query_data.data
|
||||
data = self.query_rel.latest_query_data.data if self.query_rel.latest_query_data else None
|
||||
new_state = self.UNKNOWN_STATE
|
||||
|
||||
if data["rows"] and self.options["column"] in data["rows"][0]:
|
||||
if data and data["rows"] and self.options["column"] in data["rows"][0]:
|
||||
op = OPERATORS.get(self.options["op"], lambda v, t: False)
|
||||
|
||||
value = data["rows"][0][self.options["column"]]
|
||||
if "selector" not in self.options:
|
||||
selector = "first"
|
||||
else:
|
||||
selector = self.options["selector"]
|
||||
|
||||
try:
|
||||
if selector == "max":
|
||||
max_val = float("-inf")
|
||||
for i in range(len(data["rows"])):
|
||||
max_val = max(max_val, float(data["rows"][i][self.options["column"]]))
|
||||
value = max_val
|
||||
elif selector == "min":
|
||||
min_val = float("inf")
|
||||
for i in range(len(data["rows"])):
|
||||
min_val = min(min_val, float(data["rows"][i][self.options["column"]]))
|
||||
value = min_val
|
||||
else:
|
||||
value = data["rows"][0][self.options["column"]]
|
||||
|
||||
except ValueError:
|
||||
return self.UNKNOWN_STATE
|
||||
|
||||
threshold = self.options["value"]
|
||||
|
||||
new_state = next_state(op, value, threshold)
|
||||
else:
|
||||
new_state = self.UNKNOWN_STATE
|
||||
if value is not None:
|
||||
new_state = next_state(op, value, threshold)
|
||||
|
||||
return new_state
|
||||
|
||||
@@ -997,11 +1023,11 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
|
||||
result_table = [] # A two-dimensional array which can rendered as a table in Mustache
|
||||
for row in data["rows"]:
|
||||
result_table.append([row[col["name"]] for col in data["columns"]])
|
||||
|
||||
context = {
|
||||
"ALERT_NAME": self.name,
|
||||
"ALERT_URL": "{host}/alerts/{alert_id}".format(host=host, alert_id=self.id),
|
||||
"ALERT_STATUS": self.state.upper(),
|
||||
"ALERT_SELECTOR": self.options["selector"],
|
||||
"ALERT_CONDITION": self.options["op"],
|
||||
"ALERT_THRESHOLD": self.options["value"],
|
||||
"QUERY_NAME": self.query_rel.name,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from functools import partial
|
||||
from numbers import Number
|
||||
|
||||
@@ -88,6 +89,16 @@ def _is_number(string):
|
||||
return True
|
||||
|
||||
|
||||
def _is_regex_pattern(value, regex):
|
||||
try:
|
||||
if re.compile(regex).fullmatch(value):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
|
||||
def _is_date(string):
|
||||
parse(string)
|
||||
return True
|
||||
@@ -135,6 +146,7 @@ class ParameterizedQuery:
|
||||
|
||||
enum_options = definition.get("enumOptions")
|
||||
query_id = definition.get("queryId")
|
||||
regex = definition.get("regex")
|
||||
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
|
||||
|
||||
if isinstance(enum_options, str):
|
||||
@@ -142,6 +154,7 @@ class ParameterizedQuery:
|
||||
|
||||
validators = {
|
||||
"text": lambda value: isinstance(value, str),
|
||||
"text-pattern": lambda value: _is_regex_pattern(value, regex),
|
||||
"number": _is_number,
|
||||
"enum": lambda value: _is_value_within_options(value, enum_options, allow_multiple_values),
|
||||
"query": lambda value: _is_value_within_options(
|
||||
|
||||
@@ -3,10 +3,21 @@ from sqlalchemy.ext.mutable import Mutable
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
from sqlalchemy_utils import EncryptedType
|
||||
|
||||
from redash.models.base import db
|
||||
from redash.utils import json_dumps, json_loads
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
|
||||
from .base import db
|
||||
|
||||
|
||||
class Configuration(TypeDecorator):
|
||||
impl = db.Text
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
return value.to_json()
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return ConfigurationContainer.from_json(value)
|
||||
|
||||
|
||||
class EncryptedConfiguration(EncryptedType):
|
||||
def process_bind_param(self, value, dialect):
|
||||
|
||||
@@ -166,7 +166,7 @@ class User(TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCh
|
||||
if self._profile_image_url:
|
||||
return self._profile_image_url
|
||||
|
||||
email_md5 = hashlib.md5(self.email.lower().encode()).hexdigest()
|
||||
email_md5 = hashlib.md5(self.email.lower().encode(), usedforsecurity=False).hexdigest()
|
||||
return "https://www.gravatar.com/avatar/{}?s=40&d=identicon".format(email_md5)
|
||||
|
||||
@property
|
||||
@@ -233,7 +233,9 @@ class User(TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCh
|
||||
return AccessPermission.exists(obj, access_type, grantee=self)
|
||||
|
||||
def get_id(self):
|
||||
identity = hashlib.md5("{},{}".format(self.email, self.password_hash).encode()).hexdigest()
|
||||
identity = hashlib.md5(
|
||||
"{},{}".format(self.email, self.password_hash).encode(), usedforsecurity=False
|
||||
).hexdigest()
|
||||
return "{0}-{1}".format(self.id, identity)
|
||||
|
||||
def get_actual_user(self):
|
||||
|
||||
@@ -59,7 +59,7 @@ def get_status():
|
||||
|
||||
|
||||
def rq_job_ids():
|
||||
queues = Queue.all(connection=redis_connection)
|
||||
queues = Queue.all(connection=rq_redis_connection)
|
||||
|
||||
started_jobs = [StartedJobRegistry(queue=q).get_job_ids() for q in queues]
|
||||
queued_jobs = [q.job_ids for q in queues]
|
||||
|
||||
@@ -288,7 +288,10 @@ class BaseSQLQueryRunner(BaseQueryRunner):
|
||||
return True
|
||||
|
||||
def query_is_select_no_limit(self, query):
|
||||
parsed_query = sqlparse.parse(query)[0]
|
||||
parsed_query_list = sqlparse.parse(query)
|
||||
if len(parsed_query_list) == 0:
|
||||
return False
|
||||
parsed_query = parsed_query_list[0]
|
||||
last_keyword_idx = find_last_keyword_idx(parsed_query)
|
||||
# Either invalid query or query that is not select
|
||||
if last_keyword_idx == -1 or parsed_query.tokens[0].value.upper() != "SELECT":
|
||||
|
||||
@@ -21,9 +21,7 @@ OPTIONAL_CREDENTIALS = parse_boolean(os.environ.get("ATHENA_OPTIONAL_CREDENTIALS
|
||||
|
||||
try:
|
||||
import boto3
|
||||
import pandas as pd
|
||||
import pyathena
|
||||
from pyathena.pandas_cursor import PandasCursor
|
||||
|
||||
enabled = True
|
||||
except ImportError:
|
||||
@@ -78,6 +76,10 @@ class Athena(BaseQueryRunner):
|
||||
"default": "default",
|
||||
},
|
||||
"glue": {"type": "boolean", "title": "Use Glue Data Catalog"},
|
||||
"catalog_ids": {
|
||||
"type": "string",
|
||||
"title": "Enter Glue Data Catalog IDs, separated by commas (leave blank for default catalog)",
|
||||
},
|
||||
"work_group": {
|
||||
"type": "string",
|
||||
"title": "Athena Work Group",
|
||||
@@ -88,15 +90,26 @@ class Athena(BaseQueryRunner):
|
||||
"title": "Athena cost per Tb scanned (USD)",
|
||||
"default": 5,
|
||||
},
|
||||
"result_reuse_enable": {
|
||||
"type": "boolean",
|
||||
"title": "Reuse Athena query results",
|
||||
},
|
||||
"result_reuse_minutes": {
|
||||
"type": "number",
|
||||
"title": "Minutes to reuse Athena query results",
|
||||
"default": 60,
|
||||
},
|
||||
},
|
||||
"required": ["region", "s3_staging_dir"],
|
||||
"extra_options": ["glue", "cost_per_tb"],
|
||||
"extra_options": ["glue", "catalog_ids", "cost_per_tb", "result_reuse_enable", "result_reuse_minutes"],
|
||||
"order": [
|
||||
"region",
|
||||
"s3_staging_dir",
|
||||
"schema",
|
||||
"work_group",
|
||||
"cost_per_tb",
|
||||
"result_reuse_enable",
|
||||
"result_reuse_minutes",
|
||||
],
|
||||
"secret": ["aws_secret_key"],
|
||||
}
|
||||
@@ -174,60 +187,53 @@ class Athena(BaseQueryRunner):
|
||||
"region_name": self.configuration["region"],
|
||||
}
|
||||
|
||||
def __get_schema_from_glue(self):
|
||||
def __get_schema_from_glue(self, catalog_id=""):
|
||||
client = boto3.client("glue", **self._get_iam_credentials())
|
||||
schema = {}
|
||||
|
||||
database_paginator = client.get_paginator("get_databases")
|
||||
table_paginator = client.get_paginator("get_tables")
|
||||
|
||||
for databases in database_paginator.paginate():
|
||||
databases_iterator = database_paginator.paginate(
|
||||
**({"CatalogId": catalog_id} if catalog_id != "" else {}),
|
||||
)
|
||||
|
||||
for databases in databases_iterator:
|
||||
for database in databases["DatabaseList"]:
|
||||
iterator = table_paginator.paginate(DatabaseName=database["Name"])
|
||||
iterator = table_paginator.paginate(
|
||||
DatabaseName=database["Name"],
|
||||
**({"CatalogId": catalog_id} if catalog_id != "" else {}),
|
||||
)
|
||||
for table in iterator.search("TableList[]"):
|
||||
table_name = "%s.%s" % (database["Name"], table["Name"])
|
||||
if "StorageDescriptor" not in table:
|
||||
logger.warning("Glue table doesn't have StorageDescriptor: %s", table_name)
|
||||
continue
|
||||
if table_name not in schema:
|
||||
columns = []
|
||||
for cols in table["StorageDescriptor"]["Columns"]:
|
||||
c = {
|
||||
"name": cols["Name"],
|
||||
}
|
||||
if "Type" in cols:
|
||||
c["type"] = cols["Type"]
|
||||
if "Comment" in cols:
|
||||
c["comment"] = cols["Comment"]
|
||||
columns.append(c)
|
||||
schema[table_name] = {"name": table_name, "columns": []}
|
||||
|
||||
schema[table_name] = {
|
||||
"name": table_name,
|
||||
"columns": columns,
|
||||
"description": table.get("Description"),
|
||||
for column_data in table["StorageDescriptor"]["Columns"]:
|
||||
column = {
|
||||
"name": column_data["Name"],
|
||||
"type": column_data["Type"] if "Type" in column_data else None,
|
||||
}
|
||||
for idx, partition in enumerate(table.get("PartitionKeys", [])):
|
||||
schema[table_name]["columns"].append(
|
||||
{
|
||||
"name": partition["Name"],
|
||||
"type": "partition",
|
||||
"idx": idx,
|
||||
}
|
||||
)
|
||||
if "Type" in partition:
|
||||
_type = partition["Type"]
|
||||
c["type"] = f"partition ({_type})"
|
||||
if "Comment" in partition:
|
||||
c["comment"] = partition["Comment"]
|
||||
schema[table_name]["columns"].append(column)
|
||||
for partition in table.get("PartitionKeys", []):
|
||||
partition_column = {
|
||||
"name": partition["Name"],
|
||||
"type": partition["Type"] if "Type" in partition else None,
|
||||
}
|
||||
schema[table_name]["columns"].append(partition_column)
|
||||
return list(schema.values())
|
||||
|
||||
def get_schema(self, get_stats=False):
|
||||
if self.configuration.get("glue", False):
|
||||
return self.__get_schema_from_glue()
|
||||
catalog_ids = [id.strip() for id in self.configuration.get("catalog_ids", "").split(",")]
|
||||
return sum([self.__get_schema_from_glue(catalog_id) for catalog_id in catalog_ids], [])
|
||||
|
||||
schema = {}
|
||||
query = """
|
||||
SELECT table_schema, table_name, column_name
|
||||
SELECT table_schema, table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema NOT IN ('information_schema')
|
||||
"""
|
||||
@@ -240,7 +246,7 @@ class Athena(BaseQueryRunner):
|
||||
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
|
||||
if table_name not in schema:
|
||||
schema[table_name] = {"name": table_name, "columns": []}
|
||||
schema[table_name]["columns"].append(row["column_name"])
|
||||
schema[table_name]["columns"].append({"name": row["column_name"], "type": row["data_type"]})
|
||||
|
||||
return list(schema.values())
|
||||
|
||||
@@ -252,7 +258,8 @@ class Athena(BaseQueryRunner):
|
||||
kms_key=self.configuration.get("kms_key", None),
|
||||
work_group=self.configuration.get("work_group", "primary"),
|
||||
formatter=SimpleFormatter(),
|
||||
cursor_class=PandasCursor,
|
||||
result_reuse_enable=self.configuration.get("result_reuse_enable", False),
|
||||
result_reuse_minutes=self.configuration.get("result_reuse_minutes", 60),
|
||||
**self._get_iam_credentials(user=user),
|
||||
).cursor()
|
||||
|
||||
@@ -260,8 +267,7 @@ class Athena(BaseQueryRunner):
|
||||
cursor.execute(query)
|
||||
column_tuples = [(i[0], _TYPE_MAPPINGS.get(i[1], None)) for i in cursor.description]
|
||||
columns = self.fetch_columns(column_tuples)
|
||||
df = cursor.as_pandas().replace({pd.NA: None})
|
||||
rows = df.to_dict(orient="records")
|
||||
rows = [dict(zip(([c["name"] for c in columns]), r)) for i, r in enumerate(cursor.fetchall())]
|
||||
qbytes = None
|
||||
athena_query_id = None
|
||||
try:
|
||||
|
||||
@@ -11,12 +11,12 @@ from redash.query_runner import (
|
||||
from redash.utils import json_loads
|
||||
|
||||
try:
|
||||
from azure.kusto.data.exceptions import KustoServiceError
|
||||
from azure.kusto.data.request import (
|
||||
from azure.kusto.data import (
|
||||
ClientRequestProperties,
|
||||
KustoClient,
|
||||
KustoConnectionStringBuilder,
|
||||
)
|
||||
from azure.kusto.data.exceptions import KustoServiceError
|
||||
|
||||
enabled = True
|
||||
except ImportError:
|
||||
@@ -37,6 +37,34 @@ TYPES_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _get_data_scanned(kusto_response):
|
||||
try:
|
||||
metadata_table = next(
|
||||
(table for table in kusto_response.tables if table.table_name == "QueryCompletionInformation"),
|
||||
None,
|
||||
)
|
||||
|
||||
if metadata_table:
|
||||
resource_usage_json = next(
|
||||
(row["Payload"] for row in metadata_table.rows if row["EventTypeName"] == "QueryResourceConsumption"),
|
||||
"{}",
|
||||
)
|
||||
resource_usage = json_loads(resource_usage_json).get("resource_usage", {})
|
||||
|
||||
data_scanned = (
|
||||
resource_usage["cache"]["shards"]["cold"]["hitbytes"]
|
||||
+ resource_usage["cache"]["shards"]["cold"]["missbytes"]
|
||||
+ resource_usage["cache"]["shards"]["hot"]["hitbytes"]
|
||||
+ resource_usage["cache"]["shards"]["hot"]["missbytes"]
|
||||
+ resource_usage["cache"]["shards"]["bypassbytes"]
|
||||
)
|
||||
|
||||
except Exception:
|
||||
data_scanned = 0
|
||||
|
||||
return int(data_scanned)
|
||||
|
||||
|
||||
class AzureKusto(BaseQueryRunner):
|
||||
should_annotate_query = False
|
||||
noop_query = "let noop = datatable (Noop:string)[1]; noop"
|
||||
@@ -44,8 +72,6 @@ class AzureKusto(BaseQueryRunner):
|
||||
def __init__(self, configuration):
|
||||
super(AzureKusto, self).__init__(configuration)
|
||||
self.syntax = "custom"
|
||||
self.client_request_properties = ClientRequestProperties()
|
||||
self.client_request_properties.application = "redash"
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls):
|
||||
@@ -60,12 +86,14 @@ class AzureKusto(BaseQueryRunner):
|
||||
},
|
||||
"azure_ad_tenant_id": {"type": "string", "title": "Azure AD Tenant Id"},
|
||||
"database": {"type": "string"},
|
||||
"msi": {"type": "boolean", "title": "Use Managed Service Identity"},
|
||||
"user_msi": {
|
||||
"type": "string",
|
||||
"title": "User-assigned managed identity client ID",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"cluster",
|
||||
"azure_ad_client_id",
|
||||
"azure_ad_client_secret",
|
||||
"azure_ad_tenant_id",
|
||||
"database",
|
||||
],
|
||||
"order": [
|
||||
@@ -91,18 +119,48 @@ class AzureKusto(BaseQueryRunner):
|
||||
return "Azure Data Explorer (Kusto)"
|
||||
|
||||
def run_query(self, query, user):
|
||||
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
|
||||
connection_string=self.configuration["cluster"],
|
||||
aad_app_id=self.configuration["azure_ad_client_id"],
|
||||
app_key=self.configuration["azure_ad_client_secret"],
|
||||
authority_id=self.configuration["azure_ad_tenant_id"],
|
||||
)
|
||||
cluster = self.configuration["cluster"]
|
||||
msi = self.configuration.get("msi", False)
|
||||
# Managed Service Identity(MSI)
|
||||
if msi:
|
||||
# If user-assigned managed identity is used, the client ID must be provided
|
||||
if self.configuration.get("user_msi"):
|
||||
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(
|
||||
cluster,
|
||||
client_id=self.configuration["user_msi"],
|
||||
)
|
||||
else:
|
||||
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(cluster)
|
||||
# Service Principal auth
|
||||
else:
|
||||
aad_app_id = self.configuration.get("azure_ad_client_id")
|
||||
app_key = self.configuration.get("azure_ad_client_secret")
|
||||
authority_id = self.configuration.get("azure_ad_tenant_id")
|
||||
|
||||
if not (aad_app_id and app_key and authority_id):
|
||||
raise ValueError(
|
||||
"Azure AD Client ID, Client Secret, and Tenant ID are required for Service Principal authentication."
|
||||
)
|
||||
|
||||
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
|
||||
connection_string=cluster,
|
||||
aad_app_id=aad_app_id,
|
||||
app_key=app_key,
|
||||
authority_id=authority_id,
|
||||
)
|
||||
|
||||
client = KustoClient(kcsb)
|
||||
|
||||
request_properties = ClientRequestProperties()
|
||||
request_properties.application = "redash"
|
||||
|
||||
if user:
|
||||
request_properties.user = user.email
|
||||
request_properties.set_option("request_description", user.email)
|
||||
|
||||
db = self.configuration["database"]
|
||||
try:
|
||||
response = client.execute(db, query, self.client_request_properties)
|
||||
response = client.execute(db, query, request_properties)
|
||||
|
||||
result_cols = response.primary_results[0].columns
|
||||
result_rows = response.primary_results[0].rows
|
||||
@@ -123,14 +181,15 @@ class AzureKusto(BaseQueryRunner):
|
||||
rows.append(row.to_dict())
|
||||
|
||||
error = None
|
||||
data = {"columns": columns, "rows": rows}
|
||||
data = {
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"metadata": {"data_scanned": _get_data_scanned(response)},
|
||||
}
|
||||
|
||||
except KustoServiceError as err:
|
||||
data = None
|
||||
try:
|
||||
error = err.args[1][0]["error"]["@message"]
|
||||
except (IndexError, KeyError):
|
||||
error = err.args[1]
|
||||
error = str(err)
|
||||
|
||||
return data, error
|
||||
|
||||
@@ -143,7 +202,10 @@ class AzureKusto(BaseQueryRunner):
|
||||
self._handle_run_query_error(error)
|
||||
|
||||
schema_as_json = json_loads(results["rows"][0]["DatabaseSchema"])
|
||||
tables_list = schema_as_json["Databases"][self.configuration["database"]]["Tables"].values()
|
||||
tables_list = [
|
||||
*(schema_as_json["Databases"][self.configuration["database"]]["Tables"].values()),
|
||||
*(schema_as_json["Databases"][self.configuration["database"]]["MaterializedViews"].values()),
|
||||
]
|
||||
|
||||
schema = {}
|
||||
|
||||
@@ -154,7 +216,9 @@ class AzureKusto(BaseQueryRunner):
|
||||
schema[table_name] = {"name": table_name, "columns": []}
|
||||
|
||||
for column in table["OrderedColumns"]:
|
||||
schema[table_name]["columns"].append(column["Name"])
|
||||
schema[table_name]["columns"].append(
|
||||
{"name": column["Name"], "type": TYPES_MAP.get(column["CslType"], None)}
|
||||
)
|
||||
|
||||
return list(schema.values())
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ from base64 import b64decode
|
||||
from redash import settings
|
||||
from redash.query_runner import (
|
||||
TYPE_BOOLEAN,
|
||||
TYPE_DATE,
|
||||
TYPE_DATETIME,
|
||||
TYPE_FLOAT,
|
||||
TYPE_INTEGER,
|
||||
TYPE_STRING,
|
||||
BaseQueryRunner,
|
||||
BaseSQLQueryRunner,
|
||||
InterruptException,
|
||||
JobTimeoutException,
|
||||
register,
|
||||
@@ -37,6 +38,8 @@ types_map = {
|
||||
"BOOLEAN": TYPE_BOOLEAN,
|
||||
"STRING": TYPE_STRING,
|
||||
"TIMESTAMP": TYPE_DATETIME,
|
||||
"DATETIME": TYPE_DATETIME,
|
||||
"DATE": TYPE_DATE,
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +86,7 @@ def _get_query_results(jobs, project_id, location, job_id, start_index):
|
||||
).execute()
|
||||
logging.debug("query_reply %s", query_reply)
|
||||
if not query_reply["jobComplete"]:
|
||||
time.sleep(10)
|
||||
time.sleep(1)
|
||||
return _get_query_results(jobs, project_id, location, job_id, start_index)
|
||||
|
||||
return query_reply
|
||||
@@ -95,7 +98,7 @@ def _get_total_bytes_processed_for_resp(bq_response):
|
||||
return int(bq_response.get("totalBytesProcessed", "0"))
|
||||
|
||||
|
||||
class BigQuery(BaseQueryRunner):
|
||||
class BigQuery(BaseSQLQueryRunner):
|
||||
noop_query = "SELECT 1"
|
||||
|
||||
def __init__(self, configuration):
|
||||
@@ -301,7 +304,7 @@ class BigQuery(BaseQueryRunner):
|
||||
datasets = self._get_project_datasets(project_id)
|
||||
|
||||
query_base = """
|
||||
SELECT table_schema, table_name, field_path
|
||||
SELECT table_schema, table_name, field_path, data_type
|
||||
FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
|
||||
WHERE table_schema NOT IN ('information_schema')
|
||||
"""
|
||||
@@ -322,7 +325,7 @@ class BigQuery(BaseQueryRunner):
|
||||
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
|
||||
if table_name not in schema:
|
||||
schema[table_name] = {"name": table_name, "columns": []}
|
||||
schema[table_name]["columns"].append(row["field_path"])
|
||||
schema[table_name]["columns"].append({"name": row["field_path"], "type": row["data_type"]})
|
||||
|
||||
return list(schema.values())
|
||||
|
||||
|
||||
@@ -91,8 +91,8 @@ class BaseElasticSearch(BaseQueryRunner):
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
self.server_url = self.configuration["server"]
|
||||
if self.server_url[-1] == "/":
|
||||
self.server_url = self.configuration.get("server", "")
|
||||
if self.server_url and self.server_url[-1] == "/":
|
||||
self.server_url = self.server_url[:-1]
|
||||
|
||||
basic_auth_user = self.configuration.get("basic_auth_user", None)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
@@ -64,6 +65,7 @@ class ElasticSearch2(BaseHTTPQueryRunner):
|
||||
return data, error
|
||||
|
||||
def _build_query(self, query: str) -> Tuple[dict, str, Optional[list]]:
|
||||
query = json.loads(query)
|
||||
index_name = query.pop("index", "")
|
||||
result_fields = query.pop("result_fields", None)
|
||||
url = "/{}/_search".format(index_name)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user