Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions
542c1c324c Snapshot: 24.01.0-dev 2024-01-01 01:37:58 +00:00
242 changed files with 6214 additions and 8912 deletions

View File

@@ -1,11 +1,11 @@
FROM cypress/browsers:node18.12.0-chrome106-ff106
FROM cypress/browsers:node16.18.0-chrome90-ff88
ENV APP /usr/src/app
WORKDIR $APP
COPY package.json yarn.lock .yarnrc $APP/
COPY viz-lib $APP/viz-lib
RUN npm install yarn@1.22.22 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null
RUN npm install yarn@1.22.19 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null
COPY . $APP

View File

@@ -1,3 +1,4 @@
version: '2.2'
services:
redash:
build: ../
@@ -18,7 +19,7 @@ services:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: pgautoupgrade/pgautoupgrade:latest
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped
environment:

View File

@@ -1,3 +1,4 @@
version: "2.2"
x-redash-service: &redash-service
build:
context: ../
@@ -66,7 +67,7 @@ services:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: pgautoupgrade/pgautoupgrade:latest
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped
environment:

View File

@@ -1,4 +1,5 @@
client/.tmp/
client/dist/
node_modules/
viz-lib/node_modules/
.tmp/

View File

@@ -4,23 +4,16 @@ on:
branches:
- master
pull_request:
branches:
- master
env:
NODE_VERSION: 18
YARN_VERSION: 1.22.22
NODE_VERSION: 16.20.1
jobs:
backend-lint:
runs-on: ubuntu-22.04
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-python@v5
- uses: actions/setup-python@v4
with:
python-version: '3.8'
- run: sudo pip install black==23.1.0 ruff==0.0.287
@@ -31,18 +24,14 @@ jobs:
runs-on: ubuntu-22.04
needs: backend-lint
env:
COMPOSE_FILE: .ci/compose.ci.yaml
COMPOSE_FILE: .ci/docker-compose.ci.yml
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
run: exit 1
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- name: Build Docker Images
run: |
set -x
@@ -60,17 +49,15 @@ jobs:
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
- name: Store Test Results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: backend-test-results
name: test-results
path: /tmp/test-results
- name: Store Coverage Results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: coverage
path: coverage.xml
@@ -78,47 +65,39 @@ jobs:
frontend-lint:
runs-on: ubuntu-22.04
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install Dependencies
run: |
npm install --global --force yarn@$YARN_VERSION
npm install --global --force yarn@1.22.19
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Run Lint
run: yarn lint:ci
- name: Store Test Results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: frontend-test-results
name: test-results
path: /tmp/test-results
frontend-unit-tests:
runs-on: ubuntu-22.04
needs: frontend-lint
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install Dependencies
run: |
npm install --global --force yarn@$YARN_VERSION
npm install --global --force yarn@1.22.19
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Run App Tests
run: yarn test
@@ -130,22 +109,18 @@ jobs:
runs-on: ubuntu-22.04
needs: frontend-lint
env:
COMPOSE_FILE: .ci/compose.cypress.yaml
COMPOSE_FILE: .ci/docker-compose.cypress.yml
COMPOSE_PROJECT_NAME: cypress
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
# PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
# CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
@@ -155,7 +130,7 @@ jobs:
echo "CODE_COVERAGE=true" >> "$GITHUB_ENV"
- name: Install Dependencies
run: |
npm install --global --force yarn@$YARN_VERSION
npm install --global --force yarn@1.22.19
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Setup Redash Server
run: |
@@ -171,7 +146,93 @@ jobs:
- name: Copy Code Coverage Results
run: docker cp cypress:/usr/src/app/coverage ./coverage || true
- name: Store Coverage Results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: coverage
path: coverage
build-skip-check:
runs-on: ubuntu-22.04
outputs:
skip: ${{ steps.skip-check.outputs.skip }}
steps:
- name: Skip?
id: skip-check
run: |
if [[ "${{ vars.DOCKER_USER }}" == '' ]]; then
echo 'Docker user is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
echo 'Docker password is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ github.ref_name }}" != 'master' ]]; then
echo 'Ref name is not `master`. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
else
echo 'Docker user and password are set and branch is `master`.'
echo 'Building + pushing `preview` image.'
echo skip=false >> "$GITHUB_OUTPUT"
fi
build-docker-image:
runs-on: ubuntu-22.04
needs:
- backend-unit-tests
- frontend-unit-tests
- frontend-e2e-tests
- build-skip-check
if: needs.build-skip-check.outputs.skip == 'false'
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 1
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install Dependencies
run: |
npm install --global --force yarn@1.22.19
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Set up QEMU
timeout-minutes: 1
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Bump version
id: version
run: |
set -x
.ci/update_version
VERSION=$(jq -r .version package.json)
VERSION_TAG="${VERSION}.b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}"
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
- name: Build and push preview image to Docker Hub
uses: docker/build-push-action@v4
with:
push: true
tags: |
redash/redash:preview
redash/preview:${{ steps.version.outputs.VERSION_TAG }}
context: .
build-args: |
test_all_deps=true
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
env:
DOCKER_CONTENT_TRUST: true
- name: "Failure: output container logs to console"
if: failure()
run: docker compose logs

View File

@@ -1,24 +1,11 @@
name: Periodic Snapshot
# 10 minutes after midnight on the first of every month
on:
schedule:
- cron: '10 0 1 * *' # 10 minutes after midnight on the first of every month
workflow_dispatch:
inputs:
bump:
description: 'Bump the last digit of the version'
required: false
type: boolean
version:
description: 'Specific version to set'
required: false
default: ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- cron: "10 0 1 * *"
permissions:
actions: write
contents: write
jobs:
@@ -26,60 +13,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.ACTION_PUSH_KEY }}
- run: |
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
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 add package.json redash/__init__.py pyproject.toml
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
git commit -m "Snapshot: ${date}"
git tag $date
git push --atomic origin master refs/tags/$date

View File

@@ -1,185 +0,0 @@
name: Preview Image
on:
push:
tags:
- '*-dev'
workflow_dispatch:
inputs:
dockerRepository:
description: 'Docker repository'
required: true
default: 'preview'
type: choice
options:
- preview
- redash
env:
NODE_VERSION: 18
jobs:
build-skip-check:
runs-on: ubuntu-22.04
outputs:
skip: ${{ steps.skip-check.outputs.skip }}
steps:
- name: Skip?
id: skip-check
run: |
if [[ "${{ vars.DOCKER_USER }}" == '' ]]; then
echo 'Docker user is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
echo 'Docker password is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ vars.DOCKER_REPOSITORY }}" == '' ]]; then
echo 'Docker repository is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
else
echo 'Docker user and password are set and branch is `master`.'
echo 'Building + pushing `preview` image.'
echo skip=false >> "$GITHUB_OUTPUT"
fi
build-docker-image:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
arch:
- amd64
- arm64
include:
- arch: amd64
os: ubuntu-22.04
- arch: arm64
os: ubuntu-22.04-arm
outputs:
VERSION_TAG: ${{ steps.version.outputs.VERSION_TAG }}
needs:
- build-skip-check
if: needs.build-skip-check.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.push.after }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: |
npm install --global --force yarn@1.22.22
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Set version
id: version
run: |
set -x
.ci/update_version
VERSION_TAG=$(jq -r .version package.json)
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
- name: Build and push preview image to Docker Hub
id: build-preview
uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
with:
tags: |
${{ vars.DOCKER_USER }}/redash
${{ vars.DOCKER_USER }}/preview
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env:
DOCKER_CONTENT_TRUST: true
- name: Build and push release image to Docker Hub
id: build-release
uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
with:
tags: |
${{ vars.DOCKER_USER }}/redash:${{ steps.version.outputs.VERSION_TAG }}
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env:
DOCKER_CONTENT_TRUST: true
- name: "Failure: output container logs to console"
if: failure()
run: docker compose logs
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
if [[ "${{ github.event.inputs.dockerRepository }}" == 'preview' || !github.event.workflow_run ]]; then
digest="${{ steps.build-preview.outputs.digest}}"
else
digest="${{ steps.build-release.outputs.digest}}"
fi
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
merge-docker-image:
runs-on: ubuntu-22.04
needs: build-docker-image
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Create and push manifest for the preview image
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:preview \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:preview@sha256:%s ' *)
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
- name: Create and push manifest for the release image
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)

View File

@@ -1,36 +0,0 @@
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
View File

@@ -17,7 +17,6 @@ client/dist
_build
.vscode
.env
.tool-versions
dump.rdb

