diff --git a/.ci/Dockerfile.cypress b/.ci/Dockerfile.cypress index 9a8140304e..e595fcc1ba 100644 --- a/.ci/Dockerfile.cypress +++ b/.ci/Dockerfile.cypress @@ -5,7 +5,7 @@ WORKDIR $APP COPY package.json yarn.lock .yarnrc $APP/ COPY viz-lib $APP/viz-lib -RUN npm install yarn@1.22.19 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null +RUN npm install yarn@1.22.22 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null COPY . $APP diff --git a/.ci/compose.ci.yaml b/.ci/compose.ci.yaml index dd6f3a0e9c..984bf7b123 100644 --- a/.ci/compose.ci.yaml +++ b/.ci/compose.ci.yaml @@ -18,7 +18,7 @@ services: image: redis:7-alpine restart: unless-stopped postgres: - image: pgautoupgrade/pgautoupgrade:15-alpine3.8 + image: postgres:18-alpine command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" restart: unless-stopped environment: diff --git a/.ci/compose.cypress.yaml b/.ci/compose.cypress.yaml index e47ec92e58..ba95f53fec 100644 --- a/.ci/compose.cypress.yaml +++ b/.ci/compose.cypress.yaml @@ -66,7 +66,7 @@ services: image: redis:7-alpine restart: unless-stopped postgres: - image: pgautoupgrade/pgautoupgrade:15-alpine3.8 + image: postgres:18-alpine command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" restart: unless-stopped environment: diff --git a/.dockerignore b/.dockerignore index 8e3dfae173..b5a2c33ebb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ client/.tmp/ -client/dist/ node_modules/ viz-lib/node_modules/ .tmp/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7273b324e9..1cee14f8ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,11 +3,12 @@ on: push: branches: - master - pull_request_target: + pull_request: branches: - master env: NODE_VERSION: 18 + YARN_VERSION: 1.22.22 jobs: backend-lint: runs-on: ubuntu-22.04 @@ -59,15 +60,17 @@ 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 + # - name: Upload coverage reports to Codecov + # uses: codecov/codecov-action@v3 + # with: + # token: ${{ secrets.CODECOV_TOKEN }} - name: Store Test Results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: test-results + name: backend-test-results path: /tmp/test-results - name: Store Coverage Results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage path: coverage.xml @@ -88,14 +91,14 @@ jobs: cache: 'yarn' - name: Install Dependencies run: | - npm install --global --force yarn@1.22.19 + npm install --global --force yarn@$YARN_VERSION yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 - name: Run Lint run: yarn lint:ci - name: Store Test Results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: test-results + name: frontend-test-results path: /tmp/test-results frontend-unit-tests: @@ -115,7 +118,7 @@ jobs: cache: 'yarn' - name: Install Dependencies run: | - npm install --global --force yarn@1.22.19 + npm install --global --force yarn@$YARN_VERSION yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 - name: Run App Tests run: yarn test @@ -131,9 +134,9 @@ jobs: COMPOSE_PROJECT_NAME: cypress 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 }} + # 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 @@ -152,7 +155,7 @@ jobs: echo "CODE_COVERAGE=true" >> "$GITHUB_ENV" - name: Install Dependencies run: | - npm install --global --force yarn@1.22.19 + npm install --global --force yarn@$YARN_VERSION yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 - name: Setup Redash Server run: | @@ -168,7 +171,7 @@ jobs: - name: Copy Code Coverage Results run: docker cp cypress:/usr/src/app/coverage ./coverage || true - name: Store Coverage Results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage path: coverage diff --git a/.github/workflows/periodic-snapshot.yml b/.github/workflows/periodic-snapshot.yml index dbfb68c5a2..cc9f82b855 100644 --- a/.github/workflows/periodic-snapshot.yml +++ b/.github/workflows/periodic-snapshot.yml @@ -1,28 +1,86 @@ name: Periodic Snapshot -# 10 minutes after midnight on the first of every month on: schedule: - - cron: "10 0 1 * *" + - cron: '10 0 1 * *' # 10 minutes after midnight on the first day of every month + workflow_dispatch: + inputs: + bump: + description: 'Bump the last digit of the version' + required: false + type: boolean + version: + description: 'Specific version to set' + required: false + default: '' + +env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} permissions: + actions: write contents: write jobs: bump-version-and-tag: runs-on: ubuntu-latest + if: github.ref_name == github.event.repository.default_branch steps: - uses: actions/checkout@v4 with: - ssh-key: ${{secrets.ACTION_PUSH_KEY}} + ssh-key: ${{ secrets.ACTION_PUSH_KEY }} + - run: | - date="$(date +%y.%m).0-dev" - gawk -i inplace -F: -v q=\" -v tag=$date '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json - gawk -i inplace -F= -v q=\" -v tag=$date '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py - gawk -i inplace -F= -v q=\" -v tag=$date '/^version =/ { print $1 FS, q tag q; next} { print }' pyproject.toml - git config user.name github-actions - git config user.email github-actions@github.com + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + # Function to bump the version + bump_version() { + local version="$1" + local IFS=. + read -r major minor patch <<< "$version" + patch=$((patch + 1)) + echo "$major.$minor.$patch-dev" + } + + # Determine the new version tag + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + BUMP_INPUT="${{ github.event.inputs.bump }}" + SPECIFIC_VERSION="${{ github.event.inputs.version }}" + + # Check if both bump and specific version are provided + if [ "$BUMP_INPUT" = "true" ] && [ -n "$SPECIFIC_VERSION" ]; then + echo "::error::Error: Cannot specify both bump and specific version." + exit 1 + fi + + if [ -n "$SPECIFIC_VERSION" ]; then + TAG_NAME="$SPECIFIC_VERSION-dev" + elif [ "$BUMP_INPUT" = "true" ]; then + CURRENT_VERSION=$(grep '"version":' package.json | awk -F\" '{print $4}') + TAG_NAME=$(bump_version "$CURRENT_VERSION") + else + echo "No version bump or specific version provided for manual dispatch." + exit 1 + fi + else + TAG_NAME="$(date +%y.%m).0-dev" + fi + + echo "New version tag: $TAG_NAME" + + # Update version in files + gawk -i inplace -F: -v q=\" -v tag=${TAG_NAME} '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json + gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py + gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^version =/ { print $1 FS, q tag q; next} { print }' pyproject.toml + git add package.json redash/__init__.py pyproject.toml - git commit -m "Snapshot: ${date}" - git tag $date - git push --atomic origin master refs/tags/$date + git commit -m "Snapshot: ${TAG_NAME}" + git tag ${TAG_NAME} + git push --atomic origin master refs/tags/${TAG_NAME} + + # Run the 'preview-image' workflow if run this workflow manually + # For more information, please see the: https://docs.github.com/en/actions/security-guides/automatic-token-authentication + if [ "$BUMP_INPUT" = "true" ] || [ -n "$SPECIFIC_VERSION" ]; then + gh workflow run preview-image.yml --ref $TAG_NAME + fi diff --git a/.github/workflows/preview-image.yml b/.github/workflows/preview-image.yml index cf8b024103..24269d5608 100644 --- a/.github/workflows/preview-image.yml +++ b/.github/workflows/preview-image.yml @@ -3,6 +3,16 @@ on: push: tags: - '*-dev' + workflow_dispatch: + inputs: + dockerRepository: + description: 'Docker repository' + required: true + default: 'preview' + type: choice + options: + - preview + - redash env: NODE_VERSION: 18 @@ -22,6 +32,9 @@ jobs: elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then echo 'Docker password is empty. Skipping build+push' echo skip=true >> "$GITHUB_OUTPUT" + elif [[ "${{ vars.DOCKER_REPOSITORY }}" == '' ]]; then + echo 'Docker repository is empty. Skipping build+push' + echo skip=true >> "$GITHUB_OUTPUT" else echo 'Docker user and password are set and branch is `master`.' echo 'Building + pushing `preview` image.' @@ -29,7 +42,20 @@ jobs: fi build-docker-image: - runs-on: ubuntu-22.04 + 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' @@ -44,11 +70,6 @@ jobs: 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 Docker Buildx uses: docker/setup-buildx-action@v3 @@ -58,6 +79,13 @@ jobs: 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: | @@ -67,21 +95,91 @@ jobs: 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: - push: true tags: | - redash/redash:preview - redash/preview:${{ steps.version.outputs.VERSION_TAG }} + ${{ vars.DOCKER_REPOSITORY }}/redash + ${{ vars.DOCKER_REPOSITORY }}/preview context: . build-args: | test_all_deps=true - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64 + outputs: type=image,push-by-digest=true,push=true + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + env: + DOCKER_CONTENT_TRUST: true + + - name: Build and push release image to Docker Hub + id: build-release + uses: docker/build-push-action@v4 + if: ${{ github.event.inputs.dockerRepository == 'redash' }} + with: + tags: | + ${{ vars.DOCKER_REPOSITORY }}/redash:${{ steps.version.outputs.VERSION_TAG }} + context: . + build-args: | + test_all_deps=true + outputs: type=image,push-by-digest=false,push=true + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} env: DOCKER_CONTENT_TRUST: true - name: "Failure: output container logs to console" if: failure() run: docker compose logs + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + if [[ "${{ github.event.inputs.dockerRepository }}" == 'preview' || !github.event.workflow_run ]]; then + digest="${{ steps.build-preview.outputs.digest}}" + else + digest="${{ steps.build-release.outputs.digest}}" + fi + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + + merge-docker-image: + runs-on: ubuntu-22.04 + needs: build-docker-image + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASS }} + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Create and push manifest for the preview image + if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }} + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:preview \ + $(printf '${{ vars.DOCKER_REPOSITORY }}/redash:preview@sha256:%s ' *) + docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \ + $(printf '${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *) + + - name: Create and push manifest for the release image + if: ${{ github.event.inputs.dockerRepository == 'redash' }} + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \ + $(printf '${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *) diff --git a/.github/workflows/restyled.yml b/.github/workflows/restyled.yml new file mode 100644 index 0000000000..3482740947 --- /dev/null +++ b/.github/workflows/restyled.yml @@ -0,0 +1,36 @@ +name: Restyled + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + restyled: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: restyled-io/actions/setup@v4 + - id: restyler + uses: restyled-io/actions/run@v4 + with: + fail-on-differences: true + + - if: | + !cancelled() && + steps.restyler.outputs.success == 'true' && + github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/create-pull-request@v6 + with: + base: ${{ steps.restyler.outputs.restyled-base }} + branch: ${{ steps.restyler.outputs.restyled-head }} + title: ${{ steps.restyler.outputs.restyled-title }} + body: ${{ steps.restyler.outputs.restyled-body }} + labels: "restyled" + reviewers: ${{ github.event.pull_request.user.login }} + delete-branch: true diff --git a/.gitignore b/.gitignore index b324689c96..3fba4897ec 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ client/dist _build .vscode .env +.tool-versions dump.rdb diff --git a/.nvmrc b/.nvmrc index 53d0020fde..3f430af82b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.20.1 +v18 diff --git a/.restyled.yaml b/.restyled.yaml index 9aea7fc1b1..ddb249dab0 100644 --- a/.restyled.yaml +++ b/.restyled.yaml @@ -38,7 +38,9 @@ request_review: author # # These can be used to tell other automation to avoid our PRs. # -labels: ["Skip CI"] +labels: + - restyled + - "Skip CI" # Labels to ignore # @@ -50,13 +52,13 @@ labels: ["Skip CI"] # Restylers to run, and how restylers: - name: black - image: restyled/restyler-black:v19.10b0 + image: restyled/restyler-black:v24.4.2 include: - redash - tests - migrations/versions - name: prettier - image: restyled/restyler-prettier:v1.19.1-2 + image: restyled/restyler-prettier:v3.3.2-2 command: - prettier - --write diff --git a/Dockerfile b/Dockerfile index 95b8291556..8ce6fd0259 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM node:18-bookworm as frontend-builder +FROM node:18-bookworm AS frontend-builder -RUN npm install --global --force yarn@1.22.19 +RUN npm install --global --force yarn@1.22.22 # Controls whether to build the frontend assets ARG skip_frontend_build @@ -20,13 +20,24 @@ COPY --chown=redash scripts /frontend/scripts 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 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.8-slim-bookworm +RUN < /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 +RUN < /etc/apt/sources.list.d/mssql-release.list + apt-get update + ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 + apt-get clean + rm -rf /var/lib/apt/lists/* + curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip + chmod 600 /tmp/simba_odbc.zip + unzip /tmp/simba_odbc.zip -d /tmp/simba + dpkg -i /tmp/simba/*.deb + printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini + rm /tmp/simba_odbc.zip + rm -rf /tmp/simba + fi +EOF WORKDIR /app -ENV POETRY_VERSION=1.6.1 +ENV POETRY_VERSION=2.1.4 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" diff --git a/Makefile b/Makefile index aa9d750efa..d2ed5d9108 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,11 @@ compose_build: .env COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build up: - COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build + docker compose up -d redis postgres --remove-orphans + docker compose exec -u postgres postgres psql postgres --csv \ + -1tqc "SELECT table_name FROM information_schema.tables WHERE table_name = 'organizations'" 2> /dev/null \ + | grep -q "organizations" || make create_database + COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build --remove-orphans test_db: @for i in `seq 1 5`; do \ @@ -17,7 +21,16 @@ create_database: .env docker compose run server create_db clean: - docker compose down && docker compose rm + docker compose down + docker compose --project-name cypress down + docker compose rm --stop --force + docker compose --project-name cypress rm --stop --force + docker image rm --force \ + cypress-server:latest cypress-worker:latest cypress-scheduler:latest \ + redash-server:latest redash-worker:latest redash-scheduler:latest + docker container prune --force + docker image prune --force + docker volume prune --force down: docker compose down diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 5e777c10ed..b7a6ebd8c7 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -46,7 +46,7 @@ server() { MAX_REQUESTS=${MAX_REQUESTS:-1000} MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100} TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60} - exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT + exec /usr/local/bin/gunicorn -b [::]:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT --limit-request-line ${REDASH_GUNICORN_LIMIT_REQUEST_LINE:-0} } create_db() { @@ -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 ptvsd" + echo "debug -- start Flask development server with remote debugger via debugpy" echo "create_db -- create database tables" echo "manage -- CLI to manage redash" echo "tests -- run tests" diff --git a/client/app/assets/css/login.css b/client/app/assets/css/login.css index cf46eefb0c..1120ec1e7c 100644 --- a/client/app/assets/css/login.css +++ b/client/app/assets/css/login.css @@ -15,7 +15,7 @@ body { display: table; width: 100%; padding: 10px; - height: calc(100vh - 116px); + height: calc(100% - 116px); } @media (min-width: 992px) { diff --git a/client/app/assets/images/db-logos/duckdb.png b/client/app/assets/images/db-logos/duckdb.png new file mode 100644 index 0000000000..d2c5b31e50 Binary files /dev/null and b/client/app/assets/images/db-logos/duckdb.png differ diff --git a/client/app/assets/images/db-logos/qubole.png b/client/app/assets/images/db-logos/qubole.png deleted file mode 100644 index dfdc2fa2e2..0000000000 Binary files a/client/app/assets/images/db-logos/qubole.png and /dev/null differ diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index 14a37f3470..80dc02e81d 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -20,7 +20,7 @@ html { html, body { - min-height: 100vh; + height: 100%; } body { @@ -35,7 +35,7 @@ body { } #application-root { - min-height: 100vh; + height: 100%; } #application-root, diff --git a/client/app/assets/less/inc/login.less b/client/app/assets/less/inc/login.less index 81ee077790..a820159350 100755 --- a/client/app/assets/less/inc/login.less +++ b/client/app/assets/less/inc/login.less @@ -10,7 +10,7 @@ vertical-align: middle; display: inline-block; width: 1px; - height: 100vh; + height: 100%; } } @@ -135,4 +135,4 @@ } } - \ No newline at end of file + diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 57bccbc8c4..6a5e872fc6 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -8,7 +8,7 @@ body.fixed-layout { padding-bottom: 0; width: 100vw; - height: 100vh; + height: 100%; .application-layout-content > div { display: flex; @@ -90,7 +90,7 @@ body.fixed-layout { .embed__vis { display: flex; flex-flow: column; - height: calc(~'100vh - 25px'); + height: calc(~'100% - 25px'); > .embed-heading { flex: 0 0 auto; diff --git a/client/app/components/ApplicationArea/ApplicationLayout/index.less b/client/app/components/ApplicationArea/ApplicationLayout/index.less index 8da48be1af..74074f686e 100644 --- a/client/app/components/ApplicationArea/ApplicationLayout/index.less +++ b/client/app/components/ApplicationArea/ApplicationLayout/index.less @@ -7,10 +7,10 @@ body #application-root { flex-direction: row; justify-content: stretch; padding-bottom: 0 !important; - height: 100vh; + height: 100%; .application-layout-side-menu { - height: 100vh; + height: 100%; position: relative; @media @mobileBreakpoint { @@ -47,6 +47,10 @@ body #application-root { } } +body > section { + height: 100%; +} + body.fixed-layout #application-root { .application-layout-content { padding-bottom: 0; diff --git a/client/app/components/BeaconConsent.jsx b/client/app/components/BeaconConsent.jsx index 3ee9b24751..4da6337de9 100644 --- a/client/app/components/BeaconConsent.jsx +++ b/client/app/components/BeaconConsent.jsx @@ -22,7 +22,7 @@ function BeaconConsent() { setHide(true); }; - const confirmConsent = confirm => { + const confirmConsent = (confirm) => { let message = "🙏 Thank you."; if (!confirm) { @@ -47,7 +47,8 @@ function BeaconConsent() { } - bordered={false}> + bordered={false} + > Help Redash improve by automatically sending anonymous usage data:
    @@ -66,8 +67,7 @@ function BeaconConsent() {
- You can change this setting anytime from the{" "} - Organization Settings page. + You can change this setting anytime from the Settings page.
diff --git a/client/app/components/EditParameterSettingsDialog.jsx b/client/app/components/EditParameterSettingsDialog.jsx index a206c1fc7d..f182bb9d1e 100644 --- a/client/app/components/EditParameterSettingsDialog.jsx +++ b/client/app/components/EditParameterSettingsDialog.jsx @@ -12,6 +12,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import QuerySelector from "@/components/QuerySelector"; import { Query } from "@/services/query"; import { useUniqueId } from "@/lib/hooks/useUniqueId"; +import "./EditParameterSettingsDialog.less"; const { Option } = Select; const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } }; @@ -26,7 +27,7 @@ function isTypeDateRange(type) { function joinExampleList(multiValuesOptions) { const { prefix, suffix } = multiValuesOptions; - return ["value1", "value2", "value3"].map(value => `${prefix}${value}${suffix}`).join(","); + return ["value1", "value2", "value3"].map((value) => `${prefix}${value}${suffix}`).join(","); } function NameInput({ name, type, onChange, existingNames, setValidation }) { @@ -54,7 +55,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) { return ( - onChange(e.target.value)} autoFocus /> + onChange(e.target.value)} autoFocus /> ); } @@ -71,6 +72,8 @@ function EditParameterSettingsDialog(props) { const [param, setParam] = useState(clone(props.parameter)); const [isNameValid, setIsNameValid] = useState(true); const [initialQuery, setInitialQuery] = useState(); + const [userInput, setUserInput] = useState(param.regex || ""); + const [isValidRegex, setIsValidRegex] = useState(true); const isNew = !props.parameter.name; @@ -114,6 +117,17 @@ function EditParameterSettingsDialog(props) { const paramFormId = useUniqueId("paramForm"); + const handleRegexChange = (e) => { + setUserInput(e.target.value); + try { + new RegExp(e.target.value); + setParam({ ...param, regex: e.target.value }); + setIsValidRegex(true); + } catch (error) { + setIsValidRegex(false); + } + }; + return ( + data-test="SaveParameterSettings" + > {isNew ? "Add Parameter" : "OK"} , - ]}> + ]} + >
{isNew && ( setParam({ ...param, name })} + onChange={(name) => setParam({ ...param, name })} setValidation={setIsNameValid} existingNames={props.existingParams} type={param.type} @@ -146,15 +162,16 @@ function EditParameterSettingsDialog(props) { setParam({ ...param, title: e.target.value })} + onChange={(e) => setParam({ ...param, title: e.target.value })} data-test="ParameterTitleInput" /> - setParam({ ...param, type })} data-test="ParameterTypeSelect"> + @@ -180,12 +197,26 @@ function EditParameterSettingsDialog(props) { + {param.type === "text-pattern" && ( + + + + )} {param.type === "enum" && ( setParam({ ...param, enumOptions: e.target.value })} + onChange={(e) => setParam({ ...param, enumOptions: e.target.value })} /> )} @@ -193,7 +224,7 @@ function EditParameterSettingsDialog(props) { setParam({ ...param, queryId: q && q.id })} + onChange={(q) => setParam({ ...param, queryId: q && q.id })} type="select" /> @@ -202,7 +233,7 @@ function EditParameterSettingsDialog(props) { + onChange={(e) => setParam({ ...param, multiValuesOptions: e.target.checked @@ -214,7 +245,8 @@ function EditParameterSettingsDialog(props) { : null, }) } - data-test="AllowMultipleValuesCheckbox"> + data-test="AllowMultipleValuesCheckbox" + > Allow multiple values @@ -227,10 +259,11 @@ function EditParameterSettingsDialog(props) { Placed in query as: {joinExampleList(param.multiValuesOptions)} } - {...formItemProps}> + {...formItemProps} + > 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 this.updateParamMapping({ mapTo })} options={options} />; } renderStaticValue() { @@ -226,7 +226,8 @@ export class ParameterMappingInput extends React.Component { enumOptions={mapping.param.enumOptions} queryId={mapping.param.queryId} parameter={mapping.param} - onSelect={value => this.updateParamMapping({ value })} + onSelect={(value) => this.updateParamMapping({ value })} + regex={mapping.param.regex} /> ); } @@ -284,12 +285,12 @@ class MappingEditor extends React.Component { }; } - onVisibleChange = visible => { + onVisibleChange = (visible) => { if (visible) this.show(); else this.hide(); }; - onChange = mapping => { + onChange = (mapping) => { let inputError = null; if (mapping.type === MappingType.DashboardAddNew) { @@ -351,7 +352,8 @@ class MappingEditor extends React.Component { trigger="click" content={this.renderContent()} visible={visible} - onVisibleChange={this.onVisibleChange}> + onVisibleChange={this.onVisibleChange} + > @@ -376,14 +378,14 @@ class TitleEditor extends React.Component { title: "", // will be set on editing }; - onPopupVisibleChange = showPopup => { + onPopupVisibleChange = (showPopup) => { this.setState({ showPopup, title: showPopup ? this.getMappingTitle() : "", }); }; - onEditingTitleChange = event => { + onEditingTitleChange = (event) => { this.setState({ title: event.target.value }); }; @@ -460,7 +462,8 @@ class TitleEditor extends React.Component { trigger="click" content={this.renderPopover()} visible={this.state.showPopup} - onVisibleChange={this.onPopupVisibleChange}> + onVisibleChange={this.onPopupVisibleChange} + > @@ -508,7 +511,7 @@ export class ParameterMappingListInput extends React.Component { // just to be safe, array or object if (typeof value === "object") { - return map(value, v => this.getStringValue(v)).join(", "); + return map(value, (v) => this.getStringValue(v)).join(", "); } // rest @@ -574,7 +577,7 @@ export class ParameterMappingListInput extends React.Component { render() { const { existingParams } = this.props; // eslint-disable-line react/prop-types - const dataSource = this.props.mappings.map(mapping => ({ mapping })); + const dataSource = this.props.mappings.map((mapping) => ({ mapping })); return (
@@ -583,11 +586,11 @@ export class ParameterMappingListInput extends React.Component { title="Title" dataIndex="mapping" key="title" - render={mapping => ( + render={(mapping) => ( this.updateParamMapping(mapping, newMapping)} + onChange={(newMapping) => this.updateParamMapping(mapping, newMapping)} /> )} /> @@ -596,19 +599,19 @@ export class ParameterMappingListInput extends React.Component { dataIndex="mapping" key="keyword" className="keyword" - render={mapping => {`{{ ${mapping.name} }}`}} + render={(mapping) => {`{{ ${mapping.name} }}`}} /> this.constructor.getDefaultValue(mapping, this.props.existingParams)} + render={(mapping) => this.constructor.getDefaultValue(mapping, this.props.existingParams)} /> { + render={(mapping) => { const existingParamsNames = existingParams .filter(({ type }) => type === mapping.param.type) // exclude mismatching param types .map(({ name }) => name); // keep names only diff --git a/client/app/components/ParameterValueInput.jsx b/client/app/components/ParameterValueInput.jsx index f2ad8c7a94..894530e30b 100644 --- a/client/app/components/ParameterValueInput.jsx +++ b/client/app/components/ParameterValueInput.jsx @@ -9,11 +9,12 @@ import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParamet import QueryBasedParameterInput from "./QueryBasedParameterInput"; import "./ParameterValueInput.less"; +import Tooltip from "./Tooltip"; const multipleValuesProps = { maxTagCount: 3, maxTagTextLength: 10, - maxTagPlaceholder: num => `+${num.length} more`, + maxTagPlaceholder: (num) => `+${num.length} more`, }; class ParameterValueInput extends React.Component { @@ -25,6 +26,7 @@ class ParameterValueInput extends React.Component { parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types onSelect: PropTypes.func, className: PropTypes.string, + regex: PropTypes.string, }; static defaultProps = { @@ -35,6 +37,7 @@ class ParameterValueInput extends React.Component { parameter: null, onSelect: () => {}, className: "", + regex: "", }; constructor(props) { @@ -45,7 +48,7 @@ class ParameterValueInput extends React.Component { }; } - componentDidUpdate = prevProps => { + componentDidUpdate = (prevProps) => { const { value, parameter } = this.props; // if value prop updated, reset dirty state if (prevProps.value !== value || prevProps.parameter !== parameter) { @@ -56,7 +59,7 @@ class ParameterValueInput extends React.Component { } }; - onSelect = value => { + onSelect = (value) => { const isDirty = !isEqual(value, this.props.value); this.setState({ value, isDirty }); this.props.onSelect(value, isDirty); @@ -93,9 +96,9 @@ class ParameterValueInput extends React.Component { renderEnumInput() { const { enumOptions, parameter } = this.props; const { value } = this.state; - const enumOptionsArray = enumOptions.split("\n").filter(v => v !== ""); + const enumOptionsArray = enumOptions.split("\n").filter((v) => v !== ""); // Antd Select doesn't handle null in multiple mode - const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val); + const normalize = (val) => (parameter.multiValuesOptions && val === null ? [] : val); return ( ({ label: String(opt), value: opt }))} + options={map(enumOptionsArray, (opt) => ({ label: String(opt), value: opt }))} showSearch showArrow notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null} @@ -133,18 +136,36 @@ class ParameterValueInput extends React.Component { const { className } = this.props; const { value } = this.state; - const normalize = val => (isNaN(val) ? undefined : val); + const normalize = (val) => (isNaN(val) ? undefined : val); return ( this.onSelect(normalize(val))} + onChange={(val) => this.onSelect(normalize(val))} /> ); } + renderTextPatternInput() { + const { className } = this.props; + const { value } = this.state; + + return ( + + + this.onSelect(e.target.value)} + /> + + + ); + } + renderTextInput() { const { className } = this.props; const { value } = this.state; @@ -155,7 +176,7 @@ class ParameterValueInput extends React.Component { value={value} aria-label="Parameter text value" data-test="TextParamInput" - onChange={e => this.onSelect(e.target.value)} + onChange={(e) => this.onSelect(e.target.value)} /> ); } @@ -177,6 +198,8 @@ class ParameterValueInput extends React.Component { return this.renderQueryBasedInput(); case "number": return this.renderNumberInput(); + case "text-pattern": + return this.renderTextPatternInput(); default: return this.renderTextInput(); } diff --git a/client/app/components/Parameters.jsx b/client/app/components/Parameters.jsx index 2e504bba32..ef4d30ed45 100644 --- a/client/app/components/Parameters.jsx +++ b/client/app/components/Parameters.jsx @@ -14,7 +14,7 @@ import "./Parameters.less"; function updateUrl(parameters) { const params = extend({}, location.search); - parameters.forEach(param => { + parameters.forEach((param) => { extend(params, param.toUrlParams()); }); location.setSearch(params, true); @@ -43,7 +43,7 @@ export default class Parameters extends React.Component { appendSortableToParent: true, }; - toCamelCase = str => { + toCamelCase = (str) => { if (isEmpty(str)) { return ""; } @@ -59,10 +59,10 @@ export default class Parameters extends React.Component { } const hideRegex = /hide_filter=([^&]+)/g; const matches = window.location.search.matchAll(hideRegex); - this.hideValues = Array.from(matches, match => match[1]); + this.hideValues = Array.from(matches, (match) => match[1]); } - componentDidUpdate = prevProps => { + componentDidUpdate = (prevProps) => { const { parameters, disableUrlUpdate } = this.props; const parametersChanged = prevProps.parameters !== parameters; const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate; @@ -74,7 +74,7 @@ export default class Parameters extends React.Component { } }; - handleKeyDown = e => { + handleKeyDown = (e) => { // Cmd/Ctrl/Alt + Enter if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) { e.stopPropagation(); @@ -109,8 +109,8 @@ export default class Parameters extends React.Component { applyChanges = () => { const { onValuesChange, disableUrlUpdate } = this.props; this.setState(({ parameters }) => { - const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue); - forEach(parameters, p => p.applyPendingValue()); + const parametersWithPendingValues = parameters.filter((p) => p.hasPendingValue); + forEach(parameters, (p) => p.applyPendingValue()); if (!disableUrlUpdate) { updateUrl(parameters); } @@ -121,7 +121,7 @@ export default class Parameters extends React.Component { showParameterSettings = (parameter, index) => { const { onParametersEdit } = this.props; - EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => { + EditParameterSettingsDialog.showModal({ parameter }).onClose((updated) => { this.setState(({ parameters }) => { const updatedParameter = extend(parameter, updated); parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId); @@ -132,7 +132,7 @@ export default class Parameters extends React.Component { }; renderParameter(param, index) { - if (this.hideValues.some(value => this.toCamelCase(value) === this.toCamelCase(param.name))) { + if (this.hideValues.some((value) => this.toCamelCase(value) === this.toCamelCase(param.name))) { return null; } const { editable } = this.props; @@ -149,7 +149,8 @@ export default class Parameters extends React.Component { aria-label="Edit" onClick={() => this.showParameterSettings(param, index)} data-test={`ParameterSettings-${param.name}`} - type="button"> + type="button" + >