Compare commits

..
Author SHA1 Message Date
Enzo Martellucci e3a64a14dc Merge branch 'master' into enxdev/chore/sync-translations 2026-06-05 18:45:02 +02:00
Richard Fogaca NienkotterandAmin Ghadersohi 601f9c2b8c fix(embedded): add guest token to streaming exports (#40712)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-06-05 13:27:06 -03:00
madhushreeagandmadhushree agarwal fa42b13eb8 fix(dataset): preserve numeric column types when pydruid infers STRING from first-row value (#40677)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-05 09:25:57 -07:00
Enzo Martellucci 3d244ac287 Merge branch 'master' into enxdev/chore/sync-translations 2026-06-05 17:36:45 +02:00
aa4092ba68 fix(mcp): add select_columns lean defaults to get_dashboard_info, get_chart_info, get_dataset_info (#40473)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Richard Fogaça <richardfogaca@gmail.com>
2026-06-05 11:10:13 -03:00
Enzo Martellucci 9b5d8546df Merge branch 'master' into enxdev/chore/sync-translations 2026-06-05 10:04:20 +02:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>EvanClaude Opus 4.8
45a616439b chore(deps): update dayjs requirement from ^1.11.20 to ^1.11.21 in /superset-frontend/packages/superset-ui-core (#40736)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:59:17 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 98c096df05 chore(deps): bump @babel/runtime from 7.29.2 to 7.29.7 in /superset-frontend (#40753)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 20:59:08 -07:00
Elizabeth ThompsonandClaude Sonnet 4.6 42367afb25 fix(reports): add per-tile animation wait to prevent partial ECharts renders in tiled screenshots (#40694)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:43:34 -07:00
Evan RusackasandClaude Code 875673f670 fix(asyncEvent): use Map for job listener/retry registries (#40747)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 14:16:44 -07:00
Evan RusackasandClaude Code 79c74af2e9 ci: cache npm downloads in frontend-heavy workflows (#40744)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 13:34:35 -07:00
Vitor Avila 7406098708 fix(dashboard-filter): Consider dashboard filters to charts not declared in the dashboard position (#40774) 2026-06-04 16:43:38 -03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ccce0cab18 chore(deps): bump content-disposition from 2.0.0 to 2.0.1 in /superset-frontend (#40750)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:32:09 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 94c1a1b1f2 chore(deps-dev): bump @babel/runtime-corejs3 from 7.29.2 to 7.29.7 in /superset-frontend (#40751)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:31:59 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 04939c94cc chore(deps-dev): bump @babel/node from 7.29.0 to 7.29.7 in /superset-frontend (#40752)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:31:49 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 937eff6d52 chore(deps-dev): bump oxlint from 1.66.0 to 1.67.0 in /superset-frontend (#40755)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:31:23 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f5f4a41598 chore(deps-dev): bump @babel/register from 7.29.3 to 7.29.7 in /superset-frontend (#40757)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:30:55 -07:00
Evan RusackasandClaude Opus 4.5 639866625d fix(echarts): Show full labels in bar chart tooltips (#34759)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-04 12:29:48 -07:00
Evan RusackasandClaude 7d323dc0ae fix(filters): Enable decimal values in Range filter slider (#34742)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 12:29:33 -07:00
Evan RusackasandClaude Code 0d1b702ce8 feat(extensions): static supply-chain controls — denylist + version policy (#40668)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 12:29:03 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>EvanClaude Opus 4.8
ddeec68c88 chore(deps): bump dompurify from 3.4.5 to 3.4.7 in /superset-frontend/plugins/legacy-preset-chart-nvd3 (#40735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:28:13 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>EvanClaude Opus 4.8
0ad09d5cd0 chore(deps): bump dompurify from 3.4.8 to 3.4.7 in /superset-frontend/packages/superset-ui-core (#40737)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:27:26 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6662529306 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40739)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:27:18 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 09cd2c26cd chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40740)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:27:05 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cbd731e661 chore(deps-dev): bump webpack from 5.107.1 to 5.107.2 in /superset-frontend (#40741)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:26:54 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3f94c9db2d chore(deps): bump query-string from 9.3.1 to 9.4.0 in /superset-frontend (#40742)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 12:26:46 -07:00
Evan RusackasandClaude Code 80a3df3550 ci: run full Python-version matrix on push, current-only on PRs (#40722)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 21:17:29 +02:00
Evan RusackasandClaude Opus 4.5 6f97d9817e fix(database): preserve engine_information when creating database connection (#38107)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-04 12:04:59 -07:00
Amin Ghadersohi 7d69f76127 fix(mcp): API key authentication for MCP — transport, validation, and RBAC (#39604) 2026-06-04 15:04:43 -04:00
Evan RusackasandClaude Opus 4.8 9a31362fa5 fix(reports): stamp email subject date at send time, not import time (#40693)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:03:28 -07:00
Joe LiandClaude Opus 4.7 cd5bdf11ac fix(playwright): de-flake list-view delete and bulk-export specs (#39980)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 11:41:36 -07:00
Evan RusackasandClaude Code 75d94ff466 fix(SafeMarkdown): block script-executing link protocols regardless of EscapeMarkdownHtml (#40622)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 11:13:31 -07:00
Evan RusackasandClaude Code c505c70c52 fix(databases): do not render existing encrypted field value in edit mode (#40628)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 10:15:01 -07:00
Evan RusackasandClaude Code 23d18743bd fix(deck.gl): strip all JS-executed form_data keys when JavaScript controls are disabled (#40602)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 10:14:33 -07:00
ddb09f468d fix(plugin-chart-ag-grid-table): enforce numeric bounds for range (BETWEEN) filters (#40607)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Shaitan <105581038+sha174n@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-04 10:14:21 -07:00
Evan RusackasandClaude Code 8dcc7e7eec ci: stable required-check anchors for skippable matrix test jobs (#40772)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:50:06 -07:00
Evan RusackasandClaude Code ff5e43c8a0 ci: add timeout-minutes to compute-heavy workflow jobs (#40743)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:47:55 -07:00
Evan RusackasandClaude Code bdb081329f feat(websocket): validate WebSocket upgrade Origin against an allowlist (#40625)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:43:16 -07:00
Evan RusackasandClaude Code aa547da960 fix: remove registration_hash in the registrations API (#40643)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:43:03 -07:00
Evan RusackasandClaude Code 966c243db6 ci: drop removed Cypress shards from required status checks (#40770)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 18:23:47 +02:00
Enzo MartellucciandClaude Opus 4.8 9560ff6227 chore(translations): re-extract messages.pot with pinned Babel 2.17.0
Master's committed messages.pot had drifted from source: it was generated
off-pin with Babel 2.18.0, and several source strings added by recently
merged features (ag-grid numbered column, Google Sheets connector, DB
engine error handling) were never re-extracted. This re-syncs the catalog
with a clean babel_update.sh run under the pinned Babel 2.17.0.

What the diff does:
- Re-pin/regenerate the .pot under Babel 2.17.0 (Generated-By 2.18.0 ->
  2.17.0), matching requirements/{base,development,translations}.txt and
  superset/translations/requirements.txt, which all pin 2.17.0 and note CI
  extracts with that version.
- Add 10 newly-extracted source strings to every catalog as empty entries
  (no msgstr borrowed): "Add numbered column", "Whether to display the
  numbered column", "Custom SQL fields cannot be parsed as a single SQL
  statement.", "Custom SQL fields cannot contain set operations.", "Not
  found", "Unexpected HTTP 401 response. Check your credentials.", "Type of
  Google Sheets allowed", "Publicly shared sheets only", "Public and
  privately shared sheets", and the numero sign.
- Strip stale python-format sub-flags (93 entries) that Babel 2.18.0 emitted
  on msgids without a % placeholder; 2.17.0 correctly omits them. msgstr
  text is untouched and entries stay fuzzy, so no translation is harmed.
- Remove a leaked non-i18n msgid (a slab of zod email-regex TypeScript that
  Babel had misflagged python-brace-format from regex quantifiers). It is no
  longer extracted from a clean dependency tree and will not reappear.
- Reconcile "Layer attribution" copyright string into the catalogs that
  lacked it.

No human translations are removed: zero non-empty msgstr deletions and no
obsolete-entry churn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:08:34 +02:00
Evan RusackasandClaude Code 696705794b ci: gate docker image builds at the job level (#40723)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-03 15:39:01 -07:00
ShaitanandClaude Sonnet 4.6 41572dbf9d fix(chart): restrict owner lookup to users with write access (#39304)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:00:31 +01:00
Evan Rusackas 5ba60d51fd ci: gate CodeQL analysis at the job level for docs-only PRs (#40724) 2026-06-03 23:49:59 +02:00
136 changed files with 5305 additions and 1762 deletions
+2 -7
View File
@@ -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
+27 -8
View File
@@ -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}}"
+33 -21
View File
@@ -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
+7 -1
View File
@@ -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: |
+25 -67
View File
@@ -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
+6
View File
@@ -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:
+27 -1
View File
@@ -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"
+1
View File
@@ -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
+9
View File
@@ -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:
-16
View File
@@ -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.
-34
View File
@@ -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
+139 -143
View File
@@ -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"
+9 -9
View File
@@ -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",
@@ -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>
@@ -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', () => {
@@ -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"
}
}
@@ -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!);
@@ -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);
@@ -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');
@@ -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
+16 -21
View File
@@ -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',
+19
View File
@@ -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`):
+2 -1
View File
@@ -18,5 +18,6 @@
"redisStreamPrefix": "async-events-",
"jwtAlgorithms": ["HS256"],
"jwtSecret": "CHANGE-ME",
"jwtCookieName": "async-token"
"jwtCookieName": "async-token",
"allowedOrigins": []
}
+99
View File
@@ -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) => {
+8 -1
View File
@@ -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 =>
+37
View File
@@ -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) {
+9
View File
@@ -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(
+11
View File
@@ -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
+33
View File
@@ -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]]:
+26
View File
@@ -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
+79
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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)
+49
View File
@@ -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"
+86
View File
@@ -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"
+145 -42
View File
@@ -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:
+5 -3
View File
@@ -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(
+8 -2
View File
@@ -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,
)
+13 -3
View File
@@ -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
View File
@@ -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)
+12 -1
View File
@@ -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",
]
+73 -2
View File
@@ -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 laxe 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 laxe 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 ""
+38 -22
View File
@@ -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