1
.npmrc
View File

@@ -1 +0,0 @@
engine-strict = true

2
.nvmrc
View File

@@ -1 +1 @@
v18
v16.20.1

View File

@@ -38,9 +38,7 @@ request_review: author
#
# These can be used to tell other automation to avoid our PRs.
#
labels:
- restyled
- "Skip CI"
labels: ["Skip CI"]
# Labels to ignore
#
@@ -52,13 +50,13 @@ labels:
# Restylers to run, and how
restylers:
- name: black
image: restyled/restyler-black:v24.4.2
image: restyled/restyler-black:v19.10b0
include:
- redash
- tests
- migrations/versions
- name: prettier
image: restyled/restyler-prettier:v3.3.2-2
image: restyled/restyler-prettier:v1.19.1-2
command:
- prettier
- --write

View File

@@ -1,6 +1,6 @@
FROM node:18-bookworm AS frontend-builder
FROM node:16.20.1-bookworm as frontend-builder
RUN npm install --global --force yarn@1.22.22
RUN npm install --global --force yarn@1.22.19
# Controls whether to build the frontend assets
ARG skip_frontend_build
@@ -14,30 +14,18 @@ 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
# 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 <<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
RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
FROM python:3.10-slim-bookworm
FROM python:3.8-slim-bookworm
EXPOSE 5000
@@ -75,34 +63,28 @@ RUN apt-get update && \
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 <<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
RUN 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 msodbcsql17 \
&& 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
WORKDIR /app
ENV POETRY_VERSION=1.8.3
ENV POETRY_VERSION=1.6.1
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"

View File

@@ -1,14 +1,10 @@
.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
.PHONY: compose_build up test_db create_database clean down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash
compose_build: .env
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
up:
docker compose up -d redis postgres --remove-orphans
docker compose exec -u postgres postgres psql postgres --csv \
-1tqc "SELECT table_name FROM information_schema.tables WHERE table_name = 'organizations'" 2> /dev/null \
| grep -q "organizations" || make create_database
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build --remove-orphans
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build
test_db:
@for i in `seq 1 5`; do \
@@ -21,21 +17,7 @@ create_database: .env
docker compose run server create_db
clean:
docker compose down
docker compose --project-name cypress down
docker compose rm --stop --force
docker compose --project-name cypress rm --stop --force
docker image rm --force \
cypress-server:latest cypress-worker:latest cypress-scheduler:latest \
redash-server:latest redash-worker:latest redash-scheduler:latest
docker container prune --force
docker image prune --force
docker volume prune --force
clean-all: clean
docker image rm --force \
redash/redash:latest redis:7-alpine maildev/maildev:latest \
pgautoupgrade/pgautoupgrade:15-alpine3.8 pgautoupgrade/pgautoupgrade:latest
docker compose down && docker compose rm
down:
docker compose down

View File

@@ -84,7 +84,6 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
- Python
- Qubole
- Rockset
- RisingWave
- Salesforce
- ScyllaDB
- Shell Scripts

View File

@@ -67,7 +67,7 @@ help() {
echo ""
echo "shell -- open shell"
echo "dev_server -- start Flask development server with debugger and auto reload"
echo "debug -- start Flask development server with remote debugger via debugpy"
echo "debug -- start Flask development server with remote debugger via ptvsd"
echo "create_db -- create database tables"
echo "manage -- CLI to manage redash"
echo "tests -- run tests"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -22,7 +22,7 @@ function BeaconConsent() {
setHide(true);
};
const confirmConsent = (confirm) => {
const confirmConsent = confirm => {
let message = "🙏 Thank you.";
if (!confirm) {
@@ -47,8 +47,7 @@ function BeaconConsent() {
<HelpTrigger type="USAGE_DATA_SHARING" />
</>
}
bordered={false}
>
bordered={false}>
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
<div className="m-t-5">
<ul>
@@ -67,7 +66,8 @@ function BeaconConsent() {
</div>
<div className="m-t-15">
<Text type="secondary">
You can change this setting anytime from the <Link href="settings/general">Settings</Link> page.
You can change this setting anytime from the{" "}
<Link href="settings/organization">Organization Settings</Link> page.
</Text>
</div>
</Card>

View File

@@ -12,7 +12,6 @@ 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 } };
@@ -27,7 +26,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 }) {
@@ -55,7 +54,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>
);
}
@@ -72,8 +71,6 @@ 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;
@@ -117,17 +114,6 @@ 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}
@@ -143,17 +129,15 @@ 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}
@@ -162,16 +146,15 @@ 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>
@@ -197,26 +180,12 @@ 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>
)}
@@ -224,7 +193,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>
@@ -233,7 +202,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
@@ -245,8 +214,7 @@ function EditParameterSettingsDialog(props) {
: null,
})
}
data-test="AllowMultipleValuesCheckbox"
>
data-test="AllowMultipleValuesCheckbox">
Allow multiple values
</Checkbox>
</Form.Item>
@@ -259,11 +227,10 @@ 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: {
@@ -273,8 +240,7 @@ 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">

View File

@@ -1,3 +0,0 @@
.input-error {
border-color: red !important;
}

View File

@@ -101,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 });
@@ -116,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;
}
@@ -134,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();
@@ -144,7 +144,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
}
};
closeDrawer = (event) => {
closeDrawer = event => {
if (event) {
event.preventDefault();
}
@@ -161,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 (
@@ -180,15 +180,13 @@ 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>
@@ -199,8 +197,7 @@ 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 && (

View File

@@ -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,8 +226,7 @@ export class ParameterMappingInput extends React.Component {
enumOptions={mapping.param.enumOptions}
queryId={mapping.param.queryId}
parameter={mapping.param}
onSelect={(value) => this.updateParamMapping({ value })}
regex={mapping.param.regex}
onSelect={value => this.updateParamMapping({ value })}
/>
);
}
@@ -285,12 +284,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) {
@@ -352,8 +351,7 @@ 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>
@@ -378,14 +376,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 });
};
@@ -462,8 +460,7 @@ 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>
@@ -511,7 +508,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
@@ -577,7 +574,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">
@@ -586,11 +583,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)}
/>
)}
/>
@@ -599,19 +596,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

View File

@@ -9,12 +9,11 @@ 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 {
@@ -26,7 +25,6 @@ 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 = {
@@ -37,7 +35,6 @@ class ParameterValueInput extends React.Component {
parameter: null,
onSelect: () => {},
className: "",
regex: "",
};
constructor(props) {
@@ -48,7 +45,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) {
@@ -59,7 +56,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);
@@ -96,9 +93,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
@@ -106,7 +103,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}
@@ -136,36 +133,18 @@ 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;
@@ -176,7 +155,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)}
/>
);
}
@@ -198,8 +177,6 @@ class ParameterValueInput extends React.Component {
return this.renderQueryBasedInput();
case "number":
return this.renderNumberInput();
case "text-pattern":
return this.renderTextPatternInput();
default:
return this.renderTextInput();
}

View File

@@ -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,8 +149,7 @@ 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>
)}
@@ -163,7 +162,6 @@ 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>
);
@@ -180,22 +178,20 @@ 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>

View File

@@ -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}>

View File

@@ -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&apos;s get started</h4>
<ol>{stepsItems.map((item) => item.node)}</ol>
<ol>{stepsItems.map(item => item.node)}</ol>
{helpMessage}
</div>
</div>

View File

@@ -28,7 +28,6 @@ export interface Controller<I, P = any> {
orderByField?: string;
orderByReverse: boolean;
toggleSorting: (orderByField: string) => void;
setSorting: (orderByField: string, orderByReverse: boolean) => void;
// pagination
page: number;
@@ -140,11 +139,10 @@ export function wrap<I, P = any>(
this.props.onError!(error);
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
const { updatePagination, toggleSorting, setSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
const { updatePagination, toggleSorting, 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

View File

@@ -39,12 +39,14 @@ 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)
@@ -57,7 +59,7 @@ export class ItemsSource {
return this._afterUpdate();
}
})
.catch((error) => this.handleError(error));
.catch(error => this.handleError(error));
});
}
@@ -122,20 +124,13 @@ export class ItemsSource {
});
};
toggleSorting = (orderByField) => {
toggleSorting = orderByField => {
this._sorter.toggleField(orderByField);
this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true });
};
setSorting = (orderByField, orderByReverse) => {
this._sorter.setField(orderByField);
this._sorter.setReverse(orderByReverse);
this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true });
};
updateSearch = (searchTerm) => {
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
@@ -150,7 +145,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 } });
@@ -158,7 +153,7 @@ export class ItemsSource {
update = () => this._changed();
handleError = (error) => {
handleError = error => {
if (isFunction(this.onError)) {
this.onError(error);
}
@@ -177,7 +172,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));
},
});
}

