mirror of
https://github.com/apache/superset.git
synced 2026-09-01 04:51:23 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3a64a14dc | ||
|
|
601f9c2b8c | ||
|
|
fa42b13eb8 | ||
|
|
3d244ac287 | ||
|
|
aa4092ba68 | ||
|
|
9b5d8546df | ||
|
|
45a616439b | ||
|
|
98c096df05 | ||
|
|
42367afb25 | ||
|
|
875673f670 | ||
|
|
79c74af2e9 | ||
|
|
7406098708 | ||
|
|
ccce0cab18 | ||
|
|
94c1a1b1f2 | ||
|
|
04939c94cc | ||
|
|
937eff6d52 | ||
|
|
f5f4a41598 | ||
|
|
639866625d | ||
|
|
7d323dc0ae | ||
|
|
0d1b702ce8 | ||
|
|
ddeec68c88 | ||
|
|
0ad09d5cd0 | ||
|
|
6662529306 | ||
|
|
09cd2c26cd | ||
|
|
cbd731e661 | ||
|
|
3f94c9db2d | ||
|
|
80a3df3550 | ||
|
|
6f97d9817e | ||
|
|
7d69f76127 | ||
|
|
9a31362fa5 | ||
|
|
cd5bdf11ac | ||
|
|
75d94ff466 | ||
|
|
c505c70c52 | ||
|
|
23d18743bd | ||
|
|
ddb09f468d | ||
|
|
8dcc7e7eec | ||
|
|
ff5e43c8a0 | ||
|
|
bdb081329f | ||
|
|
aa547da960 | ||
|
|
966c243db6 | ||
|
|
9560ff6227 | ||
|
|
696705794b | ||
|
|
41572dbf9d | ||
|
|
5ba60d51fd |
@@ -79,21 +79,16 @@ github:
|
||||
- lint-check
|
||||
- cypress-matrix (0, chrome)
|
||||
- cypress-matrix (1, chrome)
|
||||
- cypress-matrix (2, chrome)
|
||||
- cypress-matrix (3, chrome)
|
||||
- cypress-matrix (4, chrome)
|
||||
- cypress-matrix (5, chrome)
|
||||
- dependency-review
|
||||
- frontend-build
|
||||
- playwright-tests (chromium)
|
||||
- pre-commit (current)
|
||||
- pre-commit (previous)
|
||||
- test-mysql
|
||||
- test-postgres (current)
|
||||
- test-postgres-required
|
||||
- test-postgres-hive
|
||||
- test-postgres-presto
|
||||
- test-sqlite
|
||||
- unit-tests (current)
|
||||
- unit-tests-required
|
||||
|
||||
required_pull_request_reviews:
|
||||
dismiss_stale_reviews: false
|
||||
|
||||
@@ -15,9 +15,35 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
analyze:
|
||||
name: Analyze
|
||||
needs: changes
|
||||
# Skip on PRs that touch neither code group (e.g. docs-only) so the
|
||||
# analysis runners don't spin up. push/schedule runs always proceed:
|
||||
# the change-detector returns "all changed" for non-PR events.
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -31,16 +57,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
@@ -54,7 +74,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -19,8 +19,30 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
docker: ${{ steps.check.outputs.docker }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
setup_matrix:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
matrix_config: ${{ steps.set_matrix.outputs.matrix_config }}
|
||||
steps:
|
||||
@@ -32,8 +54,13 @@ jobs:
|
||||
|
||||
docker-build:
|
||||
name: docker-build
|
||||
needs: setup_matrix
|
||||
needs: [setup_matrix, changes]
|
||||
if: >-
|
||||
needs.changes.outputs.python == 'true' ||
|
||||
needs.changes.outputs.frontend == 'true' ||
|
||||
needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
build_preset: ${{fromJson(needs.setup_matrix.outputs.matrix_config)}}
|
||||
@@ -50,14 +77,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Docker Environment
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
@@ -65,11 +85,9 @@ jobs:
|
||||
build: "true"
|
||||
|
||||
- name: Setup supersetbot
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Build Docker Image
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -95,7 +113,7 @@ jobs:
|
||||
|
||||
# in the context of push (using multi-platform build), we need to pull the image locally
|
||||
- name: Docker pull
|
||||
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
for i in 1 2 3; do
|
||||
docker pull $IMAGE_TAG && break
|
||||
@@ -103,7 +121,6 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Print docker stats
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
run: |
|
||||
echo "SHA: ${{ github.sha }}"
|
||||
echo "IMAGE: $IMAGE_TAG"
|
||||
@@ -111,7 +128,7 @@ jobs:
|
||||
docker history $IMAGE_TAG
|
||||
|
||||
- name: docker-compose sanity check
|
||||
if: (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'dev'
|
||||
if: matrix.build_preset == 'dev'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_PRESET: ${{ matrix.build_preset }}
|
||||
@@ -124,20 +141,16 @@ jobs:
|
||||
docker-compose-image-tag:
|
||||
# Run this job only on pushes to master (not for PRs)
|
||||
# goal is to check that building the latest image works, not required for all PR pushes
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
needs: changes
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Docker Environment
|
||||
if: steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
@@ -145,7 +158,6 @@ jobs:
|
||||
build: "false"
|
||||
install-docker-compose: "true"
|
||||
- name: docker-compose sanity check
|
||||
if: steps.check.outputs.docker
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose -f docker-compose-image-tag.yml up superset-init --exit-code-from superset-init
|
||||
|
||||
@@ -19,9 +19,13 @@ concurrency:
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous", "next"]
|
||||
# Run the full version spread on push (master/release) and nightly,
|
||||
# but only the current version on PRs — lint/format/type results
|
||||
# rarely differ across patch versions, so 3x per PR is wasteful.
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -45,6 +49,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: |
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
# Gated behind pre-commit: this workflow runs only after the "pre-commit
|
||||
# checks" workflow completes, and (via the job-level `if` below) only when
|
||||
# it succeeded. That keeps the expensive Cypress/Playwright runners from
|
||||
# spinning up while a PR still has formatting/lint/type errors that
|
||||
# pre-commit catches in a fraction of the time. pre-commit itself runs on
|
||||
# push (master/release) and pull_request, so this preserves coverage for
|
||||
# both event types.
|
||||
workflow_run:
|
||||
workflows: ["pre-commit checks"]
|
||||
types: [completed]
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
use_dashboard:
|
||||
@@ -27,19 +23,13 @@ on:
|
||||
default: ''
|
||||
|
||||
concurrency:
|
||||
# workflow_run has no PR number in context; key on the originating branch.
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
# The pre-commit gate: only proceed when pre-commit succeeded (or on a
|
||||
# manual dispatch). On failure this job is skipped, and every downstream
|
||||
# job (needs: changes) is skipped with it — no runners are provisioned.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -51,24 +41,18 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
# The shared change-detector action reads the live event context, which
|
||||
# under workflow_run points at the default branch. Call the script
|
||||
# directly instead, passing the originating event/SHA/PR via WF_RUN_*.
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WF_RUN_EVENT: ${{ github.event.workflow_run.event }}
|
||||
WF_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WF_RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
run: python scripts/change_detector.py
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
cypress-matrix:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
# Somehow one test flakes on 24.04 for unknown reasons, this is the only GHA left on 22.04
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -81,7 +65,7 @@ jobs:
|
||||
matrix:
|
||||
parallel_id: [0, 1]
|
||||
browser: ["chrome"]
|
||||
app_root: ${{ github.event.workflow_run.event == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
# The /app/prefix variant (push events only) is smoke-tested on a single
|
||||
# shard rather than the full matrix, so exclude it from the other shards.
|
||||
exclude:
|
||||
@@ -111,13 +95,13 @@ jobs:
|
||||
steps:
|
||||
# -------------------------------------------------------
|
||||
# Conditional checkout based on context
|
||||
- name: Checkout (gated by pre-commit via workflow_run)
|
||||
if: github.event_name == 'workflow_run'
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -147,6 +131,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
@@ -188,6 +174,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -195,7 +182,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: ["chromium"]
|
||||
app_root: ${{ github.event.workflow_run.event == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -218,13 +205,13 @@ jobs:
|
||||
steps:
|
||||
# -------------------------------------------------------
|
||||
# Conditional checkout based on context (same as Cypress workflow)
|
||||
- name: Checkout (gated by pre-commit via workflow_run)
|
||||
if: github.event_name == 'workflow_run'
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -254,6 +241,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
@@ -292,34 +281,3 @@ jobs:
|
||||
${{ github.workspace }}/superset-frontend/playwright-results/
|
||||
${{ github.workspace }}/superset-frontend/test-results/
|
||||
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
|
||||
# workflow_run runs don't attach their checks to the originating PR, so post
|
||||
# an aggregate commit status back onto the PR head SHA. Make THIS the required
|
||||
# status check in branch protection (in place of the individual E2E jobs).
|
||||
report-status:
|
||||
needs: [cypress-matrix, playwright-tests]
|
||||
if: always() && github.event_name == 'workflow_run'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
statuses: write
|
||||
steps:
|
||||
- name: Report aggregate E2E status to PR commit
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
// 'skipped' is acceptable: the change-detector legitimately skips
|
||||
// jobs when no relevant files changed. Only real failures fail.
|
||||
const results = [
|
||||
'${{ needs.cypress-matrix.result }}',
|
||||
'${{ needs.playwright-tests.result }}',
|
||||
];
|
||||
const ok = results.every((r) => r === 'success' || r === 'skipped');
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: context.payload.workflow_run.head_sha,
|
||||
state: ok ? 'success' : 'failure',
|
||||
context: 'E2E / required',
|
||||
description: ok ? 'E2E passed (or skipped)' : 'E2E failed',
|
||||
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
});
|
||||
|
||||
@@ -20,9 +20,12 @@ concurrency:
|
||||
jobs:
|
||||
test-superset-extensions-cli-package:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["previous", "current", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: superset-extensions-cli
|
||||
|
||||
@@ -22,6 +22,7 @@ permissions:
|
||||
jobs:
|
||||
frontend-build:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
should-run: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
@@ -74,6 +75,7 @@ jobs:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -103,6 +105,7 @@ jobs:
|
||||
needs: [sharded-jest-tests]
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
@@ -144,6 +147,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -168,6 +172,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -187,6 +192,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
||||
@@ -25,6 +25,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -48,6 +49,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -115,6 +117,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
|
||||
@@ -16,6 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -36,6 +37,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -121,11 +123,14 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -179,6 +184,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -222,3 +228,25 @@ jobs:
|
||||
verbose: true
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
|
||||
# Stable required-status-check anchor for the matrix-based test-postgres job.
|
||||
# It is gated on change detection, so on non-Python PRs it is skipped and
|
||||
# never produces its `test-postgres (current)` context (a job-level skip
|
||||
# happens before matrix expansion). This always-running job reports a single
|
||||
# context branch protection can require: it passes when test-postgres
|
||||
# succeeded or was skipped, and fails only on a real failure.
|
||||
test-postgres-required:
|
||||
needs: [changes, test-postgres]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check test-postgres result
|
||||
env:
|
||||
RESULT: ${{ needs.test-postgres.result }}
|
||||
run: |
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "test-postgres did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "test-postgres result: $RESULT"
|
||||
|
||||
@@ -17,6 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -37,6 +38,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -99,6 +101,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
|
||||
@@ -17,6 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -37,11 +38,14 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["previous", "current", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
steps:
|
||||
@@ -74,3 +78,25 @@ jobs:
|
||||
verbose: true
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
|
||||
# Stable required-status-check anchor. `unit-tests` is a matrix job gated on
|
||||
# change detection, so on non-Python PRs it is skipped and never produces its
|
||||
# `unit-tests (current)` context (a job-level skip happens before matrix
|
||||
# expansion). This always-running job reports a single context that branch
|
||||
# protection can require: it passes when unit-tests succeeded or was skipped,
|
||||
# and fails only on a real failure.
|
||||
unit-tests-required:
|
||||
needs: [changes, unit-tests]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check unit-tests result
|
||||
env:
|
||||
RESULT: ${{ needs.unit-tests.result }}
|
||||
run: |
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "unit-tests did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "unit-tests result: $RESULT"
|
||||
|
||||
@@ -22,6 +22,7 @@ concurrency:
|
||||
jobs:
|
||||
app-checks:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
@@ -40,6 +40,15 @@ Importing a dataset now validates the `catalog` field against the target databas
|
||||
|
||||
If you relied on importing datasets with a non-default catalog, enable "Allow changing catalogs" on the target connection, or set the dataset's catalog to the connection's default before importing.
|
||||
|
||||
### Extension supply-chain controls (denylist + version policy)
|
||||
|
||||
Two opt-in static gates control which extensions are allowed to load:
|
||||
|
||||
- `EXTENSION_DENYLIST` refuses extensions matching an id (every version) or `id@version` (a single version), e.g. `["compromised-extension", "other-ext@1.2.3"]`.
|
||||
- `EXTENSION_VERSION_POLICY` enforces a minimum version per extension id, e.g. `{"acme.widget": "1.2.0"}` (PEP 440 comparison); a release below the minimum is refused.
|
||||
|
||||
Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENSIONS` and `EXTENSIONS_PATH` load paths.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -109,37 +109,6 @@ def is_int(s: str) -> bool:
|
||||
return bool(re.match(r"^-?\d+$", s))
|
||||
|
||||
|
||||
def resolve_workflow_run_files(repo: str, sha: str) -> Optional[List[str]]:
|
||||
"""Resolve changed files for a workflow_run-triggered run.
|
||||
|
||||
When a workflow is gated behind another (e.g. running only after
|
||||
pre-commit succeeds), GitHub re-dispatches it as a `workflow_run` event
|
||||
whose context points at the default branch rather than the originating
|
||||
diff. Recover the original event and head SHA from the workflow_run
|
||||
payload, exposed via the WF_RUN_* env vars. Returns ``None`` (meaning
|
||||
"assume all changed") when the diff can't be resolved.
|
||||
"""
|
||||
original_event = os.getenv("WF_RUN_EVENT") or "push"
|
||||
print("ORIGINAL_EVENT", original_event)
|
||||
if original_event == "pull_request":
|
||||
pr_number = os.getenv("WF_RUN_PR_NUMBER", "")
|
||||
if not is_int(pr_number):
|
||||
# Fork PRs don't populate workflow_run.pull_requests, so we can't
|
||||
# resolve the diff -> assume all changed (run everything).
|
||||
print("workflow_run without PR context, assuming all changed")
|
||||
return None
|
||||
files = fetch_changed_files_pr(repo, pr_number)
|
||||
print("PR files:")
|
||||
print_files(files)
|
||||
return files
|
||||
|
||||
head_sha = os.getenv("WF_RUN_HEAD_SHA") or sha
|
||||
files = fetch_changed_files_push(repo, head_sha)
|
||||
print("Files touched since previous commit:")
|
||||
print_files(files)
|
||||
return files
|
||||
|
||||
|
||||
def main(event_type: str, sha: str, repo: str) -> None:
|
||||
"""Main function to check for file changes based on event context."""
|
||||
print("SHA:", sha)
|
||||
@@ -157,9 +126,6 @@ def main(event_type: str, sha: str, repo: str) -> None:
|
||||
print("Files touched since previous commit:")
|
||||
print_files(files)
|
||||
|
||||
elif event_type == "workflow_run":
|
||||
files = resolve_workflow_run_files(repo, sha)
|
||||
|
||||
elif event_type in ("workflow_dispatch", "schedule"):
|
||||
# Manual or cron-triggered runs aren't tied to a specific diff, so
|
||||
# treat every group as changed. `files = None` makes the loop below
|
||||
|
||||
Generated
+139
-143
@@ -86,10 +86,10 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "^1.11.21",
|
||||
"dom-to-image-more": "^3.7.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
@@ -119,7 +119,7 @@
|
||||
"nanoid": "^5.1.11",
|
||||
"ol": "^10.9.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
@@ -165,7 +165,7 @@
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.29.7",
|
||||
"@babel/node": "^7.29.0",
|
||||
"@babel/node": "^7.29.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
|
||||
@@ -173,9 +173,9 @@
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
"@babel/register": "^7.29.3",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime-corejs3": "^7.29.2",
|
||||
"@babel/register": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@babel/runtime-corejs3": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
@@ -270,7 +270,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"oxlint": "^1.67.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
@@ -292,7 +292,7 @@
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
@@ -980,13 +980,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/node": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/node/-/node-7.29.0.tgz",
|
||||
"integrity": "sha512-9UeU8F3rx2lOZXneEW2HTnTYdA8+fXP0kr54tk7d0fPomWNlZ6WJ2H9lunr5dSvr8FNY0CDnop3Km6jZ5NAUsQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/node/-/node-7.29.7.tgz",
|
||||
"integrity": "sha512-nfdPXz8/mD3/t+1nE1DKwGR14Ccjt5xeF7u3g7sqWnLi4yR6n+9Z0kThIROF8SRM07ZKpEtiWSKpWKxsMiJeew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/register": "^7.28.6",
|
||||
"@babel/register": "^7.29.7",
|
||||
"commander": "^6.2.0",
|
||||
"core-js": "^3.48.0",
|
||||
"node-environment-flags": "^1.0.5",
|
||||
@@ -2576,9 +2576,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/register": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.3.tgz",
|
||||
"integrity": "sha512-F6C1KpIdoImKQfsD6HSxZ+mS4YY/2Q+JsqrmTC5ApVkTR2rG+nnbpjhWwzA5bDNu8mJjB3AryqDaWFLd4gCbJQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz",
|
||||
"integrity": "sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2596,18 +2596,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime-corejs3": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz",
|
||||
"integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.7.tgz",
|
||||
"integrity": "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8653,9 +8653,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.66.0.tgz",
|
||||
"integrity": "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.67.0.tgz",
|
||||
"integrity": "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8670,9 +8670,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.67.0.tgz",
|
||||
"integrity": "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8687,9 +8687,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.67.0.tgz",
|
||||
"integrity": "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8704,9 +8704,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-x64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.66.0.tgz",
|
||||
"integrity": "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.67.0.tgz",
|
||||
"integrity": "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8721,9 +8721,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.66.0.tgz",
|
||||
"integrity": "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.67.0.tgz",
|
||||
"integrity": "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8738,9 +8738,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.66.0.tgz",
|
||||
"integrity": "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.67.0.tgz",
|
||||
"integrity": "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8755,9 +8755,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.66.0.tgz",
|
||||
"integrity": "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.67.0.tgz",
|
||||
"integrity": "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8772,9 +8772,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8789,9 +8789,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.67.0.tgz",
|
||||
"integrity": "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8806,9 +8806,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -8823,9 +8823,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -8840,9 +8840,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.67.0.tgz",
|
||||
"integrity": "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -8857,9 +8857,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -8874,9 +8874,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8891,9 +8891,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.67.0.tgz",
|
||||
"integrity": "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8908,9 +8908,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.67.0.tgz",
|
||||
"integrity": "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8925,9 +8925,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.67.0.tgz",
|
||||
"integrity": "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8942,9 +8942,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.67.0.tgz",
|
||||
"integrity": "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -8959,9 +8959,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.67.0.tgz",
|
||||
"integrity": "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -18934,9 +18934,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.0.tgz",
|
||||
"integrity": "sha512-qqGFOrKmFP1lTfG24opOJFcTMza1BqyTSUKVbMGUP5uRsBH+C00Q1loOk+JSFshyRE0ji4HtCJeNN2WHWd6PGw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -20750,9 +20750,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -21801,9 +21801,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"version": "5.22.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz",
|
||||
"integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -37424,9 +37424,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.66.0.tgz",
|
||||
"integrity": "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.67.0.tgz",
|
||||
"integrity": "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -37439,32 +37439,36 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint/binding-android-arm-eabi": "1.66.0",
|
||||
"@oxlint/binding-android-arm64": "1.66.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.66.0",
|
||||
"@oxlint/binding-darwin-x64": "1.66.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.66.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.66.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.66.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.66.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.66.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.66.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.66.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.66.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.66.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.66.0"
|
||||
"@oxlint/binding-android-arm-eabi": "1.67.0",
|
||||
"@oxlint/binding-android-arm64": "1.67.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.67.0",
|
||||
"@oxlint/binding-darwin-x64": "1.67.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.67.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.67.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.67.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.67.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.67.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.67.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.67.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.67.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.67.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.67.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint-tsgolint": ">=0.22.1"
|
||||
"oxlint-tsgolint": ">=0.22.1",
|
||||
"vite-plus": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"oxlint-tsgolint": {
|
||||
"optional": true
|
||||
},
|
||||
"vite-plus": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -39401,9 +39405,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz",
|
||||
"integrity": "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==",
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.4.0.tgz",
|
||||
"integrity": "sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.4.1",
|
||||
@@ -47310,9 +47314,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.107.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.1.tgz",
|
||||
"integrity": "sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==",
|
||||
"version": "5.107.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz",
|
||||
"integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -47325,7 +47329,7 @@
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.21.4",
|
||||
"enhanced-resolve": "^5.22.0",
|
||||
"es-module-lexer": "^2.1.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
@@ -47338,7 +47342,7 @@
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.5.0",
|
||||
"watchpack": "^2.5.1",
|
||||
"webpack-sources": "^3.4.1"
|
||||
"webpack-sources": "^3.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"webpack": "bin/webpack.js"
|
||||
@@ -49297,6 +49301,7 @@
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
@@ -49311,8 +49316,8 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.4.5",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.7",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
@@ -49326,7 +49331,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -49427,15 +49432,6 @@
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
@@ -49446,9 +49442,9 @@
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -49827,7 +49823,7 @@
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-tip": "^0.9.1",
|
||||
"dompurify": "^3.4.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
@@ -49838,14 +49834,14 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -49941,7 +49937,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "*",
|
||||
"memoize-one": "*",
|
||||
"react": "^18.2.0"
|
||||
@@ -49999,7 +49995,7 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"ace-builds": "^1.4.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.18.1",
|
||||
"react": "^18.2.0",
|
||||
@@ -50047,7 +50043,7 @@
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -50215,7 +50211,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"mapbox-gl": ">=1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -169,10 +169,10 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "^1.11.21",
|
||||
"dom-to-image-more": "^3.7.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
@@ -202,7 +202,7 @@
|
||||
"nanoid": "^5.1.11",
|
||||
"ol": "^10.9.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
@@ -248,7 +248,7 @@
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.29.7",
|
||||
"@babel/node": "^7.29.0",
|
||||
"@babel/node": "^7.29.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
|
||||
@@ -256,9 +256,9 @@
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
"@babel/register": "^7.29.3",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime-corejs3": "^7.29.2",
|
||||
"@babel/register": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@babel/runtime-corejs3": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
@@ -353,7 +353,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"oxlint": "^1.67.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
@@ -375,7 +375,7 @@
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.4.5",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.7",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
@@ -57,7 +57,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
+48
-1
@@ -31,6 +31,53 @@ interface SafeMarkdownProps {
|
||||
htmlSchemaOverrides?: typeof defaultSchema;
|
||||
}
|
||||
|
||||
// Link protocols that can execute script when used as an href.
|
||||
const DANGEROUS_LINK_PROTOCOLS = ['javascript', 'vbscript', 'data'];
|
||||
|
||||
/**
|
||||
* Sanitize link hrefs without using react-markdown's default protocol
|
||||
* allowlist, which would strip the custom link schemes that Superset markdown
|
||||
* is expected to support (see #26211). Instead of allowlisting known-safe
|
||||
* protocols, this blocks the protocols that enable script execution and leaves
|
||||
* everything else (http(s), mailto, relative URLs, anchors and custom schemes)
|
||||
* untouched. Applied regardless of the EscapeMarkdownHtml feature flag.
|
||||
*/
|
||||
export function transformLinkUri(uri: string): string {
|
||||
// Per the WHATWG URL parser, browsers strip leading C0 control
|
||||
// characters (\x00-\x1f) and space before resolving the scheme, so e.g.
|
||||
// "\x01javascript:alert(1)" executes on click. Strip them here too,
|
||||
// otherwise the blocklist check below could be bypassed with a leading
|
||||
// control character. The pattern is anchored at the start so it runs in
|
||||
// linear time; trailing whitespace does not affect the scheme and is
|
||||
// left for the renderer to handle.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const url = (uri || '').replace(/^[\u0000-\u0020]+/, '');
|
||||
const first = url.charAt(0);
|
||||
// Anchors and absolute/relative paths have no protocol.
|
||||
if (first === '#' || first === '/') {
|
||||
return url;
|
||||
}
|
||||
const colon = url.indexOf(':');
|
||||
if (colon === -1) {
|
||||
return url;
|
||||
}
|
||||
// A ':' after a '?' or '#' belongs to the query/fragment, not a scheme.
|
||||
const queryIndex = url.indexOf('?');
|
||||
if (queryIndex !== -1 && colon > queryIndex) {
|
||||
return url;
|
||||
}
|
||||
const hashIndex = url.indexOf('#');
|
||||
if (hashIndex !== -1 && colon > hashIndex) {
|
||||
return url;
|
||||
}
|
||||
// Whitespace and C0 control characters inside the scheme (e.g.
|
||||
// "java\tscript:" or "java\x01script:") are ignored by browsers, so strip
|
||||
// them before comparing against the blocklist.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const scheme = url.slice(0, colon).replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
||||
return DANGEROUS_LINK_PROTOCOLS.includes(scheme) ? '' : url;
|
||||
}
|
||||
|
||||
export function getOverrideHtmlSchema(
|
||||
originalSchema: typeof defaultSchema,
|
||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||
@@ -82,7 +129,7 @@ export function SafeMarkdown({
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml={false}
|
||||
transformLinkUri={null}
|
||||
transformLinkUri={transformLinkUri}
|
||||
>
|
||||
{source}
|
||||
</ReactMarkdown>
|
||||
|
||||
+6
@@ -214,6 +214,12 @@ test('Bulk selection should work with pagination', () => {
|
||||
// Check that selection checkboxes are rendered
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBeGreaterThan(0);
|
||||
|
||||
// Guard: the select-all column header carries `data-test="header-toggle-all"`,
|
||||
// which the `header.cell` slot keys on antd's internal `ant-table-selection-column`
|
||||
// class. If antd renames that class, this assertion fails fast at the unit level
|
||||
// instead of leaking into Playwright as a flake.
|
||||
expect(screen.getByTestId('header-toggle-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call setSortBy when clicking sortable column header', () => {
|
||||
|
||||
+23
-3
@@ -196,6 +196,14 @@ function TableCollection<T extends object>({
|
||||
const rowSelection: TableRowSelection | undefined = useMemo(() => {
|
||||
if (!bulkSelectEnabled) return undefined;
|
||||
|
||||
// antd Table's `rowSelection` API renders its own checkbox column.
|
||||
// The select-all `data-test` lives on the `<th>` via `header.cell`
|
||||
// below (keyed on antd's `ant-table-selection-column` className), NOT
|
||||
// via `columnTitle` — rc-table's MeasureCell renders the column
|
||||
// `title` verbatim inside `<tbody>`, so a `columnTitle` wrapper leaks
|
||||
// any `data-test` attr into the measure row and breaks Playwright
|
||||
// strict-mode selectors. `renderCell` only renders in real body rows,
|
||||
// so wrapping per-row checkboxes there is safe.
|
||||
return {
|
||||
selectedRowKeys,
|
||||
onSelect: (record, selected) => {
|
||||
@@ -204,6 +212,9 @@ function TableCollection<T extends object>({
|
||||
onSelectAll: (selected: boolean) => {
|
||||
toggleAllRowsSelected?.(selected);
|
||||
},
|
||||
renderCell: (_value, _record, _index, originNode) => (
|
||||
<span data-test="row-select-checkbox">{originNode}</span>
|
||||
),
|
||||
};
|
||||
}, [
|
||||
bulkSelectEnabled,
|
||||
@@ -306,9 +317,18 @@ function TableCollection<T extends object>({
|
||||
rowClassName={getRowClassName}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th {...props} data-test="sort-header" />
|
||||
),
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => {
|
||||
const isSelectionColumn =
|
||||
props.className?.includes('ant-table-selection-column') ?? false;
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
data-test={
|
||||
isSelectionColumn ? 'header-toggle-all' : 'sort-header'
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
body: {
|
||||
row: (props: HTMLAttributes<HTMLTableRowElement>) => (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { render } from '@testing-library/react';
|
||||
import {
|
||||
getOverrideHtmlSchema,
|
||||
SafeMarkdown,
|
||||
transformLinkUri,
|
||||
} from '../../src/components/SafeMarkdown/SafeMarkdown';
|
||||
|
||||
/**
|
||||
@@ -52,6 +53,63 @@ describe('getOverrideHtmlSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformLinkUri', () => {
|
||||
// Build script-executing protocols via concatenation so the literal URLs
|
||||
// don't trip the no-script-url lint rule.
|
||||
const js = `java${'script'}`;
|
||||
const vbs = `vb${'script'}`;
|
||||
|
||||
// Cases are [label, uri] pairs: the raw URIs contain C0 control characters
|
||||
// (\x00, \x01, \x1F) that are invalid in XML, so they must not be
|
||||
// interpolated into the test name (the HTML/JUnit reporters serialize names
|
||||
// to XML and would crash). The label keeps the reported name printable while
|
||||
// the uri is exercised in the body.
|
||||
test.each([
|
||||
['javascript', `${js}:alert(1)`],
|
||||
['mixed-case JavaScript', `Java${'Script'}:alert(1)`],
|
||||
['leading whitespace', ` ${js}:alert(document.cookie)`],
|
||||
['tab inside scheme', `java\t${'script'}:alert(1)`],
|
||||
// Leading C0 control characters are stripped by the WHATWG URL parser
|
||||
// before the scheme is resolved, so they must not bypass the blocklist.
|
||||
['leading 0x01 control', `\x01${js}:alert(1)`],
|
||||
['leading NUL (0x00)', `\x00${js}:alert(1)`],
|
||||
['leading 0x1F control', `\x1F${js}:alert(1)`],
|
||||
// C0 control characters inside the scheme are ignored by browsers too.
|
||||
['0x01 control inside scheme', `java\x01${'script'}:alert(1)`],
|
||||
['vbscript', `${vbs}:msgbox(1)`],
|
||||
['data: text/html', 'data:text/html,<script>alert(1)</script>'],
|
||||
])(
|
||||
'blocks the script-executing protocol (%s)',
|
||||
(_label: string, uri: string) => {
|
||||
expect(transformLinkUri(uri)).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
'https://superset.apache.org',
|
||||
'http://example.com/path?q=1',
|
||||
'mailto:someone@example.com',
|
||||
'/relative/path',
|
||||
'#section',
|
||||
])('keeps the safe URL %p unchanged', uri => {
|
||||
expect(transformLinkUri(uri)).toBe(uri);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'custom-scheme://open/thing',
|
||||
'slack://channel?id=1',
|
||||
`foo:bar?${js}:alert(1)`,
|
||||
])('preserves custom link scheme %p (see #26211)', uri => {
|
||||
expect(transformLinkUri(uri)).toBe(uri);
|
||||
});
|
||||
|
||||
test('handles empty and nullish input', () => {
|
||||
expect(transformLinkUri('')).toBe('');
|
||||
// @ts-expect-error -- guarding runtime nullish input
|
||||
expect(transformLinkUri(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SafeMarkdown', () => {
|
||||
describe('remark-gfm compatibility tests', () => {
|
||||
/**
|
||||
|
||||
@@ -17,14 +17,24 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
import { Button, Checkbox, Table } from '../core';
|
||||
|
||||
const BULK_SELECT_SELECTORS = {
|
||||
CONTROLS: '[data-test="bulk-select-controls"]',
|
||||
ACTION: '[data-test="bulk-select-action"]',
|
||||
HEADER_TOGGLE: '[data-test="header-toggle-all"]',
|
||||
ROW_CHECKBOX: '[data-test="row-select-checkbox"]',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Stable keys for ListView bulk actions, matching `action.key` in the
|
||||
* `bulkActions` prop passed to `ListView` (see `src/pages/*List`). Using
|
||||
* the key — not the localized button text — keeps selectors valid across
|
||||
* locales.
|
||||
*/
|
||||
export type BulkSelectActionKey = 'delete' | 'export';
|
||||
|
||||
/**
|
||||
* BulkSelect component for Superset ListView bulk operations.
|
||||
* Provides a reusable interface for bulk selection and actions across list pages.
|
||||
@@ -34,7 +44,7 @@ const BULK_SELECT_SELECTORS = {
|
||||
* await bulkSelect.enable();
|
||||
* await bulkSelect.selectRow('my-dataset');
|
||||
* await bulkSelect.selectRow('another-dataset');
|
||||
* await bulkSelect.clickAction('Delete');
|
||||
* await bulkSelect.clickAction('delete');
|
||||
*/
|
||||
export class BulkSelect {
|
||||
private readonly page: Page;
|
||||
@@ -56,35 +66,67 @@ export class BulkSelect {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables bulk selection mode by clicking the toggle button
|
||||
* Enables bulk selection mode by clicking the toggle button.
|
||||
*
|
||||
* Waits for the bulk-select column header to render so the next row
|
||||
* interaction does not race the table re-render that adds the checkbox
|
||||
* column. The `data-test="header-toggle-all"` attribute is on the
|
||||
* select-all `<th>` itself (see `TableCollection`'s `components.header.cell`
|
||||
* slot, which keys on antd's `ant-table-selection-column` className).
|
||||
* It deliberately is NOT injected via `rowSelection.columnTitle` because
|
||||
* rc-table's measure row in `<tbody>` clones `columnTitle` and any
|
||||
* `data-test` would duplicate, breaking Playwright strict mode.
|
||||
*/
|
||||
async enable(): Promise<void> {
|
||||
await this.getToggleButton().click();
|
||||
await this.page.locator(BULK_SELECT_SELECTORS.HEADER_TOGGLE).waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the checkbox for a row by name
|
||||
* Gets the bulk-select checkbox for a row by name.
|
||||
*
|
||||
* The `data-test="row-select-checkbox"` attribute is on the `<span>`
|
||||
* wrapper that `TableCollection`'s `rowSelection.renderCell` puts around
|
||||
* antd's checkbox originNode (the attribute can't be moved directly
|
||||
* onto antd's `<input>` from `renderCell` because the originNode is
|
||||
* opaque). We drill into `input[type="checkbox"]` so Playwright's
|
||||
* `.check()` operates on the real input — `.check()` on the wrapper
|
||||
* `<span>` throws "Not a checkbox or radio button".
|
||||
*
|
||||
* @param rowName - The name/text identifying the row
|
||||
*/
|
||||
getRowCheckbox(rowName: string): Checkbox {
|
||||
const row = this.table.getRow(rowName);
|
||||
return new Checkbox(this.page, row.getByRole('checkbox'));
|
||||
return new Checkbox(
|
||||
this.page,
|
||||
row.locator(
|
||||
`${BULK_SELECT_SELECTORS.ROW_CHECKBOX} input[type="checkbox"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a row's checkbox in bulk select mode
|
||||
* Selects a row's checkbox in bulk select mode.
|
||||
* Asserts the checkbox is checked afterwards so any state-update race
|
||||
* surfaces here rather than as a missing bulk-action button later.
|
||||
* @param rowName - The name/text identifying the row to select
|
||||
*/
|
||||
async selectRow(rowName: string): Promise<void> {
|
||||
await this.getRowCheckbox(rowName).check();
|
||||
const checkbox = this.getRowCheckbox(rowName);
|
||||
await checkbox.check();
|
||||
await expect(checkbox.element).toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselects a row's checkbox in bulk select mode
|
||||
* Deselects a row's checkbox in bulk select mode.
|
||||
* Mirrors selectRow: asserts the unchecked state so any lingering selection
|
||||
* surfaces here rather than as a stale bulk-action count later.
|
||||
* @param rowName - The name/text identifying the row to deselect
|
||||
*/
|
||||
async deselectRow(rowName: string): Promise<void> {
|
||||
await this.getRowCheckbox(rowName).uncheck();
|
||||
const checkbox = this.getRowCheckbox(rowName);
|
||||
await checkbox.uncheck();
|
||||
await expect(checkbox.element).not.toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,22 +137,30 @@ export class BulkSelect {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a bulk action button by name
|
||||
* @param actionName - The name of the bulk action (e.g., "Export", "Delete")
|
||||
* Gets a bulk action button by its stable action key.
|
||||
*
|
||||
* Scoping by `data-test-action-key` (rendered from `action.key`) instead
|
||||
* of visible text keeps this selector valid across locales — the
|
||||
* button's label is localized via i18n, but the action key is not.
|
||||
*
|
||||
* @param actionKey - The stable key of the bulk action (e.g., "delete", "export")
|
||||
*/
|
||||
getActionButton(actionName: string): Button {
|
||||
getActionButton(actionKey: BulkSelectActionKey): Button {
|
||||
const controls = this.getControls();
|
||||
return new Button(
|
||||
this.page,
|
||||
controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
|
||||
controls.locator(
|
||||
`${BULK_SELECT_SELECTORS.ACTION}[data-test-action-key="${actionKey}"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key.
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickAction(actionName: string): Promise<void> {
|
||||
await this.getActionButton(actionName).click();
|
||||
async clickAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
const button = this.getActionButton(actionKey);
|
||||
await button.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@
|
||||
|
||||
// ListView-specific Playwright Components for Superset
|
||||
export { BulkSelect } from './BulkSelect';
|
||||
export type { BulkSelectActionKey } from './BulkSelect';
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { Modal, Input } from '../core';
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,8 @@ import { Modal, Input } from '../core';
|
||||
*/
|
||||
export class DeleteConfirmationModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
CONFIRMATION_INPUT: 'input[type="text"]',
|
||||
CONFIRMATION_INPUT: '[data-test="delete-modal-input"]',
|
||||
CONFIRM_BUTTON: '[data-test="modal-confirm-button"]',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,12 +38,16 @@ export class DeleteConfirmationModal extends Modal {
|
||||
private get confirmationInput(): Input {
|
||||
return new Input(
|
||||
this.page,
|
||||
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
|
||||
this.element.locator(
|
||||
DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the confirmation input with the specified text.
|
||||
* Waits for the input to be visible before filling so callers don't race
|
||||
* with the modal's open animation / focus effect.
|
||||
*
|
||||
* @param confirmationText - The text to type
|
||||
* @param options - Optional fill options (timeout, force)
|
||||
@@ -57,11 +63,25 @@ export class DeleteConfirmationModal extends Modal {
|
||||
confirmationText: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.confirmationInput.element.waitFor({
|
||||
state: 'visible',
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
await this.confirmationInput.fill(confirmationText, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Delete button in the footer
|
||||
* Clicks the Delete button in the footer.
|
||||
*
|
||||
* Targets the confirm button by data-test rather than going through
|
||||
* Modal.clickFooterButton, which finds buttons by their visible text. The
|
||||
* button label is i18n'd ("Delete" / "Supprimer" / …) so name-based lookups
|
||||
* break in non-English locales.
|
||||
*
|
||||
* Also waits for the button to become enabled before clicking: it is
|
||||
* disabled until the confirmation text matches "DELETE", and React's state
|
||||
* update from fillConfirmationInput is asynchronous, so an immediate click
|
||||
* can race the disabled→enabled transition.
|
||||
*
|
||||
* @param options - Optional click options (timeout, force, delay)
|
||||
*/
|
||||
@@ -70,6 +90,10 @@ export class DeleteConfirmationModal extends Modal {
|
||||
force?: boolean;
|
||||
delay?: number;
|
||||
}): Promise<void> {
|
||||
await this.clickFooterButton('Delete', options);
|
||||
const confirmButton = this.element.locator(
|
||||
DeleteConfirmationModal.SELECTORS.CONFIRM_BUTTON,
|
||||
);
|
||||
await expect(confirmButton).toBeEnabled({ timeout: options?.timeout });
|
||||
await confirmButton.click(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
@@ -32,13 +32,12 @@ export class ChartListPage {
|
||||
readonly bulkSelect: BulkSelect;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* Verified: ChartList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
|
||||
* Stable data-test keys for the row action buttons in ChartList.
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'chart-row-delete',
|
||||
EDIT: 'chart-row-edit',
|
||||
EXPORT: 'chart-row-export',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -98,9 +97,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickDeleteAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,9 +106,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickEditAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,9 +115,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickExportAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,11 +134,11 @@ export class ChartListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
// --- Card view methods ---
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Button, Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
@@ -32,13 +32,12 @@ export class DashboardListPage {
|
||||
readonly bulkSelect: BulkSelect;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* DashboardList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
|
||||
* Stable data-test keys for the row action buttons in DashboardList.
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'dashboard-row-delete',
|
||||
EDIT: 'dashboard-row-edit',
|
||||
EXPORT: 'dashboard-row-export',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -81,9 +80,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickDeleteAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,9 +89,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickEditAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,9 +98,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickExportAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,11 +117,11 @@ export class DashboardListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Button, Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
@@ -36,13 +36,14 @@ export class DatasetListPage {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* Stable data-test keys for the row action buttons in DatasetList
|
||||
* (shared with the semantic-view rendering since only one renders per row).
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload', // Export button uses upload icon
|
||||
DUPLICATE: 'copy',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'dataset-row-delete',
|
||||
EDIT: 'dataset-row-edit',
|
||||
EXPORT: 'dataset-row-export',
|
||||
DUPLICATE: 'dataset-row-duplicate',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -97,9 +98,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickDeleteAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,9 +107,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickEditAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,9 +116,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickExportAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,9 +125,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickDuplicateAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DUPLICATE })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.DUPLICATE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,11 +144,11 @@ export class DatasetListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
expectStatusOneOf,
|
||||
expectValidExportZip,
|
||||
} from '../../helpers/api/assertions';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
|
||||
@@ -62,8 +63,11 @@ test('should delete a chart with confirmation', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click delete action button
|
||||
await chartListPage.clickDeleteAction(chartName);
|
||||
@@ -81,12 +85,14 @@ test('should delete a chart with confirmation', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify chart is removed from list
|
||||
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
|
||||
// Verify chart is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(chartListPage.getChartRow(chartName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: API returns 404
|
||||
await expectDeleted(page, ENDPOINTS.CHART, chartId, {
|
||||
@@ -111,8 +117,11 @@ test('should edit chart name via properties modal', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click edit action to open properties modal
|
||||
await chartListPage.clickEditAction(chartName);
|
||||
@@ -137,7 +146,7 @@ test('should edit chart name via properties modal', async ({
|
||||
// Modal should close
|
||||
await propertiesModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
@@ -164,8 +173,11 @@ test('should export a chart as a zip file', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
|
||||
@@ -186,7 +198,7 @@ test('should bulk delete multiple charts', async ({
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway charts for bulk delete
|
||||
const [chart1, chart2] = await Promise.all([
|
||||
@@ -202,9 +214,14 @@ test('should bulk delete multiple charts', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify both charts are visible in list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created charts appear.
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await chartListPage.clickBulkSelectButton();
|
||||
@@ -214,7 +231,7 @@ test('should bulk delete multiple charts', async ({
|
||||
await chartListPage.selectChartCheckbox(chart2.name);
|
||||
|
||||
// Click bulk delete action
|
||||
await chartListPage.clickBulkAction('Delete');
|
||||
await chartListPage.clickBulkAction('delete');
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
@@ -229,13 +246,17 @@ test('should bulk delete multiple charts', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify both charts are removed from list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible();
|
||||
// Verify both charts are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: Both return 404
|
||||
for (const chart of [chart1, chart2]) {
|
||||
@@ -259,8 +280,11 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
|
||||
await cardListPage.gotoCardView();
|
||||
await cardListPage.waitForCardLoad();
|
||||
|
||||
// Verify chart card is visible
|
||||
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart card appears.
|
||||
await expect(cardListPage.getChartCard(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Open card dropdown and click edit
|
||||
await cardListPage.clickCardEditAction(chartName);
|
||||
@@ -285,13 +309,18 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
|
||||
// Modal should close
|
||||
await propertiesModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify the renamed card appears in card view and old name is gone
|
||||
await expect(cardListPage.getChartCard(newName)).toBeVisible();
|
||||
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
|
||||
// (the old card name is removed from the DOM after the rename re-render).
|
||||
await expect(cardListPage.getChartCard(newName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(cardListPage.getChartCard(chartName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: API returns updated name
|
||||
const response = await apiGetChart(page, chartId);
|
||||
@@ -304,6 +333,11 @@ test('should bulk export multiple charts', async ({
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Chains create×2 → refresh → bulk select → export. Matches the
|
||||
// sibling bulk-delete test's budget so the export response wait below
|
||||
// can exceed the 30s default without hitting the test timeout.
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway charts for bulk export
|
||||
const [chart1, chart2] = await Promise.all([
|
||||
createTestChart(page, testAssets, test.info(), {
|
||||
@@ -318,9 +352,14 @@ test('should bulk export multiple charts', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify both charts are visible in list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created charts appear.
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await chartListPage.clickBulkSelectButton();
|
||||
@@ -329,11 +368,15 @@ test('should bulk export multiple charts', async ({
|
||||
await chartListPage.selectChartCheckbox(chart1.name);
|
||||
await chartListPage.selectChartCheckbox(chart2.name);
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
|
||||
// Set up API response intercept BEFORE the click that triggers it.
|
||||
// Exports of multiple charts can take longer than 30s under load,
|
||||
// so use SLOW_TEST instead of the default test-timeout-bound budget.
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT, {
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
|
||||
// Click bulk export action
|
||||
await chartListPage.clickBulkAction('Export');
|
||||
await chartListPage.clickBulkAction('export');
|
||||
|
||||
// Wait for export API response and validate zip contains both charts
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
|
||||
@@ -68,8 +68,11 @@ test('should delete a dashboard with confirmation', async ({
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify dashboard is visible in list
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dashboard appears.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click delete action button
|
||||
await dashboardListPage.clickDeleteAction(dashboardName);
|
||||
@@ -81,20 +84,25 @@ test('should delete a dashboard with confirmation', async ({
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
// Click the Delete button (waits for it to become enabled)
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify dashboard is removed from list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboardName),
|
||||
).not.toBeVisible();
|
||||
// Verify dashboard is removed from list (extended timeout for slow CI
|
||||
// post-delete propagation — the default 8s expect.timeout intermittently
|
||||
// expires before the listview re-fetch lands).
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
|
||||
0,
|
||||
{
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
},
|
||||
);
|
||||
|
||||
// Backend verification: API returns 404
|
||||
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
|
||||
@@ -119,8 +127,11 @@ test('should export a dashboard as a zip file', async ({
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify dashboard is visible in list
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dashboard appears.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
|
||||
@@ -141,7 +152,7 @@ test('should bulk delete multiple dashboards', async ({
|
||||
dashboardListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway dashboards for bulk delete
|
||||
const [dashboard1, dashboard2] = await Promise.all([
|
||||
@@ -157,13 +168,14 @@ test('should bulk delete multiple dashboards', async ({
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify both dashboards are visible in list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard1.name),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard2.name),
|
||||
).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dashboards appear.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await dashboardListPage.clickBulkSelectButton();
|
||||
@@ -173,7 +185,7 @@ test('should bulk delete multiple dashboards', async ({
|
||||
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
|
||||
|
||||
// Click bulk delete action
|
||||
await dashboardListPage.clickBulkAction('Delete');
|
||||
await dashboardListPage.clickBulkAction('delete');
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
@@ -188,17 +200,19 @@ test('should bulk delete multiple dashboards', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify both dashboards are removed from list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard1.name),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard2.name),
|
||||
).not.toBeVisible();
|
||||
// Verify both dashboards are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toHaveCount(
|
||||
0,
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toHaveCount(
|
||||
0,
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
|
||||
// Backend verification: Both return 404
|
||||
for (const dashboard of [dashboard1, dashboard2]) {
|
||||
@@ -213,6 +227,11 @@ test('should bulk export multiple dashboards', async ({
|
||||
dashboardListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Chains create×2 → refresh → bulk select → export. Matches the
|
||||
// sibling bulk-delete test's budget so the export response wait below
|
||||
// can exceed the 30s default without hitting the test timeout.
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway dashboards for bulk export
|
||||
const [dashboard1, dashboard2] = await Promise.all([
|
||||
createTestDashboard(page, testAssets, test.info(), {
|
||||
@@ -227,26 +246,31 @@ test('should bulk export multiple dashboards', async ({
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify both dashboards are visible in list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard1.name),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard2.name),
|
||||
).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dashboards appear.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
// Enable bulk select mode (waits for the checkbox column to render)
|
||||
await dashboardListPage.clickBulkSelectButton();
|
||||
|
||||
// Select both dashboards
|
||||
// Select both dashboards (each call asserts the checkbox is checked)
|
||||
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
|
||||
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
|
||||
// Set up API response intercept BEFORE the click that triggers it.
|
||||
// Exports of multiple dashboards can take longer than 30s under load,
|
||||
// so use SLOW_TEST instead of the default test-timeout-bound budget.
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT, {
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
|
||||
// Click bulk export action
|
||||
await dashboardListPage.clickBulkAction('Export');
|
||||
// Click bulk export action (waits for the action button to render)
|
||||
await dashboardListPage.clickBulkAction('export');
|
||||
|
||||
// Wait for export API response and validate zip contains both dashboards
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
@@ -262,14 +286,15 @@ test('should bulk export multiple dashboards', async ({
|
||||
// this prevents race conditions when parallel workers import the same dashboard.
|
||||
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
|
||||
test.describe('import dashboard', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
// `timeout` on describe.configure also bounds fixture setup, so the
|
||||
// `dashboardListPage` navigation gets the SLOW_TEST budget too —
|
||||
// inline `test.setTimeout()` only applies once the test body runs.
|
||||
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
|
||||
test('should import a dashboard from a zip file', async ({
|
||||
page,
|
||||
dashboardListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Create a dashboard, export it via API, then delete it, then reimport via UI
|
||||
const { id: dashboardId, name: dashboardName } = await createTestDashboard(
|
||||
page,
|
||||
@@ -293,12 +318,13 @@ test.describe('import dashboard', () => {
|
||||
label: `Dashboard ${dashboardId}`,
|
||||
});
|
||||
|
||||
// Refresh to confirm dashboard is no longer in the list
|
||||
// Refresh to confirm dashboard is no longer in the list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboardName),
|
||||
).not.toBeVisible();
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
|
||||
0,
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
|
||||
// Click the import button
|
||||
await dashboardListPage.clickImportButton();
|
||||
@@ -328,7 +354,7 @@ test.describe('import dashboard', () => {
|
||||
// Handle overwrite confirmation if dashboard already exists
|
||||
const overwriteInput = importModal.getOverwriteInput();
|
||||
await overwriteInput
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT.CONFIRM_DIALOG })
|
||||
.catch(error => {
|
||||
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
|
||||
throw error;
|
||||
@@ -350,18 +376,21 @@ test.describe('import dashboard', () => {
|
||||
// Modal should close on success
|
||||
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
|
||||
await expect(toast.getSuccess()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Refresh to see the imported dashboard
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify dashboard appears in list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboardName),
|
||||
).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-imported dashboard appears.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Track for cleanup: look up the reimported dashboard by title
|
||||
const reimported = await getDashboardByName(page, dashboardName);
|
||||
|
||||
@@ -107,8 +107,11 @@ test('should delete a dataset with confirmation', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify dataset is visible in list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dataset appears.
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click delete action button
|
||||
await datasetListPage.clickDeleteAction(datasetName);
|
||||
@@ -126,14 +129,15 @@ test('should delete a dataset with confirmation', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears with correct message
|
||||
// Verify success toast appears with correct message.
|
||||
const toast = new Toast(page);
|
||||
const successToast = toast.getSuccess();
|
||||
await expect(successToast).toBeVisible();
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
await expect(toast.getMessage()).toContainText('Deleted');
|
||||
|
||||
// Verify dataset is removed from list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
|
||||
// Verify dataset is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Verify via API that dataset no longer exists (404)
|
||||
await expectDeleted(page, ENDPOINTS.DATASET, datasetId, {
|
||||
@@ -155,10 +159,13 @@ test('should duplicate a dataset with new name', async ({
|
||||
);
|
||||
const duplicateName = `duplicate_${Date.now()}_${test.info().parallelIndex}`;
|
||||
|
||||
// Navigate to list and verify original dataset is visible
|
||||
// Navigate to list and verify original dataset is visible.
|
||||
// The list query is asynchronous; allow extra time on slow CI.
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Set up response intercept to capture duplicate dataset ID
|
||||
const duplicateResponsePromise = waitForPost(
|
||||
@@ -201,9 +208,14 @@ test('should duplicate a dataset with new name', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify both datasets exist in list
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// duplicate appears alongside the original.
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// API Verification: Fetch both datasets via detail API for consistent comparison
|
||||
// (list API may return undefined for fields that detail API returns as null)
|
||||
@@ -256,6 +268,11 @@ test('should export multiple datasets via bulk select action', async ({
|
||||
datasetListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Chains create×2 → refresh → bulk select → export. Matches the
|
||||
// sibling bulk-delete test's budget so the export response wait below
|
||||
// can exceed the 30s default without hitting the test timeout.
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway datasets for bulk export
|
||||
const [dataset1, dataset2] = await Promise.all([
|
||||
createTestDataset(page, testAssets, test.info(), {
|
||||
@@ -270,9 +287,14 @@ test('should export multiple datasets via bulk select action', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify both datasets are visible in list
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created datasets appear.
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await datasetListPage.clickBulkSelectButton();
|
||||
@@ -281,11 +303,15 @@ test('should export multiple datasets via bulk select action', async ({
|
||||
await datasetListPage.selectDatasetCheckbox(dataset1.name);
|
||||
await datasetListPage.selectDatasetCheckbox(dataset2.name);
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
|
||||
// Set up API response intercept BEFORE the click that triggers it.
|
||||
// Exports of multiple datasets can take longer than 30s under load,
|
||||
// so use SLOW_TEST instead of the default test-timeout-bound budget.
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT, {
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
|
||||
// Click bulk export action
|
||||
await datasetListPage.clickBulkAction('Export');
|
||||
await datasetListPage.clickBulkAction('export');
|
||||
|
||||
// Wait for export API response and validate zip contains multiple datasets
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
@@ -312,8 +338,11 @@ test('should edit dataset name via modal', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify dataset is visible in list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dataset appears.
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click edit action to open modal
|
||||
await datasetListPage.clickEditAction(datasetName);
|
||||
@@ -348,9 +377,9 @@ test('should edit dataset name via modal', async ({
|
||||
// Modal should close
|
||||
await editModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
|
||||
await expect(toast.getSuccess()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Verify via API that name was saved
|
||||
const updatedDatasetRes = await apiGetDataset(page, datasetId);
|
||||
@@ -363,6 +392,8 @@ test('should bulk delete multiple datasets', async ({
|
||||
datasetListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway datasets for bulk delete
|
||||
const [dataset1, dataset2] = await Promise.all([
|
||||
createTestDataset(page, testAssets, test.info(), {
|
||||
@@ -377,9 +408,14 @@ test('should bulk delete multiple datasets', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify both datasets are visible in list
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created datasets appear.
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await datasetListPage.clickBulkSelectButton();
|
||||
@@ -389,7 +425,7 @@ test('should bulk delete multiple datasets', async ({
|
||||
await datasetListPage.selectDatasetCheckbox(dataset2.name);
|
||||
|
||||
// Click bulk delete action
|
||||
await datasetListPage.clickBulkAction('Delete');
|
||||
await datasetListPage.clickBulkAction('delete');
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
@@ -404,13 +440,17 @@ test('should bulk delete multiple datasets', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify both datasets are removed from list
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
|
||||
// Verify both datasets are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Verify via API that datasets no longer exist (404)
|
||||
await expectDeleted(page, ENDPOINTS.DATASET, dataset1.id, {
|
||||
@@ -426,14 +466,15 @@ test('should bulk delete multiple datasets', async ({
|
||||
// this prevents race conditions when parallel workers import the same dataset.
|
||||
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
|
||||
test.describe('import dataset', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
// `timeout` on describe.configure also bounds fixture setup, so the
|
||||
// `datasetListPage` navigation gets the SLOW_TEST budget too —
|
||||
// inline `test.setTimeout()` only applies once the test body runs.
|
||||
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
|
||||
test('should import a dataset from a zip file', async ({
|
||||
page,
|
||||
datasetListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Create a dataset, export it via API, then delete it, then reimport via UI
|
||||
const { id: datasetId, name: datasetName } = await createTestDataset(
|
||||
page,
|
||||
@@ -455,10 +496,12 @@ test.describe('import dataset', () => {
|
||||
label: `Dataset ${datasetId}`,
|
||||
});
|
||||
|
||||
// Refresh to confirm dataset is no longer in the list
|
||||
// Refresh to confirm dataset is no longer in the list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click the import button
|
||||
await datasetListPage.clickImportButton();
|
||||
@@ -485,7 +528,7 @@ test.describe('import dataset', () => {
|
||||
// First response may be 409/422 indicating overwrite is required
|
||||
const overwriteInput = importModal.getOverwriteInput();
|
||||
await overwriteInput
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT.CONFIRM_DIALOG })
|
||||
.catch(error => {
|
||||
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
|
||||
throw error;
|
||||
@@ -507,16 +550,21 @@ test.describe('import dataset', () => {
|
||||
// Modal should close on success
|
||||
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
|
||||
await expect(toast.getSuccess()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Refresh to see the imported dataset
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify dataset appears in list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-imported dataset appears.
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Track for cleanup: the dataset import API returns {"message": "OK"}
|
||||
// with no ID, so look up the reimported dataset by name.
|
||||
|
||||
@@ -65,9 +65,12 @@ export const TIMEOUT = {
|
||||
UI_TRANSITION: 5000, // 5s ceiling for Ant Design animations (~300-500ms actual)
|
||||
|
||||
/**
|
||||
* SQL query execution (query → backend processing → results)
|
||||
* SQL query execution (query → backend processing → results).
|
||||
* 30s matches Playwright's default test timeout — cold-start CI on the
|
||||
* /app/prefix variant has been observed running trivial SELECTs in
|
||||
* ~25s before results render, which exceeded the previous 15s budget.
|
||||
*/
|
||||
QUERY_EXECUTION: 15000, // 15s for SQL queries that may take longer than default expect timeout
|
||||
QUERY_EXECUTION: 30000, // 30s for SQL queries
|
||||
|
||||
/**
|
||||
* Extended test timeout for multi-step tests (page load + query execution + assertions).
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.4.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
@@ -42,7 +42,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -165,6 +165,26 @@ function escapeSQLString(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a range-filter bound to a finite number, or null if it is not a valid
|
||||
* numeric value. Unlike a bare Number() call, empty and whitespace-only strings
|
||||
* are rejected (Number('') === 0), so they never get interpolated into SQL.
|
||||
* @param value - Raw bound value from the AG Grid filter model
|
||||
* @returns The finite number, or null if the value is not numeric
|
||||
*/
|
||||
function toFiniteNumber(value: FilterValue | undefined): number | null {
|
||||
// Number(null) and Number('') both coerce to 0 and pass Number.isFinite,
|
||||
// so reject nullish and empty/whitespace-only strings before coercing.
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const coerced = Number(value);
|
||||
return Number.isFinite(coerced) ? coerced : null;
|
||||
}
|
||||
|
||||
// Maximum column name length - conservative upper bound that exceeds all common
|
||||
// database identifier limits (MySQL: 64, PostgreSQL: 63, SQL Server: 128, Oracle: 128)
|
||||
const MAX_COLUMN_NAME_LENGTH = 255;
|
||||
@@ -378,8 +398,17 @@ function simpleFilterToWhereClause(
|
||||
return '';
|
||||
}
|
||||
|
||||
if (type === FILTER_OPERATORS.IN_RANGE && filterTo !== undefined) {
|
||||
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${value} AND ${filterTo}`;
|
||||
// Handle IN_RANGE unconditionally so a missing/cleared upper bound can never
|
||||
// fall through to the generic clause below and emit an invalid single-operand
|
||||
// BETWEEN. Range bounds are interpolated into the clause without quoting, so
|
||||
// both ends must coerce to finite numbers; otherwise the clause is dropped.
|
||||
if (type === FILTER_OPERATORS.IN_RANGE) {
|
||||
const lowerBound = toFiniteNumber(value);
|
||||
const upperBound = toFiniteNumber(filterTo);
|
||||
if (lowerBound === null || upperBound === null) {
|
||||
return '';
|
||||
}
|
||||
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${lowerBound} AND ${upperBound}`;
|
||||
}
|
||||
|
||||
const formattedValue = formatValueForOperator(type, value!);
|
||||
|
||||
+61
@@ -284,6 +284,67 @@ describe('agGridFilterConverter', () => {
|
||||
val: 18,
|
||||
});
|
||||
});
|
||||
|
||||
test('should emit a numeric BETWEEN clause for a metric range filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: 10,
|
||||
filterTo: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// revenue is a metric, so the range filter renders as a HAVING clause
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['revenue']);
|
||||
|
||||
expect(result.havingClause).toContain('BETWEEN 10 AND 20');
|
||||
});
|
||||
|
||||
test('should drop a metric range filter whose bounds are not numeric', () => {
|
||||
const filterModel = {
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: '0',
|
||||
filterTo: '100 OR 1=1',
|
||||
},
|
||||
} as unknown as AgGridFilterModel;
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['revenue']);
|
||||
|
||||
// a non-numeric bound must never be interpolated into the clause
|
||||
expect(result.havingClause).toBeUndefined();
|
||||
|
||||
const emptyBoundFilterModel = {
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: '0',
|
||||
filterTo: '',
|
||||
},
|
||||
} as unknown as AgGridFilterModel;
|
||||
|
||||
const result2 = convertAgGridFiltersToSQL(emptyBoundFilterModel, [
|
||||
'revenue',
|
||||
]);
|
||||
expect(result2.havingClause).toBeUndefined();
|
||||
|
||||
// A missing upper bound must drop the clause rather than fall through
|
||||
// to a generic single-operand BETWEEN.
|
||||
const missingBoundFilterModel = {
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: '0',
|
||||
},
|
||||
} as unknown as AgGridFilterModel;
|
||||
|
||||
const result3 = convertAgGridFiltersToSQL(missingBoundFilterModel, [
|
||||
'revenue',
|
||||
]);
|
||||
expect(result3.havingClause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Null/blank filters', () => {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "*",
|
||||
"memoize-one": "*",
|
||||
"react": "^18.2.0"
|
||||
|
||||
@@ -1016,8 +1016,12 @@ export default function transformProps(
|
||||
trigger: richTooltip ? 'axis' : 'item',
|
||||
formatter: (params: any) => {
|
||||
const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1];
|
||||
// For axis tooltips, prefer axisValue/axisValueLabel which contains the full label
|
||||
// even when the axis label is visually truncated
|
||||
const xValue: number = richTooltip
|
||||
? params[0].value[xIndex]
|
||||
? (params[0].axisValue ??
|
||||
params[0].axisValueLabel ??
|
||||
params[0].value[xIndex])
|
||||
: params.value[xIndex];
|
||||
const forecastValue: CallbackDataParams[] = richTooltip
|
||||
? params
|
||||
|
||||
@@ -1657,3 +1657,100 @@ test('should assign distinct dash patterns for multiple time offsets consistentl
|
||||
// must be different patterns
|
||||
expect(symbol1).not.toEqual(symbol2);
|
||||
});
|
||||
|
||||
describe('Tooltip with long labels', () => {
|
||||
test('should use axisValue for tooltip when available (richTooltip)', () => {
|
||||
const longLabelData: ChartDataResponseResult[] = [
|
||||
createTestQueryData([
|
||||
{
|
||||
'This is a very long category name that would normally be truncated': 100,
|
||||
__timestamp: 599616000000,
|
||||
},
|
||||
{
|
||||
'Another extremely long category name for testing purposes': 200,
|
||||
__timestamp: 599916000000,
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
richTooltip: true,
|
||||
},
|
||||
queriesData: longLabelData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
// Get the tooltip formatter function
|
||||
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
|
||||
.formatter;
|
||||
|
||||
// Simulate params from ECharts with axisValue containing full label
|
||||
// Use distinct values for axisValue and seriesName to verify axisValue is used
|
||||
const mockParams = [
|
||||
{
|
||||
axisValue:
|
||||
'This is a very long category name that would normally be truncated',
|
||||
value: [599616000000, 100],
|
||||
seriesName: 'Some Series Name',
|
||||
},
|
||||
];
|
||||
|
||||
// Call the formatter and check it uses the full label from axisValue
|
||||
const result = tooltipFormatter(mockParams);
|
||||
expect(result).toContain(
|
||||
'This is a very long category name that would normally be truncated',
|
||||
);
|
||||
});
|
||||
|
||||
test('should fallback to value when axisValue is not available', () => {
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
richTooltip: true,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
|
||||
.formatter;
|
||||
|
||||
// Simulate params without axisValue
|
||||
const mockParams = [
|
||||
{
|
||||
value: [599616000000, 1],
|
||||
seriesName: 'San Francisco',
|
||||
},
|
||||
];
|
||||
|
||||
// Should fall back to the x-value (value[xIndex]) and render it in the title
|
||||
const result = tooltipFormatter(mockParams);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain('599616000000');
|
||||
});
|
||||
|
||||
test('should handle item tooltips correctly', () => {
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
richTooltip: false,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
|
||||
.formatter;
|
||||
|
||||
// For item tooltips, params is a single object
|
||||
const mockParams = {
|
||||
value: [599616000000, 1],
|
||||
seriesName: 'San Francisco',
|
||||
};
|
||||
|
||||
// The item-tooltip x-value (value[xIndex]) should appear in the title
|
||||
const result = tooltipFormatter(mockParams);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain('599616000000');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"ace-builds": "^1.4.14",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.18.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.2.0",
|
||||
"react-ace": "^10.1.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"mapbox-gl": ">=1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -239,7 +239,10 @@ describe('ListView', () => {
|
||||
});
|
||||
|
||||
test('calls fetchData on sort', async () => {
|
||||
const sortHeader = screen.getAllByTestId('sort-header')[1];
|
||||
// sort-header[0] is the first data column ('id'); the select-all
|
||||
// column header carries `data-test="header-toggle-all"` instead
|
||||
// of `sort-header` (see TableCollection's `header.cell` slot).
|
||||
const sortHeader = screen.getAllByTestId('sort-header')[0];
|
||||
await userEvent.click(sortHeader);
|
||||
|
||||
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalledWith({
|
||||
|
||||
@@ -33,7 +33,6 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
Icons,
|
||||
EmptyState,
|
||||
Loading,
|
||||
@@ -179,21 +178,6 @@ const BulkSelectWrapper = styled(Alert)`
|
||||
`}
|
||||
`;
|
||||
|
||||
const bulkSelectColumnConfig = {
|
||||
Cell: ({ row }: any) => (
|
||||
<Checkbox {...row.getToggleRowSelectedProps()} id={row.id} />
|
||||
),
|
||||
Header: ({ getToggleAllRowsSelectedProps }: any) => (
|
||||
<Checkbox
|
||||
{...getToggleAllRowsSelectedProps()}
|
||||
id="header-toggle-all"
|
||||
data-test="header-toggle-all"
|
||||
/>
|
||||
),
|
||||
id: 'selection',
|
||||
size: 'sm',
|
||||
};
|
||||
|
||||
const ViewModeContainer = styled.div`
|
||||
${({ theme }) => `
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
@@ -375,8 +359,6 @@ export function ListView<T extends object = any>({
|
||||
state: { pageIndex, pageSize, internalFilters, sortBy, viewMode },
|
||||
query,
|
||||
} = useListViewState({
|
||||
bulkSelectColumnConfig,
|
||||
bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length),
|
||||
columns,
|
||||
count,
|
||||
data,
|
||||
@@ -527,6 +509,7 @@ export function ListView<T extends object = any>({
|
||||
{bulkActions.map(action => (
|
||||
<Button
|
||||
data-test="bulk-select-action"
|
||||
data-test-action-key={action.key}
|
||||
key={action.key}
|
||||
buttonStyle={action.type}
|
||||
cta
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
useFilters,
|
||||
usePagination,
|
||||
@@ -192,13 +192,7 @@ interface UseListViewConfig {
|
||||
count: number;
|
||||
initialPageSize: number;
|
||||
initialSort?: SortColumn[];
|
||||
bulkSelectMode?: boolean;
|
||||
initialFilters?: Filter[];
|
||||
bulkSelectColumnConfig?: {
|
||||
id: string;
|
||||
Header: (conf: any) => ReactNode;
|
||||
Cell: (conf: any) => ReactNode;
|
||||
};
|
||||
renderCard?: boolean;
|
||||
defaultViewMode?: ViewModeType;
|
||||
}
|
||||
@@ -211,8 +205,6 @@ export function useListViewState({
|
||||
initialPageSize,
|
||||
initialFilters = [],
|
||||
initialSort = [],
|
||||
bulkSelectMode = false,
|
||||
bulkSelectColumnConfig,
|
||||
renderCard = false,
|
||||
defaultViewMode = 'card',
|
||||
}: UseListViewConfig) {
|
||||
@@ -246,13 +238,11 @@ export function useListViewState({
|
||||
(renderCard ? defaultViewMode : 'table'),
|
||||
);
|
||||
|
||||
const columnsWithSelect = useMemo(() => {
|
||||
const columnsWithFilter = useMemo(
|
||||
// add exact filter type so filters with falsy values are not filtered out
|
||||
const columnsWithFilter = columns.map(f => ({ ...f, filter: 'exact' }));
|
||||
return bulkSelectMode
|
||||
? [bulkSelectColumnConfig, ...columnsWithFilter]
|
||||
: columnsWithFilter;
|
||||
}, [bulkSelectMode, columns]);
|
||||
() => columns.map(f => ({ ...f, filter: 'exact' })),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
@@ -271,7 +261,7 @@ export function useListViewState({
|
||||
state: { pageIndex, pageSize, sortBy, filters },
|
||||
} = useTable(
|
||||
{
|
||||
columns: columnsWithSelect,
|
||||
columns: columnsWithFilter,
|
||||
data,
|
||||
disableFilters: true,
|
||||
disableSortRemove: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
SupersetClient: {
|
||||
getCSRFToken: jest.fn(() => Promise.resolve('mock-csrf-token')),
|
||||
getGuestToken: jest.fn(() => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -47,9 +48,12 @@ global.URL.revokeObjectURL = jest.fn();
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = jest.fn();
|
||||
SupersetClient.getGuestToken.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
test('useStreamingExport initializes with default progress state', () => {
|
||||
@@ -238,6 +242,32 @@ const createPrefixTestMockFetch = () =>
|
||||
},
|
||||
});
|
||||
|
||||
test('chart streaming export includes guest token in form body when configured', async () => {
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
const mockFetch = createPrefixTestMockFetch();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/chart/data',
|
||||
payload: { datasource: '1__table', viz_type: 'table' },
|
||||
exportType: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
expect(request.body.get('guest_token')).toBe('guest-token');
|
||||
expect(request.body.get('form_data')).toBe(
|
||||
JSON.stringify({ datasource: '1__table', viz_type: 'table' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('URL prefix guard applies prefix to unprefixed relative URL when app root is configured', async () => {
|
||||
const appRoot = '/superset';
|
||||
applicationRoot.mockReturnValue(appRoot);
|
||||
@@ -652,6 +682,8 @@ test('completes XLSX export successfully with correct filename', async () => {
|
||||
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
|
||||
});
|
||||
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
expect(request.body.get('guest_token')).toBeNull();
|
||||
expect(result.current.progress.filename).toBe('report.xlsx');
|
||||
expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'report.xlsx');
|
||||
});
|
||||
|
||||
@@ -118,6 +118,11 @@ const createFetchRequest = async (
|
||||
formParams.expected_rows = expectedRows.toString();
|
||||
}
|
||||
|
||||
const guestToken = SupersetClient.getGuestToken();
|
||||
if (guestToken) {
|
||||
formParams.guest_token = guestToken;
|
||||
}
|
||||
|
||||
if ('client_id' in payload) {
|
||||
// SQL Lab export - pass client_id directly
|
||||
formParams.client_id = String(payload.client_id);
|
||||
|
||||
+50
-14
@@ -161,22 +161,43 @@ describe('EncryptedField', () => {
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('Parameter Value Processing', () => {
|
||||
const testCases = [
|
||||
// In edit mode the existing credential value must never be rendered into
|
||||
// the field, regardless of how it was returned from the backend.
|
||||
const editModeInputs = [
|
||||
{
|
||||
input: { key: 'value', nested: { data: 'test' } },
|
||||
description: 'objects',
|
||||
},
|
||||
{ input: true, description: 'booleans' },
|
||||
{ input: false, description: 'false booleans' },
|
||||
{ input: 'test-string', description: 'strings' },
|
||||
{ input: 123, description: 'numbers' },
|
||||
];
|
||||
|
||||
test.each(editModeInputs)(
|
||||
'does not render existing $description in edit mode',
|
||||
({ input }) => {
|
||||
const mockDb = createMockDb('gsheets', {
|
||||
service_account_info: input,
|
||||
});
|
||||
const props = { ...defaultProps, db: mockDb, isEditMode: true };
|
||||
|
||||
const { container } = render(<EncryptedField {...props} />);
|
||||
const textarea = container.querySelector('textarea');
|
||||
|
||||
expect(textarea?.value).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
// The copy/paste (create) flow is controlled by the parent, which echoes
|
||||
// typed content back through `db.parameters`. Verify that values are still
|
||||
// serialized for display when not in edit mode.
|
||||
const createModeCases = [
|
||||
{
|
||||
input: { key: 'value', nested: { data: 'test' } },
|
||||
expected: '{"key":"value","nested":{"data":"test"}}',
|
||||
description: 'objects to JSON strings',
|
||||
},
|
||||
{
|
||||
input: true,
|
||||
expected: 'true',
|
||||
description: 'booleans to strings',
|
||||
},
|
||||
{
|
||||
input: false,
|
||||
expected: 'false',
|
||||
description: 'false booleans to strings',
|
||||
},
|
||||
{
|
||||
input: 'test-string',
|
||||
expected: 'test-string',
|
||||
@@ -187,15 +208,30 @@ describe('EncryptedField', () => {
|
||||
expected: '123',
|
||||
description: 'numbers to strings',
|
||||
},
|
||||
{
|
||||
input: true,
|
||||
expected: 'true',
|
||||
description: 'true booleans to strings',
|
||||
},
|
||||
{
|
||||
input: false,
|
||||
expected: 'false',
|
||||
description: 'false booleans to strings',
|
||||
},
|
||||
];
|
||||
|
||||
test.each(testCases)(
|
||||
'processes $description correctly',
|
||||
test.each(createModeCases)(
|
||||
'processes $description correctly in create mode',
|
||||
({ input, expected }) => {
|
||||
const mockDb = createMockDb('gsheets', {
|
||||
service_account_info: input,
|
||||
});
|
||||
const props = { ...defaultProps, db: mockDb, isEditMode: true };
|
||||
const props = {
|
||||
...defaultProps,
|
||||
db: mockDb,
|
||||
isEditMode: false,
|
||||
editNewDb: true,
|
||||
};
|
||||
|
||||
const { container } = render(<EncryptedField {...props} />);
|
||||
const textarea = container.querySelector('textarea');
|
||||
|
||||
+9
-3
@@ -67,10 +67,16 @@ export const EncryptedField = ({
|
||||
encryptedCredentialsMap[db.engine as keyof typeof encryptedCredentialsMap];
|
||||
const paramValue =
|
||||
db?.parameters?.[encryptedField as keyof DatabaseParameters];
|
||||
// In edit mode the backend may return the existing (masked) credential in
|
||||
// the parameters. Do not surface any pre-existing value in the field; the
|
||||
// user must re-enter credentials to change them. This also matches the
|
||||
// mount effect below, which resets the parameter to an empty string.
|
||||
const encryptedValue =
|
||||
paramValue && typeof paramValue === 'object'
|
||||
? JSON.stringify(paramValue)
|
||||
: paramValue;
|
||||
isEditMode || paramValue == null
|
||||
? ''
|
||||
: typeof paramValue === 'object'
|
||||
? JSON.stringify(paramValue)
|
||||
: paramValue;
|
||||
|
||||
const handlePublicToggle = (value: string) => {
|
||||
const nextIsPublic = value === 'true';
|
||||
|
||||
@@ -2248,6 +2248,43 @@ describe('dbReducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/apache/superset/issues/30504
|
||||
// When creating a database, the POST response doesn't include engine_information,
|
||||
// but it should be preserved from the state populated when the user selects a
|
||||
// database engine.
|
||||
test('it preserves engine_information when Fetched action payload lacks it', () => {
|
||||
const initialState: Partial<DatabaseObject> = {
|
||||
database_name: 'TestDB',
|
||||
engine: 'postgresql',
|
||||
configuration_method: ConfigurationMethod.SqlalchemyUri,
|
||||
engine_information: {
|
||||
supports_file_upload: true,
|
||||
disable_ssh_tunneling: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Simulate POST response that doesn't include engine_information
|
||||
const action: DBReducerActionType = {
|
||||
type: ActionType.Fetched,
|
||||
payload: {
|
||||
id: 123,
|
||||
database_name: 'TestDB',
|
||||
backend: 'postgresql',
|
||||
configuration_method: ConfigurationMethod.SqlalchemyUri,
|
||||
// Note: engine_information is NOT in POST response
|
||||
},
|
||||
};
|
||||
|
||||
const currentState = dbReducer(initialState, action);
|
||||
|
||||
// engine_information should be preserved from initialState
|
||||
expect(currentState).not.toBeNull();
|
||||
expect(currentState!.engine_information).toEqual({
|
||||
supports_file_upload: true,
|
||||
disable_ssh_tunneling: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('it will add a SSH Tunnel config parameter', () => {
|
||||
const action: DBReducerActionType = {
|
||||
type: ActionType.ParametersSSHTunnelChange,
|
||||
|
||||
@@ -591,6 +591,10 @@ export function dbReducer(
|
||||
catalog: payloadCatalog,
|
||||
},
|
||||
// eslint-disable-next-line camelcase
|
||||
engine_information:
|
||||
action.payload.engine_information ||
|
||||
trimmedState.engine_information,
|
||||
// eslint-disable-next-line camelcase
|
||||
query_input,
|
||||
};
|
||||
}
|
||||
@@ -602,6 +606,9 @@ export function dbReducer(
|
||||
parameters: action.payload.parameters || trimmedState.parameters,
|
||||
ssh_tunnel: action.payload.ssh_tunnel || trimmedState.ssh_tunnel,
|
||||
// eslint-disable-next-line camelcase
|
||||
engine_information:
|
||||
action.payload.engine_information || trimmedState.engine_information,
|
||||
// eslint-disable-next-line camelcase
|
||||
query_input,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { AppSection } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import RangeFilterPlugin from './RangeFilterPlugin';
|
||||
import RangeFilterPlugin, { calculateStep } from './RangeFilterPlugin';
|
||||
import { RangeDisplayMode, type PluginFilterRangeProps } from './types';
|
||||
import { SingleValueType } from './SingleValueType';
|
||||
import transformProps from './transformProps';
|
||||
@@ -320,4 +320,161 @@ describe('RangeFilterPlugin', () => {
|
||||
expect(sliders.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Decimal value handling', () => {
|
||||
test('should handle decimal ranges correctly (0.03 to 1.08)', () => {
|
||||
const decimalProps = {
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 1,
|
||||
colnames: ['min', 'max'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
|
||||
data: [{ min: 0.03, max: 1.08 }],
|
||||
applied_filters: [],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: { value: [0.5, 0.8] },
|
||||
};
|
||||
getWrapper(decimalProps);
|
||||
|
||||
const inputs = screen.getAllByRole('spinbutton');
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0]).toHaveValue('0.5');
|
||||
expect(inputs[1]).toHaveValue('0.8');
|
||||
|
||||
// Verify the slider exists and can handle decimal values
|
||||
const sliders = screen.getAllByRole('slider');
|
||||
expect(sliders.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should calculate appropriate step size for small decimal ranges', () => {
|
||||
const smallRangeProps = {
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 1,
|
||||
colnames: ['min', 'max'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
|
||||
data: [{ min: 0.001, max: 0.01 }],
|
||||
applied_filters: [],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: { value: [0.005, 0.008] },
|
||||
};
|
||||
getWrapper(smallRangeProps);
|
||||
|
||||
const inputs = screen.getAllByRole('spinbutton');
|
||||
expect(inputs[0]).toHaveValue('0.005');
|
||||
expect(inputs[1]).toHaveValue('0.008');
|
||||
});
|
||||
|
||||
test('should handle very large ranges with appropriate step size', () => {
|
||||
const largeRangeProps = {
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 1,
|
||||
colnames: ['min', 'max'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
|
||||
data: [{ min: 0, max: 1000000 }],
|
||||
applied_filters: [],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: { value: [100000, 500000] },
|
||||
};
|
||||
getWrapper(largeRangeProps);
|
||||
|
||||
const inputs = screen.getAllByRole('spinbutton');
|
||||
expect(inputs[0]).toHaveValue('100000');
|
||||
expect(inputs[1]).toHaveValue('500000');
|
||||
});
|
||||
|
||||
test('should handle negative decimal ranges', () => {
|
||||
const negativeDecimalProps = {
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 1,
|
||||
colnames: ['min', 'max'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
|
||||
data: [{ min: -1.5, max: 2.5 }],
|
||||
applied_filters: [],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: { value: [-0.5, 1.5] },
|
||||
};
|
||||
getWrapper(negativeDecimalProps);
|
||||
|
||||
const inputs = screen.getAllByRole('spinbutton');
|
||||
expect(inputs[0]).toHaveValue('-0.5');
|
||||
expect(inputs[1]).toHaveValue('1.5');
|
||||
});
|
||||
|
||||
test('should allow decimal input via keyboard', async () => {
|
||||
const decimalProps = {
|
||||
queriesData: [
|
||||
{
|
||||
rowcount: 1,
|
||||
colnames: ['min', 'max'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
|
||||
data: [{ min: 0, max: 10 }],
|
||||
applied_filters: [],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
filterState: { value: [null, null] },
|
||||
};
|
||||
getWrapper(decimalProps);
|
||||
|
||||
const inputs = screen.getAllByRole('spinbutton');
|
||||
const fromInput = inputs[0];
|
||||
|
||||
await userEvent.clear(fromInput);
|
||||
await userEvent.type(fromInput, '2.5');
|
||||
await userEvent.tab();
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterState: expect.objectContaining({
|
||||
value: [2.5, null],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('calculateStep returns ~100 steps for integer ranges', () => {
|
||||
// 0..100 -> 1, 0..1000 -> 10
|
||||
expect(calculateStep(0, 100)).toBe(1);
|
||||
expect(calculateStep(0, 1000)).toBe(10);
|
||||
});
|
||||
|
||||
test('calculateStep produces sub-unit steps for small decimal ranges', () => {
|
||||
// 0..1 -> ~0.01, giving roughly 100 increments
|
||||
expect(calculateStep(0, 1)).toBeCloseTo(0.01, 10);
|
||||
// 0..0.1 -> ~0.001
|
||||
expect(calculateStep(0, 0.1)).toBeCloseTo(0.001, 10);
|
||||
});
|
||||
|
||||
test('calculateStep is numerically stable for floating-point ranges', () => {
|
||||
// 0.05..0.07 computes to 0.020000000000000004 internally; should still
|
||||
// yield a sensible step rather than over-counting decimal places.
|
||||
const step = calculateStep(0.05, 0.07);
|
||||
expect(step).toBeGreaterThan(0);
|
||||
expect(step).toBeLessThanOrEqual(0.01);
|
||||
});
|
||||
|
||||
test('calculateStep handles negative and offset decimal ranges', () => {
|
||||
expect(calculateStep(-1, 1)).toBeCloseTo(0.02, 10);
|
||||
});
|
||||
|
||||
test('calculateStep never returns 0 for tiny ranges', () => {
|
||||
expect(calculateStep(0, 0.0000001)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('calculateStep falls back for non-positive ranges', () => {
|
||||
expect(calculateStep(5, 5)).toBe(0.01);
|
||||
expect(calculateStep(10, 5)).toBe(0.01);
|
||||
});
|
||||
|
||||
@@ -143,6 +143,24 @@ const getLabel = (
|
||||
return '';
|
||||
};
|
||||
|
||||
// Calculate appropriate step size for decimal values.
|
||||
// Uses a consistent approach for all ranges to avoid floating-point string parsing issues.
|
||||
export const calculateStep = (minValue: number, maxValue: number): number => {
|
||||
const range = maxValue - minValue;
|
||||
if (range <= 0) return 0.01;
|
||||
|
||||
// Calculate step to give approximately 100 steps across the range
|
||||
const idealSteps = 100;
|
||||
let step = range / idealSteps;
|
||||
|
||||
// Round step to a nice value (0.0001, 0.001, 0.01, 0.1, 1, 10, etc.)
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(step)));
|
||||
step = Math.round(step / magnitude) * magnitude;
|
||||
|
||||
// Ensure we don't return 0 for very small ranges
|
||||
return step || 0.0001;
|
||||
};
|
||||
|
||||
const validateRange = (
|
||||
values: RangeValue,
|
||||
min: number,
|
||||
@@ -234,6 +252,12 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
const [row] = data;
|
||||
// @ts-expect-error
|
||||
const { min, max }: { min: number; max: number } = row;
|
||||
|
||||
const sliderStep = useMemo(
|
||||
() =>
|
||||
min !== undefined && max !== undefined ? calculateStep(min, max) : 0.01,
|
||||
[min, max],
|
||||
);
|
||||
const { groupby, enableSingleValue, enableEmptyFilter, defaultValue } =
|
||||
formData;
|
||||
|
||||
@@ -548,6 +572,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={sliderStep}
|
||||
value={Array.isArray(sliderValue) ? sliderValue[0] : sliderValue}
|
||||
onChange={handleSliderChange}
|
||||
tooltip={{
|
||||
@@ -562,6 +587,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={sliderStep}
|
||||
range
|
||||
value={Array.isArray(sliderValue) ? sliderValue : [min, sliderValue]}
|
||||
onChange={handleSliderChange}
|
||||
|
||||
@@ -172,6 +172,34 @@ describe('asyncEvent middleware', () => {
|
||||
expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Regression guard for the motivating CodeQL case: a job_id that collides
|
||||
// with a built-in Object property (e.g. "__proto__"/"constructor") must be
|
||||
// routed through the Map-based registries without triggering prototype
|
||||
// pollution or losing the listener to a prototype-bearing lookup.
|
||||
test.each(['__proto__', 'constructor', 'prototype', 'hasOwnProperty'])(
|
||||
'resolves listeners keyed by reserved job_id "%s"',
|
||||
async jobId => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
fetchMock.get(EVENTS_ENDPOINT, {
|
||||
status: 200,
|
||||
body: { result: [{ ...asyncDoneEvent, job_id: jobId }] },
|
||||
});
|
||||
fetchMock.get(CACHED_DATA_ENDPOINT, {
|
||||
status: 200,
|
||||
body: { result: chartData },
|
||||
});
|
||||
|
||||
const actualResolved = await asyncEvent.waitForAsyncData({
|
||||
...asyncPendingEvent,
|
||||
job_id: jobId,
|
||||
});
|
||||
expect(actualResolved).toEqual([chartData]);
|
||||
expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
@@ -63,17 +63,17 @@ let config: AppConfig;
|
||||
let transport: string;
|
||||
let pollingDelayMs: number;
|
||||
let pollingTimeoutId: number;
|
||||
let listenersByJobId: Record<string, ListenerFn>;
|
||||
let retriesByJobId: Record<string, number>;
|
||||
let listenersByJobId: Map<string, ListenerFn>;
|
||||
let retriesByJobId: Map<string, number>;
|
||||
let lastReceivedEventId: string | null | undefined;
|
||||
|
||||
const addListener = (id: string, fn: any) => {
|
||||
listenersByJobId[id] = fn;
|
||||
const addListener = (id: string, fn: ListenerFn) => {
|
||||
listenersByJobId.set(id, fn);
|
||||
};
|
||||
|
||||
const removeListener = (id: string) => {
|
||||
if (!listenersByJobId[id]) return;
|
||||
delete listenersByJobId[id];
|
||||
if (!listenersByJobId.has(id)) return;
|
||||
listenersByJobId.delete(id);
|
||||
};
|
||||
|
||||
const fetchCachedData = async (
|
||||
@@ -143,27 +143,22 @@ const setLastId = (asyncEvent: AsyncEvent) => {
|
||||
export const processEvents = async (events: AsyncEvent[]) => {
|
||||
events.forEach((asyncEvent: AsyncEvent) => {
|
||||
const jobId = asyncEvent.job_id;
|
||||
const listener = Object.prototype.hasOwnProperty.call(
|
||||
listenersByJobId,
|
||||
jobId,
|
||||
)
|
||||
? listenersByJobId[jobId]
|
||||
: undefined;
|
||||
const listener = listenersByJobId.get(jobId);
|
||||
if (listener) {
|
||||
listener(asyncEvent);
|
||||
delete retriesByJobId[jobId];
|
||||
retriesByJobId.delete(jobId);
|
||||
} else {
|
||||
// handle race condition where event is received
|
||||
// before listener is registered
|
||||
if (!retriesByJobId[jobId]) retriesByJobId[jobId] = 0;
|
||||
retriesByJobId[jobId] += 1;
|
||||
const retries = (retriesByJobId.get(jobId) ?? 0) + 1;
|
||||
retriesByJobId.set(jobId, retries);
|
||||
|
||||
if (retriesByJobId[jobId] <= MAX_RETRIES) {
|
||||
if (retries <= MAX_RETRIES) {
|
||||
setTimeout(() => {
|
||||
processEvents([asyncEvent]);
|
||||
}, RETRY_DELAY * retriesByJobId[jobId]);
|
||||
}, RETRY_DELAY * retries);
|
||||
} else {
|
||||
delete retriesByJobId[jobId];
|
||||
retriesByJobId.delete(jobId);
|
||||
logging.warn('listener not found for job_id', asyncEvent.job_id);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +168,7 @@ export const processEvents = async (events: AsyncEvent[]) => {
|
||||
|
||||
const loadEventsFromApi = async () => {
|
||||
const eventArgs = lastReceivedEventId ? { last_id: lastReceivedEventId } : {};
|
||||
if (Object.keys(listenersByJobId).length) {
|
||||
if (listenersByJobId.size) {
|
||||
try {
|
||||
const { result: events } = await fetchEvents(eventArgs);
|
||||
if (events?.length) await processEvents(events);
|
||||
@@ -236,8 +231,8 @@ export const init = (appConfig?: AppConfig) => {
|
||||
if (!isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) return;
|
||||
if (pollingTimeoutId) clearTimeout(pollingTimeoutId);
|
||||
|
||||
listenersByJobId = {};
|
||||
retriesByJobId = {};
|
||||
listenersByJobId = new Map();
|
||||
retriesByJobId = new Map();
|
||||
lastReceivedEventId = null;
|
||||
|
||||
config = appConfig || getBootstrapData().common.conf;
|
||||
|
||||
@@ -522,6 +522,7 @@ function ChartList(props: ChartListProps) {
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="chart-row-edit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
@@ -538,6 +539,7 @@ function ChartList(props: ChartListProps) {
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="chart-row-export"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
@@ -565,6 +567,7 @@ function ChartList(props: ChartListProps) {
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="chart-row-delete"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
|
||||
@@ -447,6 +447,7 @@ function DashboardList(props: DashboardListProps) {
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dashboard-row-edit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
@@ -463,6 +464,7 @@ function DashboardList(props: DashboardListProps) {
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dashboard-row-export"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
@@ -490,6 +492,7 @@ function DashboardList(props: DashboardListProps) {
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dashboard-row-delete"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
|
||||
@@ -818,6 +818,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dataset-row-delete"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
@@ -834,6 +835,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dataset-row-edit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
@@ -879,6 +881,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dataset-row-edit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`action-button ${allowEdit ? '' : 'disabled'}`}
|
||||
@@ -895,6 +898,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dataset-row-export"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
@@ -911,6 +915,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dataset-row-duplicate"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
@@ -927,6 +932,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
data-test="dataset-row-delete"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
|
||||
@@ -30,7 +30,6 @@ const mockUserRegistrations = Array.from({ length: 5 }, (_, i) => ({
|
||||
last_name: `Test${i}`,
|
||||
email: `user${i}@test.com`,
|
||||
registration_date: new Date(2025, 2, 25, 11, 4, 32 + i).toISOString(),
|
||||
registration_hash: `hash${i}`,
|
||||
}));
|
||||
|
||||
fetchMock.get(userRegistrationsEndpoint, {
|
||||
@@ -53,4 +52,10 @@ describe('UserRegistrations', () => {
|
||||
const calls = fetchMock.callHistory.calls(userRegistrationsEndpoint);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('does not expose the registration hash', async () => {
|
||||
expect(await screen.findByText('User registrations')).toBeVisible();
|
||||
// The activation hash is a bearer token and must not be shown in the UI.
|
||||
expect(screen.queryByText('Registration hash')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,6 @@ export type UserRegistration = {
|
||||
last_name: string;
|
||||
email: string;
|
||||
registration_date: string;
|
||||
registration_hash: string;
|
||||
};
|
||||
|
||||
export default function UserRegistrations() {
|
||||
@@ -110,12 +109,6 @@ export default function UserRegistrations() {
|
||||
Header: t('Email'),
|
||||
Cell: ({ row: { original } }: any) => original.email,
|
||||
},
|
||||
{
|
||||
accessor: 'registration_hash',
|
||||
id: 'registration_hash',
|
||||
Header: t('Registration hash'),
|
||||
Cell: ({ row: { original } }: any) => original.registration_hash,
|
||||
},
|
||||
{
|
||||
accessor: 'registration_date',
|
||||
id: 'registration_date',
|
||||
@@ -177,13 +170,6 @@ export default function UserRegistrations() {
|
||||
input: 'search',
|
||||
operator: ListViewFilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Registration hash'),
|
||||
key: 'registration_hash',
|
||||
id: 'registration_hash',
|
||||
input: 'search',
|
||||
operator: ListViewFilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
Header: t('Registration date'),
|
||||
key: 'registration_date',
|
||||
|
||||
@@ -67,6 +67,25 @@ Copy `config.example.json` to `config.json` and adjust the values for your envir
|
||||
|
||||
Configuration via environment variables is also supported which can be helpful in certain contexts, e.g., deployment. `src/config.ts` can be consulted to see the full list of supported values.
|
||||
|
||||
### Restricting WebSocket origins
|
||||
|
||||
To mitigate Cross-Site WebSocket Hijacking, set `allowedOrigins` (or the
|
||||
`ALLOWED_ORIGINS` environment variable, comma-separated) to the list of origins
|
||||
permitted to open WebSocket connections, e.g. the origin Superset is served
|
||||
from:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedOrigins": ["https://superset.example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
The `Origin` header of each upgrade request must exactly match one of the
|
||||
configured values. When `allowedOrigins` is empty (the default) the check is
|
||||
skipped and any origin is accepted; a single `"*"` entry explicitly allows any
|
||||
origin. Setting this is recommended for production deployments, especially when
|
||||
the JWT cookie uses `SameSite=None`.
|
||||
|
||||
## Superset Configuration
|
||||
|
||||
Configure the Superset Flask app to enable global async queries (in `superset_config.py`):
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
"redisStreamPrefix": "async-events-",
|
||||
"jwtAlgorithms": ["HS256"],
|
||||
"jwtSecret": "CHANGE-ME",
|
||||
"jwtCookieName": "async-token"
|
||||
"jwtCookieName": "async-token",
|
||||
"allowedOrigins": []
|
||||
}
|
||||
|
||||
@@ -547,6 +547,105 @@ describe('server', () => {
|
||||
expect(socketDestroySpy).not.toHaveBeenCalled();
|
||||
expect(wssUpgradeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('origin validation', () => {
|
||||
afterEach(() => {
|
||||
server.opts.allowedOrigins = [];
|
||||
});
|
||||
|
||||
const getRequestWithOrigin = (
|
||||
token: string,
|
||||
origin?: string,
|
||||
): http.IncomingMessage => {
|
||||
const request = new http.IncomingMessage(new net.Socket());
|
||||
request.method = 'GET';
|
||||
request.headers = { cookie: `${config.jwtCookieName}=${token}` };
|
||||
if (origin) request.headers.origin = origin;
|
||||
request.url = 'http://localhost';
|
||||
return request;
|
||||
};
|
||||
|
||||
test('rejects upgrade from a disallowed origin', () => {
|
||||
server.opts.allowedOrigins = ['https://superset.example.com'];
|
||||
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
||||
const request = getRequestWithOrigin(
|
||||
validToken,
|
||||
'https://evil.example',
|
||||
);
|
||||
|
||||
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
||||
|
||||
expect(socketDestroySpy).toHaveBeenCalled();
|
||||
expect(wssUpgradeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects upgrade with no origin when an allowlist is set', () => {
|
||||
server.opts.allowedOrigins = ['https://superset.example.com'];
|
||||
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
||||
const request = getRequestWithOrigin(validToken);
|
||||
|
||||
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
||||
|
||||
expect(socketDestroySpy).toHaveBeenCalled();
|
||||
expect(wssUpgradeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('allows upgrade from an allowed origin', () => {
|
||||
server.opts.allowedOrigins = ['https://superset.example.com'];
|
||||
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
||||
const request = getRequestWithOrigin(
|
||||
validToken,
|
||||
'https://superset.example.com',
|
||||
);
|
||||
|
||||
server.httpUpgrade(request, socket, Buffer.alloc(5));
|
||||
|
||||
expect(socketDestroySpy).not.toHaveBeenCalled();
|
||||
expect(wssUpgradeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOriginAllowed', () => {
|
||||
const makeRequest = (origin?: string): http.IncomingMessage => {
|
||||
const request = new http.IncomingMessage(new net.Socket());
|
||||
if (origin) request.headers.origin = origin;
|
||||
return request;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
server.opts.allowedOrigins = [];
|
||||
});
|
||||
|
||||
test('allows any origin when allowlist is empty', () => {
|
||||
server.opts.allowedOrigins = [];
|
||||
expect(server.isOriginAllowed(makeRequest('https://anything'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(server.isOriginAllowed(makeRequest())).toBe(true);
|
||||
});
|
||||
|
||||
test('allows any origin when allowlist contains a wildcard', () => {
|
||||
server.opts.allowedOrigins = ['*'];
|
||||
expect(server.isOriginAllowed(makeRequest('https://anything'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows an exact-match origin', () => {
|
||||
server.opts.allowedOrigins = ['https://a.example', 'https://b.example'];
|
||||
expect(server.isOriginAllowed(makeRequest('https://b.example'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a non-matching or missing origin', () => {
|
||||
server.opts.allowedOrigins = ['https://a.example'];
|
||||
expect(server.isOriginAllowed(makeRequest('https://evil.example'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(server.isOriginAllowed(makeRequest())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const setReadyState = (ws: WebSocket, value: typeof ws.readyState) => {
|
||||
|
||||
@@ -47,6 +47,7 @@ type ConfigType = {
|
||||
jwtSecret: string;
|
||||
jwtCookieName: string;
|
||||
jwtChannelIdKey: string;
|
||||
allowedOrigins: string[];
|
||||
socketResponseTimeoutMs: number;
|
||||
pingSocketsIntervalMs: number;
|
||||
gcChannelsIntervalMs: number;
|
||||
@@ -65,6 +66,7 @@ function defaultConfig(): ConfigType {
|
||||
jwtSecret: '',
|
||||
jwtCookieName: 'async-token',
|
||||
jwtChannelIdKey: 'channel',
|
||||
allowedOrigins: [],
|
||||
socketResponseTimeoutMs: 60 * 1000,
|
||||
pingSocketsIntervalMs: 20 * 1000,
|
||||
gcChannelsIntervalMs: 120 * 1000,
|
||||
@@ -99,7 +101,11 @@ function configFromFile(): Partial<ConfigType> {
|
||||
const isPresent = (s: string) => /\S+/.test(s);
|
||||
const toNumber = Number;
|
||||
const toBoolean = (s: string) => s.toLowerCase() === 'true';
|
||||
const toStringArray = (s: string) => s.split(',');
|
||||
const toStringArray = (s: string) =>
|
||||
s
|
||||
.split(',')
|
||||
.map(entry => entry.trim())
|
||||
.filter(entry => entry.length > 0);
|
||||
|
||||
function applyEnvOverrides(config: ConfigType): ConfigType {
|
||||
const envVarConfigSetter: { [envVar: string]: (val: string) => void } = {
|
||||
@@ -114,6 +120,7 @@ function applyEnvOverrides(config: ConfigType): ConfigType {
|
||||
(config.redisStreamReadBlockMs = toNumber(val)),
|
||||
JWT_SECRET: val => (config.jwtSecret = val),
|
||||
JWT_COOKIE_NAME: val => (config.jwtCookieName = val),
|
||||
ALLOWED_ORIGINS: val => (config.allowedOrigins = toStringArray(val)),
|
||||
SOCKET_RESPONSE_TIMEOUT_MS: val =>
|
||||
(config.socketResponseTimeoutMs = toNumber(val)),
|
||||
PING_SOCKETS_INTERVAL_MS: val =>
|
||||
|
||||
@@ -389,6 +389,33 @@ export const httpRequest = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the `Origin` header of a WebSocket upgrade request against the
|
||||
* configured `allowedOrigins` list, mitigating Cross-Site WebSocket Hijacking.
|
||||
*
|
||||
* When `allowedOrigins` is empty the check is skipped (preserving existing
|
||||
* behavior); a single `'*'` entry explicitly allows any origin. Otherwise the
|
||||
* request's `Origin` must exactly match one of the configured origins.
|
||||
*/
|
||||
export const isOriginAllowed = (request: http.IncomingMessage): boolean => {
|
||||
const { allowedOrigins } = opts;
|
||||
|
||||
if (!allowedOrigins || allowedOrigins.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (allowedOrigins.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `origin` is typed as `string | string[] | undefined`; only a single,
|
||||
// unambiguous string header is acceptable for an exact-match comparison.
|
||||
const origin = request.headers.origin;
|
||||
if (typeof origin !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return allowedOrigins.includes(origin);
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP `upgrade` event handler, called via httpServer
|
||||
*/
|
||||
@@ -397,6 +424,16 @@ export const httpUpgrade = (
|
||||
socket: net.Socket,
|
||||
head: Buffer,
|
||||
) => {
|
||||
if (!isOriginAllowed(request)) {
|
||||
logger.error(
|
||||
`Rejecting WebSocket upgrade from disallowed origin: ${
|
||||
request.headers.origin || '(none)'
|
||||
}`,
|
||||
);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
readChannelId(request);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1026,6 +1026,15 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
|
||||
return self.response(200, result="OK")
|
||||
|
||||
def _pre_related_check(self, column_name: str) -> Optional[Response]:
|
||||
"""Restrict the owners related field to users with write access."""
|
||||
if (
|
||||
column_name == "owners"
|
||||
and not security_manager.can_access_all_datasources()
|
||||
):
|
||||
return self.response_403()
|
||||
return None
|
||||
|
||||
@expose("/warm_up_cache", methods=("PUT",))
|
||||
@protect()
|
||||
@safe
|
||||
|
||||
@@ -78,13 +78,21 @@ def _is_filter_in_scope_for_chart(
|
||||
position_json: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Determines whether a native filter applies to a given chart. When
|
||||
chartsInScope is present on the filter config, uses that directly.
|
||||
Otherwise falls back to scope.rootPath and scope.excluded with
|
||||
the dashboard layout.
|
||||
Determines whether a native filter applies to a given chart.
|
||||
|
||||
When chartsInScope is present on the filter config, uses that directly.
|
||||
Otherwise falls back to scope.rootPath and scope.excluded with the
|
||||
dashboard layout. Also considers charts that were added to the dashboard
|
||||
but were not instantiated in the layout.
|
||||
"""
|
||||
if (charts_in_scope := filter_config.get("chartsInScope")) is not None:
|
||||
return chart_id in charts_in_scope
|
||||
if chart_id in charts_in_scope:
|
||||
return True
|
||||
|
||||
# If the chart is found in position_json and not in chartsInScope,
|
||||
# it was explicitly excluded by the filter scope config.
|
||||
if _find_chart_layout_item(chart_id, position_json) is not None:
|
||||
return False
|
||||
|
||||
scope = filter_config.get("scope", {})
|
||||
root_path: list[str] = scope.get("rootPath", [])
|
||||
@@ -93,12 +101,14 @@ def _is_filter_in_scope_for_chart(
|
||||
if chart_id in excluded:
|
||||
return False
|
||||
|
||||
chart_layout_item = _find_chart_layout_item(chart_id, position_json)
|
||||
if not chart_layout_item:
|
||||
return False
|
||||
if chart_layout_item := _find_chart_layout_item(chart_id, position_json):
|
||||
parents: list[str] = chart_layout_item.get("parents", [])
|
||||
return any(parent in root_path for parent in parents)
|
||||
|
||||
parents: list[str] = chart_layout_item.get("parents", [])
|
||||
return any(parent in root_path for parent in parents)
|
||||
# If the chart doesn't exist in the dashboard layout, treat it as a
|
||||
# root-level chart.
|
||||
else:
|
||||
return "ROOT_ID" in root_path
|
||||
|
||||
|
||||
def _find_chart_layout_item(
|
||||
|
||||
@@ -2535,6 +2535,17 @@ except ImportError:
|
||||
|
||||
LOCAL_EXTENSIONS: list[str] = []
|
||||
EXTENSIONS_PATH: str | None = None
|
||||
# Extensions that must not be loaded, even if present in LOCAL_EXTENSIONS or
|
||||
# EXTENSIONS_PATH. Each entry is an extension id (denies every version) or
|
||||
# "<id>@<version>" (denies a specific version). Use this to disable an
|
||||
# extension found to be vulnerable or otherwise undesirable.
|
||||
EXTENSION_DENYLIST: list[str] = []
|
||||
|
||||
# Minimum allowed version per extension id. An extension whose version is below
|
||||
# the configured minimum is refused, so a vulnerable release can be required to
|
||||
# be patched before it loads. Versions are compared with PEP 440 semantics, e.g.
|
||||
# EXTENSION_VERSION_POLICY = {"acme.widget": "1.2.0"}
|
||||
EXTENSION_VERSION_POLICY: dict[str, str] = {}
|
||||
|
||||
# Default polling interval for tasks (seconds)
|
||||
TASK_ABORT_POLLING_DEFAULT_INTERVAL = 10
|
||||
|
||||
@@ -540,6 +540,7 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
# Set this to True on any engine spec where at least one row must be
|
||||
# fetched for cursor.description to be populated.
|
||||
type_probe_needs_row: bool = False
|
||||
requires_column_value_normalization: bool = False
|
||||
try_remove_schema_from_table_name = True # pylint: disable=invalid-name
|
||||
run_multiple_statements_as_one = False
|
||||
custom_errors: dict[
|
||||
@@ -1304,6 +1305,38 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
return type_code.upper()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def normalize_column_values(cls, col_values: list[Any]) -> list[Any]:
|
||||
"""
|
||||
Engine-specific hook to normalize column values before PyArrow conversion.
|
||||
|
||||
Called when the initial pa.array() conversion raises an exception, giving
|
||||
the engine a chance to clean up values (e.g. replace sentinel strings with
|
||||
None) before a second conversion attempt.
|
||||
|
||||
:param col_values: Raw Python values for one column
|
||||
:return: Normalized values; return the input list unchanged by default
|
||||
"""
|
||||
return col_values
|
||||
|
||||
@classmethod
|
||||
def resolve_column_type(
|
||||
cls, cursor_type: str | None, pa_mapped: str | None
|
||||
) -> str | None:
|
||||
"""
|
||||
Choose the reported column type from the cursor description type and the
|
||||
type inferred by PyArrow.
|
||||
|
||||
The default prefers the cursor description when available. Override in
|
||||
engine specs where the cursor description is unreliable (e.g. pydruid
|
||||
infers STRING from a None or special-float first row value).
|
||||
|
||||
:param cursor_type: Type string from the cursor description, or None
|
||||
:param pa_mapped: Type string inferred by PyArrow, or None
|
||||
:return: The type string to report for this column
|
||||
"""
|
||||
return cursor_type or pa_mapped
|
||||
|
||||
@classmethod
|
||||
@deprecated(deprecated_in="3.0")
|
||||
def normalize_indexes(cls, indexes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -37,6 +37,8 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
_DRUID_FLOAT_SPECIAL = frozenset({"NaN", "Infinity", "-Infinity"})
|
||||
|
||||
|
||||
class DruidEngineSpec(BaseEngineSpec):
|
||||
"""Engine spec for Druid.io"""
|
||||
@@ -49,6 +51,7 @@ class DruidEngineSpec(BaseEngineSpec):
|
||||
# pydruid builds cursor.description from the first returned row, so a
|
||||
# WHERE FALSE query (zero rows) leaves description as None.
|
||||
type_probe_needs_row = True
|
||||
requires_column_value_normalization = True
|
||||
|
||||
metadata = {
|
||||
"description": (
|
||||
@@ -215,3 +218,26 @@ class DruidEngineSpec(BaseEngineSpec):
|
||||
return {
|
||||
requests_exceptions.ConnectionError: SupersetDBAPIConnectionError,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def normalize_column_values(cls, col_values: list[Any]) -> list[Any]:
|
||||
# pydruid emits NaN, Infinity, and -Infinity as JSON strings because
|
||||
# the JSON spec does not support those values. Replace them with None
|
||||
# so PyArrow can keep the column numeric after the initial conversion
|
||||
# attempt fails.
|
||||
return [
|
||||
None if isinstance(v, str) and v in _DRUID_FLOAT_SPECIAL else v
|
||||
for v in col_values
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def resolve_column_type(
|
||||
cls, cursor_type: str | None, pa_mapped: str | None
|
||||
) -> str | None:
|
||||
# pydruid infers column types from the first row value. A None or
|
||||
# special-float-string first value causes the column to be labelled
|
||||
# STRING even when the actual data is numeric. When PyArrow infers a
|
||||
# more specific type, prefer it over the cursor-description STRING.
|
||||
if cursor_type == "STRING" and pa_mapped is not None and pa_mapped != "STRING":
|
||||
return pa_mapped
|
||||
return cursor_type or pa_mapped
|
||||
|
||||
@@ -254,6 +254,65 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
|
||||
return extension_data
|
||||
|
||||
|
||||
def is_extension_denied(extension: LoadedExtension) -> bool:
|
||||
"""
|
||||
Return True if the extension is denied by the ``EXTENSION_DENYLIST`` config.
|
||||
|
||||
Each denylist entry is either an extension id (denies every version of that
|
||||
extension) or ``"<id>@<version>"`` (denies only that exact version).
|
||||
"""
|
||||
denylist = set(current_app.config.get("EXTENSION_DENYLIST") or [])
|
||||
if not denylist:
|
||||
return False
|
||||
return extension.id in denylist or f"{extension.id}@{extension.version}" in denylist
|
||||
|
||||
|
||||
def is_extension_below_min_version(extension: LoadedExtension) -> bool:
|
||||
"""
|
||||
Return True if the extension's version is below the minimum required for its
|
||||
id by the ``EXTENSION_VERSION_POLICY`` config.
|
||||
|
||||
Versions are compared with PEP 440 semantics. An unparseable version (the
|
||||
extension's or the configured minimum) fails closed — the extension is
|
||||
treated as below the minimum rather than allowed past the policy.
|
||||
"""
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
policy: dict[str, str] = current_app.config.get("EXTENSION_VERSION_POLICY") or {}
|
||||
minimum = policy.get(extension.id)
|
||||
if not minimum:
|
||||
return False
|
||||
try:
|
||||
return Version(extension.version) < Version(minimum)
|
||||
except InvalidVersion:
|
||||
logger.warning(
|
||||
"Could not compare extension %s version %r against the minimum %r "
|
||||
"required by EXTENSION_VERSION_POLICY; refusing it",
|
||||
extension.id,
|
||||
extension.version,
|
||||
minimum,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_extension_rejection_reason(extension: LoadedExtension) -> str | None:
|
||||
"""
|
||||
Return why an extension must not be loaded, or None if it may load.
|
||||
|
||||
Combines the static supply-chain checks (``EXTENSION_DENYLIST`` and
|
||||
``EXTENSION_VERSION_POLICY``).
|
||||
"""
|
||||
if is_extension_denied(extension):
|
||||
return "it is in EXTENSION_DENYLIST"
|
||||
if is_extension_below_min_version(extension):
|
||||
minimum = current_app.config["EXTENSION_VERSION_POLICY"][extension.id]
|
||||
return (
|
||||
f"its version {extension.version} is below the minimum {minimum} "
|
||||
"required by EXTENSION_VERSION_POLICY"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_extensions() -> dict[str, LoadedExtension]:
|
||||
extensions: dict[str, LoadedExtension] = {}
|
||||
|
||||
@@ -265,6 +324,16 @@ def get_extensions() -> dict[str, LoadedExtension]:
|
||||
abs_dist_path = str((Path(path) / "dist").resolve())
|
||||
extension = get_loaded_extension(files, source_base_path=abs_dist_path)
|
||||
extension_id = extension.manifest.id
|
||||
if reason := get_extension_rejection_reason(extension):
|
||||
logger.warning(
|
||||
"Refusing to load extension %s (ID: %s, version: %s) from "
|
||||
"local filesystem: %s",
|
||||
extension.name,
|
||||
extension_id,
|
||||
extension.version,
|
||||
reason,
|
||||
)
|
||||
continue
|
||||
extensions[extension_id] = extension
|
||||
logger.info(
|
||||
"Loading extension %s (ID: %s) from local filesystem",
|
||||
@@ -280,6 +349,16 @@ def get_extensions() -> dict[str, LoadedExtension]:
|
||||
|
||||
for extension in discover_and_load_extensions(extensions_path):
|
||||
extension_id = extension.manifest.id
|
||||
if reason := get_extension_rejection_reason(extension):
|
||||
logger.warning(
|
||||
"Refusing to load extension %s (ID: %s, version: %s) from "
|
||||
"discovery path: %s",
|
||||
extension.name,
|
||||
extension_id,
|
||||
extension.version,
|
||||
reason,
|
||||
)
|
||||
continue
|
||||
if extension_id not in extensions: # Don't override LOCAL_EXTENSIONS
|
||||
extensions[extension_id] = extension
|
||||
logger.info(
|
||||
|
||||
+165
-128
@@ -49,7 +49,17 @@ from contextlib import AbstractContextManager, nullcontext
|
||||
from typing import Any, Callable, TYPE_CHECKING, TypeVar
|
||||
|
||||
from flask import current_app, g, has_app_context, has_request_context
|
||||
from flask_appbuilder.security.sqla.models import Group, User
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
|
||||
from superset import security_manager
|
||||
from superset.mcp_service.composite_token_verifier import (
|
||||
API_KEY_PASSTHROUGH_CLAIM,
|
||||
API_KEY_VALIDATED_USERNAME_CLAIM,
|
||||
)
|
||||
from superset.mcp_service.mcp_config import (
|
||||
default_user_resolver,
|
||||
get_mcp_api_key_enabled,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
@@ -71,18 +81,22 @@ _warned_permissionless_tools: set[str] = set()
|
||||
|
||||
|
||||
class MCPNoAuthSourceError(ValueError):
|
||||
"""Raised when no authentication source is available for a request.
|
||||
"""Raised when no authentication source is configured for MCP.
|
||||
|
||||
Subclasses ``ValueError`` so existing ``except ValueError`` handlers and
|
||||
tests keep working, while callers that need to distinguish "no auth source
|
||||
configured at all" (fail open in dev/internal deployments) from a genuine
|
||||
credential failure (fail closed) can ``isinstance``-check instead of
|
||||
matching a fragile message string.
|
||||
Inherits from ``ValueError`` so callers can catch ``ValueError`` broadly
|
||||
and then use ``isinstance(exc, MCPNoAuthSourceError)`` to distinguish
|
||||
"no auth configured at all" (safe to fail open) from other value errors
|
||||
(fail closed).
|
||||
"""
|
||||
|
||||
|
||||
class MCPPermissionDeniedError(Exception):
|
||||
"""Raised when user lacks required RBAC permission for an MCP tool."""
|
||||
class MCPPermissionDeniedError(PermissionError):
|
||||
"""Raised when user lacks required RBAC permission for an MCP tool.
|
||||
|
||||
Inherits from ``PermissionError`` so the middleware classifies denials as
|
||||
user errors (HTTP 403 / WARNING log / "Access denied" sanitized message)
|
||||
rather than unexpected server errors.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -122,13 +136,9 @@ def check_tool_permission(func: Callable[..., Any], *, log_denial: bool = True)
|
||||
True if user has permission or no permission is required.
|
||||
"""
|
||||
try:
|
||||
from flask import current_app
|
||||
|
||||
if not current_app.config.get("MCP_RBAC_ENABLED", True):
|
||||
return True
|
||||
|
||||
from superset import security_manager
|
||||
|
||||
if not hasattr(g, "user") or not g.user:
|
||||
if log_denial:
|
||||
logger.warning(
|
||||
@@ -166,16 +176,16 @@ def check_tool_permission(func: Callable[..., Any], *, log_denial: bool = True)
|
||||
if not has_permission:
|
||||
if log_denial:
|
||||
logger.warning(
|
||||
"Permission denied for user %s: %s on %s (tool: %s)",
|
||||
g.user.username,
|
||||
"Permission denied for user id=%s: %s on %s (tool: %s)",
|
||||
getattr(g.user, "id", "?"),
|
||||
permission_str,
|
||||
class_permission_name,
|
||||
func.__name__,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Tool hidden for user %s: %s on %s (tool: %s)",
|
||||
g.user.username,
|
||||
"Tool hidden for user id=%s: %s on %s (tool: %s)",
|
||||
getattr(g.user, "id", "?"),
|
||||
permission_str,
|
||||
class_permission_name,
|
||||
func.__name__,
|
||||
@@ -205,8 +215,6 @@ def is_tool_visible_to_current_user(tool: Any) -> bool:
|
||||
True if the tool is visible to the current user, False otherwise.
|
||||
"""
|
||||
try:
|
||||
from flask import current_app
|
||||
|
||||
if not current_app.config.get("MCP_RBAC_ENABLED", True):
|
||||
return True
|
||||
|
||||
@@ -232,32 +240,21 @@ def is_tool_visible_to_current_user(tool: Any) -> bool:
|
||||
return check_tool_permission(tool_func, log_denial=False)
|
||||
|
||||
except (AttributeError, RuntimeError, ValueError):
|
||||
logger.debug(
|
||||
"Could not evaluate tool visibility for current user", exc_info=True
|
||||
)
|
||||
logger.debug("Could not evaluate tool visibility for current user")
|
||||
return False
|
||||
|
||||
|
||||
def load_user_with_relationships(
|
||||
username: str | None = None, email: str | None = None
|
||||
) -> User | None:
|
||||
"""
|
||||
Load a user with all relationships needed for permission checks.
|
||||
"""Load a user with roles and group roles eagerly loaded.
|
||||
|
||||
This function eagerly loads User.roles, User.groups, and Group.roles
|
||||
to prevent detached instance errors when the session is closed/rolled back.
|
||||
|
||||
IMPORTANT: Always use this function instead of security_manager.find_user()
|
||||
when loading users for MCP tool execution. The find_user() method doesn't
|
||||
eagerly load Group.roles, causing "detached instance" errors when permission
|
||||
checks access group.roles after the session is rolled back.
|
||||
|
||||
Args:
|
||||
username: The username to look up (optional if email provided)
|
||||
email: The email to look up (optional if username provided)
|
||||
|
||||
Returns:
|
||||
User object with relationships loaded, or None if not found
|
||||
Delegates to :meth:`SupersetSecurityManager.find_user_with_relationships`,
|
||||
which mirrors FAB's ``find_user`` (including ``auth_username_ci`` and
|
||||
``MultipleResultsFound`` handling) while adding eager loading of
|
||||
``User.roles`` and ``User.groups.roles`` to prevent detached-instance
|
||||
errors when the SQLAlchemy session is closed or rolled back after the
|
||||
lookup — as happens in MCP tool-execution contexts.
|
||||
|
||||
Raises:
|
||||
ValueError: If neither username nor email is provided
|
||||
@@ -265,21 +262,7 @@ def load_user_with_relationships(
|
||||
if not username and not email:
|
||||
raise ValueError("Either username or email must be provided")
|
||||
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from superset.extensions import db
|
||||
|
||||
query = db.session.query(User).options(
|
||||
joinedload(User.roles),
|
||||
joinedload(User.groups).joinedload(Group.roles),
|
||||
)
|
||||
|
||||
if username:
|
||||
query = query.filter(User.username == username)
|
||||
else:
|
||||
query = query.filter(User.email == email)
|
||||
|
||||
return query.first()
|
||||
return security_manager.find_user_with_relationships(username=username, email=email)
|
||||
|
||||
|
||||
def _resolve_user_from_jwt_context(app: Any) -> User | None:
|
||||
@@ -311,9 +294,25 @@ def _resolve_user_from_jwt_context(app: Any) -> User | None:
|
||||
if access_token is None:
|
||||
return None
|
||||
|
||||
# Use configurable resolver or default
|
||||
from superset.mcp_service.mcp_config import default_user_resolver
|
||||
# API key pass-through: CompositeTokenVerifier accepted this token
|
||||
# at the transport layer but defers actual validation to
|
||||
# _resolve_user_from_api_key() (priority 2 in get_user_from_request).
|
||||
# Require client_id=="api_key" (set by CompositeTokenVerifier) in addition
|
||||
# to the claim so that an external IdP JWT that happens to include the
|
||||
# claim name is not misclassified as an API-key pass-through.
|
||||
claims = getattr(access_token, "claims", None)
|
||||
if isinstance(claims, dict) and claims.get(API_KEY_PASSTHROUGH_CLAIM):
|
||||
if getattr(access_token, "client_id", None) == "api_key":
|
||||
logger.debug(
|
||||
"API key pass-through token detected, deferring to API key auth"
|
||||
)
|
||||
return None
|
||||
logger.debug(
|
||||
"API key passthrough claim present but client_id is not 'api_key';"
|
||||
" processing as JWT"
|
||||
)
|
||||
|
||||
# Use configurable resolver or default
|
||||
resolver = app.config.get("MCP_USER_RESOLVER", default_user_resolver)
|
||||
username = resolver(app, access_token)
|
||||
|
||||
@@ -331,47 +330,44 @@ def _resolve_user_from_jwt_context(app: Any) -> User | None:
|
||||
if not user:
|
||||
# Fail closed: JWT says this user should exist but they don't.
|
||||
# Do NOT fall through to MCP_DEV_USERNAME or stale g.user.
|
||||
# Avoid echoing the JWT-extracted username in the exception message
|
||||
# (CodeQL py/clear-text-logging-sensitive-data).
|
||||
logger.debug("JWT-authenticated user not found in database (identity from JWT)")
|
||||
raise ValueError(
|
||||
f"JWT authenticated user '{username}' not found in Superset database. "
|
||||
f"Ensure the user exists before granting MCP access."
|
||||
"JWT authenticated user not found in Superset database. "
|
||||
"Ensure the user exists before granting MCP access."
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _resolve_user_from_api_key(app: Any) -> User | None:
|
||||
"""
|
||||
Resolve the current user from an API key in the Authorization header.
|
||||
def _redact_access_token(access_token: Any) -> None:
|
||||
"""Redact the raw token value after validation so it does not persist."""
|
||||
try:
|
||||
object.__setattr__(access_token, "token", "")
|
||||
except (AttributeError, TypeError):
|
||||
# Immutable AccessToken: the raw token still lives on the object.
|
||||
# Log so the failure is visible; downstream log sanitization (where
|
||||
# configured) must redact it.
|
||||
logger.debug("Could not redact raw API key from AccessToken")
|
||||
|
||||
Uses FAB SecurityManager's API key validation. Only attempts when
|
||||
FAB_API_KEY_ENABLED is True and a request context is active.
|
||||
|
||||
Returns:
|
||||
User object with relationships loaded, or None if no API key present
|
||||
or API key auth is not enabled/available.
|
||||
def _load_api_key_user_by_username(username: str) -> User:
|
||||
"""Load a user by username after transport-layer API key validation."""
|
||||
user_with_rels = load_user_with_relationships(username=username)
|
||||
if user_with_rels is None:
|
||||
raise PermissionError(f"API key owner '{username}' not found in database.")
|
||||
return user_with_rels
|
||||
|
||||
Raises:
|
||||
PermissionError: If an API key is present but invalid/expired,
|
||||
or if validation is not available in this FAB version.
|
||||
"""
|
||||
if not app.config.get("FAB_API_KEY_ENABLED", False) or not has_request_context():
|
||||
return None
|
||||
|
||||
def _validate_api_key_fallback(app: Any, api_key_string: str | None) -> User:
|
||||
"""Validate an API key via FAB when transport-layer validation was skipped."""
|
||||
if not api_key_string:
|
||||
raise PermissionError(
|
||||
"API key pass-through token is missing the raw token value."
|
||||
)
|
||||
|
||||
sm = app.appbuilder.sm
|
||||
# extract_api_key_from_request is FAB's method for reading
|
||||
# the Bearer token from the Authorization header and matching prefixes.
|
||||
# Not all FAB versions include this method, so guard with hasattr.
|
||||
if not hasattr(sm, "extract_api_key_from_request"):
|
||||
logger.debug(
|
||||
"FAB SecurityManager does not have extract_api_key_from_request; "
|
||||
"API key authentication is not available in this FAB version"
|
||||
)
|
||||
return None
|
||||
|
||||
api_key_string = sm.extract_api_key_from_request()
|
||||
if api_key_string is None:
|
||||
return None
|
||||
|
||||
if not hasattr(sm, "validate_api_key"):
|
||||
logger.warning(
|
||||
"FAB SecurityManager does not have validate_api_key; "
|
||||
@@ -383,25 +379,79 @@ def _resolve_user_from_api_key(app: Any) -> User | None:
|
||||
|
||||
user = sm.validate_api_key(api_key_string)
|
||||
if not user:
|
||||
create_url = app.config.get("MCP_API_KEY_CREATE_URL", "/profile/")
|
||||
raise PermissionError(
|
||||
"Invalid or expired API key. "
|
||||
"Create a new key at /api/v1/security/api_keys/."
|
||||
f"Invalid or expired API key. Create a new key at {create_url}."
|
||||
)
|
||||
|
||||
# Reload user with all relationships eagerly loaded to avoid
|
||||
# detached-instance errors during later permission checks.
|
||||
user_with_rels = load_user_with_relationships(username=user.username)
|
||||
if user_with_rels is None:
|
||||
logger.warning(
|
||||
"Failed to reload API key user %s with relationships; "
|
||||
"using original user object which may have lazy-loaded "
|
||||
"relationships",
|
||||
user.username,
|
||||
"Failed to reload API key user id=%s with relationships; "
|
||||
"using original user object which may have lazy-loaded relationships",
|
||||
getattr(user, "id", "?"),
|
||||
)
|
||||
return user
|
||||
return user_with_rels
|
||||
|
||||
|
||||
def _resolve_user_from_api_key(app: Any) -> User | None:
|
||||
"""
|
||||
Resolve the current user from an API key passed via Bearer token.
|
||||
|
||||
Reads the token from FastMCP's per-request ``AccessToken`` (set by
|
||||
``CompositeTokenVerifier`` when a Bearer token matches an API key
|
||||
prefix). The streamable-http transport does not push a Flask request
|
||||
context, so we cannot rely on ``flask.request`` headers — the verifier
|
||||
already saw the token and stashed it on the ``AccessToken``.
|
||||
|
||||
Returns:
|
||||
User object with relationships loaded, or None if no API key
|
||||
pass-through token is present or API key auth is not enabled.
|
||||
|
||||
Raises:
|
||||
PermissionError: If an API key pass-through token is present but
|
||||
invalid/expired (fail closed — do NOT fall through to weaker
|
||||
auth sources like ``MCP_DEV_USERNAME``), or if validation is
|
||||
not available in this FAB version.
|
||||
"""
|
||||
if not get_mcp_api_key_enabled(app):
|
||||
return None
|
||||
|
||||
try:
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
except ImportError:
|
||||
logger.debug("fastmcp.server.dependencies not available, skipping API key auth")
|
||||
return None
|
||||
|
||||
access_token = get_access_token()
|
||||
if access_token is None:
|
||||
return None
|
||||
|
||||
# Only validate tokens that the CompositeTokenVerifier flagged as
|
||||
# API key pass-throughs. Plain JWTs were already validated by the JWT
|
||||
# verifier and resolved in _resolve_user_from_jwt_context.
|
||||
claims = getattr(access_token, "claims", None)
|
||||
if not (isinstance(claims, dict) and claims.get(API_KEY_PASSTHROUGH_CLAIM)):
|
||||
return None
|
||||
# Defense-in-depth: require client_id=="api_key" (set by CompositeTokenVerifier)
|
||||
# to guard against rogue external IdP JWTs that include the passthrough claim.
|
||||
if getattr(access_token, "client_id", None) != "api_key":
|
||||
return None
|
||||
|
||||
# Fast path: transport layer already validated the key and stored the
|
||||
# username in the claim — skip the second DB call.
|
||||
if validated_username := claims.get(API_KEY_VALIDATED_USERNAME_CLAIM):
|
||||
_redact_access_token(access_token)
|
||||
return _load_api_key_user_by_username(validated_username)
|
||||
|
||||
# Fallback: no transport-level validation (app=None in CompositeTokenVerifier).
|
||||
# Validate the raw token against FAB here instead.
|
||||
api_key_string = getattr(access_token, "token", None)
|
||||
_redact_access_token(access_token)
|
||||
return _validate_api_key_fallback(app, api_key_string)
|
||||
|
||||
|
||||
def get_user_from_request() -> User:
|
||||
"""
|
||||
Get the current user for the MCP tool request.
|
||||
@@ -423,8 +473,6 @@ def get_user_from_request() -> User:
|
||||
Raises:
|
||||
ValueError: If user cannot be authenticated or found
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
# Priority 1: JWT context (per-request safe via ContextVar)
|
||||
if (jwt_user := _resolve_user_from_jwt_context(current_app)) is not None:
|
||||
return jwt_user
|
||||
@@ -447,26 +495,21 @@ def get_user_from_request() -> User:
|
||||
if hasattr(g, "user") and g.user:
|
||||
return g.user
|
||||
|
||||
# No auth source available. Keep the client-facing message generic so it
|
||||
# does not disclose server configuration; the detailed diagnostics are
|
||||
# logged server-side only.
|
||||
# No auth source available — log diagnostics server-side, raise generic
|
||||
# client-facing error so no config details leak toward the client.
|
||||
auth_enabled = current_app.config.get("MCP_AUTH_ENABLED", False)
|
||||
jwt_configured = bool(
|
||||
current_app.config.get("MCP_JWKS_URI")
|
||||
or current_app.config.get("MCP_JWT_PUBLIC_KEY")
|
||||
or current_app.config.get("MCP_JWT_SECRET")
|
||||
)
|
||||
dev_username_configured = bool(current_app.config.get("MCP_DEV_USERNAME"))
|
||||
logger.debug(
|
||||
"MCP authentication failed: no valid credentials provided "
|
||||
"(no JWT access token, no API key, no g.user from middleware)"
|
||||
)
|
||||
logger.debug(
|
||||
"MCP auth diagnostics: MCP_AUTH_ENABLED=%s, JWT keys configured=%s, "
|
||||
"No auth source found. "
|
||||
"MCP_AUTH_ENABLED=%s, JWT keys configured=%s, "
|
||||
"MCP_DEV_USERNAME configured=%s",
|
||||
auth_enabled,
|
||||
jwt_configured,
|
||||
dev_username_configured,
|
||||
bool(current_app.config.get("MCP_DEV_USERNAME")),
|
||||
)
|
||||
raise MCPNoAuthSourceError(
|
||||
"Authentication required. No valid credentials provided."
|
||||
@@ -491,8 +534,6 @@ def has_dataset_access(dataset: "SqlaTable") -> bool:
|
||||
Returns False on any error to fail securely.
|
||||
"""
|
||||
try:
|
||||
from superset import security_manager
|
||||
|
||||
# Check if user has read access to the dataset
|
||||
if hasattr(g, "user") and g.user:
|
||||
# Use Superset's security manager to check dataset access
|
||||
@@ -524,14 +565,14 @@ def check_chart_data_access(chart: Any) -> "DatasetValidationResult":
|
||||
return validate_chart_dataset(chart, check_access=True)
|
||||
|
||||
|
||||
def _log_user_resolution_failure(exc: ValueError) -> None:
|
||||
"""Log a user-resolution ValueError at the appropriate level.
|
||||
def _log_user_resolution_failure(exc: ValueError | PermissionError) -> None:
|
||||
"""Log a user-resolution failure at the appropriate level.
|
||||
|
||||
``MCPNoAuthSourceError`` (no JWT, no API key, no MCP_DEV_USERNAME
|
||||
configured) is expected in unauthenticated/dev deployments and during
|
||||
tools/list scanning — log at DEBUG to avoid ERROR noise. All other
|
||||
ValueErrors (e.g. dev username not in DB) are genuine credential failures
|
||||
and are logged at ERROR.
|
||||
"No authenticated user found" is expected in unauthenticated/dev
|
||||
deployments (no JWT, no API key, no MCP_DEV_USERNAME configured) and
|
||||
during tools/list scanning — log at DEBUG to avoid ERROR noise.
|
||||
All other failures (e.g. dev username not in DB, permission denied) are
|
||||
genuine credential failures and are logged at ERROR.
|
||||
"""
|
||||
if isinstance(exc, MCPNoAuthSourceError):
|
||||
logger.debug("MCP: no auth source configured, unauthenticated request")
|
||||
@@ -539,17 +580,14 @@ def _log_user_resolution_failure(exc: ValueError) -> None:
|
||||
logger.error("MCP user resolution failed, denying request: %s", exc)
|
||||
|
||||
|
||||
def _reject_if_inactive(user: User | None) -> None:
|
||||
"""Raise ``ValueError`` if the resolved user account is deactivated.
|
||||
|
||||
A still-valid JWT or API key must not grant MCP access to a user whose
|
||||
account has been disabled. This mirrors Flask-Login's ``is_active`` check
|
||||
for web sessions, which the MCP auth path does not otherwise go through.
|
||||
"""
|
||||
if user is not None and not (
|
||||
getattr(user, "is_active", True) and getattr(user, "active", True)
|
||||
):
|
||||
raise ValueError("User account is disabled")
|
||||
def _assert_user_active(user: User | None) -> None:
|
||||
"""Raise ValueError if the user account is disabled (no-op for None)."""
|
||||
if user is None:
|
||||
return
|
||||
if not getattr(user, "is_active", getattr(user, "active", True)):
|
||||
raise ValueError(
|
||||
f"Account for user '{getattr(user, 'username', user)}' is disabled."
|
||||
)
|
||||
|
||||
|
||||
def _setup_user_context() -> User | None:
|
||||
@@ -567,7 +605,6 @@ def _setup_user_context() -> User | None:
|
||||
# tool calls when no per-request middleware refreshes it.
|
||||
# Only clear in app-context-only mode; preserve g.user when
|
||||
# a request context is active (external middleware set it).
|
||||
from flask import has_request_context
|
||||
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
@@ -579,7 +616,6 @@ def _setup_user_context() -> User | None:
|
||||
for attempt in range(2):
|
||||
try:
|
||||
user = get_user_from_request()
|
||||
_reject_if_inactive(user)
|
||||
|
||||
# Validate user has necessary relationships loaded.
|
||||
# Force access to ensure they're loaded if lazy.
|
||||
@@ -612,7 +648,7 @@ def _setup_user_context() -> User | None:
|
||||
logger.error("DB connection failed on retry during user setup: %s", e)
|
||||
_cleanup_session_on_error()
|
||||
raise
|
||||
except ValueError as e:
|
||||
except (ValueError, PermissionError) as e:
|
||||
# User resolution failed — fail closed. Do not fall back to
|
||||
# g.user from middleware, as that could allow a request to
|
||||
# proceed as a different user in multi-tenant deployments.
|
||||
@@ -623,6 +659,7 @@ def _setup_user_context() -> User | None:
|
||||
g.pop("user", None)
|
||||
raise
|
||||
|
||||
_assert_user_active(user)
|
||||
g.user = user
|
||||
return user
|
||||
|
||||
|
||||
@@ -249,6 +249,29 @@ class VersionedResponse(BaseModel):
|
||||
api_version: str = Field("v1", description="MCP API version")
|
||||
|
||||
|
||||
DEFAULT_GET_CHART_INFO_COLUMNS: List[str] = [
|
||||
"id",
|
||||
"slice_name",
|
||||
"viz_type",
|
||||
"datasource_name",
|
||||
"datasource_type",
|
||||
"url",
|
||||
"description",
|
||||
"cache_timeout",
|
||||
"changed_on",
|
||||
"changed_on_humanized",
|
||||
"created_on",
|
||||
"created_on_humanized",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"uuid",
|
||||
"tags",
|
||||
"filters",
|
||||
"form_data_key",
|
||||
"is_unsaved_state",
|
||||
]
|
||||
|
||||
|
||||
class GetChartInfoRequest(BaseModel):
|
||||
"""Request schema for get_chart_info with support for ID, UUID, or form_data_key.
|
||||
|
||||
@@ -289,6 +312,17 @@ class GetChartInfoRequest(BaseModel):
|
||||
"and the caller to have dashboard access."
|
||||
),
|
||||
)
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=lambda: list(DEFAULT_GET_CHART_INFO_COLUMNS),
|
||||
description=(
|
||||
"Top-level fields to include in the response. Defaults to a lean "
|
||||
"set that excludes 'form_data' (the full chart config, can be 50KB+). "
|
||||
"Add 'form_data' explicitly when you need the raw chart configuration."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_identifier_or_form_data_key(self) -> "GetChartInfoRequest":
|
||||
@@ -298,6 +332,16 @@ class GetChartInfoRequest(BaseModel):
|
||||
)
|
||||
return self
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def _parse_select_columns(cls, value: Any) -> Any:
|
||||
from superset.mcp_service.utils.schema_utils import parse_json_or_list
|
||||
|
||||
if value is None:
|
||||
return list(DEFAULT_GET_CHART_INFO_COLUMNS)
|
||||
parsed = parse_json_or_list(value, "select_columns")
|
||||
return parsed if parsed else list(DEFAULT_GET_CHART_INFO_COLUMNS)
|
||||
|
||||
|
||||
def extract_filters_from_form_data(
|
||||
form_data: Dict[str, Any] | None,
|
||||
|
||||
@@ -20,6 +20,7 @@ MCP tool: get_chart_info
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Context
|
||||
from sqlalchemy.orm import subqueryload
|
||||
@@ -213,7 +214,7 @@ def _apply_unsaved_state_override(result: ChartInfo, form_data_key: str) -> None
|
||||
)
|
||||
async def get_chart_info(
|
||||
request: GetChartInfoRequest, ctx: Context
|
||||
) -> ChartInfo | ChartError:
|
||||
) -> dict[str, Any] | ChartError:
|
||||
"""Get chart metadata by ID or UUID.
|
||||
|
||||
IMPORTANT FOR LLM CLIENTS:
|
||||
@@ -277,9 +278,14 @@ async def get_chart_info(
|
||||
"form_data_key=%s" % (request.form_data_key,)
|
||||
)
|
||||
result = _build_unsaved_chart_info(request.form_data_key)
|
||||
if isinstance(result, ChartError):
|
||||
return result
|
||||
if not can_view_data_model_metadata:
|
||||
return redact_chart_data_model_fields(result)
|
||||
return result
|
||||
result = redact_chart_data_model_fields(result)
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": request.select_columns},
|
||||
)
|
||||
|
||||
# At this point identifier must be set (validator ensures at least one
|
||||
# of identifier/form_data_key is provided, and the form_data_key-only
|
||||
@@ -333,6 +339,11 @@ async def get_chart_info(
|
||||
error = await _attach_dashboard_filters(result, request.dashboard_id, ctx)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": request.select_columns},
|
||||
)
|
||||
else:
|
||||
await ctx.warning("Chart retrieval failed: error=%s" % (str(result),))
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Composite token verifier for MCP authentication.
|
||||
|
||||
Routes Bearer tokens to the appropriate verifier based on prefix:
|
||||
- Tokens matching FAB_API_KEY_PREFIXES (e.g. ``sst_``) are validated against
|
||||
the FAB database at the transport layer. Invalid keys are rejected before
|
||||
any MCP method (tools/list, resources/list, tool calls) is reached.
|
||||
- All other tokens are delegated to the wrapped JWT verifier (when one is
|
||||
configured); when no JWT verifier is configured, non-API-key tokens are
|
||||
rejected at the transport layer.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.server.auth import AccessToken
|
||||
from fastmcp.server.auth.providers.jwt import TokenVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Namespaced claim that flags an AccessToken as an API-key token.
|
||||
# Namespacing avoids collision with custom claims an external IdP might
|
||||
# happen to mint on a JWT — a plain ``_api_key_passthrough`` claim could
|
||||
# be silently misidentified as a Superset API-key request.
|
||||
API_KEY_PASSTHROUGH_CLAIM = "_superset_mcp_api_key_passthrough"
|
||||
|
||||
# Claim that carries the FAB-validated username after transport-layer
|
||||
# API key validation. When present, ``_resolve_user_from_api_key`` skips
|
||||
# the second DB call and loads the user directly by username.
|
||||
API_KEY_VALIDATED_USERNAME_CLAIM = "_superset_mcp_validated_username"
|
||||
|
||||
|
||||
class CompositeTokenVerifier(TokenVerifier):
|
||||
"""Routes Bearer tokens between API key validation and JWT verification.
|
||||
|
||||
API key tokens (identified by prefix) are validated against the FAB
|
||||
database at the transport layer so that invalid keys are rejected before
|
||||
any MCP method (tools/list, resources/list, tool calls) is reached.
|
||||
|
||||
Args:
|
||||
jwt_verifier: The wrapped JWT verifier for non-API-key tokens.
|
||||
When ``None``, only API-key tokens are accepted; all other
|
||||
Bearer tokens are rejected at the transport layer (used when
|
||||
``MCP_AUTH_ENABLED=False`` but ``FAB_API_KEY_ENABLED=True``).
|
||||
api_key_prefixes: List of prefixes that identify API key tokens
|
||||
(e.g. ``["sst_"]``).
|
||||
app: Flask application instance used to push an app context for
|
||||
FAB SecurityManager access during token validation. When
|
||||
``None``, prefix matching is used without DB validation
|
||||
(backward-compatible / test mode).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jwt_verifier: TokenVerifier | None,
|
||||
api_key_prefixes: list[str],
|
||||
app: Any = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
base_url=getattr(jwt_verifier, "base_url", None),
|
||||
required_scopes=getattr(jwt_verifier, "required_scopes", None) or [],
|
||||
)
|
||||
self._jwt_verifier = jwt_verifier
|
||||
self._app = app
|
||||
if app is None:
|
||||
logger.warning(
|
||||
"CompositeTokenVerifier created without a Flask app; API keys "
|
||||
"will not be validated at the transport layer. Invalid keys are "
|
||||
"rejected later at the Flask layer instead. Pass app=<flask_app> "
|
||||
"to enable transport-layer rejection."
|
||||
)
|
||||
valid: list[str] = []
|
||||
invalid_count = 0
|
||||
for prefix in api_key_prefixes:
|
||||
if isinstance(prefix, str) and (normalized := prefix.strip()):
|
||||
valid.append(normalized)
|
||||
else:
|
||||
invalid_count += 1
|
||||
if invalid_count:
|
||||
# Log count only — actual values may be config secrets
|
||||
# (CodeQL py/clear-text-logging-sensitive-data).
|
||||
logger.warning(
|
||||
"FAB_API_KEY_PREFIXES has %d invalid entries (empty/non-string)"
|
||||
" — ignored",
|
||||
invalid_count,
|
||||
)
|
||||
self._api_key_prefixes = tuple(valid)
|
||||
|
||||
def _validate_api_key_sync(self, token: str) -> str | None:
|
||||
"""Validate an API key against FAB and return the owner's username.
|
||||
|
||||
Runs synchronously inside a thread executor. Pushes a fresh Flask
|
||||
app context so that FAB's SecurityManager can access the database.
|
||||
|
||||
Returns the username on success, or ``None`` if the key is invalid,
|
||||
FAB does not support ``validate_api_key``, or an unexpected error
|
||||
occurs (fail closed).
|
||||
"""
|
||||
if self._app is None:
|
||||
return None
|
||||
try:
|
||||
with self._app.app_context():
|
||||
sm = self._app.appbuilder.sm
|
||||
if not hasattr(sm, "validate_api_key"):
|
||||
logger.warning(
|
||||
"FAB SecurityManager does not support validate_api_key; "
|
||||
"rejecting API key token at transport"
|
||||
)
|
||||
return None
|
||||
user = sm.validate_api_key(token)
|
||||
username = user.username if user else None
|
||||
# Unbind the local reference so this frame no longer points at
|
||||
# the raw token (defense-in-depth). Python does not zero the
|
||||
# underlying string memory on rebind.
|
||||
token = "" # noqa: S105
|
||||
return username
|
||||
except Exception: # noqa: BLE001 — catch-all: DB errors, FAB internals, etc.
|
||||
logger.warning(
|
||||
"API key transport validation failed unexpectedly; rejecting token",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify a Bearer token.
|
||||
|
||||
For API key tokens (prefix match):
|
||||
- When a Flask app is configured, validates the key against FAB at
|
||||
the transport layer and rejects invalid keys with a transport-level
|
||||
401 before any MCP method is reached.
|
||||
- When no app is configured (test/compat mode), falls back to prefix-
|
||||
only acceptance and defers DB validation to the Flask layer.
|
||||
|
||||
For all other tokens, delegates to the wrapped JWT verifier when one
|
||||
is configured; rejects if no JWT verifier is configured.
|
||||
"""
|
||||
if any(token.startswith(prefix) for prefix in self._api_key_prefixes):
|
||||
if self._app is not None:
|
||||
loop = asyncio.get_running_loop()
|
||||
username = await loop.run_in_executor(
|
||||
None, self._validate_api_key_sync, token
|
||||
)
|
||||
if username is None:
|
||||
logger.debug(
|
||||
"API key rejected at transport layer (invalid or expired)"
|
||||
)
|
||||
return None
|
||||
logger.debug(
|
||||
"API key validated at transport layer for user=%s", username
|
||||
)
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="api_key",
|
||||
scopes=list(self.required_scopes or []),
|
||||
claims={
|
||||
API_KEY_PASSTHROUGH_CLAIM: True,
|
||||
API_KEY_VALIDATED_USERNAME_CLAIM: username,
|
||||
},
|
||||
)
|
||||
|
||||
# No app configured: fall back to prefix-only pass-through so
|
||||
# ``_resolve_user_from_api_key`` handles DB validation.
|
||||
# NOTE: ``MCP_REQUIRED_SCOPES`` is intentionally not enforced for
|
||||
# API-key auth — FAB API keys do not carry scopes. Authorization is
|
||||
# enforced downstream via ``check_tool_permission`` (RBAC).
|
||||
logger.debug("API key token detected (prefix match), passing through")
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="api_key",
|
||||
scopes=list(self.required_scopes or []),
|
||||
claims={API_KEY_PASSTHROUGH_CLAIM: True},
|
||||
)
|
||||
|
||||
if self._jwt_verifier is None:
|
||||
logger.debug(
|
||||
"Bearer token does not match any API key prefix and no JWT "
|
||||
"verifier is configured; rejecting"
|
||||
)
|
||||
return None
|
||||
|
||||
return await self._jwt_verifier.verify_token(token)
|
||||
@@ -288,6 +288,32 @@ class ListDashboardsRequest(OwnedByMeMixin, CreatedByMeMixin, MetadataCacheContr
|
||||
return self
|
||||
|
||||
|
||||
DEFAULT_GET_DASHBOARD_INFO_COLUMNS: List[str] = [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"description",
|
||||
"certified_by",
|
||||
"certification_details",
|
||||
"published",
|
||||
"is_managed_externally",
|
||||
"external_url",
|
||||
"created_on",
|
||||
"changed_on",
|
||||
"uuid",
|
||||
"url",
|
||||
"created_on_humanized",
|
||||
"changed_on_humanized",
|
||||
"chart_count",
|
||||
"tags",
|
||||
"charts",
|
||||
"native_filters",
|
||||
"cross_filters_enabled",
|
||||
"is_permalink_state",
|
||||
"permalink_key",
|
||||
]
|
||||
|
||||
|
||||
class GetDashboardInfoRequest(MetadataCacheControl):
|
||||
"""Request schema for get_dashboard_info with support for ID, UUID, or slug.
|
||||
|
||||
@@ -312,6 +338,29 @@ class GetDashboardInfoRequest(MetadataCacheControl):
|
||||
"from that permalink."
|
||||
),
|
||||
)
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=lambda: list(DEFAULT_GET_DASHBOARD_INFO_COLUMNS),
|
||||
description=(
|
||||
"Top-level fields to include in the response. Defaults to a lean "
|
||||
"set that excludes 'css' (raw CSS, can be many KB) and 'filter_state' "
|
||||
"(only relevant when permalink_key is provided). Pass an explicit list "
|
||||
"to override, e.g. ['id','dashboard_title','charts'] for minimal "
|
||||
"output, or add 'css' to include raw dashboard CSS."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def _parse_select_columns(cls, value: Any) -> Any:
|
||||
from superset.mcp_service.utils.schema_utils import parse_json_or_list
|
||||
|
||||
if value is None:
|
||||
return list(DEFAULT_GET_DASHBOARD_INFO_COLUMNS)
|
||||
parsed = parse_json_or_list(value, "select_columns")
|
||||
return parsed if parsed else list(DEFAULT_GET_DASHBOARD_INFO_COLUMNS)
|
||||
|
||||
|
||||
class GetDashboardLayoutRequest(BaseModel):
|
||||
|
||||
@@ -24,6 +24,7 @@ about a specific dashboard.
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Context
|
||||
from flask import g, has_request_context
|
||||
@@ -38,6 +39,7 @@ from superset.mcp_service.dashboard.schemas import (
|
||||
dashboard_serializer,
|
||||
DashboardError,
|
||||
DashboardInfo,
|
||||
DEFAULT_GET_DASHBOARD_INFO_COLUMNS,
|
||||
GetDashboardInfoRequest,
|
||||
redact_filter_state_data_model_metadata,
|
||||
)
|
||||
@@ -114,7 +116,7 @@ def _get_permalink_state(permalink_key: str) -> DashboardPermalinkValue | None:
|
||||
)
|
||||
async def get_dashboard_info(
|
||||
request: GetDashboardInfoRequest, ctx: Context
|
||||
) -> DashboardInfo | DashboardError:
|
||||
) -> dict[str, Any] | DashboardError:
|
||||
"""
|
||||
Get dashboard metadata by ID, UUID, or slug.
|
||||
|
||||
@@ -247,6 +249,19 @@ async def get_dashboard_info(
|
||||
result.is_permalink_state,
|
||||
)
|
||||
)
|
||||
# When permalink_key is supplied and the caller did not explicitly
|
||||
# override select_columns, ensure filter_state is present so the
|
||||
# caller gets the data they came for.
|
||||
effective_select_columns = list(request.select_columns)
|
||||
if request.permalink_key and effective_select_columns == list(
|
||||
DEFAULT_GET_DASHBOARD_INFO_COLUMNS
|
||||
):
|
||||
effective_select_columns.append("filter_state")
|
||||
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": effective_select_columns},
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Dashboard retrieval failed: error_type=%s, error=%s"
|
||||
|
||||
@@ -97,6 +97,25 @@ class TableColumnInfo(BaseModel):
|
||||
filterable: bool | None = Field(None, description="Is filterable")
|
||||
description: str | None = Field(None, description="Column description")
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_column_fields_by_context(
|
||||
self, serializer: Any, info: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""Filter column fields based on serialization context.
|
||||
|
||||
If context contains 'column_fields', only include those fields plus
|
||||
column_name (always required). Keeps wide datasets small when the
|
||||
caller only needs column_name + type.
|
||||
"""
|
||||
data = serializer(self)
|
||||
if info.context and isinstance(info.context, dict):
|
||||
column_fields = info.context.get("column_fields")
|
||||
if column_fields is not None:
|
||||
requested = set(column_fields)
|
||||
requested.add("column_name")
|
||||
return {k: v for k, v in data.items() if k in requested}
|
||||
return data
|
||||
|
||||
|
||||
class SqlMetricInfo(BaseModel):
|
||||
metric_name: str = Field(
|
||||
@@ -315,6 +334,29 @@ class DatasetError(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_GET_DATASET_INFO_COLUMNS: List[str] = [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"database_name",
|
||||
"database_id",
|
||||
"uuid",
|
||||
"is_virtual",
|
||||
"description",
|
||||
"main_dttm_col",
|
||||
"sql",
|
||||
"url",
|
||||
"columns",
|
||||
"metrics",
|
||||
]
|
||||
|
||||
DEFAULT_GET_DATASET_INFO_COLUMN_FIELDS: List[str] = [
|
||||
"column_name",
|
||||
"type",
|
||||
"is_dttm",
|
||||
]
|
||||
|
||||
|
||||
class GetDatasetInfoRequest(MetadataCacheControl):
|
||||
"""Request schema for get_dataset_info with support for ID or UUID."""
|
||||
|
||||
@@ -322,6 +364,50 @@ class GetDatasetInfoRequest(MetadataCacheControl):
|
||||
int | str,
|
||||
Field(description="Dataset identifier - can be numeric ID or UUID string"),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=lambda: list(DEFAULT_GET_DATASET_INFO_COLUMNS),
|
||||
description=(
|
||||
"Top-level fields to include in the response. Defaults to a lean "
|
||||
"set that excludes verbose fields like params, template_params, "
|
||||
"extra, tags, certification_details. Pass an explicit list to "
|
||||
"override (e.g. ['id','table_name','columns'] for minimal output)."
|
||||
),
|
||||
),
|
||||
]
|
||||
column_fields: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=lambda: list(DEFAULT_GET_DATASET_INFO_COLUMN_FIELDS),
|
||||
description=(
|
||||
"Per-column fields to include for entries in 'columns'. Defaults "
|
||||
"to ['column_name','type','is_dttm']. Pass a wider list to "
|
||||
"include 'verbose_name','groupby','filterable','description' "
|
||||
"when needed."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def _parse_select_columns(cls, value: Any) -> Any:
|
||||
from superset.mcp_service.utils.schema_utils import parse_json_or_list
|
||||
|
||||
if value is None:
|
||||
return list(DEFAULT_GET_DATASET_INFO_COLUMNS)
|
||||
parsed = parse_json_or_list(value, "select_columns")
|
||||
return parsed if parsed else list(DEFAULT_GET_DATASET_INFO_COLUMNS)
|
||||
|
||||
@field_validator("column_fields", mode="before")
|
||||
@classmethod
|
||||
def _parse_column_fields(cls, value: Any) -> Any:
|
||||
from superset.mcp_service.utils.schema_utils import parse_json_or_list
|
||||
|
||||
if value is None or value == "":
|
||||
return list(DEFAULT_GET_DATASET_INFO_COLUMN_FIELDS)
|
||||
parsed = parse_json_or_list(value, "column_fields")
|
||||
return parsed
|
||||
|
||||
|
||||
class CreateVirtualDatasetRequest(BaseModel):
|
||||
|
||||
@@ -24,6 +24,7 @@ about a specific dataset.
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Context
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
@@ -58,7 +59,7 @@ logger = logging.getLogger(__name__)
|
||||
@requires_data_model_metadata_access
|
||||
async def get_dataset_info(
|
||||
request: GetDatasetInfoRequest, ctx: Context
|
||||
) -> DatasetInfo | DatasetError:
|
||||
) -> dict[str, Any] | DatasetError:
|
||||
"""Get dataset metadata by ID or UUID.
|
||||
|
||||
Returns columns, metrics, and schema details.
|
||||
@@ -144,6 +145,19 @@ async def get_dataset_info(
|
||||
len(result.metrics) if result.metrics else 0,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Filtering response: select_columns=%s, column_fields=%s"
|
||||
% (request.select_columns, request.column_fields)
|
||||
)
|
||||
with event_logger.log_context(action="mcp.get_dataset_info.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
by_alias=True,
|
||||
context={
|
||||
"select_columns": request.select_columns,
|
||||
"column_fields": request.column_fields,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Dataset retrieval failed: error_type=%s, error=%s"
|
||||
|
||||
@@ -18,14 +18,18 @@
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from authlib.jose.errors import JoseError
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from flask import Flask
|
||||
|
||||
from superset.mcp_service.composite_token_verifier import CompositeTokenVerifier
|
||||
from superset.mcp_service.constants import (
|
||||
DEFAULT_TOKEN_LIMIT,
|
||||
DEFAULT_WARN_THRESHOLD_PCT,
|
||||
)
|
||||
from superset.mcp_service.jwt_verifier import DetailedJWTVerifier, MCPJWTVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -79,6 +83,19 @@ MCP_DISABLED_TOOLS: set[str] = set()
|
||||
# per RFC 6750 Section 3.1. This flag NEVER affects client-facing output.
|
||||
MCP_JWT_DEBUG_ERRORS = False
|
||||
|
||||
# MCP API Key Authentication - controls whether FAB API keys are accepted by
|
||||
# the MCP transport. When None (default), falls back to FAB_API_KEY_ENABLED.
|
||||
# Set explicitly to True/False to control MCP transport behavior independently
|
||||
# of the FAB REST API setting. When FAB_API_KEY_ENABLED=True and this is None,
|
||||
# Superset logs a startup warning to make the implicit enablement visible.
|
||||
MCP_API_KEY_ENABLED: bool | None = None
|
||||
|
||||
# URL surfaced to users when an API key is rejected, pointing them at the
|
||||
# place to create or rotate a key. Defaults to the FAB user profile page;
|
||||
# deployments that manage keys elsewhere can override this to point at their
|
||||
# own key-management UI without forking the auth code.
|
||||
MCP_API_KEY_CREATE_URL = "/profile/"
|
||||
|
||||
|
||||
# Session configuration for local development
|
||||
MCP_SESSION_CONFIG = {
|
||||
@@ -303,57 +320,143 @@ MCP_TOOL_SEARCH_CONFIG: Dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
def get_mcp_api_key_enabled(app: Flask, *, startup_warning: bool = False) -> bool:
|
||||
"""Return whether API key auth is enabled for the MCP transport.
|
||||
|
||||
Prefers ``MCP_API_KEY_ENABLED`` when explicitly set; falls back to
|
||||
``FAB_API_KEY_ENABLED``. When ``startup_warning=True`` and the value
|
||||
is inherited from ``FAB_API_KEY_ENABLED``, logs a warning so operators
|
||||
know a FAB config change now also affects the MCP transport.
|
||||
"""
|
||||
if (mcp_setting := app.config.get("MCP_API_KEY_ENABLED", None)) is not None:
|
||||
return bool(mcp_setting)
|
||||
fab_enabled = bool(app.config.get("FAB_API_KEY_ENABLED", False))
|
||||
if startup_warning and fab_enabled:
|
||||
logger.warning(
|
||||
"MCP API key auth is enabled via FAB_API_KEY_ENABLED=True. "
|
||||
"Set MCP_API_KEY_ENABLED=True to silence this warning or "
|
||||
"MCP_API_KEY_ENABLED=False to disable API keys on the MCP "
|
||||
"transport without affecting the FAB REST API."
|
||||
)
|
||||
return fab_enabled
|
||||
|
||||
|
||||
def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
|
||||
"""Default MCP auth factory using app.config values."""
|
||||
if not app.config.get("MCP_AUTH_ENABLED", False):
|
||||
"""Default MCP auth factory using app.config values.
|
||||
|
||||
Returns an auth provider when ``MCP_AUTH_ENABLED=True`` (JWT verifier,
|
||||
optionally wrapped with ``CompositeTokenVerifier`` for API keys) or
|
||||
when only ``MCP_API_KEY_ENABLED=True`` (or ``FAB_API_KEY_ENABLED=True``
|
||||
as a fallback) — API-key-only verifier that rejects all non-API-key
|
||||
Bearer tokens at the transport.
|
||||
|
||||
``MCP_API_KEY_ENABLED=None`` (default) defers to ``FAB_API_KEY_ENABLED``
|
||||
and logs a startup warning when that setting is True, so operators are
|
||||
aware that a FAB config change now also affects the MCP transport.
|
||||
"""
|
||||
auth_enabled = app.config.get("MCP_AUTH_ENABLED", False)
|
||||
api_key_enabled = get_mcp_api_key_enabled(app, startup_warning=True)
|
||||
|
||||
if not (auth_enabled or api_key_enabled):
|
||||
return None
|
||||
|
||||
jwks_uri = app.config.get("MCP_JWKS_URI")
|
||||
public_key = app.config.get("MCP_JWT_PUBLIC_KEY")
|
||||
secret = app.config.get("MCP_JWT_SECRET")
|
||||
jwt_verifier: Any | None = None
|
||||
|
||||
if not (jwks_uri or public_key or secret):
|
||||
logger.warning("MCP_AUTH_ENABLED is True but no JWT keys/secret configured")
|
||||
return None
|
||||
if auth_enabled:
|
||||
jwks_uri = app.config.get("MCP_JWKS_URI")
|
||||
public_key = app.config.get("MCP_JWT_PUBLIC_KEY")
|
||||
secret = app.config.get("MCP_JWT_SECRET")
|
||||
|
||||
try:
|
||||
debug_errors = app.config.get("MCP_JWT_DEBUG_ERRORS", False)
|
||||
|
||||
common_kwargs: dict[str, Any] = {
|
||||
"issuer": app.config.get("MCP_JWT_ISSUER"),
|
||||
"audience": app.config.get("MCP_JWT_AUDIENCE"),
|
||||
"required_scopes": app.config.get("MCP_REQUIRED_SCOPES", []),
|
||||
}
|
||||
|
||||
# For HS256 (symmetric), use the secret as the public_key parameter
|
||||
if app.config.get("MCP_JWT_ALGORITHM") == "HS256" and secret:
|
||||
common_kwargs["public_key"] = secret
|
||||
common_kwargs["algorithm"] = "HS256"
|
||||
if not (jwks_uri or public_key or secret):
|
||||
logger.warning("MCP_AUTH_ENABLED is True but no JWT keys/secret configured")
|
||||
if not api_key_enabled:
|
||||
return None
|
||||
else:
|
||||
# For RS256 (asymmetric), use public key or JWKS
|
||||
common_kwargs["jwks_uri"] = jwks_uri
|
||||
common_kwargs["public_key"] = public_key
|
||||
common_kwargs["algorithm"] = app.config.get("MCP_JWT_ALGORITHM", "RS256")
|
||||
try:
|
||||
jwt_verifier = _build_jwt_verifier(
|
||||
app=app,
|
||||
jwks_uri=jwks_uri,
|
||||
public_key=public_key,
|
||||
secret=secret,
|
||||
)
|
||||
except (ValueError, JoseError):
|
||||
# Do not log the exception — it may contain secrets (e.g., key material)
|
||||
logger.error("Failed to create MCP JWT verifier")
|
||||
if not api_key_enabled:
|
||||
return None
|
||||
|
||||
if debug_errors:
|
||||
# DetailedJWTVerifier: detailed server-side logging of JWT
|
||||
# validation failures. HTTP responses are always generic per
|
||||
# RFC 6750 Section 3.1.
|
||||
from superset.mcp_service.jwt_verifier import DetailedJWTVerifier
|
||||
if api_key_enabled:
|
||||
return _build_composite_verifier(app, jwt_verifier)
|
||||
|
||||
auth_provider = DetailedJWTVerifier(**common_kwargs)
|
||||
else:
|
||||
# MCPJWTVerifier: minimal logging + browser-friendly error page.
|
||||
from superset.mcp_service.jwt_verifier import MCPJWTVerifier
|
||||
return jwt_verifier
|
||||
|
||||
auth_provider = MCPJWTVerifier(**common_kwargs)
|
||||
|
||||
return auth_provider
|
||||
except Exception:
|
||||
# Do not log the exception — it may contain the HS256 secret
|
||||
# from common_kwargs["public_key"]
|
||||
logger.error("Failed to create MCP auth provider")
|
||||
return None
|
||||
def _build_composite_verifier(app: Flask, jwt_verifier: Any) -> CompositeTokenVerifier:
|
||||
"""Build a CompositeTokenVerifier with API key prefixes from config."""
|
||||
if required_scopes := app.config.get("MCP_REQUIRED_SCOPES", []):
|
||||
logger.warning(
|
||||
"MCP_REQUIRED_SCOPES is configured but API key tokens bypass "
|
||||
"scope enforcement. API key holders gain access regardless of "
|
||||
"MCP_REQUIRED_SCOPES=%r. Enforce per-key authorization via FAB "
|
||||
"roles/RBAC instead.",
|
||||
required_scopes,
|
||||
)
|
||||
raw_prefixes: str | Sequence[str] = app.config.get("FAB_API_KEY_PREFIXES", ["sst_"])
|
||||
# Normalize: a plain string (e.g. "sst_") would iterate as characters;
|
||||
# wrap it in a list so CompositeTokenVerifier receives a proper sequence.
|
||||
# Guard against non-iterable config values (e.g. None, integers) that
|
||||
# would raise TypeError and cause _create_auth_provider to fail open.
|
||||
if isinstance(raw_prefixes, str):
|
||||
api_key_prefixes: list[str] = [raw_prefixes]
|
||||
else:
|
||||
try:
|
||||
api_key_prefixes = list(raw_prefixes)
|
||||
except TypeError:
|
||||
logger.warning(
|
||||
"FAB_API_KEY_PREFIXES must be a string or list; using default"
|
||||
)
|
||||
api_key_prefixes = ["sst_"]
|
||||
logger.info("API key auth enabled for MCP")
|
||||
return CompositeTokenVerifier(
|
||||
jwt_verifier=jwt_verifier,
|
||||
api_key_prefixes=api_key_prefixes,
|
||||
app=app,
|
||||
)
|
||||
|
||||
|
||||
def _build_jwt_verifier(
|
||||
app: Flask,
|
||||
jwks_uri: Optional[str],
|
||||
public_key: Optional[str],
|
||||
secret: Optional[str],
|
||||
) -> JWTVerifier:
|
||||
"""Construct the JWT verifier from configured keys/secret."""
|
||||
debug_errors = app.config.get("MCP_JWT_DEBUG_ERRORS", False)
|
||||
|
||||
common_kwargs: Dict[str, Any] = {
|
||||
"issuer": app.config.get("MCP_JWT_ISSUER"),
|
||||
"audience": app.config.get("MCP_JWT_AUDIENCE"),
|
||||
"required_scopes": app.config.get("MCP_REQUIRED_SCOPES", []),
|
||||
}
|
||||
|
||||
# For HS256 (symmetric), use the secret as the public_key parameter
|
||||
if app.config.get("MCP_JWT_ALGORITHM") == "HS256" and secret:
|
||||
common_kwargs["public_key"] = secret
|
||||
common_kwargs["algorithm"] = "HS256"
|
||||
else:
|
||||
# For RS256 (asymmetric), use public key or JWKS
|
||||
common_kwargs["jwks_uri"] = jwks_uri
|
||||
common_kwargs["public_key"] = public_key
|
||||
common_kwargs["algorithm"] = app.config.get("MCP_JWT_ALGORITHM", "RS256")
|
||||
|
||||
if debug_errors:
|
||||
# DetailedJWTVerifier: detailed server-side logging of JWT
|
||||
# validation failures. HTTP responses are always generic per
|
||||
# RFC 6750 Section 3.1.
|
||||
return DetailedJWTVerifier(**common_kwargs)
|
||||
|
||||
# MCPJWTVerifier: minimal logging + browser-friendly error page.
|
||||
return MCPJWTVerifier(**common_kwargs)
|
||||
|
||||
|
||||
def default_user_resolver(app: Any, access_token: Any) -> str | None:
|
||||
|
||||
@@ -633,6 +633,11 @@ class GlobalErrorHandlerMiddleware(Middleware):
|
||||
elif isinstance(error, HTTPException):
|
||||
# HTTP errors from screenshot endpoints or API calls
|
||||
raise ToolError(f"Service error in {tool_name}: {error.detail}") from error
|
||||
elif isinstance(error, MCPPermissionDeniedError):
|
||||
# MCP RBAC permission denied — convert to structured ToolError.
|
||||
# Must come before the generic PermissionError branch because
|
||||
# MCPPermissionDeniedError inherits from PermissionError.
|
||||
raise ToolError(str(error)) from error
|
||||
elif isinstance(error, PermissionError):
|
||||
# Permission/authorization errors
|
||||
raise ToolError(
|
||||
@@ -649,9 +654,6 @@ class GlobalErrorHandlerMiddleware(Middleware):
|
||||
raise ToolError(
|
||||
f"Invalid request for {tool_name}: {_sanitize_error_for_logging(error)}"
|
||||
) from error
|
||||
elif isinstance(error, MCPPermissionDeniedError):
|
||||
# MCP RBAC permission denied — convert to structured ToolError
|
||||
raise ToolError(str(error)) from error
|
||||
elif isinstance(error, (ForbiddenError, SupersetSecurityException)):
|
||||
# Superset access denied — agent tried a tool it can't use
|
||||
raise ToolError(
|
||||
|
||||
@@ -666,7 +666,9 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
"""Create an auth provider from Flask app config.
|
||||
|
||||
Tries MCP_AUTH_FACTORY first, then falls back to the default factory
|
||||
when MCP_AUTH_ENABLED is True.
|
||||
when either ``MCP_AUTH_ENABLED`` (JWT auth), ``MCP_API_KEY_ENABLED``, or
|
||||
``FAB_API_KEY_ENABLED`` (API key auth) is True. The default factory builds a
|
||||
``CompositeTokenVerifier`` that handles either or both auth modes.
|
||||
"""
|
||||
auth_provider = None
|
||||
if auth_factory := flask_app.config.get("MCP_AUTH_FACTORY"):
|
||||
@@ -679,7 +681,11 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
except Exception:
|
||||
# Do not log the exception — it may contain secrets
|
||||
logger.error("Failed to create auth provider from MCP_AUTH_FACTORY")
|
||||
elif flask_app.config.get("MCP_AUTH_ENABLED", False):
|
||||
elif (
|
||||
flask_app.config.get("MCP_AUTH_ENABLED", False)
|
||||
or flask_app.config.get("MCP_API_KEY_ENABLED", False)
|
||||
or flask_app.config.get("FAB_API_KEY_ENABLED", False)
|
||||
):
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
)
|
||||
|
||||
@@ -28,8 +28,8 @@ from pytz import timezone
|
||||
|
||||
from superset import is_feature_enabled
|
||||
from superset.exceptions import SupersetErrorsException
|
||||
from superset.reports.models import ReportRecipientType
|
||||
from superset.reports.notifications.base import BaseNotification
|
||||
from superset.reports.models import ReportRecipients, ReportRecipientType
|
||||
from superset.reports.notifications.base import BaseNotification, NotificationContent
|
||||
from superset.reports.notifications.exceptions import NotificationError
|
||||
from superset.utils import json
|
||||
from superset.utils.core import HeaderDataType, send_email_smtp
|
||||
@@ -83,7 +83,17 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
"""
|
||||
|
||||
type = ReportRecipientType.EMAIL
|
||||
now = datetime.now(timezone("UTC"))
|
||||
|
||||
def __init__(
|
||||
self, recipient: ReportRecipients, content: NotificationContent
|
||||
) -> None:
|
||||
super().__init__(recipient, content)
|
||||
# Stamp each notification with its own timestamp at construction, which
|
||||
# happens per recipient immediately before the email is dispatched. The
|
||||
# date rendered into the subject (when DATE_FORMAT_IN_EMAIL_SUBJECT is
|
||||
# enabled) therefore tracks the dispatch time. A module- or class-level
|
||||
# value would instead freeze on the first import in a long-running worker.
|
||||
self.now = datetime.now(timezone("UTC"))
|
||||
|
||||
@property
|
||||
def _name(self) -> str:
|
||||
|
||||
+12
-13
@@ -171,11 +171,14 @@ class SupersetResultSet:
|
||||
# only do expensive recasting if datatype is not standard list of tuples
|
||||
if data and (not isinstance(data, list) or not isinstance(data[0], tuple)):
|
||||
data = [tuple(row) for row in data]
|
||||
array = np.array(data, dtype=numpy_dtype)
|
||||
columns = np.array(data, dtype=numpy_dtype)
|
||||
|
||||
for column in column_names:
|
||||
col_values = columns[column].tolist()
|
||||
if db_engine_spec.requires_column_value_normalization:
|
||||
col_values = db_engine_spec.normalize_column_values(col_values)
|
||||
try:
|
||||
pa_data.append(pa.array(array[column].tolist()))
|
||||
pa_data.append(pa.array(col_values))
|
||||
except (
|
||||
pa.lib.ArrowInvalid,
|
||||
pa.lib.ArrowTypeError,
|
||||
@@ -185,7 +188,7 @@ class SupersetResultSet:
|
||||
# https://issues.apache.org/jira/browse/ARROW-7855
|
||||
):
|
||||
# attempt serialization of values as strings
|
||||
stringified_arr = stringify_values(array[column])
|
||||
stringified_arr = stringify_values(columns[column])
|
||||
pa_data.append(pa.array(stringified_arr.tolist()))
|
||||
|
||||
if pa_data: # pylint: disable=too-many-nested-blocks
|
||||
@@ -194,19 +197,19 @@ class SupersetResultSet:
|
||||
# TODO: revisit nested column serialization once nested types
|
||||
# are added as a natively supported column type in Superset
|
||||
# (superset.utils.core.GenericDataType).
|
||||
stringified_arr = stringify_values(array[column])
|
||||
stringified_arr = stringify_values(columns[column])
|
||||
pa_data[i] = pa.array(stringified_arr.tolist())
|
||||
|
||||
elif pa.types.is_temporal(pa_data[i].type):
|
||||
# workaround for bug converting
|
||||
# `psycopg2.tz.FixedOffsetTimezone` tzinfo values.
|
||||
# related: https://issues.apache.org/jira/browse/ARROW-5248
|
||||
sample = self.first_nonempty(array[column])
|
||||
sample = self.first_nonempty(columns[column])
|
||||
if sample and isinstance(sample, datetime.datetime):
|
||||
try:
|
||||
if sample.tzinfo:
|
||||
tz = sample.tzinfo
|
||||
series = pd.Series(array[column])
|
||||
series = pd.Series(columns[column])
|
||||
series = pd.to_datetime(
|
||||
series, utc=True, errors="coerce"
|
||||
)
|
||||
@@ -277,13 +280,9 @@ class SupersetResultSet:
|
||||
|
||||
def data_type(self, col_name: str, pa_dtype: pa.DataType) -> Optional[str]:
|
||||
"""Given a pyarrow data type, Returns a generic database type"""
|
||||
if set_type := self._type_dict.get(col_name):
|
||||
return set_type
|
||||
|
||||
if mapped_type := self.convert_pa_dtype(pa_dtype):
|
||||
return mapped_type
|
||||
|
||||
return None
|
||||
set_type = self._type_dict.get(col_name)
|
||||
pa_mapped = self.convert_pa_dtype(pa_dtype)
|
||||
return self.db_engine_spec.resolve_column_type(set_type, pa_mapped)
|
||||
|
||||
def to_pandas_df(self) -> pd.DataFrame:
|
||||
return self.convert_table_to_df(self.table)
|
||||
|
||||
@@ -370,6 +370,11 @@ class UserRegistrationsRestAPI(BaseSupersetModelRestApi):
|
||||
resource_name = "security/user_registrations"
|
||||
datamodel = SQLAInterface(RegisterUser)
|
||||
allow_browser_login = True
|
||||
# NOTE: registration_hash is intentionally excluded from both list_columns
|
||||
# and search_columns. It is a bearer token for the
|
||||
# /register/activation/<hash> flow; exposing it in API responses (and thus
|
||||
# logs/caches) or allowing it to be filtered on would let a holder activate
|
||||
# the pending account.
|
||||
list_columns = [
|
||||
"id",
|
||||
"username",
|
||||
@@ -377,5 +382,11 @@ class UserRegistrationsRestAPI(BaseSupersetModelRestApi):
|
||||
"first_name",
|
||||
"last_name",
|
||||
"registration_date",
|
||||
"registration_hash",
|
||||
]
|
||||
search_columns = [
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"registration_date",
|
||||
]
|
||||
|
||||
@@ -51,9 +51,10 @@ from flask_appbuilder.security.views import (
|
||||
from flask_babel import lazy_gettext as _
|
||||
from flask_login import AnonymousUserMixin, LoginManager
|
||||
from jwt.api_jwt import _jwt_global_obj
|
||||
from sqlalchemy import and_, inspect, or_
|
||||
from sqlalchemy import and_, func as sa_func, inspect, or_
|
||||
from sqlalchemy.engine.base import Connection
|
||||
from sqlalchemy.orm import eagerload
|
||||
from sqlalchemy.orm import eagerload, joinedload
|
||||
from sqlalchemy.orm.exc import MultipleResultsFound
|
||||
from sqlalchemy.orm.mapper import Mapper
|
||||
from sqlalchemy.orm.query import Query as SqlaQuery
|
||||
from sqlalchemy.sql import exists
|
||||
@@ -463,6 +464,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
"PermissionViewMenu",
|
||||
"ViewMenu",
|
||||
"User",
|
||||
# FAB registers ApiKeyApi when FAB_API_KEY_ENABLED=True
|
||||
"ApiKey",
|
||||
} | USER_MODEL_VIEWS
|
||||
|
||||
ALPHA_ONLY_VIEW_MENUS = {
|
||||
@@ -1439,6 +1442,14 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
self.add_permission_view_menu("can_drill", "Dashboard")
|
||||
self.add_permission_view_menu("can_tag", "Chart")
|
||||
self.add_permission_view_menu("can_tag", "Dashboard")
|
||||
# FAB registers ApiKeyApi when FAB_API_KEY_ENABLED=True, using
|
||||
# @permission_name("revoke") for the DELETE endpoint. Create it
|
||||
# explicitly here so sync_role_definitions assigns it to Admin even
|
||||
# when create_missing_perms (called later in the same transaction) fails
|
||||
# due to unrelated schema gaps.
|
||||
if current_app.config.get("FAB_API_KEY_ENABLED", False):
|
||||
for perm in ("can_list", "can_create", "can_get", "can_revoke"):
|
||||
self.add_permission_view_menu(perm, "ApiKey")
|
||||
|
||||
def create_missing_perms(self) -> None:
|
||||
"""
|
||||
@@ -3288,6 +3299,66 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
def find_user_with_relationships(
|
||||
self,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> Optional[User]:
|
||||
"""Find a user with roles and group roles eagerly loaded.
|
||||
|
||||
Mirrors FAB's ``SecurityManager.find_user``
|
||||
(including ``auth_username_ci`` case-insensitive handling and
|
||||
``MultipleResultsFound`` guard) and additionally eager-loads
|
||||
``User.roles`` and ``User.groups.roles`` to prevent detached-instance
|
||||
errors when the SQLAlchemy session is closed or rolled back after the
|
||||
lookup — as happens in MCP tool-execution contexts.
|
||||
|
||||
FAB does not expose an eager-loading option on ``find_user``, so the
|
||||
query logic is mirrored here with joinedload options added. Review this
|
||||
method when upgrading FAB to ensure it stays in sync with upstream.
|
||||
|
||||
Mirrors ``BaseSecurityManager.find_user`` as of flask-appbuilder==5.2.1
|
||||
(``flask_appbuilder/security/sqla/manager.py``). Re-check upstream when
|
||||
bumping the FAB pin in ``requirements/base.txt``.
|
||||
"""
|
||||
eager = [
|
||||
joinedload(self.user_model.roles),
|
||||
joinedload(self.user_model.groups).joinedload(self.group_model.roles),
|
||||
]
|
||||
if username:
|
||||
try:
|
||||
if self.auth_username_ci:
|
||||
return (
|
||||
self.session.query(self.user_model)
|
||||
.options(*eager)
|
||||
.filter(
|
||||
sa_func.lower(self.user_model.username)
|
||||
== sa_func.lower(username)
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
return (
|
||||
self.session.query(self.user_model)
|
||||
.options(*eager)
|
||||
.filter(self.user_model.username == username)
|
||||
.one_or_none()
|
||||
)
|
||||
except MultipleResultsFound:
|
||||
logger.error("Multiple results found for username lookup")
|
||||
return None
|
||||
if email:
|
||||
try:
|
||||
return (
|
||||
self.session.query(self.user_model)
|
||||
.options(*eager)
|
||||
.filter(self.user_model.email == email)
|
||||
.one_or_none()
|
||||
)
|
||||
except MultipleResultsFound:
|
||||
logger.error("Multiple results found for email lookup")
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_anonymous_user(self) -> User:
|
||||
return AnonymousUserMixin()
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2024-07-14 15:10+0300\n"
|
||||
"Last-Translator: Abdalrahim G. Fakhouri <abdilra7eem@yahoo.com>\n"
|
||||
"Language: ar\n"
|
||||
@@ -34,7 +34,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1256,6 +1256,9 @@ msgstr "إضافة منسق لون جديد"
|
||||
msgid "Add new formatter"
|
||||
msgstr "إضافة منسق جديد"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "إضافة فلاتر وتعديلها"
|
||||
@@ -1813,7 +1816,7 @@ msgstr "حدث خطأ أثناء جلب قيم المخطط: %s"
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "حدث خطأ أثناء جلب قوالب CSS المتوفرة"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "حدث خطأ أثناء جلب قيم المخطط: %s"
|
||||
|
||||
@@ -1884,7 +1887,7 @@ msgstr "حدث خطأ أثناء تحليل المفتاح."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "حدث خطأ أثناء تقليم السجلات "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "حدث خطأ أثناء عرض المرئيات: %s"
|
||||
|
||||
@@ -2434,7 +2437,7 @@ msgstr "منفذ قاعدة البيانات"
|
||||
msgid "Base height"
|
||||
msgstr "ارتفاع الرسم البياني"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "نمط خريطة الطبقة الأساسية. راجع وثائق Mapbox: %s"
|
||||
|
||||
@@ -4249,6 +4252,12 @@ msgstr "SQL مخصص"
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr "لم يتم تمكين مقاييس SQL المخصصة المخصصة لمجموعة البيانات هذه"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "لا يمكن أن تحتوي حقول SQL المخصصة على استعلامات فرعية."
|
||||
|
||||
@@ -9465,7 +9474,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "لا توجد أعمدة جدول"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "لا %s حتى الآن"
|
||||
|
||||
@@ -9558,6 +9567,9 @@ msgstr "لم يتم تعريفه"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "لا يساوي (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "ليس في"
|
||||
|
||||
@@ -10613,6 +10625,12 @@ msgstr "تم إهماله"
|
||||
msgid "Proportional"
|
||||
msgstr "نسبي"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "منشورة"
|
||||
|
||||
@@ -11393,6 +11411,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "مختبر إس كيو إل"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "الاستعلامات المحفوظة"
|
||||
@@ -15609,6 +15633,9 @@ msgstr "اكتب قيمة"
|
||||
msgid "Type is required"
|
||||
msgstr "النوع مطلوب"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -15769,6 +15796,9 @@ msgstr "التراجع عن الإجراء"
|
||||
msgid "Undo?"
|
||||
msgstr "التراجع؟"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "خطأ غير متوقع"
|
||||
|
||||
@@ -16707,6 +16737,9 @@ msgstr "ما إذا كان سيتم عرض القيم الدنيا والقصو
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "ما إذا كان سيتم عرض القيم الدنيا والقصوى للمحور Y"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "ما إذا كان سيتم عرض القيم العددية داخل الخلايا"
|
||||
|
||||
@@ -18318,25 +18351,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr ""
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -21,7 +21,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2025-06-27 12:56+0200\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: ca\n"
|
||||
@@ -30,7 +30,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1250,6 +1250,9 @@ msgstr "Afegir nou formatador de color"
|
||||
msgid "Add new formatter"
|
||||
msgstr "Afegir nou formatador"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "Afegir o editar filtres"
|
||||
@@ -1827,7 +1830,7 @@ msgstr "S'ha produït un error mentre es recuperaven els valors de l'esquema: %s
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "S'ha produït un error mentre es recuperaven les plantilles CSS disponibles"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "S'ha produït un error mentre es recuperaven els valors de l'esquema: %s"
|
||||
|
||||
@@ -1902,7 +1905,7 @@ msgstr "S'ha produït un error mentre s'analitzava la clau."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "S'ha produït un error mentre es netejaven els registres "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "S'ha produït un error mentre es renderitzava la visualització: %s"
|
||||
|
||||
@@ -2455,7 +2458,7 @@ msgstr "Exponent base"
|
||||
msgid "Base height"
|
||||
msgstr "Alçada base"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "Estil de mapa de capa base. Consulta la documentació de Mapbox: %s"
|
||||
|
||||
@@ -4250,6 +4253,12 @@ msgstr ""
|
||||
"Les mètriques SQL ad-hoc personalitzades no estan habilitades per a "
|
||||
"aquest conjunt de dades"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "Els camps SQL personalitzats no poden contenir sub-consultes."
|
||||
|
||||
@@ -9369,7 +9378,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "Cap columna de taula"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "Cap %s encara"
|
||||
|
||||
@@ -9461,6 +9470,9 @@ msgstr "No definit"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "No igual a (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "No en"
|
||||
|
||||
@@ -10526,6 +10538,12 @@ msgstr "ID del Projecte"
|
||||
msgid "Proportional"
|
||||
msgstr "Proporcional"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "Publicat"
|
||||
|
||||
@@ -11296,6 +11314,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "Consultes del SQL Lab"
|
||||
|
||||
@@ -15597,6 +15621,9 @@ msgstr "Escriu un valor"
|
||||
msgid "Type is required"
|
||||
msgstr "El tipus és obligatori"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -15760,6 +15787,9 @@ msgstr "Desfer l'acció"
|
||||
msgid "Undo?"
|
||||
msgstr "Desfer?"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "Error inesperat"
|
||||
|
||||
@@ -16698,6 +16728,9 @@ msgstr "Si mostrar els valors mínims i màxims de l'eix X"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Si mostrar els valors mínims i màxims de l'eix Y"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "Si mostrar els valors numèrics dins les cel·les"
|
||||
|
||||
@@ -18311,25 +18344,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr "àrea de zoom"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr "© Atribució de capa"
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -18,7 +18,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2016-05-02 08:49-0700\n"
|
||||
"Last-Translator: Jan Šmejkal <jan.smejkal@orgis.cz>\n"
|
||||
"Language: cs\n"
|
||||
@@ -28,7 +28,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1254,6 +1254,9 @@ msgstr "Přidat nový formátovač barev"
|
||||
msgid "Add new formatter"
|
||||
msgstr "Přidat nový formátovač"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "Přidat nebo upravit ovládací prvky zobrazení"
|
||||
@@ -1814,7 +1817,7 @@ msgstr "Při načítání hodnot schématu došlo k chybě: %s"
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "Při načítání dostupných témat došlo k chybě"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Při načítání hodnot schématu došlo k chybě: %s"
|
||||
|
||||
@@ -1887,7 +1890,7 @@ msgstr "Při parsování klíče došlo k chybě."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "Při promazávání protokolů došlo k chybě "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "Při vykreslování vizualizace došlo k chybě: %s"
|
||||
|
||||
@@ -2447,7 +2450,7 @@ msgstr "Exponent základu"
|
||||
msgid "Base height"
|
||||
msgstr "Výška základu"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "Styl mapy základní vrstvy. Viz dokumentaci Mapbox: %s"
|
||||
|
||||
@@ -4227,6 +4230,12 @@ msgstr "Vlastní SQL"
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr "Vlastní SQL ad-hoc metriky nejsou pro tuto sadu dat povoleny"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "Vlastní pole SQL nemohou obsahovat poddotazy."
|
||||
|
||||
@@ -9357,7 +9366,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "Žádné sloupce tabulky"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "Zatím žádné %s"
|
||||
|
||||
@@ -9446,6 +9455,9 @@ msgstr "Nedefinováno"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "Nerovná se (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "Není v"
|
||||
|
||||
@@ -10500,6 +10512,12 @@ msgstr "ID projektu"
|
||||
msgid "Proportional"
|
||||
msgstr "Proporcionální"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "Zveřejněno"
|
||||
|
||||
@@ -11269,6 +11287,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "Dotazy SQL Labu"
|
||||
|
||||
@@ -15524,6 +15548,9 @@ msgstr "Napište hodnotu"
|
||||
msgid "Type is required"
|
||||
msgstr "Typ je povinný"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr "Typ grafu pro zobrazení ve sparkline"
|
||||
|
||||
@@ -15687,6 +15714,9 @@ msgstr "Vrátit akci zpět"
|
||||
msgid "Undo?"
|
||||
msgstr "Zpět?"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "Neočekávaná chyba"
|
||||
|
||||
@@ -16645,6 +16675,9 @@ msgstr "Zda zobrazit minimální a maximální hodnoty osy X"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Zda zobrazit minimální a maximální hodnoty osy Y"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "Zda zobrazit číselné hodnoty v buňkách"
|
||||
|
||||
@@ -18252,25 +18285,8 @@ msgstr "your-project-1234-a1"
|
||||
msgid "zoom area"
|
||||
msgstr "oblast přiblížení"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr "© Atribuce vrstvy"
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -18,7 +18,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2023-04-07 19:45+0200\n"
|
||||
"Last-Translator: Holger Bruch <holger.bruch@wattbewerb.de>\n"
|
||||
"Language: de\n"
|
||||
@@ -27,7 +27,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1258,6 +1258,9 @@ msgstr "Neuen Farbformatierer hinzufügen"
|
||||
msgid "Add new formatter"
|
||||
msgstr "Neuen Formatierer hinzufügen"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "Hinzufügen und Bearbeiten von Filtern"
|
||||
@@ -1831,7 +1834,7 @@ msgstr "Beim Abrufen von Schemawerten ist ein Fehler aufgetreten: %s"
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "Beim Abrufen verfügbarer CSS-Vorlagen ist ein Fehler aufgetreten"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Beim Abrufen von Schemawerten ist ein Fehler aufgetreten: %s"
|
||||
|
||||
@@ -1906,7 +1909,7 @@ msgstr "Beim Parsen des Schlüssels ist ein Fehler aufgetreten."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "Beim Kürzen von Protokollen ist ein Fehler aufgetreten "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "Bei der Darstellung dieser Visualisierung ist ein Fehler aufgetreten: %s"
|
||||
|
||||
@@ -2469,7 +2472,7 @@ msgstr "Datenbankport"
|
||||
msgid "Base height"
|
||||
msgstr "Diagrammhöhe"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "Kartenstil der Basisebene. Siehe Mapbox-Dokumentation: %s"
|
||||
|
||||
@@ -4313,6 +4316,12 @@ msgstr ""
|
||||
"Benutzerdefinierte SQL-Ad-hoc-Metriken sind für diesen Datasatz nicht "
|
||||
"aktiviert"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "Benutzerdefinierte SQL-Felder dürfen keine Unterabfragen enthalten."
|
||||
|
||||
@@ -9528,7 +9537,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "Keine Tabellenspalten"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "Noch keine %s"
|
||||
|
||||
@@ -9621,6 +9630,9 @@ msgstr "Nicht definiert"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "Ist nicht gleich (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "Nicht in"
|
||||
|
||||
@@ -10721,6 +10733,12 @@ msgstr "Veraltet"
|
||||
msgid "Proportional"
|
||||
msgstr "Proportional"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "Veröffentlicht"
|
||||
|
||||
@@ -11502,6 +11520,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "gespeicherte Abfragen"
|
||||
@@ -15892,6 +15916,9 @@ msgstr "Geben Sie einen Wert ein"
|
||||
msgid "Type is required"
|
||||
msgstr "Typ ist erforderlich"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -16059,6 +16086,9 @@ msgstr "Aktion rückgängig machen"
|
||||
msgid "Undo?"
|
||||
msgstr "Rückgängig machen?"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "Unerwarteter Fehler"
|
||||
|
||||
@@ -17056,6 +17086,9 @@ msgstr "Ob die Min- und Max-Werte der X-Achse angezeigt werden sollen"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Ob die Min- und Max-Werte der Y-Achse angezeigt werden sollen"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "Ob die numerischen Werte innerhalb der Zellen angezeigt werden sollen"
|
||||
|
||||
@@ -18693,25 +18726,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr "Zoombereich"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr ""
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -17,7 +17,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2016-05-02 08:49-0700\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: en\n"
|
||||
@@ -26,7 +26,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1105,6 +1105,9 @@ msgstr ""
|
||||
msgid "Add new formatter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add or edit display controls"
|
||||
msgstr ""
|
||||
|
||||
@@ -3832,6 +3835,12 @@ msgstr ""
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr ""
|
||||
|
||||
@@ -4541,7 +4550,7 @@ msgstr ""
|
||||
msgid "Delete Role?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Delete Semantic Layer?"
|
||||
msgstr ""
|
||||
|
||||
@@ -6165,7 +6174,7 @@ msgstr ""
|
||||
msgid "Filter only displays values relevant to selections made in other filters."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Filter options"
|
||||
msgstr ""
|
||||
|
||||
@@ -8593,6 +8602,9 @@ msgstr ""
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr ""
|
||||
|
||||
@@ -9553,6 +9565,12 @@ msgstr ""
|
||||
msgid "Proportional"
|
||||
msgstr ""
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr ""
|
||||
|
||||
@@ -9717,7 +9735,7 @@ msgstr ""
|
||||
msgid "Recipients are separated by \",\" or \";\""
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Records"
|
||||
msgstr ""
|
||||
|
||||
@@ -10268,6 +10286,12 @@ msgstr ""
|
||||
msgid "SQL Lab"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
msgid "SQL Lab queries"
|
||||
msgstr ""
|
||||
|
||||
@@ -14015,6 +14039,9 @@ msgstr ""
|
||||
msgid "Type is required"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -14152,6 +14179,9 @@ msgstr ""
|
||||
msgid "Undo?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr ""
|
||||
|
||||
@@ -14982,6 +15012,9 @@ msgstr ""
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr ""
|
||||
|
||||
@@ -16461,25 +16494,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr ""
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -17,7 +17,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2016-05-02 08:49-0700\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es\n"
|
||||
@@ -26,7 +26,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1269,6 +1269,9 @@ msgstr "Añadir nuevo formateador de color"
|
||||
msgid "Add new formatter"
|
||||
msgstr "Añadir nuevo formateador"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "Añadir o editar filtros"
|
||||
@@ -1850,7 +1853,7 @@ msgstr "Se ha producido un error al recuperar los valores del esquema: %s"
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Se ha producido un error al recuperar los valores del esquema: %s"
|
||||
|
||||
@@ -1925,7 +1928,7 @@ msgstr "Se ha producido un error al analizar la clave."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "Se ha producido un error al depurar los registros "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "Se ha producido un error al renderizar la visualización: %s"
|
||||
|
||||
@@ -2512,7 +2515,7 @@ msgstr "Exponente de la base"
|
||||
msgid "Base height"
|
||||
msgstr "Altura de la base"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "Estilo del mapa de la capa base. Consulta la documentación de Mapbox: %s"
|
||||
|
||||
@@ -4383,6 +4386,12 @@ msgstr ""
|
||||
"Las métricas «ad hoc» SQL personalizadas no están habilitadas para este "
|
||||
"conjunto de datos"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "Los campos SQL personalizados no pueden contener subconsultas."
|
||||
|
||||
@@ -9727,7 +9736,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "No hay columnas de la tabla"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "Todavía no hay %s"
|
||||
|
||||
@@ -9819,6 +9828,9 @@ msgstr "Sin definir"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "No es igual a (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "No está en"
|
||||
|
||||
@@ -10907,6 +10919,12 @@ msgstr "ID del proyecto"
|
||||
msgid "Proportional"
|
||||
msgstr "Proporcional"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "Publicado"
|
||||
|
||||
@@ -11706,6 +11724,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "Consultas de SQL Lab"
|
||||
|
||||
@@ -16219,6 +16243,9 @@ msgstr "Introduce un valor"
|
||||
msgid "Type is required"
|
||||
msgstr "El tipo es obligatorio"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: fr, ru]
|
||||
#, fuzzy
|
||||
msgid "Type of chart to display in sparkline"
|
||||
@@ -16399,6 +16426,9 @@ msgstr "Deshacer la acción"
|
||||
msgid "Undo?"
|
||||
msgstr "¿Deshacer?"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "Error inesperado"
|
||||
|
||||
@@ -17393,6 +17423,9 @@ msgstr "Si se deben mostrar los valores mínimo y máximo del eje X"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Si se deben mostrar los valores mínimo y máximo del eje Y"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "Si se deben mostrar los valores numéricos dentro de las celdas"
|
||||
|
||||
@@ -19036,25 +19069,8 @@ msgstr "your-project-1234-a1"
|
||||
msgid "zoom area"
|
||||
msgstr "área del «zoom»"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr "© Atribución de la capa"
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -21,7 +21,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2024-10-20 00:07+0330\n"
|
||||
"Last-Translator: Emad Rad <codewithemad@gmail.com>\n"
|
||||
"Language: fa\n"
|
||||
@@ -30,7 +30,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1224,6 +1224,9 @@ msgstr "فرمتکننده رنگ جدیدی اضافه کنید."
|
||||
msgid "Add new formatter"
|
||||
msgstr "فرمتکننده جدید اضافه کن"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "فیلترها را اضافه و ویرایش کنید"
|
||||
@@ -1773,7 +1776,7 @@ msgstr "در حین دریافت ارزشهای طرح خطایی رخ داد
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "در هنگام بارگیری الگوهای CSS موجود یک خطا رخ داد"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "در حین دریافت ارزشهای طرح خطایی رخ داد: %s"
|
||||
|
||||
@@ -1844,7 +1847,7 @@ msgstr "یک خطا در حین تجزیه کلید رخ داد."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "در حین قطع کردن گزارشها خطایی رخ داد"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "یک خطا در هنگام رسم تجسم رخ داده است: %s"
|
||||
|
||||
@@ -2401,7 +2404,7 @@ msgstr "پورت پایگاه داده"
|
||||
msgid "Base height"
|
||||
msgstr "ارتفاع نمودار"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "سبک نقشه لایه پایه. به مستندات Mapbox مراجعه کنید: %s"
|
||||
|
||||
@@ -4194,6 +4197,12 @@ msgstr "SQL سفارشی"
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr "متریکهای SQL سفارشی ad-hoc برای این مجموعه داده فعال نیستند."
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "فیلدهای SQL سفارشی نمیتوانند شامل زیرکوئریها باشند."
|
||||
|
||||
@@ -9342,7 +9351,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "هیچ ستونی در جدول وجود ندارد."
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "هنوز هیچ %s وجود ندارد"
|
||||
|
||||
@@ -9433,6 +9442,9 @@ msgstr "تعریف نشده است"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "نابرابر با (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "نه در"
|
||||
|
||||
@@ -10490,6 +10502,12 @@ msgstr "منسوخ شده"
|
||||
msgid "Proportional"
|
||||
msgstr "تناسبی"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "منتشر شده"
|
||||
|
||||
@@ -11259,6 +11277,12 @@ msgstr "اس کیو ال"
|
||||
msgid "SQL Lab"
|
||||
msgstr "آزمایشگاه SQL"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "کوئریهای ذخیرهشده"
|
||||
@@ -15512,6 +15536,9 @@ msgstr "یک مقدار وارد کنید"
|
||||
msgid "Type is required"
|
||||
msgstr "نوع الزامی است"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -15670,6 +15697,9 @@ msgstr "عملیات را لغو کنید"
|
||||
msgid "Undo?"
|
||||
msgstr "بازگردانی؟"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "خطای غیرمنتظره"
|
||||
|
||||
@@ -16621,6 +16651,9 @@ msgstr "آیا باید مقادیر حداقل و حداکثر محور X نم
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "آیا باید مقادیر حداقل و حداکثر محور Y نمایش داده شود؟"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "آیا باید مقادیر عددی را درون سلولها نمایش داد؟"
|
||||
|
||||
@@ -18240,25 +18273,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr "منطقهی زوم"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr ""
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -17,7 +17,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: fi\n"
|
||||
@@ -26,7 +26,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ca, cs, de,
|
||||
# es, fa, fr, ja, lv, mi, pt_BR, ru, sk, sl, uk]
|
||||
@@ -2023,6 +2023,11 @@ msgstr "Lisää uusi värimuotoilija"
|
||||
msgid "Add new formatter"
|
||||
msgstr "Lisää uusi muotoilija"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, ru, sk, sl, uk]
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: fr, ja, lv,
|
||||
# ru, sk, uk]
|
||||
#, fuzzy
|
||||
@@ -3015,7 +3020,7 @@ msgstr "Käytettävissä olevien teemojen hakemisessa tapahtui virhe"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, it, ja, lv, mi, nl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Skeeman arvojen hakemisessa tapahtui virhe: %s"
|
||||
|
||||
@@ -3140,7 +3145,7 @@ msgstr "Lokien siistimisen aikana tapahtui virhe "
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, it, ja, lv, mi, nl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "Visualisoinnin renderöinnin aikana tapahtui virhe: %s"
|
||||
|
||||
@@ -4152,7 +4157,7 @@ msgstr "Pohjan korkeus"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, ru, sk, sl, uk]
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "Peruskerroksen karttatyyli. Katso Mapbox-dokumentaatio: %s"
|
||||
|
||||
@@ -7406,6 +7411,14 @@ msgstr "Mukautettu SQL"
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr "Mukautetut SQL-ad-hoc-mittarit eivät ole käytössä tässä tietojoukossa"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, uk]
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, uk]
|
||||
#, fuzzy
|
||||
@@ -16563,7 +16576,7 @@ msgstr "Ei taulukon sarakkeita"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "Ei vielä %s"
|
||||
|
||||
@@ -16727,6 +16740,11 @@ msgstr "Ei määritelty"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "Ei yhtä suuri kuin (≠)"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, ru, sk, sl, tr, uk]
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, uk]
|
||||
#, fuzzy
|
||||
@@ -18637,6 +18655,12 @@ msgstr "Projektin tunnus"
|
||||
msgid "Proportional"
|
||||
msgstr "Suhteellinen"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, tr, uk, zh, zh_TW]
|
||||
#, fuzzy
|
||||
@@ -20071,6 +20095,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ca, cs, es,
|
||||
# ja, lv, mi, ru, sk, uk]
|
||||
#, fuzzy
|
||||
@@ -27334,6 +27364,9 @@ msgstr "Kirjoita arvo"
|
||||
msgid "Type is required"
|
||||
msgstr "Tyyppi vaaditaan"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: cs, ja, lv,
|
||||
# ru, sk, uk]
|
||||
#, fuzzy
|
||||
@@ -27597,6 +27630,11 @@ msgstr "Kumoa toiminto"
|
||||
msgid "Undo?"
|
||||
msgstr "Kumotaanko?"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, tr, uk, zh, zh_TW]
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, tr, uk, zh, zh_TW]
|
||||
#, fuzzy
|
||||
@@ -29207,6 +29245,11 @@ msgstr "Näytetäänkö X-akselin minimi- ja maksimiarvot"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Näytetäänkö Y-akselin minimi- ja maksimiarvot"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
|
||||
# de, es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, uk, zh, zh_TW]
|
||||
#, fuzzy
|
||||
@@ -32119,28 +32162,11 @@ msgstr "your-project-1234-a1"
|
||||
msgid "zoom area"
|
||||
msgstr "zoomausalue"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ca, cs, es,
|
||||
# fr, ja, lv, mi, ru, sk, uk]
|
||||
#, fuzzy
|
||||
msgid "© Layer attribution"
|
||||
msgstr "© Kerroksen attribuutio"
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -17,7 +17,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2025-06-26 15:34+0200\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: fr\n"
|
||||
@@ -26,7 +26,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1240,6 +1240,9 @@ msgstr "Ajouter un nouveau formateur de couleur"
|
||||
msgid "Add new formatter"
|
||||
msgstr "Ajouter un nouveau formateur"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "Ajouter ou modifier les contrôles d'affichage"
|
||||
|
||||
@@ -1809,7 +1812,7 @@ msgstr "Une erreur s'est produite lors de l'extraction des valeurs du schéma :
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "Une erreur s'est produite lors de l'extraction des Thèmes "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Une erreur s'est produite lors de l'extraction des valeurs du schéma : %s"
|
||||
|
||||
@@ -1885,7 +1888,7 @@ msgstr "Une erreur s'est produite lors de l'analyse de la clé."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "Une erreur s'est produite lors de la suppression des journaux "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "Une erreur s'est produite durant le rendu de la visualisation : %s"
|
||||
|
||||
@@ -2452,7 +2455,7 @@ msgstr "Port de la base de données"
|
||||
msgid "Base height"
|
||||
msgstr "Hauteur de base"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "Style de la couche de base de la carte. Voir la documentation Mapbox : %s"
|
||||
|
||||
@@ -4245,6 +4248,12 @@ msgstr ""
|
||||
"Les mesures SQL ponctuelles personnalisées ne sont pas activées pour cet "
|
||||
"ensemble de données"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "Les champs SQL personnalisés ne peuvent pas contenir de sous-requêtes."
|
||||
|
||||
@@ -9503,7 +9512,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "Pas de colonnes de tableau"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "Pas encore de %s"
|
||||
|
||||
@@ -9599,6 +9608,9 @@ msgstr "Indéfini"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "Différent de (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Not in"
|
||||
msgstr "Pas dans"
|
||||
@@ -10708,6 +10720,12 @@ msgstr "Déclassé"
|
||||
msgid "Proportional"
|
||||
msgstr "Proportionnel"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "Publié"
|
||||
|
||||
@@ -11517,6 +11535,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "Requêtes enregistrées"
|
||||
@@ -16003,6 +16027,9 @@ msgstr "Saisissez une valeur"
|
||||
msgid "Type is required"
|
||||
msgstr "Le type est requis"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr "Nombre de graphiques à afficher par ligne"
|
||||
@@ -16173,6 +16200,9 @@ msgstr "Annuler l'action"
|
||||
msgid "Undo?"
|
||||
msgstr "Annuler?"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "Erreur inattendue"
|
||||
|
||||
@@ -17185,6 +17215,9 @@ msgstr "Affichage ou non des valeurs min et max de l’axe des absisses"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Affichage ou non des valeurs min et max de l’axe des ordonnées"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "Affichage ou non des valeurs numériques dans les cellules"
|
||||
|
||||
@@ -18869,25 +18902,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr "zone de zoom"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr "© Attribution de la couche"
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -17,7 +17,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2018-02-11 22:26+0200\n"
|
||||
"Last-Translator: Raffaele Spangaro <raffa@raffaelespangaro.it>\n"
|
||||
"Language: it\n"
|
||||
@@ -26,7 +26,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1160,6 +1160,9 @@ msgstr ""
|
||||
msgid "Add new formatter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "Aggiungi filtro"
|
||||
@@ -1714,7 +1717,7 @@ msgstr "Errore nel rendering della visualizzazione: %s"
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "Errore nel recupero dei metadati della tabella"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Errore nel rendering della visualizzazione: %s"
|
||||
|
||||
@@ -1787,7 +1790,7 @@ msgstr "Errore nel creare il datasource"
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "Errore nel rendering della visualizzazione: %s"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "Errore nel rendering della visualizzazione: %s"
|
||||
|
||||
@@ -4151,6 +4154,12 @@ msgstr ""
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr ""
|
||||
|
||||
@@ -9397,6 +9406,9 @@ msgstr "Modificato"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Not in"
|
||||
msgstr "Azione"
|
||||
@@ -10430,6 +10442,12 @@ msgstr "Creato il"
|
||||
msgid "Proportional"
|
||||
msgstr ""
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr ""
|
||||
|
||||
@@ -11219,6 +11237,12 @@ msgstr ""
|
||||
msgid "SQL Lab"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "Query salvate"
|
||||
@@ -15303,6 +15327,9 @@ msgstr "Valore del filtro"
|
||||
msgid "Type is required"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -15447,6 +15474,9 @@ msgstr "Seleziona una colonna"
|
||||
msgid "Undo?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr ""
|
||||
|
||||
@@ -16330,6 +16360,9 @@ msgstr ""
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr ""
|
||||
|
||||
@@ -17945,25 +17978,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr ""
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -17,7 +17,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2024-05-14 13:30+0900\n"
|
||||
"Last-Translator: Yuri Umezaki <bungoume@gmail.com>\n"
|
||||
"Language: ja\n"
|
||||
@@ -26,7 +26,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1153,6 +1153,9 @@ msgstr "新しいカラー形式を追加"
|
||||
msgid "Add new formatter"
|
||||
msgstr "新しい形式を追加"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "表示コントロールを追加または編集"
|
||||
|
||||
@@ -1667,7 +1670,7 @@ msgstr "スキーマの値を取得中にエラーが発生しました: %s"
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "利用可能なテーマの取得中にエラーが発生しました"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "スキーマの値を取得中にエラーが発生しました: %s"
|
||||
|
||||
@@ -1735,7 +1738,7 @@ msgstr "キーの解析中にエラーが発生しました。"
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "ログの削除(プルーニング)中にエラーが発生しました "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "視覚化(ビジュアライゼーション)のレンダリング中にエラーが発生しました: %s"
|
||||
|
||||
@@ -2256,7 +2259,7 @@ msgstr "基本の指数"
|
||||
msgid "Base height"
|
||||
msgstr "基本の高さ"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "ベースレイヤーのマップスタイル。Mapboxのドキュメントを参照: %s"
|
||||
|
||||
@@ -3916,6 +3919,12 @@ msgstr "カスタムSQL"
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr "このデータセットではカスタムSQLアドホックメトリックが有効になっていません"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "カスタムSQLフィールドにサブクエリを含めることはできません。"
|
||||
|
||||
@@ -8661,7 +8670,7 @@ msgstr "該当する列が見つかりません。メトリックでフィルタ
|
||||
msgid "No table columns"
|
||||
msgstr "テーブル列がありません"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "%s はまだありません"
|
||||
|
||||
@@ -8746,6 +8755,9 @@ msgstr "未定義"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "等しくない (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "次を含まない (Not in)"
|
||||
|
||||
@@ -9716,6 +9728,12 @@ msgstr "プロジェクトID"
|
||||
msgid "Proportional"
|
||||
msgstr "比例"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "公開済み"
|
||||
|
||||
@@ -10447,6 +10465,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "SQL Lab クエリ"
|
||||
|
||||
@@ -14310,6 +14334,9 @@ msgstr "値を入力してください"
|
||||
msgid "Type is required"
|
||||
msgstr "種類は必須です"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr "スパークラインに表示するチャートの種類"
|
||||
|
||||
@@ -14458,6 +14485,9 @@ msgstr "操作を元に戻す"
|
||||
msgid "Undo?"
|
||||
msgstr "元に戻しますか?"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "予期しないエラー"
|
||||
|
||||
@@ -15299,6 +15329,9 @@ msgstr "X軸の最小値と最大値を表示するかどうか"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Y軸の最小値と最大値を表示するかどうか"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "セル内の数値を表示するかどうか"
|
||||
|
||||
@@ -16831,25 +16864,8 @@ msgstr "your-project-1234-a1"
|
||||
msgid "zoom area"
|
||||
msgstr "ズームエリア"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr "© レイヤー帰属情報"
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -17,7 +17,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2019-02-02 22:28+0900\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ko\n"
|
||||
@@ -26,7 +26,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1155,6 +1155,9 @@ msgstr ""
|
||||
msgid "Add new formatter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "테이블 추가"
|
||||
@@ -1720,7 +1723,7 @@ msgid ""
|
||||
"administrator."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the configuration schema"
|
||||
msgstr "데이터 베이스 목록을 가져오는 도중 에러가 발생하였습니다."
|
||||
|
||||
@@ -1774,7 +1777,7 @@ msgstr "데이터 베이스 목록을 가져오는 도중 에러가 발생하였
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "데이터 베이스 목록을 가져오는 도중 에러가 발생하였습니다."
|
||||
|
||||
@@ -4131,6 +4134,12 @@ msgstr ""
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr ""
|
||||
|
||||
@@ -9309,6 +9318,9 @@ msgstr "수정됨"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Not in"
|
||||
msgstr "주석"
|
||||
@@ -10333,6 +10345,12 @@ msgstr "생성자"
|
||||
msgid "Proportional"
|
||||
msgstr ""
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr ""
|
||||
|
||||
@@ -11115,6 +11133,12 @@ msgstr ""
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "저장된 Query"
|
||||
@@ -15180,6 +15204,9 @@ msgstr "원본 값"
|
||||
msgid "Type is required"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -15321,6 +15348,9 @@ msgstr "주석"
|
||||
msgid "Undo?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr ""
|
||||
|
||||
@@ -16199,6 +16229,9 @@ msgstr ""
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr ""
|
||||
|
||||
@@ -17780,25 +17813,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr ""
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -21,7 +21,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2026-03-27 14:01+0200\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: lv\n"
|
||||
@@ -31,7 +31,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1248,6 +1248,9 @@ msgstr "Pievienot jaunu krāsu formatētāju"
|
||||
msgid "Add new formatter"
|
||||
msgstr "Pievienot jaunu formatētāju"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "Pievienot vai rediģēt displeja vadības elementus"
|
||||
|
||||
@@ -1794,7 +1797,7 @@ msgstr "Radās kļūda, iegūstot shēmu vērtības: %s"
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "Radās kļūda, lejupielādējot pieejamos motīvus"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Radās kļūda, iegūstot shēmu vērtības: %s"
|
||||
|
||||
@@ -1864,7 +1867,7 @@ msgstr "Radās kļūda, apstrādājot atslēgu."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "Radās kļūda, tīrot žurnālus "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "Radās kļūda, attēlojot vizualizāciju: %s"
|
||||
|
||||
@@ -2417,7 +2420,7 @@ msgstr "Bāzes eksponents"
|
||||
msgid "Base height"
|
||||
msgstr "Bāzes augstums"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "Pamata slāņa kartes stils. Skatīt Mapbox dokumentāciju: %s"
|
||||
|
||||
@@ -4170,6 +4173,12 @@ msgstr "Pielāgots SQL"
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr "Pielāgoti SQL ad-hoc rādītāji nav iespējoti šai datu kopai"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "Pielāgotie SQL lauki nevar saturēt apakšvaicājumus."
|
||||
|
||||
@@ -9148,7 +9157,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "Nav tabulas kolonnu"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "Pagaidām nav %s"
|
||||
|
||||
@@ -9235,6 +9244,9 @@ msgstr "Nav definēts"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "Nav vienāds ar (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "Neietilpst"
|
||||
|
||||
@@ -10270,6 +10282,12 @@ msgstr "Projekta ID"
|
||||
msgid "Proportional"
|
||||
msgstr "Proporcionāls"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "Publicēts"
|
||||
|
||||
@@ -11020,6 +11038,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL laboratorija"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "SQL laboratorijas vaicājumi"
|
||||
|
||||
@@ -15189,6 +15213,9 @@ msgstr "Ierakstiet vērtību"
|
||||
msgid "Type is required"
|
||||
msgstr "Veids ir obligāts"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr "Diagrammas veids, kas jāparāda sparkline"
|
||||
|
||||
@@ -15352,6 +15379,9 @@ msgstr "Atsaukt darbību"
|
||||
msgid "Undo?"
|
||||
msgstr "Atsaukt?"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "Neparedzēta kļūda"
|
||||
|
||||
@@ -16291,6 +16321,9 @@ msgstr "Vai rādīt X ass minimālās un maksimālās vērtības"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Vai rādīt Y ass minimālās un maksimālās vērtības"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "Vai rādīt skaitliskās vērtības šūnās"
|
||||
|
||||
@@ -17874,25 +17907,8 @@ msgstr "jūsu-projekts-1234-a1"
|
||||
msgid "zoom area"
|
||||
msgstr "tālummaiņas zona"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr "© Slāņa attiecinājums"
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -25,14 +25,14 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1110,6 +1110,9 @@ msgstr ""
|
||||
msgid "Add new formatter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add or edit display controls"
|
||||
msgstr ""
|
||||
|
||||
@@ -3834,6 +3837,12 @@ msgstr ""
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr ""
|
||||
|
||||
@@ -8584,6 +8593,9 @@ msgstr ""
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr ""
|
||||
|
||||
@@ -9542,6 +9554,12 @@ msgstr ""
|
||||
msgid "Proportional"
|
||||
msgstr ""
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr ""
|
||||
|
||||
@@ -10256,6 +10274,12 @@ msgstr ""
|
||||
msgid "SQL Lab"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
msgid "SQL Lab queries"
|
||||
msgstr ""
|
||||
|
||||
@@ -13993,6 +14017,9 @@ msgstr ""
|
||||
msgid "Type is required"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -14129,6 +14156,9 @@ msgstr ""
|
||||
msgid "Undo?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr ""
|
||||
|
||||
@@ -14959,6 +14989,9 @@ msgstr ""
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr ""
|
||||
|
||||
@@ -16436,25 +16469,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr ""
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
@@ -21,7 +21,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-31 07:03-0700\n"
|
||||
"POT-Creation-Date: 2026-06-04 15:45+0200\n"
|
||||
"PO-Revision-Date: 2026-01-25 16:09+1300\n"
|
||||
"Last-Translator: karo.co.nz\n"
|
||||
"Language: mi\n"
|
||||
@@ -30,7 +30,7 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid ""
|
||||
"\n"
|
||||
@@ -1219,6 +1219,9 @@ msgstr "Tāpiri kaihōputu tae hou"
|
||||
msgid "Add new formatter"
|
||||
msgstr "Tāpiri kaihōputu hou"
|
||||
|
||||
msgid "Add numbered column"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add or edit display controls"
|
||||
msgstr "Tāpiri, whakatika rānei i ngā tātari"
|
||||
@@ -1782,7 +1785,7 @@ msgstr "I puta he hapa i te tikitanga o ngā uara hanga: %s"
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "I puta he hapa i te tikitanga o ngā tauira CSS wātea"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "I puta he hapa i te tikitanga o ngā uara hanga: %s"
|
||||
|
||||
@@ -1857,7 +1860,7 @@ msgstr "I puta he hapa i te poroporo o te kī."
|
||||
msgid "An error occurred while pruning logs "
|
||||
msgstr "I puta he hapa i te puretanga o ngā rārangi "
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "An error occurred while refreshing the configuration schema"
|
||||
msgstr "I puta he hapa i te whakaatutanga o te whakakitenga: %s"
|
||||
|
||||
@@ -2408,7 +2411,7 @@ msgstr "Pūmahi pūtake"
|
||||
msgid "Base height"
|
||||
msgstr "Teitei pūtake"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "Base layer map style. Accepts a MapLibre-compatible style URL."
|
||||
msgstr "Kāhua papa pūtake. Tirohia te tuhinga a Mapbox: %s"
|
||||
|
||||
@@ -4186,6 +4189,12 @@ msgstr "SQL Ritenga"
|
||||
msgid "Custom SQL ad-hoc metrics are not enabled for this dataset"
|
||||
msgstr "Kāore i whakahohengia ngā ine ad-hoc SQL Ritenga mō tēnei rārangi raraunga"
|
||||
|
||||
msgid "Custom SQL fields cannot be parsed as a single SQL statement."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain set operations."
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom SQL fields cannot contain sub-queries."
|
||||
msgstr "Kāore e taea e ngā āpure SQL Ritenga te whai i ngā pātai-iti."
|
||||
|
||||
@@ -9295,7 +9304,7 @@ msgstr ""
|
||||
msgid "No table columns"
|
||||
msgstr "Kāore he tīwae ripanga"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, fuzzy
|
||||
msgid "No tasks yet"
|
||||
msgstr "Kāore anō he %s"
|
||||
|
||||
@@ -9385,6 +9394,9 @@ msgstr "Kāore i tautuhia"
|
||||
msgid "Not equal to (≠)"
|
||||
msgstr "Kāore e ōrite ki (≠)"
|
||||
|
||||
msgid "Not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not in"
|
||||
msgstr "Kāore i roto i"
|
||||
|
||||
@@ -10441,6 +10453,12 @@ msgstr "ID Kaupapa"
|
||||
msgid "Proportional"
|
||||
msgstr "Tauōrite"
|
||||
|
||||
msgid "Public and privately shared sheets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Publicly shared sheets only"
|
||||
msgstr ""
|
||||
|
||||
msgid "Published"
|
||||
msgstr "Kua Whakaputaina"
|
||||
|
||||
@@ -11220,6 +11238,12 @@ msgstr "SQL"
|
||||
msgid "SQL Lab"
|
||||
msgstr "SQL Lab"
|
||||
|
||||
msgid ""
|
||||
"SQL Lab cannot authorise a statement that could not be fully parsed. "
|
||||
"Qualify tables explicitly and avoid dynamic SQL inside stored-procedure "
|
||||
"or vendor-specific calls."
|
||||
msgstr ""
|
||||
|
||||
msgid "SQL Lab queries"
|
||||
msgstr "Pātai SQL Lab"
|
||||
|
||||
@@ -15480,6 +15504,9 @@ msgstr "Pato tētahi uara"
|
||||
msgid "Type is required"
|
||||
msgstr "E hiahiatia ana te momo"
|
||||
|
||||
msgid "Type of Google Sheets allowed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type of chart to display in sparkline"
|
||||
msgstr ""
|
||||
|
||||
@@ -15641,6 +15668,9 @@ msgstr "Wetekina te mahi"
|
||||
msgid "Undo?"
|
||||
msgstr "Wete?"
|
||||
|
||||
msgid "Unexpected HTTP 401 response. Check your credentials."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error"
|
||||
msgstr "Hapa ohorere"
|
||||
|
||||
@@ -16620,6 +16650,9 @@ msgstr "Mēnā ka whakaatu i ngā uara iti me ngā uara rahi o te tukutuku-X"
|
||||
msgid "Whether to display the min and max values of the Y-axis"
|
||||
msgstr "Mēnā ka whakaatu i ngā uara iti me ngā uara rahi o te tukutuku-Y"
|
||||
|
||||
msgid "Whether to display the numbered column"
|
||||
msgstr ""
|
||||
|
||||
msgid "Whether to display the numerical values within the cells"
|
||||
msgstr "Mēnā ka whakaatu i ngā uara tau i roto i ngā pūtau"
|
||||
|
||||
@@ -18233,25 +18266,8 @@ msgstr ""
|
||||
msgid "zoom area"
|
||||
msgstr "horahanga topa"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** The classic emailregex.com regex for RFC 5322-compliant emails */\n"
|
||||
"export const rfc5322Email =\n"
|
||||
" "
|
||||
"/^(([^<>()[]\\.,;:s@\"]+(.[^<>()[]\\.,;:s@\"]+)*)|(\".+\"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/;"
|
||||
"\n"
|
||||
"\n"
|
||||
"/** A loose regex that allows Unicode characters, enforces length limits,"
|
||||
" and that's about it. */\n"
|
||||
"export const unicodeEmail = /^[^s@\"]{1,64}@[^s@]{1,255}$/u;\n"
|
||||
"export const idnEmail = unicodeEmail;\n"
|
||||
"\n"
|
||||
"export const browserEmail: RegExp =\n"
|
||||
" /^[a-zA-Z0-9.!#$%&'*+/=?^_"
|
||||
msgstr ""
|
||||
|
||||
msgid "© Layer attribution"
|
||||
msgstr "© Whakatuakī Papa"
|
||||
|
||||
msgid "№"
|
||||
msgstr ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user