View File

@@ -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,7 +110,6 @@ 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]),
};
@@ -128,15 +127,18 @@ export default class ItemsTable extends React.Component {
};
prepareColumns() {
const { orderByField, orderByReverse } = this.props;
const { orderByField, orderByReverse, toggleSorting } = 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;
@@ -144,13 +146,14 @@ 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)) {
@@ -169,43 +172,22 @@ 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 };
}
@@ -218,7 +200,6 @@ export default class ItemsTable extends React.Component {
rowKey={this.getRowKey}
pagination={false}
onRow={onTableRow}
onChange={onChange}
data-test={this.props["data-test"]}
{...tableDataProps}
/>

View File

@@ -65,7 +65,6 @@ 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,
@@ -84,7 +83,6 @@ 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,

View File

@@ -148,9 +148,7 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
function dismiss() {
const optionsChanged = !isEqual(options, defaultState.originalOptions);
confirmDialogClose(nameChanged || optionsChanged)
.then(dialog.dismiss)
.catch(() => {});
confirmDialogClose(nameChanged || optionsChanged).then(dialog.dismiss);
}
// When editing existing visualization chart type selector is disabled, so add only existing visualization's

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" />

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<base href="{{base_href}}" />
<title><%= htmlWebpackPlugin.options.title %></title>
<script src="<%= htmlWebpackPlugin.options.staticPath %>unsupportedRedirect.js" async></script>
<script src="/static/unsupportedRedirect.js" async></script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />

View File

@@ -16,7 +16,6 @@ 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,
@@ -65,7 +64,6 @@ class Alert extends React.Component {
this.setState({
alert: {
options: {
selector: "first",
op: ">",
value: 1,
muted: false,
@@ -77,7 +75,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);
@@ -95,7 +93,7 @@ class Alert extends React.Component {
this.onQuerySelected(alert.query);
}
})
.catch((error) => {
.catch(error => {
if (this._isMounted) {
this.props.onError(error);
}
@@ -114,7 +112,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 });
@@ -124,7 +122,7 @@ class Alert extends React.Component {
});
};
onQuerySelected = (query) => {
onQuerySelected = query => {
this.setState(({ alert }) => ({
alert: Object.assign(alert, { query }),
queryResult: null,
@@ -132,7 +130,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;
@@ -148,18 +146,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({
@@ -179,17 +177,6 @@ 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)
@@ -236,14 +223,7 @@ 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}
evaluate={this.evaluate}
/>
<MenuButton doDelete={this.delete} muted={muted} mute={this.mute} unmute={this.unmute} canEdit={canEdit} />
);
const commonProps = {
@@ -278,7 +258,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(
@@ -286,7 +266,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(
@@ -294,6 +274,6 @@ routes.register(
routeWithUserSession({
path: "/alerts/:alertId/edit",
title: "Alert",
render: (pageProps) => <Alert {...pageProps} mode={MODES.EDIT} />,
render: pageProps => <Alert {...pageProps} mode={MODES.EDIT} />,
})
);

View File

@@ -68,23 +68,13 @@ export default class AlertView extends React.Component {
<>
<Title name={name} alert={alert}>
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
{canEdit ? (
<>
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
<i className="fa fa-edit m-r-5" aria-hidden="true" />
Edit
</Button>
{menuButton}
</>
) : (
<Tooltip title="You do not have sufficient permissions to edit this alert">
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
<i className="fa fa-edit m-r-5" aria-hidden="true" />
Edit
</Button>
{menuButton}
</Tooltip>
)}
<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>
</Title>
<div className="bg-white tiled p-20">
<Grid.Row type="flex" gutter={16}>

View File

@@ -54,74 +54,23 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
return null;
})();
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>
);
}
const columnHint = (
<small className="alert-criteria-hint">
Top row value is <code className="p-0">{toString(columnValue) || "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>
@@ -134,11 +83,10 @@ 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>
@@ -177,7 +125,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>

View File

@@ -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, evaluate, muted }) {
export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
const [loading, setLoading] = useState(false);
const execute = useCallback(action => {
@@ -55,9 +55,6 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, evaluate,
<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">
@@ -72,7 +69,6 @@ MenuButton.propTypes = {
canEdit: PropTypes.bool.isRequired,
mute: PropTypes.func.isRequired,
unmute: PropTypes.func.isRequired,
evaluate: PropTypes.func.isRequired,
muted: PropTypes.bool,
};

View File

@@ -118,9 +118,28 @@ class ShareDashboardDialog extends React.Component {
/>
</Form.Item>
{dashboard.public_url && (
<Form.Item label="Secret address" {...this.formItemProps}>
<InputWithCopy value={dashboard.public_url} data-test="SecretAddress" />
</Form.Item>
<>
<Form.Item>
<Alert
message={
<div>
Custom rule for hiding filter components when sharing links:
<br />
You can hide filter components by appending `&hide_filter={"{{"} component_name{"}}"}` to the
sharing URL.
<br />
Example: http://{"{{"}ip{"}}"}:{"{{"}port{"}}"}/public/dashboards/{"{{"}id{"}}"}
?p_country=ghana&p_site=10&hide_filter=country
</div>
}
type="warning"
/>
</Form.Item>
<Form.Item label="Secret address" {...this.formItemProps}>
<InputWithCopy value={dashboard.public_url} data-test="SecretAddress" />
</Form.Item>
</>
)}
</Form>
</Modal>

View File

@@ -31,8 +31,7 @@ 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>
.
@@ -44,7 +43,7 @@ function DeprecatedEmbedFeatureAlert() {
function EmailNotVerifiedAlert() {
const verifyEmail = () => {
axios.post("verification_email/").then((data) => {
axios.post("verification_email/").then(data => {
notification.success(data.message);
});
};
@@ -101,6 +100,6 @@ routes.register(
routeWithUserSession({
path: "/",
title: "Redash",
render: (pageProps) => <Home {...pageProps} />,
render: pageProps => <Home {...pageProps} />,
})
);

View File

@@ -160,15 +160,14 @@ 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>
@@ -197,7 +196,7 @@ const QueriesListPage = itemsList(
}[currentPage];
},
getItemProcessor() {
return (item) => new Query(item);
return item => new Query(item);
},
}),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
@@ -208,7 +207,7 @@ routes.register(
routeWithUserSession({
path: "/queries",
title: "Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="all" />,
render: pageProps => <QueriesListPage {...pageProps} currentPage="all" />,
})
);
routes.register(
@@ -216,7 +215,7 @@ routes.register(
routeWithUserSession({
path: "/queries/favorites",
title: "Favorite Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" />,
render: pageProps => <QueriesListPage {...pageProps} currentPage="favorites" />,
})
);
routes.register(
@@ -224,7 +223,7 @@ routes.register(
routeWithUserSession({
path: "/queries/archive",
title: "Archived Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="archive" />,
render: pageProps => <QueriesListPage {...pageProps} currentPage="archive" />,
})
);
routes.register(
@@ -232,6 +231,6 @@ routes.register(
routeWithUserSession({
path: "/queries/my",
title: "My Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="my" />,
render: pageProps => <QueriesListPage {...pageProps} currentPage="my" />,
})
);

View File

@@ -9,7 +9,6 @@ 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";
@@ -52,8 +51,7 @@ 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>
@@ -69,9 +67,10 @@ export default function QueryExecutionMetadata({
)}
{isQueryExecuting && <span>Running&hellip;</span>}
</span>
{!isUndefined(queryResultData.metadata.data_scanned) && !isQueryExecuting && (
{queryResultData.metadata.data_scanned && (
<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>

View File

@@ -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 = {

View File

@@ -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,8 +111,7 @@ 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>
@@ -120,7 +119,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([]);
@@ -133,10 +132,9 @@ 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={
@@ -146,8 +144,7 @@ export default function QueryVisualizationTabs({
visualizationName={visualization.name}
onDelete={() => onDeleteVisualization(visualization.id)}
/>
}
>
}>
{queryResult ? (
<VisualizationRenderer
visualization={visualization}

View File

@@ -1,11 +1,16 @@
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 = true;
const isAvailable = useMemo(() => calculateTokensCount(schema) <= 5000, [schema]);
const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
const toggleAutocomplete = useCallback((state) => {
const toggleAutocomplete = useCallback(state => {
setIsEnabled(state);
localOptions.set("liveAutocomplete", state);
}, []);

View File

@@ -17,16 +17,14 @@ export default function BeaconConsentSettings(props) {
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 })}
>
onChange={e => onChange({ beacon_consent: e.target.checked })}>
Help Redash improve by automatically sending anonymous usage data
</Checkbox>
)}

View File

@@ -36,7 +36,6 @@ 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;

View File

@@ -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) })));
},
};

View File

@@ -61,7 +61,7 @@ class DateParameter extends Parameter {
return value;
}
const normalizedValue = moment(value, moment.ISO_8601, true);
const normalizedValue = moment(value);
return normalizedValue.isValid() ? normalizedValue : null;
}

View File

@@ -1,29 +0,0 @@
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;

View File

@@ -5,7 +5,6 @@ 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) {
@@ -23,8 +22,6 @@ 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);
}
@@ -37,7 +34,6 @@ function cloneParameter(param) {
export {
Parameter,
TextParameter,
TextPatternParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,

View File

@@ -1,7 +1,6 @@
import {
createParameter,
TextParameter,
TextPatternParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,
@@ -13,7 +12,6 @@ describe("Parameter", () => {
describe("create", () => {
const parameterTypes = [
["text", TextParameter],
["text-pattern", TextPatternParameter],
["number", NumberParameter],
["enum", EnumParameter],
["query", QueryBasedDropdownParameter],

View File

@@ -1,21 +0,0 @@
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();
});
});
});

View File

@@ -114,7 +114,7 @@ export function fetchDataFromJob(jobId, interval = 1000) {
}
export function isDateTime(v) {
return isString(v) && moment(v, moment.ISO_8601, true).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v);
return isString(v) && moment(v).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v);
}
class QueryResult {

View File

@@ -1,5 +1,6 @@
/* eslint-disable import/no-extraneous-dependencies, no-console */
const { find } = require("lodash");
const atob = require("atob");
const { execSync } = require("child_process");
const { get, post } = require("request").defaults({ jar: true });
const { seedData } = require("./seed-data");
@@ -59,11 +60,23 @@ function stopServer() {
function runCypressCI() {
const {
PERCY_TOKEN_ENCODED,
CYPRESS_PROJECT_ID_ENCODED,
CYPRESS_RECORD_KEY_ENCODED,
GITHUB_REPOSITORY,
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
} = process.env;
if (GITHUB_REPOSITORY === "getredash/redash" && process.env.CYPRESS_RECORD_KEY) {
if (GITHUB_REPOSITORY === "getredash/redash") {
if (PERCY_TOKEN_ENCODED) {
process.env.PERCY_TOKEN = atob(`${PERCY_TOKEN_ENCODED}`);
}
if (CYPRESS_PROJECT_ID_ENCODED) {
process.env.CYPRESS_PROJECT_ID = atob(`${CYPRESS_PROJECT_ID_ENCODED}`);
}
if (CYPRESS_RECORD_KEY_ENCODED) {
process.env.CYPRESS_RECORD_KEY = atob(`${CYPRESS_RECORD_KEY_ENCODED}`);
}
process.env.CYPRESS_OPTIONS = "--record";
}

View File

@@ -2,14 +2,16 @@ 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"));
});
@@ -17,7 +19,7 @@ describe("Parameter", () => {
cy.getByTestId("ParameterName-test-parameter")
.find(".parameter-input")
.should(($el) => {
.should($el => {
assert.isTrue($el.data("dirty"));
});
};
@@ -40,7 +42,9 @@ 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();
@@ -49,66 +53,13 @@ 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 = {
@@ -123,13 +74,17 @@ 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();
@@ -138,7 +93,9 @@ 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");
});
});
});
@@ -162,7 +119,10 @@ 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
@@ -180,10 +140,12 @@ 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();
}
@@ -198,7 +160,9 @@ 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();
});
@@ -212,7 +176,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",
@@ -244,7 +208,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",
@@ -270,7 +234,10 @@ 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
@@ -288,10 +255,12 @@ 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();
});
@@ -305,10 +274,14 @@ 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", () => {
@@ -330,10 +303,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();
@@ -341,10 +314,12 @@ 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();
@@ -375,11 +350,14 @@ 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");
@@ -390,20 +368,27 @@ 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();
@@ -412,20 +397,31 @@ 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(() => {
@@ -446,10 +442,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();
@@ -461,10 +457,12 @@ 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();
@@ -481,10 +479,15 @@ 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");
@@ -520,7 +523,10 @@ 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");
@@ -530,13 +536,21 @@ 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', () => {
@@ -546,13 +560,16 @@ 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();
@@ -577,12 +594,15 @@ 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");

View File

@@ -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,34 +107,4 @@ 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();
});
});

View File

@@ -22,7 +22,10 @@ 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 }));
});
@@ -50,7 +53,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;
@@ -58,22 +61,39 @@ 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] });
});
});
@@ -81,7 +101,10 @@ 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] });
});
});

View File

@@ -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,7 +86,6 @@ 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,
@@ -110,7 +109,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);
@@ -147,7 +146,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");
@@ -167,6 +166,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);
});

View File

@@ -3,26 +3,36 @@
* @param should Passed to should expression after plot points are captured
*/
export function assertPlotPreview(should = "exist") {
cy.getByTestId("VisualizationPreview").find("g.overplot").should("exist").find("g.points").should(should);
cy.getByTestId("VisualizationPreview")
.find("g.plot")
.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 });
});
@@ -32,13 +42,19 @@ 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();
@@ -47,29 +63,39 @@ 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]);
});

View File

@@ -1,10 +1,12 @@
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));
}

View File

@@ -1,5 +1,6 @@
# This configuration file is for the **development** setup.
# For a production example please refer to getredash/setup repository on GitHub.
version: "2.2"
x-redash-service: &redash-service
build:
context: .
@@ -10,7 +11,6 @@ x-redash-service: &redash-service
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"
@@ -53,7 +53,7 @@ services:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: pgautoupgrade/pgautoupgrade:latest
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
ports:
- "15432:5432"
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3

View File

@@ -7,7 +7,7 @@ Create Date: 2020-12-23 21:35:32.766354
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0ec979123ba4'
@@ -18,7 +18,7 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('dashboards', sa.Column('options', JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
op.add_column('dashboards', sa.Column('options', postgresql.JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
# ### end Alembic commands ###

View File

@@ -10,7 +10,8 @@ import json
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table
from redash.models import MutableDict
from redash.models import MutableDict, PseudoJSON
# revision identifiers, used by Alembic.
@@ -40,7 +41,7 @@ def upgrade():
"queries",
sa.Column(
"schedule",
sa.Text(),
MutableDict.as_mutable(PseudoJSON),
nullable=False,
server_default=json.dumps({}),
),
@@ -50,7 +51,7 @@ def upgrade():
queries = table(
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", sa.Text()),
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
sa.Column("old_schedule", sa.String(length=10)),
)
@@ -84,7 +85,7 @@ def downgrade():
"queries",
sa.Column(
"old_schedule",
sa.Text(),
MutableDict.as_mutable(PseudoJSON),
nullable=False,
server_default=json.dumps({}),
),
@@ -92,8 +93,8 @@ def downgrade():
queries = table(
"queries",
sa.Column("schedule", sa.Text()),
sa.Column("old_schedule", sa.Text()),
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
sa.Column("old_schedule", MutableDict.as_mutable(PseudoJSON)),
)
op.execute(queries.update().values({"old_schedule": queries.c.schedule}))
@@ -105,7 +106,7 @@ def downgrade():
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", sa.String(length=10)),
sa.Column("old_schedule", sa.Text()),
sa.Column("old_schedule", MutableDict.as_mutable(PseudoJSON)),
)
conn = op.get_bind()

View File

@@ -1,135 +0,0 @@
"""change type of json fields from varchar to json
Revision ID: 7205816877ec
Revises: 7ce5925f832b
Create Date: 2024-01-03 13:55:18.885021
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, JSON
# revision identifiers, used by Alembic.
revision = '7205816877ec'
down_revision = '7ce5925f832b'
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
op.alter_column('queries', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='options::jsonb',
)
op.alter_column('queries', 'schedule',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='schedule::jsonb',
)
op.alter_column('events', 'additional_properties',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='additional_properties::jsonb',
)
op.alter_column('organizations', 'settings',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='settings::jsonb',
)
op.alter_column('alerts', 'options',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='options::jsonb',
)
op.alter_column('dashboards', 'options',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
op.alter_column('dashboards', 'layout',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='layout::jsonb',
)
op.alter_column('changes', 'change',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='change::jsonb',
)
op.alter_column('visualizations', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
op.alter_column('widgets', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
def downgrade():
connection = op.get_bind()
op.alter_column('queries', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='options::text',
existing_nullable=True,
)
op.alter_column('queries', 'schedule',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='schedule::text',
existing_nullable=True,
)
op.alter_column('events', 'additional_properties',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='additional_properties::text',
existing_nullable=True,
)
op.alter_column('organizations', 'settings',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='settings::text',
existing_nullable=True,
)
op.alter_column('alerts', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='options::json',
existing_nullable=True,
)
op.alter_column('dashboards', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='options::json',
)
op.alter_column('dashboards', 'layout',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='layout::text',
)
op.alter_column('changes', 'change',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='change::json',
)
op.alter_column('visualizations', 'options',
type_=sa.Text(),
existing_type=JSONB(astext_type=sa.Text()),
postgresql_using='options::text',
)
op.alter_column('widgets', 'options',
type_=sa.Text(),
existing_type=JSONB(astext_type=sa.Text()),
postgresql_using='options::text',
)

View File

@@ -7,9 +7,10 @@ Create Date: 2019-01-17 13:22:21.729334
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql import table
from redash.models import MutableDict
from redash.models import MutableDict, PseudoJSON
# revision identifiers, used by Alembic.
revision = "73beceabb948"
@@ -42,7 +43,7 @@ def upgrade():
queries = table(
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", sa.Text()),
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
)
conn = op.get_bind()

View File

@@ -6,7 +6,7 @@ Create Date: 2018-01-31 15:20:30.396533
"""
import json
import simplejson
from alembic import op
import sqlalchemy as sa
@@ -27,7 +27,7 @@ def upgrade():
dashboard_result = db.session.execute("SELECT id, layout FROM dashboards")
for dashboard in dashboard_result:
print(" Updating dashboard: {}".format(dashboard["id"]))
layout = json.loads(dashboard["layout"])
layout = simplejson.loads(dashboard["layout"])
print(" Building widgets map:")
widgets = {}
@@ -53,7 +53,7 @@ def upgrade():
if widget is None:
continue
options = json.loads(widget["options"]) or {}
options = simplejson.loads(widget["options"]) or {}
options["position"] = {
"row": row_index,
"col": column_index * column_size,
@@ -62,7 +62,7 @@ def upgrade():
db.session.execute(
"UPDATE widgets SET options=:options WHERE id=:id",
{"options": json.dumps(options), "id": widget_id},
{"options": simplejson.dumps(options), "id": widget_id},
)
dashboard_result.close()

View File

@@ -7,7 +7,7 @@ Create Date: 2019-01-31 09:21:31.517265
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import BYTEA
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql import table
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
@@ -18,6 +18,7 @@ from redash.models.types import (
Configuration,
MutableDict,
MutableList,
PseudoJSON,
)
# revision identifiers, used by Alembic.
@@ -30,7 +31,7 @@ depends_on = None
def upgrade():
op.add_column(
"data_sources",
sa.Column("encrypted_options", BYTEA(), nullable=True),
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True),
)
# copy values

View File

@@ -1,64 +0,0 @@
"""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

View File

@@ -9,7 +9,7 @@ import re
from funcy import flatten, compact
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects import postgresql
from redash import models
# revision identifiers, used by Alembic.
@@ -21,10 +21,10 @@ depends_on = None
def upgrade():
op.add_column(
"dashboards", sa.Column("tags", ARRAY(sa.Unicode()), nullable=True)
"dashboards", sa.Column("tags", postgresql.ARRAY(sa.Unicode()), nullable=True)
)
op.add_column(
"queries", sa.Column("tags", ARRAY(sa.Unicode()), nullable=True)
"queries", sa.Column("tags", postgresql.ARRAY(sa.Unicode()), nullable=True)
)

View File

@@ -7,7 +7,7 @@ Create Date: 2020-12-14 21:42:48.661684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import BYTEA
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql import table
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
@@ -30,7 +30,7 @@ depends_on = None
def upgrade():
op.add_column(
"notification_destinations",
sa.Column("encrypted_options", BYTEA(), nullable=True)
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True)
)
# copy values

View File

@@ -7,7 +7,7 @@ Create Date: 2018-11-08 16:12:17.023569
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "e7f8a917aa8e"
@@ -21,7 +21,7 @@ def upgrade():
"users",
sa.Column(
"details",
JSON(astext_type=sa.Text()),
postgresql.JSON(astext_type=sa.Text()),
server_default="{}",
nullable=True,
),

View File

@@ -7,7 +7,7 @@ Create Date: 2022-01-31 15:24:16.507888
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON, JSONB
from sqlalchemy.dialects import postgresql
from redash.models import db
@@ -23,12 +23,12 @@ def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'details',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
existing_type=postgresql.JSON(astext_type=sa.Text()),
type_=postgresql.JSONB(astext_type=sa.Text()),
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
@@ -52,8 +52,8 @@ def downgrade():
connection.execute(update_query)
db.session.commit()
op.alter_column('users', 'details',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
existing_type=postgresql.JSONB(astext_type=sa.Text()),
type_=postgresql.JSON(astext_type=sa.Text()),
existing_nullable=True,
existing_server_default=sa.text("'{}'::json"))

View File

@@ -6,7 +6,7 @@
command = "cd ../ && yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 && yarn build && cd ./client"
[build.environment]
NODE_VERSION = "18"
NODE_VERSION = "16.20.1"
NETLIFY_USE_YARN = "true"
YARN_VERSION = "1.22.19"
CYPRESS_INSTALL_BINARY = "0"

View File

@@ -1,19 +1,20 @@
{
"name": "redash-client",
"version": "25.05.0-dev",
"version": "24.01.0-dev",
"description": "The frontend part of Redash.",
"main": "index.js",
"scripts": {
"start": "npm-run-all --parallel watch:viz webpack-dev-server",
"clean": "rm -rf ./client/dist/",
"build:viz": "(cd viz-lib && yarn build:babel)",
"build": "yarn clean && yarn build:viz && NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack",
"watch:app": "NODE_OPTIONS=--openssl-legacy-provider webpack watch --progress",
"build": "yarn clean && yarn build:viz && NODE_ENV=production webpack",
"build:old-node-version": "yarn clean && NODE_ENV=production node --max-old-space-size=4096 node_modules/.bin/webpack",
"watch:app": "webpack watch --progress",
"watch:viz": "(cd viz-lib && yarn watch:babel)",
"watch": "npm-run-all --parallel watch:*",
"webpack-dev-server": "webpack-dev-server",
"analyze": "yarn clean && BUNDLE_ANALYZER=on NODE_OPTIONS=--openssl-legacy-provider webpack",
"analyze:build": "yarn clean && NODE_ENV=production BUNDLE_ANALYZER=on NODE_OPTIONS=--openssl-legacy-provider webpack",
"analyze": "yarn clean && BUNDLE_ANALYZER=on webpack",
"analyze:build": "yarn clean && NODE_ENV=production BUNDLE_ANALYZER=on webpack",
"lint": "yarn lint:base --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
"lint:fix": "yarn lint:base --fix --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
"lint:base": "eslint --config ./client/.eslintrc.js --ignore-path ./client/.eslintignore",
@@ -33,8 +34,7 @@
"url": "git+https://github.com/getredash/redash.git"
},
"engines": {
"node": ">16.0 <21.0",
"npm": "please-use-yarn",
"node": ">14.16.0 <17.0.0",
"yarn": "^1.22.10"
},
"author": "Redash Contributors",
@@ -50,12 +50,11 @@
"antd": "^4.4.3",
"axios": "0.27.2",
"axios-auth-refresh": "3.3.6",
"bootstrap": "^3.4.1",
"bootstrap": "^3.3.7",
"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",
@@ -64,7 +63,7 @@
"mousetrap": "^1.6.1",
"mustache": "^2.3.0",
"numeral": "^2.0.6",
"path-to-regexp": "^3.3.0",
"path-to-regexp": "^3.1.0",
"prop-types": "^15.6.1",
"query-string": "^6.9.0",
"react": "16.14.0",
@@ -179,10 +178,6 @@
"viz-lib/**"
]
},
"browser": {
"fs": false,
"path": false
},
"//": "browserslist set to 'Async functions' compatibility",
"browserslist": [
"Edge >= 15",

1887
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ force-exclude = '''
[tool.poetry]
name = "redash"
version = "25.05.0-dev"
version = "24.01.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 = "43.0.1"
cryptography = "41.0.6"
disposable-email-domains = ">=0.0.52"
flask = "2.3.2"
flask-limiter = "3.3.1"
@@ -43,10 +43,10 @@ flask-wtf = "1.1.1"
funcy = "1.13"
gevent = "23.9.1"
greenlet = "2.0.2"
gunicorn = "22.0.0"
gunicorn = "20.0.4"
httplib2 = "0.19.0"
itsdangerous = "2.1.2"
jinja2 = "3.1.5"
jinja2 = "3.1.2"
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 = "24.2.1"
pyopenssl = "23.2.0"
pypd = "1.1.0"
pysaml2 = "7.3.1"
pystache = "0.6.0"
@@ -64,31 +64,28 @@ pytz = ">=2019.3"
pyyaml = "6.0.1"
redis = "4.6.0"
regex = "2023.8.8"
requests = "2.32.3"
restrictedpython = "7.3"
rq = "1.16.1"
rq-scheduler = "0.13.1"
requests = "2.31.0"
restrictedpython = "6.2"
rq = "1.9.0"
rq-scheduler = "0.11.0"
semver = "2.8.1"
sentry-sdk = "1.45.1"
sentry-sdk = "1.28.1"
simplejson = "3.16.0"
sqlalchemy = "1.3.24"
sqlalchemy-searchable = "1.2.0"
sqlalchemy-utils = "0.38.3"
sqlparse = "0.5.0"
sqlalchemy-utils = "0.34.2"
sqlparse = "0.4.4"
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.19"
urllib3 = "1.26.18"
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
@@ -114,25 +111,27 @@ nzalchemy = "^11.0.2"
nzpy = ">=1.15"
oauth2client = "4.1.3"
openpyxl = "3.0.7"
oracledb = "1.4.0"
pandas = "1.3.4"
phoenixdb = "0.7"
pinotdb = ">=0.4.5"
protobuf = "3.20.2"
pyathena = "2.25.2"
pyathena = ">=1.5.0,<=1.11.5"
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.3.1"
pyodbc = "5.1.0"
pymongo = { version = "4.3.3", extras = ["srv", "tls"] }
pymssql = "2.2.8"
pyodbc = "4.0.28"
python-arango = "6.1.0"
python-rapidjson = "1.20"
python-rapidjson = "1.1.0"
qds-sdk = ">=1.9.6"
requests-aws-sign = "0.1.5"
sasl = ">=0.1.3"
simple-salesforce = "0.74.3"
snowflake-connector-python = "3.12.3"
snowflake-connector-python = "3.4.0"
td-client = "1.0.0"
thrift = ">=0.8.0"
thrift-sasl = ">=0.1.0"
@@ -154,10 +153,11 @@ optional = true
pytest = "7.4.0"
coverage = "7.2.7"
freezegun = "1.2.1"
jwcrypto = "1.5.6"
jwcrypto = "1.5.1"
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"
@@ -169,7 +169,7 @@ build-backend = "poetry.core.masonry.api"
[tool.ruff]
exclude = [".git", "viz-lib", "node_modules", "migrations"]
ignore = ["E501"]
select = ["C9", "E", "F", "W", "I001", "UP004"]
select = ["C9", "E", "F", "W", "I001"]
[tool.ruff.mccabe]
max-complexity = 15

View File

@@ -14,14 +14,13 @@ from redash.app import create_app # noqa
from redash.destinations import import_destinations
from redash.query_runner import import_query_runners
__version__ = "25.05.0-dev"
__version__ = "24.01.0-dev"
if os.environ.get("REMOTE_DEBUG"):
import debugpy
import ptvsd
debugpy.listen(("0.0.0.0", 5678))
debugpy.wait_for_client()
ptvsd.enable_attach(address=("0.0.0.0", 5678))
def setup_logging():

View File

@@ -1,8 +1,8 @@
import json
import logging
import jwt
import requests
import simplejson
logger = logging.getLogger("jwt_auth")
@@ -25,7 +25,7 @@ def get_public_key_from_net(url):
if "keys" in data:
public_keys = []
for key_dict in data["keys"]:
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_dict))
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(simplejson.dumps(key_dict))
public_keys.append(public_key)
get_public_keys.key_cache[url] = public_keys

View File

@@ -8,7 +8,6 @@ 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(
@@ -70,7 +69,6 @@ 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(
@@ -85,7 +83,7 @@ def auth_ldap_user(username, password):
conn.search(
settings.LDAP_SEARCH_DN,
settings.LDAP_SEARCH_TEMPLATE % {"username": clean_username},
settings.LDAP_SEARCH_TEMPLATE % {"username": username},
attributes=[settings.LDAP_DISPLAY_NAME_KEY, settings.LDAP_EMAIL_KEY],
)

View File

@@ -1,6 +1,5 @@
import json
import click
import simplejson
from flask import current_app
from flask.cli import FlaskGroup, run_command, with_appcontext
from rq import Connection
@@ -54,7 +53,7 @@ def version():
@manager.command()
def status():
with Connection(rq_redis_connection):
print(json.dumps(get_status(), indent=2))
print(simplejson.dumps(get_status(), indent=2))
@manager.command()

View File

@@ -5,22 +5,6 @@ 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")

View File

@@ -5,7 +5,7 @@ logger = logging.getLogger(__name__)
__all__ = ["BaseDestination", "register", "get_destination", "import_destinations"]
class BaseDestination:
class BaseDestination(object):
deprecated = False
def __init__(self, configuration):

View File

@@ -42,8 +42,8 @@ class Discord(BaseDestination):
"inline": True,
},
]
if alert.custom_body:
fields.append({"name": "Description", "value": alert.custom_body})
if alert.options.get("custom_body"):
fields.append({"name": "Description", "value": alert.options["custom_body"]})
if new_state == Alert.TRIGGERED_STATE:
if alert.options.get("custom_subject"):
text = alert.options["custom_subject"]

View File

@@ -26,13 +26,13 @@ class Slack(BaseDestination):
fields = [
{
"title": "Query",
"type": "mrkdwn",
"value": "{host}/queries/{query_id}".format(host=host, query_id=query.id),
"short": True,
},
{
"title": "Alert",
"type": "mrkdwn",
"value": "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id),
"short": True,
},
]
if alert.custom_body:
@@ -50,7 +50,7 @@ class Slack(BaseDestination):
payload = {"attachments": [{"text": text, "color": color, "fields": fields}]}
try:
resp = requests.post(options.get("url"), data=json_dumps(payload).encode("utf-8"), timeout=5.0)
resp = requests.post(options.get("url"), data=json_dumps(payload), timeout=5.0)
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))

View File

@@ -1,5 +1,3 @@
import html
import json
import logging
from copy import deepcopy
@@ -39,129 +37,6 @@ 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",
@@ -169,7 +44,44 @@ class Webex(BaseDestination):
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"body": body,
"body": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": 4,
"items": [
{
"type": "TextBlock",
"text": {subject},
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": {description},
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
],
},
],
}
],
},
}
]
@@ -204,10 +116,6 @@ 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)

View File

@@ -1,7 +1,7 @@
from flask import request
from funcy import project
from redash import models, utils
from redash import models
from redash.handlers.base import (
BaseResource,
get_object_or_404,
@@ -14,10 +14,6 @@ from redash.permissions import (
view_only,
)
from redash.serializers import serialize_alert
from redash.tasks.alerts import (
notify_subscriptions,
should_notify,
)
class AlertResource(BaseResource):
@@ -47,21 +43,6 @@ 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)

View File

@@ -3,7 +3,6 @@ from flask_restful import Api
from werkzeug.wrappers import Response
from redash.handlers.alerts import (
AlertEvaluateResource,
AlertListResource,
AlertMuteResource,
AlertResource,
@@ -118,7 +117,6 @@ 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",

View File

@@ -29,7 +29,6 @@ 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()
@@ -41,19 +40,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=error_message,
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.",
),
400,
)

View File

@@ -5,15 +5,15 @@ from flask import Blueprint, current_app, request
from flask_login import current_user, login_required
from flask_restful import Resource, abort
from sqlalchemy import cast
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects import postgresql
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"))
@@ -114,7 +114,7 @@ def json_response(response):
def filter_by_tags(result_set, column):
if request.args.getlist("tags"):
tags = request.args.getlist("tags")
result_set = result_set.filter(cast(column, ARRAY(db.Text)).contains(tags))
result_set = result_set.filter(cast(column, postgresql.ARRAY(db.Text)).contains(tags))
return result_set

View File

@@ -96,7 +96,7 @@ class DashboardListResource(BaseResource):
org=self.current_org,
user=self.current_user,
is_draft=True,
layout=[],
layout="[]",
)
models.db.session.add(dashboard)
models.db.session.commit()

View File

@@ -7,6 +7,7 @@ from redash.permissions import (
require_permission,
)
from redash.serializers import serialize_visualization
from redash.utils import json_dumps
class VisualizationListResource(BaseResource):
@@ -17,6 +18,7 @@ class VisualizationListResource(BaseResource):
query = get_object_or_404(models.Query.get_by_id_and_org, kwargs.pop("query_id"), self.current_org)
require_object_modify_permission(query, self.current_user)
kwargs["options"] = json_dumps(kwargs["options"])
kwargs["query_rel"] = query
vis = models.Visualization(**kwargs)
@@ -32,6 +34,8 @@ class VisualizationResource(BaseResource):
require_object_modify_permission(vis.query_rel, self.current_user)
kwargs = request.get_json(force=True)
if "options" in kwargs:
kwargs["options"] = json_dumps(kwargs["options"])
kwargs.pop("id", None)
kwargs.pop("query_id", None)

View File

@@ -1,6 +1,6 @@
import json
import os
import simplejson
from flask import url_for
WEBPACK_MANIFEST_PATH = os.path.join(os.path.dirname(__file__), "../../client/dist/", "asset-manifest.json")
@@ -15,7 +15,7 @@ def configure_webpack(app):
if assets is None or app.debug:
try:
with open(WEBPACK_MANIFEST_PATH) as fp:
assets = json.load(fp)
assets = simplejson.load(fp)
except IOError:
app.logger.exception("Unable to load webpack manifest")
assets = {}

View File

@@ -9,6 +9,7 @@ from redash.permissions import (
view_only,
)
from redash.serializers import serialize_widget
from redash.utils import json_dumps
class WidgetListResource(BaseResource):
@@ -29,6 +30,7 @@ class WidgetListResource(BaseResource):
dashboard = models.Dashboard.get_by_id_and_org(widget_properties.get("dashboard_id"), self.current_org)
require_object_modify_permission(dashboard, self.current_user)
widget_properties["options"] = json_dumps(widget_properties["options"])
widget_properties.pop("id", None)
visualization_id = widget_properties.pop("visualization_id")
@@ -42,6 +44,7 @@ class WidgetListResource(BaseResource):
widget = models.Widget(**widget_properties)
models.db.session.add(widget)
models.db.session.commit()
models.db.session.commit()
return serialize_widget(widget)
@@ -62,7 +65,7 @@ class WidgetResource(BaseResource):
require_object_modify_permission(widget.dashboard, self.current_user)
widget_properties = request.get_json(force=True)
widget.text = widget_properties["text"]
widget.options = widget_properties["options"]
widget.options = json_dumps(widget_properties["options"])
models.db.session.commit()
return serialize_widget(widget)

View File

@@ -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, Join
from sqlalchemy.sql.selectable import Alias
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) or isinstance(t, Join):
while isinstance(t, _ORMJoin):
t = t.left
return t.name

View File

@@ -6,7 +6,7 @@ import time
import pytz
from sqlalchemy import UniqueConstraint, and_, cast, distinct, func, or_
from sqlalchemy.dialects.postgresql import ARRAY, DOUBLE_PRECISION, JSONB
from sqlalchemy.dialects import postgresql
from sqlalchemy.event import listens_for
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import (
@@ -40,18 +40,14 @@ from redash.models.base import (
from redash.models.changes import Change, ChangeTrackingMixin # noqa
from redash.models.mixins import BelongsToOrgMixin, TimestampMixin
from redash.models.organizations import Organization
from redash.models.parameterized_query import (
InvalidParameterError,
ParameterizedQuery,
QueryDetachedFromDataSourceError,
)
from redash.models.parameterized_query import ParameterizedQuery
from redash.models.types import (
Configuration,
EncryptedConfiguration,
JSONText,
MutableDict,
MutableList,
json_cast_property,
PseudoJSON,
pseudo_json_cast_property,
)
from redash.models.users import ( # noqa
AccessPermission,
@@ -84,7 +80,7 @@ from redash.utils.configuration import ConfigurationContainer
logger = logging.getLogger(__name__)
class ScheduledQueriesExecutions:
class ScheduledQueriesExecutions(object):
KEY_NAME = "sq:executed_at"
def __init__(self):
@@ -127,10 +123,7 @@ class DataSource(BelongsToOrgMixin, db.Model):
data_source_groups = db.relationship("DataSourceGroup", back_populates="data_source", cascade="all")
__tablename__ = "data_sources"
__table_args__ = (
db.Index("data_sources_org_id_name", "org_id", "name"),
{"extend_existing": True},
)
__table_args__ = (db.Index("data_sources_org_id_name", "org_id", "name"),)
def __eq__(self, other):
return self.id == other.id
@@ -304,11 +297,34 @@ class DataSourceGroup(db.Model):
view_only = Column(db.Boolean, default=False)
__tablename__ = "data_source_groups"
__table_args__ = ({"extend_existing": True},)
DESERIALIZED_DATA_ATTR = "_deserialized_data"
class DBPersistence(object):
@property
def data(self):
if self._data is None:
return None
if not hasattr(self, DESERIALIZED_DATA_ATTR):
setattr(self, DESERIALIZED_DATA_ATTR, json_loads(self._data))
return self._deserialized_data
@data.setter
def data(self, data):
if hasattr(self, DESERIALIZED_DATA_ATTR):
delattr(self, DESERIALIZED_DATA_ATTR)
self._data = data
QueryResultPersistence = settings.dynamic_settings.QueryResultPersistence or DBPersistence
@generic_repr("id", "org_id", "data_source_id", "query_hash", "runtime", "retrieved_at")
class QueryResult(db.Model, BelongsToOrgMixin):
class QueryResult(db.Model, QueryResultPersistence, BelongsToOrgMixin):
id = primary_key("QueryResult")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship(Organization)
@@ -316,8 +332,8 @@ class QueryResult(db.Model, BelongsToOrgMixin):
data_source = db.relationship(DataSource, backref=backref("query_results"))
query_hash = Column(db.String(32), index=True)
query_text = Column("query", db.Text)
data = Column(JSONText, nullable=True)
runtime = Column(DOUBLE_PRECISION)
_data = Column("data", db.Text)
runtime = Column(postgresql.DOUBLE_PRECISION)
retrieved_at = Column(db.DateTime(True))
__tablename__ = "query_results"
@@ -387,10 +403,6 @@ 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:
@@ -462,11 +474,11 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
last_modified_by = db.relationship(User, backref="modified_queries", foreign_keys=[last_modified_by_id])
is_archived = Column(db.Boolean, default=False, index=True)
is_draft = Column(db.Boolean, default=True, index=True)
schedule = Column(MutableDict.as_mutable(JSONB), nullable=True)
interval = json_cast_property(db.Integer, "schedule", "interval", default=0)
schedule = Column(MutableDict.as_mutable(PseudoJSON), nullable=True)
interval = pseudo_json_cast_property(db.Integer, "schedule", "interval", default=0)
schedule_failures = Column(db.Integer, default=0)
visualizations = db.relationship("Visualization", cascade="all, delete-orphan")
options = Column(MutableDict.as_mutable(JSONB), default={})
options = Column(MutableDict.as_mutable(PseudoJSON), default={})
search_vector = Column(
TSVectorType(
"id",
@@ -477,7 +489,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
),
nullable=True,
)
tags = Column("tags", MutableList.as_mutable(ARRAY(db.Unicode)), nullable=True)
tags = Column("tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True)
query_class = SearchBaseQuery
__tablename__ = "queries"
@@ -513,7 +525,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
name="Table",
description="",
type="TABLE",
options={},
options="{}",
)
)
return query
@@ -579,12 +591,11 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
@classmethod
def past_scheduled_queries(cls):
now = utils.utcnow()
queries = Query.query.filter(func.jsonb_typeof(Query.schedule) != "null").order_by(Query.id)
queries = Query.query.filter(Query.schedule.isnot(None)).order_by(Query.id)
return [
query
for query in queries
if "until" in query.schedule
and query.schedule["until"] is not None
if query.schedule["until"] is not None
and pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d")) <= now
]
@@ -592,7 +603,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
def outdated_queries(cls):
queries = (
Query.query.options(joinedload(Query.latest_query_data).load_only("retrieved_at"))
.filter(func.jsonb_typeof(Query.schedule) != "null")
.filter(Query.schedule.isnot(None))
.order_by(Query.id)
.all()
)
@@ -606,11 +617,6 @@ 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"))
@@ -622,7 +628,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
)
if should_schedule_next(
retrieved_at,
retrieved_at or now,
now,
query.schedule["interval"],
query.schedule["time"],
@@ -825,20 +831,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
def update_query_hash(self):
should_apply_auto_limit = self.options.get("apply_auto_limit", False) if self.options else False
query_runner = self.data_source.query_runner if self.data_source else BaseQueryRunner({})
query_text = self.query_text
parameters_dict = {p["name"]: p.get("value") for p in self.parameters} if self.options else {}
if any(parameters_dict):
try:
query_text = self.parameterized.apply(parameters_dict).query
except InvalidParameterError as e:
logging.info(f"Unable to update hash for query {self.id} because of invalid parameters: {str(e)}")
except QueryDetachedFromDataSourceError as e:
logging.info(
f"Unable to update hash for query {self.id} because of dropdown query {e.query_id} is unattached from datasource"
)
self.query_hash = query_runner.gen_query_hash(query_text, should_apply_auto_limit)
self.query_hash = query_runner.gen_query_hash(self.query_text, should_apply_auto_limit)
@listens_for(Query, "before_insert")
@@ -908,7 +901,6 @@ 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)
@@ -926,8 +918,6 @@ 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
@@ -939,7 +929,6 @@ 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))
@@ -947,7 +936,7 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
query_rel = db.relationship(Query, backref=backref("alerts", cascade="all"))
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User, backref="alerts")
options = Column(MutableDict.as_mutable(JSONB), nullable=True)
options = Column(MutableDict.as_mutable(PseudoJSON))
state = Column(db.String(255), default=UNKNOWN_STATE)
subscriptions = db.relationship("AlertSubscription", cascade="all, delete-orphan")
last_triggered_at = Column(db.DateTime(True), nullable=True)
@@ -969,38 +958,17 @@ 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 if self.query_rel.latest_query_data else None
new_state = self.UNKNOWN_STATE
data = self.query_rel.latest_query_data.data
if data and data["rows"] and self.options["column"] in data["rows"][0]:
if data["rows"] and self.options["column"] in data["rows"][0]:
op = OPERATORS.get(self.options["op"], lambda v, t: False)
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
value = data["rows"][0][self.options["column"]]
threshold = self.options["value"]
if value is not None:
new_state = next_state(op, value, threshold)
new_state = next_state(op, value, threshold)
else:
new_state = self.UNKNOWN_STATE
return new_state
@@ -1023,11 +991,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,
@@ -1079,13 +1047,13 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User)
# layout is no longer used, but kept so we know how to render old dashboards.
layout = Column(MutableList.as_mutable(JSONB), default=[])
layout = Column(db.Text)
dashboard_filters_enabled = Column(db.Boolean, default=False)
is_archived = Column(db.Boolean, default=False, index=True)
is_draft = Column(db.Boolean, default=True, index=True)
widgets = db.relationship("Widget", backref="dashboard", lazy="dynamic")
tags = Column("tags", MutableList.as_mutable(ARRAY(db.Unicode)), nullable=True)
options = Column(MutableDict.as_mutable(JSONB), default={})
tags = Column("tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True)
options = Column(MutableDict.as_mutable(postgresql.JSON), server_default="{}", default={})
__tablename__ = "dashboards"
__mapper_args__ = {"version_id_col": version}
@@ -1198,7 +1166,7 @@ class Visualization(TimestampMixin, BelongsToOrgMixin, db.Model):
query_rel = db.relationship(Query, back_populates="visualizations")
name = Column(db.String(255))
description = Column(db.String(4096), nullable=True)
options = Column(MutableDict.as_mutable(JSONB), nullable=True)
options = Column(db.Text)
__tablename__ = "visualizations"
@@ -1225,7 +1193,7 @@ class Widget(TimestampMixin, BelongsToOrgMixin, db.Model):
visualization = db.relationship(Visualization, backref=backref("widgets", cascade="delete"))
text = Column(db.Text, nullable=True)
width = Column(db.Integer)
options = Column(MutableDict.as_mutable(JSONB), default={})
options = Column(db.Text)
dashboard_id = Column(key_type("Dashboard"), db.ForeignKey("dashboards.id"), index=True)
__tablename__ = "widgets"
@@ -1257,7 +1225,7 @@ class Event(db.Model):
action = Column(db.String(255))
object_type = Column(db.String(255))
object_id = Column(db.String(255), nullable=True)
additional_properties = Column(MutableDict.as_mutable(JSONB), nullable=True, default={})
additional_properties = Column(MutableDict.as_mutable(PseudoJSON), nullable=True, default={})
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "events"

View File

@@ -1,13 +1,13 @@
import functools
from flask_sqlalchemy import BaseQuery, SQLAlchemy
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import object_session
from sqlalchemy.pool import NullPool
from sqlalchemy_searchable import SearchQueryMixin, make_searchable, vectorizer
from redash import settings
from redash.utils import json_dumps, json_loads
from redash.utils import json_dumps
class RedashSQLAlchemy(SQLAlchemy):
@@ -28,10 +28,7 @@ class RedashSQLAlchemy(SQLAlchemy):
return options
db = RedashSQLAlchemy(
session_options={"expire_on_commit": False},
engine_options={"json_serializer": json_dumps, "json_deserializer": json_loads},
)
db = RedashSQLAlchemy(session_options={"expire_on_commit": False})
# Make sure the SQLAlchemy mappers are all properly configured first.
# This is required by SQLAlchemy-Searchable as it adds DDL listeners
# on the configuration phase of models.
@@ -53,7 +50,7 @@ def integer_vectorizer(column):
return db.func.cast(column, db.Text)
@vectorizer(UUID)
@vectorizer(postgresql.UUID)
def uuid_vectorizer(column):
return db.func.cast(column, db.Text)
@@ -71,7 +68,7 @@ def gfk_type(cls):
return cls
class GFKBase:
class GFKBase(object):
"""
Compatibility with 'generic foreign key' approach Peewee used.
"""

View File

@@ -1,8 +1,8 @@
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.inspection import inspect
from sqlalchemy_utils.models import generic_repr
from .base import Column, GFKBase, db, key_type, primary_key
from .types import PseudoJSON
@generic_repr("id", "object_type", "object_id", "created_at")
@@ -13,7 +13,7 @@ class Change(GFKBase, db.Model):
object_version = Column(db.Integer, default=0)
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship("User", backref="changes")
change = Column(JSONB)
change = Column(PseudoJSON)
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "changes"
@@ -45,7 +45,7 @@ class Change(GFKBase, db.Model):
)
class ChangeTrackingMixin:
class ChangeTrackingMixin(object):
skipped_fields = ("id", "created_at", "updated_at", "version")
_clean_values = None

View File

@@ -3,7 +3,7 @@ from sqlalchemy.event import listens_for
from .base import Column, db
class TimestampMixin:
class TimestampMixin(object):
updated_at = Column(db.DateTime(True), default=db.func.now(), nullable=False)
created_at = Column(db.DateTime(True), default=db.func.now(), nullable=False)
@@ -17,7 +17,7 @@ def timestamp_before_update(mapper, connection, target):
target.updated_at = db.func.now()
class BelongsToOrgMixin:
class BelongsToOrgMixin(object):
@classmethod
def get_by_id_and_org(cls, object_id, org, org_cls=None):
query = cls.query.filter(cls.id == object_id)

View File

@@ -1,4 +1,3 @@
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy_utils.models import generic_repr
@@ -6,7 +5,7 @@ from redash.settings.organization import settings as org_settings
from .base import Column, db, primary_key
from .mixins import TimestampMixin
from .types import MutableDict
from .types import MutableDict, PseudoJSON
from .users import Group, User
@@ -18,7 +17,7 @@ class Organization(TimestampMixin, db.Model):
id = primary_key("Organization")
name = Column(db.String(255))
slug = Column(db.String(255), unique=True)
settings = Column(MutableDict.as_mutable(JSONB), default={})
settings = Column(MutableDict.as_mutable(PseudoJSON))
groups = db.relationship("Group", lazy="dynamic")
events = db.relationship("Event", lazy="dynamic", order_by="desc(Event.created_at)")

Some files were not shown because too many files have changed in this diff Show More