Compare commits

..

32 Commits

Author SHA1 Message Date
Hugh A Miles II
808ba6f4b4 fix(dashboard): guard rebuild against post-processing and clause mismatch
Address further review of the form-data query-context rebuild:

- Skip the rebuild when the form data relies on processing the single
  query can't reproduce: time_compare, rolling_type, resample_rule, or
  aggregation=raw. Even allowlisted viz types would otherwise export
  values that differ from the chart.
- Big Number trendline: set granularity (so time_grain_sqla buckets the
  promoted time column) even with no active time_range.
- Export applies only WHERE-clause SIMPLE filters (via a new where_only
  arg), matching the chart, while the shared helper still converts all
  SIMPLE filters for the MCP path.

Adds regression tests for each case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:04:34 -04:00
Hugh A. Miles II
eef7bc41ac Merge branch 'master' into hughhhh/handle-empty-query-context 2026-07-28 12:57:45 -04:00
Amin Ghadersohi
5aaf9d337b fix(jinja): handle non-JSON body with JSON content-type in get_form_data (#42196) 2026-07-28 09:18:08 -07:00
JUST.in DO IT
181f5bc9c1 fix(mcp): capture created chart/dashboard id in generate_chart/generate_dashboard audit logs (#42367) 2026-07-28 09:16:58 -07:00
Đỗ Trọng Hải
e426fb7bc6 chore(ci): correct GH event type for opening new issue and PR in supersetbot orglabel job (#42424)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-28 09:16:41 -07:00
Evan Rusackas
b5c8d4a5df fix(mixed-timeseries): stop duplicating first metric in multi-metric + group-by series names (#40146)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-28 09:15:26 -07:00
dependabot[bot]
236c9a52fd chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.63.0 to 8.65.0 in /superset-websocket (#42515)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 08:06:12 -07:00
Mehmet Salih Yavuz
b983161eef feat(async): cancel running chart queries under GLOBAL_ASYNC_QUERIES (#42305) 2026-07-28 18:02:16 +03:00
Evan Rusackas
7b767f6c1d chore(ci): swap linkinator-action for lychee-action in docs link checker (#42501)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:33:01 +07:00
dependabot[bot]
526cebac73 chore(deps-dev): bump prettier from 3.9.5 to 3.9.6 in /superset-websocket (#42514)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 21:30:23 +07:00
dependabot[bot]
39f9b7029b chore(deps): bump github/codeql-action/analyze from 4.37.1 to 4.37.2 (#42520)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 16:05:23 +03:00
dependabot[bot]
25a985893d chore(deps-dev): bump typescript-eslint from 8.64.0 to 8.65.0 in /superset-websocket (#42516)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:49:06 -07:00
dependabot[bot]
ad15c4ddd1 chore(deps): bump the storybook group in /docs with 2 updates (#42517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:49:02 -07:00
dependabot[bot]
a301e7c511 chore(deps-dev): bump the typescript-eslint group in /docs with 3 updates (#42518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:59 -07:00
dependabot[bot]
dd7bbe0adf chore(deps-dev): bump prettier from 3.9.5 to 3.9.6 in /docs (#42519)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:56 -07:00
dependabot[bot]
2e81eb580f chore(deps): bump baseline-browser-mapping from 2.10.43 to 2.10.44 in /docs (#42521)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:49 -07:00
dependabot[bot]
a970838b5c chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.2 (#42522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:46 -07:00
dependabot[bot]
2c061f1e05 chore(deps-dev): bump the storybook group in /superset-frontend with 5 updates (#42523)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:42 -07:00
dependabot[bot]
be012c8faa chore(deps): bump markdown-to-jsx from 9.8.2 to 9.9.0 in /superset-frontend (#42524)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:39 -07:00
dependabot[bot]
e754b73af2 chore(deps-dev): bump baseline-browser-mapping from 2.10.43 to 2.10.44 in /superset-frontend (#42526)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:35 -07:00
dependabot[bot]
435e7825a6 chore(deps-dev): bump prettier from 3.9.5 to 3.9.6 in /superset-frontend (#42527)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 03:48:32 -07:00
Mehmet Salih Yavuz
ecc7f726a4 fix: guard potential null derefs and remove dead branches (#42358)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-07-28 11:43:46 +03:00
Enzo Martellucci
0f61b3e884 Merge branch 'master' into hughhhh/handle-empty-query-context 2026-07-27 16:13:29 +02:00
Hugh A Miles II
53350ae4c9 fix(dashboard): refine form-data query-context rebuild per review
- Exclude percent_metrics from the rebuild: the chart shows them as a
  "% of total" via contribution post-processing the rebuild can't apply,
  so adding them as plain metrics would export raw aggregates that don't
  match the chart.
- Restore converting all SIMPLE adhoc filters (not just WHERE-clause) so
  SIMPLE HAVING filters the MCP compile/preview path relied on are not
  silently dropped.
- Fall back to legacy since/until when time_range is absent, so older
  charts export their configured range instead of the full history.
- Add an MCP-path test pinning that an explicitly empty `columns: []` no
  longer shadows `groupby` (intentional behavior change), plus tests for
  the since/until fallback and SIMPLE HAVING conversion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:09:54 -04:00
Hugh A. Miles II
6d25d5e93e Merge branch 'master' into hughhhh/handle-empty-query-context 2026-07-26 13:59:07 -04:00
Hugh A Miles II
670a95215c style: apply auto-walrus and ruff-format to query-context builder
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:13:44 -04:00
Hugh A Miles II
719649fe1a fix(dashboard): make form-data query-context rebuild faithful
Address EnxDev review — the generic rebuild silently dropped several
query aspects, so allowlisted charts could export a different dataset
than they show:

- Ordering: derive `orderby` from order_by_cols (raw) or the sort metric
  / first-metric-descending (aggregate), so a `row_limit` returns the
  chart's top-N instead of an arbitrary N.
- Time range: set `granularity` (from granularity/granularity_sqla) so
  `time_range` is actually applied — but only when there is an active
  range, so a numeric column saved as granularity_sqla with no range
  isn't forced through date bucketing (verified against a real export).
- Custom SQL filters: map `SQL` adhoc filters + legacy top-level `where`
  into `extras.where`/`extras.having` by clause instead of dropping them.
- Table specifics: carry `percent_metrics` into metrics and pass
  `time_grain_sqla` through `extras`.
- Big Number: only promote granularity_sqla to a grouping column for the
  trendline viz (`big_number`), never `big_number_total`.

Also point the MCP chart compile/preview helpers
(adhoc_filters_to_query_filters, _build_query_columns) at this shared
module so they stop diverging (preview_utils still had the pre-fix
`columns` bug), and address nits: drop the `-O`-stripped assert by
restructuring the loop, parse the saved query_context once, and correct
the shallow-copy comment.

Adds unit tests for every derived field plus an export test asserting the
full query body reaches ChartDataQueryContextSchema().load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:03:21 -04:00
Hugh A Miles II
de7dfc1585 fix(dashboard): harden form-data query-context rebuild edge cases
Address review of the empty query-context rebuild:

- columns_from_form_data no longer lets a stale, explicitly-present-but-
  empty `columns: []` key shadow the group-by dimensions (which silently
  dropped grouping and changed the aggregation). Prefer raw columns only
  when non-empty, else fall back to groupby.
- build_query_context_from_form_data now also honors legacy simple
  `filters` (already in QueryObject {col, op, val} shape) in addition to
  `adhoc_filters`, so legacy charts export the same filtered data they
  show; malformed entries are dropped.
- _rebuild_viz_types checks the config for None explicitly instead of
  using `or`, so an operator can disable the rebuild with an empty set
  instead of it silently falling back to the default allowlist.

Adds unit tests for each case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:06:29 -04:00
Hugh A Miles II
a1212ec089 test(dashboard): cover query-context rebuild edge cases
Add unit tests for the previously-uncovered branches of the empty
query-context handling: x_axis-as-adhoc-dict and the granularity_sqla
column fallback in the form-data builder (now 100%), and the eligible-viz
skip paths in _resolve_query_context when the saved form data is
unparseable, non-object, or has no datasource. All new source lines are
now exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:01:28 -04:00
Hugh A Miles II
2bbada837b style: apply ruff-format to empty-query-context tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:14:59 -04:00
Hugh A Miles II
de29e984ff feat(dashboard): rebuild missing query context from form data in Excel export
Charts persist a query_context only once (re-)saved in Explore, so older
charts have params (form data) but no context and were skipped entirely
by the data export. When the saved context is empty, synthesize one from
the chart's form data (columns, metrics, adhoc filters, time range) and
run that instead.

The rebuild is a generic single-query mapping — it does not reproduce
plugin post-processing (pivot, rolling, forecast) or multi-query charts —
so it is gated to a conservative viz-type allowlist
(EXCEL_EXPORT_REBUILD_VIZ_TYPES: table, big_number_total, big_number,
pie). Charts of any other type without a saved context are still skipped
and listed for re-save, so no chart exports silently wrong or incomplete
data.

The form-data -> query-context logic lives in a new shared module
(superset.common.form_data_query_context), mirroring the approach the MCP
chart compile/preview path already uses. Adds unit tests for the builder
and export integration tests for the eligible-rebuild and
ineligible-skip paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:57:19 -04:00
Hugh A Miles II
c7a6c6fc9c fix(dashboard): skip charts with empty query context cleanly in Excel export
The export guard only skipped charts whose query_context was missing
(None/blank). A present-but-unusable context slipped through and failed
mid-export: "null" parsed to None and raised TypeError on item
assignment, while "{}"/{"queries": []} failed schema validation — all
landing in the generic error bucket with a noisy traceback instead of
the "no query context" remediation ("re-save in Explore"), which is the
correct guidance for every one of these cases.

Replace the shallow truthiness check with `_has_empty_query_context`,
which also treats an unparseable, non-object, or query-less context as
empty, and route all of them to ERROR_NO_QUERY_CONTEXT. Add a
parametrized test over blank/null/{}/empty-queries/malformed inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 12:38:33 -04:00
45 changed files with 2997 additions and 880 deletions

View File

@@ -64,7 +64,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -75,6 +75,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
with:
category: "/language:${{matrix.language}}"

View File

@@ -1,134 +0,0 @@
name: Frontend bundle size (nightly baseline + analyzer)
# Refreshes the bundle-size baseline that superset-frontend.yml's `bundle-size`
# job compares PRs against, and publishes a browsable bundle-analyzer treemap
# report of the same build. Deliberately NOT triggered on every push to
# master: a day-old baseline/report is fine for catching relative
# regressions on PRs and for browsing what's actually in the bundle, and
# building the production bundle on every one of the many pushes master
# gets per day would burn CI time for no benefit a nightly refresh doesn't
# already cover.
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
env:
TAG: apache/superset:bundle-size-nightly-${{ github.run_id }}
permissions:
contents: read
jobs:
refresh-baseline:
runs-on: ubuntu-26.04
timeout-minutes: 30
steps:
- name: "Checkout master"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: master
- name: Build Docker Image
run: |
docker buildx build \
-t $TAG \
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
--target superset-node-ci \
.
# Same cache the PR-time bundle-size job restores/writes -- webpack's
# persistent filesystem cache turns a warm production build into ~20s
# instead of several minutes. See superset-frontend.yml for the
# matching restore step and why it's keyed this way.
- name: Restore webpack build cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: superset-frontend/.temp_cache
key: >-
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
'superset-frontend/webpack.config.js') }}
# Only ever pull the last recorded data point off the cache, keyed by
# run ID -- `restore-keys` prefix-matches the most recently created
# entry. Absent on the very first run ever; benchmark-action starts a
# fresh history in that case.
- name: Restore bundle size history
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: bundle-size-history.json
key: bundle-size-history-${{ github.run_id }}
restore-keys: |
bundle-size-history-
# BUNDLE_ANALYZER rides along in the same build as BUNDLE_SIZE_STATS --
# they're independent env-gated additions in webpack.config.js (one
# sets `config.stats`, the other pushes plugins), so one production
# build produces both the numeric stats.json and the analyzer's
# report.html. Only report.html is mounted out, not
# BUNDLE_ANALYZER's sibling `statistics.html` sunburst -- that file is
# documented in webpack.config.js as routinely exceeding 100MB for
# this app (it's .gitignore'd for exactly that reason), too large to
# publish as a static site page.
- name: Build production bundle with stats and analyzer report
run: |
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
mkdir -p ${{ github.workspace }}/superset/static/assets
docker run \
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
-v ${{ github.workspace }}/superset/static/assets:/app/superset/static/assets \
--rm $TAG \
bash -c \
"npm i && BUNDLE_SIZE_STATS=true BUNDLE_ANALYZER=true npm run build -- --json=bundle-stats/stats.json"
- name: Summarize bundle size
run: |
node superset-frontend/scripts/bundle-size-summary.js \
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
rm -rf superset-frontend/bundle-stats
# No PR to comment on here, so comment-on-alert is off -- the job
# summary (summary-always) is the only surface for this run.
- name: Update bundle size baseline
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
with:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json
external-data-json-path: bundle-size-history.json
fail-on-alert: false
summary-always: true
- name: Save bundle size history
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: bundle-size-history.json
key: bundle-size-history-${{ github.run_id }}
# Publishes the treemap to Netlify (the same host already used for
# superset-storybook.netlify.app and docs previews, reusing the
# existing NETLIFY_AUTH_TOKEN). Skipped until
# NETLIFY_BUNDLE_ANALYZER_SITE_ID exists -- create a new (free)
# Netlify site named superset-bundle-analyzer and add its site ID as
# that secret to turn this on; nothing else in this workflow depends
# on it.
- name: Publish bundle analyzer report to Netlify
if: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID != '' }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID }}
run: |
mkdir -p netlify-publish
cp superset/static/assets/report.html netlify-publish/index.html
# zizmor: ignore[adhoc-packages] - netlify-cli is a one-shot CI deploy
# tool, not an application dependency; a global/npx install has no
# lockfile context. Version pinned above the floor set by other
# ad-hoc installs in this repo (bump deliberately when upgrading).
npx --yes netlify-cli@27.0.1 deploy --prod --dir=netlify-publish

View File

@@ -2,10 +2,10 @@ name: supersetbot orglabel based on author
on:
issues:
types: [created, edited]
types: [opened, edited]
pull_request:
types: [created, edited]
types: [opened, edited]
jobs:
superbot-orglabel:
@@ -30,4 +30,4 @@ jobs:
# Label the issue with the appropriate org using supersetbot
# - this requires for the author to be publicly associated with their org
# - and for the org to be listed in `supersetbot/src/metadata.js`
supersetbot orglabel --issue ${{ github.event.number }} --repo ${{ github.repository }} || true
supersetbot orglabel --issue ${{ github.event.number || github.event.issue.number }} --repo ${{ github.repository }} || true

View File

@@ -21,8 +21,8 @@ permissions:
contents: read
jobs:
linkinator:
# See docs here: https://github.com/marketplace/actions/linkinator
lychee:
# See docs here: https://github.com/lycheeverse/lychee-action
# Only run on pull_request, not workflow_run
if: github.event_name == 'pull_request'
name: Link Checking
@@ -31,37 +31,52 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
# Restores the on-disk request cache (.lycheecache) lychee writes when
# `--cache` is set, so re-runs don't re-check every external link from
# scratch. Key never matches exactly (github.run_id is unique per run),
# so actions/cache always saves a fresh copy afterwards; restore-keys
# falls back to the most recent prior cache on lookup.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .lycheecache
key: lychee-cache-${{ github.run_id }}
restore-keys: lychee-cache-
# v2.8.0 and v2.9.0 are both on ASF Infra's action allowlist
# (apache/infrastructure-actions approved_patterns.yml) as of this
# writing. Do not bump past v2.9.0 without opening an Infra ticket
# to allow the new SHA first!
- uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
with:
paths: "**/*.md, **/*.mdx"
linksToSkip: >-
^https://github.com/apache/(superset|incubator-superset)/(pull|issues)/\d+,
^https://github.com/apache/(superset|incubator-superset)/commit/[a-f0-9]+,
superset-frontend/.*CHANGELOG\.md,
http://localhost:8088/,
http://127.0.0.1:3000/,
http://localhost:9001/,
https://charts.bitnami.com/bitnami,
https://www.li.me/,
https://www.fanatics.com/,
https://tails.com/gb/,
https://www.techaudit.info/,
https://avetilearning.com/,
https://www.udemy.com/,
https://trustmedis.com/,
http://theiconic.com.au/,
https://dev.mysql.com/doc/refman/5.7/en/innodb-limits.html,
^https://img\.shields\.io/.*,
https://vkusvill.ru/,
https://www.linkedin.com/in/mark-thomas-b16751158/,
https://theiconic.com.au/,
https://wattbewerb.de/,
https://timbr.ai/,
https://opensource.org/license/apache-2-0,
https://www.plaidcloud.com/
fail: false
args: >-
--verbose --no-progress
--cache --max-cache-age 7d
--exclude '^https://github.com/apache/(superset|incubator-superset)/(pull|issues)/\d+'
--exclude '^https://github.com/apache/(superset|incubator-superset)/commit/[a-f0-9]+'
--exclude 'superset-frontend/.*CHANGELOG\.md'
--exclude 'http://localhost:8088/'
--exclude 'http://127.0.0.1:3000/'
--exclude 'http://localhost:9001/'
--exclude 'https://charts.bitnami.com/bitnami'
--exclude 'https://www.li.me/'
--exclude 'https://www.fanatics.com/'
--exclude 'https://tails.com/gb/'
--exclude 'https://www.techaudit.info/'
--exclude 'https://avetilearning.com/'
--exclude 'https://www.udemy.com/'
--exclude 'https://trustmedis.com/'
--exclude 'http://theiconic.com.au/'
--exclude 'https://dev.mysql.com/doc/refman/5.7/en/innodb-limits.html'
--exclude '^https://img\.shields\.io/.*'
--exclude 'https://vkusvill.ru/'
--exclude 'https://www.linkedin.com/in/mark-thomas-b16751158/'
--exclude 'https://theiconic.com.au/'
--exclude 'https://wattbewerb.de/'
--exclude 'https://timbr.ai/'
--exclude 'https://opensource.org/license/apache-2-0'
--exclude 'https://www.plaidcloud.com/'
'./**/*.md' './**/*.mdx'
build-on-pr:
# Build docs when PR changes docs/** (uses committed databases.json)

View File

@@ -195,91 +195,3 @@ jobs:
run: |
docker run --rm $TAG bash -c \
"npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci"
# Compares a PR's own bundle size against the last nightly-recorded
# baseline (see frontend-bundle-size-nightly.yml, which owns actually
# persisting new baselines). PR-only: a push to master doesn't need this
# check re-run against itself, and re-persisting the baseline on every
# push to master -- which happens many times a day -- would burn a full
# production build for no benefit nightly refresh doesn't already cover.
bundle-size:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true' && github.event_name == 'pull_request'
runs-on: ubuntu-26.04
timeout-minutes: 15
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout Code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: docker-image
- name: Load Docker Image
run: |
zstd -d < docker-image.tar.zst | docker load
# webpack's persistent filesystem cache (superset-frontend/webpack.config.js)
# turns a warm production build into ~20s instead of several minutes,
# but GH-hosted runners are fresh VMs with nothing carried over between
# jobs -- without restoring it explicitly, every single PR would pay
# the full cold-build cost. Keyed on the same files webpack's own
# `buildDependencies` invalidates on, so a stale cache is never used.
- name: Restore webpack build cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: superset-frontend/.temp_cache
key: >-
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
'superset-frontend/webpack.config.js') }}
# Only ever pull the last recorded data point off the cache, keyed by
# run ID -- `restore-keys` prefix-matches the most recently created
# entry, which is always the latest nightly run. Absent before the
# first nightly run ever happens; benchmark-action starts a fresh
# history in that case.
- name: Restore bundle size history
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: bundle-size-history.json
key: bundle-size-history-${{ github.run_id }}
restore-keys: |
bundle-size-history-
- name: Build production bundle with stats
run: |
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
docker run \
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
--rm $TAG \
bash -c \
"npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json"
- name: Summarize bundle size
run: |
node superset-frontend/scripts/bundle-size-summary.js \
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
rm -rf superset-frontend/bundle-stats
# Comparison + alert only -- this job never persists. See
# frontend-bundle-size-nightly.yml for why.
- name: Compare bundle size against nightly baseline
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
with:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json
external-data-json-path: bundle-size-history.json
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
alert-threshold: "110%"
fail-on-alert: false
summary-always: true

View File

@@ -31,8 +31,6 @@ under the License.
[![Open PRs](https://img.shields.io/github/issues-pr/apache/superset)](https://github.com/apache/superset/pulls)
[![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](http://bit.ly/join-superset-slack)
[![Documentation](https://img.shields.io/badge/docs-apache.org-blue.svg)](https://superset.apache.org)
[![Storybook](https://img.shields.io/badge/storybook-live-ff4785.svg)](https://superset-storybook.netlify.app)
[![Bundle Analyzer](https://img.shields.io/badge/bundle%20analyzer-nightly-8dd6f9.svg)](https://superset-bundle-analyzer.netlify.app)
<picture width="500">
<source

View File

@@ -32,12 +32,21 @@ A new dashboard action exports every chart's data to a single multi-sheet
requires a running Celery worker and a configured SMTP transport, since the task
emails the requesting user a pre-signed download link. New config keys:
`EXCEL_EXPORT_S3_BUCKET`, `EXCEL_EXPORT_S3_KEY_PREFIX`,
`EXCEL_EXPORT_LINK_TTL_SECONDS`, `EXCEL_EXPORT_S3_CLIENT_KWARGS`, and
`EXCEL_EXPORT_TABLE_VIZ_TYPES`.
`EXCEL_EXPORT_LINK_TTL_SECONDS`, `EXCEL_EXPORT_S3_CLIENT_KWARGS`,
`EXCEL_EXPORT_TABLE_VIZ_TYPES`, and `EXCEL_EXPORT_REBUILD_VIZ_TYPES`.
The feature depends on `boto3`, which is **not** installed by default; install it
with `pip install apache-superset[excel-export]`.
Charts store their `query_context` only once they have been (re-)saved in
Explore, so older charts may have none. For a conservative allowlist of viz types
(`EXCEL_EXPORT_REBUILD_VIZ_TYPES`, default `table`, `big_number_total`,
`big_number`, `pie`) the export rebuilds a query context from the chart's saved
form data so those charts still export. The rebuild is a generic single-query
mapping and does **not** reproduce plugin post-processing (pivot, rolling,
forecast) or multi-query charts, so any chart of another type without a saved
query context is skipped and listed in the email for the user to re-save.
A second mode, **Export Images to Excel**, embeds non-table charts as rendered
images (which viz types stay tabular is controlled by
`EXCEL_EXPORT_TABLE_VIZ_TYPES`). It renders through the headless webdriver, so the

View File

@@ -58,11 +58,11 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.0",
"@storybook/addon-docs": "^10.5.3",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.46",
"antd": "^6.5.1",
"baseline-browser-mapping": "^2.10.43",
"baseline-browser-mapping": "^2.10.44",
"caniuse-lite": "^1.0.30001806",
"docusaurus-plugin-openapi-docs": "^5.1.2",
"docusaurus-theme-openapi-docs": "^5.1.2",
@@ -77,7 +77,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.0",
"storybook": "^10.5.3",
"swagger-ui-react": "^5.32.9",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
@@ -89,16 +89,16 @@
"@eslint/js": "^9.39.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/parser": "^8.64.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.7.0",
"prettier": "^3.9.5",
"prettier": "^3.9.6",
"typescript": "~6.0.3",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "^8.65.0",
"webpack": "^5.108.2"
},
"browserslist": {

View File

@@ -4005,23 +4005,23 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@storybook/addon-docs@^10.5.0":
version "10.5.2"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.2.tgz#e8023d1a68f637de9188be093f719895a8252d19"
integrity sha512-MoBANDsh5qEA14U+JaBoQcYsKbayJDDMopigFN0NdVAsZTdBfVIsL7cnjTFBL6ubB3ifb5M0tCXbScpml1KqiQ==
"@storybook/addon-docs@^10.5.3":
version "10.5.3"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.3.tgz#b5666cf20361d85c98cc836b2bfcdbbb72af3e97"
integrity sha512-MI1VDMSMQk78YxjIdt7WlrVOiA3TzTP00lRed1LeXh0fCvA9jxz9YXJI2+XigsLaxCSuOAEf/l35/GTLDMHD8A==
dependencies:
"@mdx-js/react" "^3.0.0"
"@storybook/csf-plugin" "10.5.2"
"@storybook/csf-plugin" "10.5.3"
"@storybook/icons" "^2.0.2"
"@storybook/react-dom-shim" "10.5.2"
"@storybook/react-dom-shim" "10.5.3"
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent "^2.0.0"
"@storybook/csf-plugin@10.5.2":
version "10.5.2"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.2.tgz#a359dffe0b842dfffacb0e94ad98da0ccb13ba69"
integrity sha512-PK/wXiALFf88mt4HmDtiZJ6NRvhExSXEM9uFIN+OIHxGqg7Xbp6MB0SPdhsTbMY9720ahiu/DJx5iIzkidcA3w==
"@storybook/csf-plugin@10.5.3":
version "10.5.3"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.3.tgz#0818709c2761910f1e42710f6231f773eb97fb41"
integrity sha512-mkPq6zru8fN5+46uC1cZEbKW2ws1hh9KvF4g4/Gu8pNbKnvqULPhk0/Bf0ZCtlr7zI7DvcFhyCy3dbvN+2n4Gw==
dependencies:
unplugin "^2.3.5"
@@ -4035,10 +4035,10 @@
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
"@storybook/react-dom-shim@10.5.2":
version "10.5.2"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.2.tgz#993077c517ee1a1c065c82bf0143ae08856a3c90"
integrity sha512-TbdYVLuD7gwj1CFsDJhCHUiwfVmzFWzalKEUGy9XgXyNpyOV1CYRsdmRdhaOHgmn2ljQZuTAxSnG7NlElghVaw==
"@storybook/react-dom-shim@10.5.3":
version "10.5.3"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz#e176a6549aa02002e375cb66467a00c5403745f8"
integrity sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==
"@superset-ui/core@^0.20.4":
version "0.20.4"
@@ -5527,100 +5527,100 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.64.0", "@typescript-eslint/eslint-plugin@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz#71a0c3d5f8a5e6c5dfdb4f0f04bd1bfb572d5e24"
integrity sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==
"@typescript-eslint/eslint-plugin@8.65.0", "@typescript-eslint/eslint-plugin@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz#0a58df6fea8c0bf6b396f518077099bc8b762bb5"
integrity sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/type-utils" "8.64.0"
"@typescript-eslint/utils" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/type-utils" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.64.0", "@typescript-eslint/parser@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.64.0.tgz#c9864a1cc28a13ff29a7314fbdef0528bb122f72"
integrity sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==
"@typescript-eslint/parser@8.65.0", "@typescript-eslint/parser@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.65.0.tgz#5295c1058c0a1dd746ef28baaf9c0341dbdf03dc"
integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==
dependencies:
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.64.0.tgz#14c4e29390d7325a7f8a1218c2788fd649b85da6"
integrity sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==
"@typescript-eslint/project-service@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.65.0.tgz#65fbbc9a1591abffaeab5513200f848271cb0aa5"
integrity sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.64.0"
"@typescript-eslint/types" "^8.64.0"
"@typescript-eslint/tsconfig-utils" "^8.65.0"
"@typescript-eslint/types" "^8.65.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz#d45f15304a94c85c39db317b717b158fb6259958"
integrity sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==
"@typescript-eslint/scope-manager@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz#9547202ce7e608e7b6283df585703b980a0ea70d"
integrity sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
"@typescript-eslint/tsconfig-utils@8.64.0", "@typescript-eslint/tsconfig-utils@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz#c62ac8ea9173c3cac8b38b8e66e30a046b548851"
integrity sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==
"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz#36f168fcdbb1295f7446ff0379667f98c3cf1bf3"
integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==
"@typescript-eslint/type-utils@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz#106fa7d58cf9cf7758f3dd8e426ac8237eceacf3"
integrity sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==
"@typescript-eslint/type-utils@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz#d316d7522d93cff4cd14f305e02f3df2d804f9c1"
integrity sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/utils" "8.64.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.64.0", "@typescript-eslint/types@^8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.64.0.tgz#b41f8ef5dd40616908658b991197a9d486cda60b"
integrity sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==
"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.65.0.tgz#3e86738416a777c8b8925ab46745f48ecf904c9f"
integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==
"@typescript-eslint/typescript-estree@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz#b8d51255e2d726eb4bd80d397a4fb4170c02eecc"
integrity sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==
"@typescript-eslint/typescript-estree@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz#f1f514808f6aa713e2d678ae8ff592a65e1632af"
integrity sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==
dependencies:
"@typescript-eslint/project-service" "8.64.0"
"@typescript-eslint/tsconfig-utils" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/visitor-keys" "8.64.0"
"@typescript-eslint/project-service" "8.65.0"
"@typescript-eslint/tsconfig-utils" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/visitor-keys" "8.65.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.64.0.tgz#98bb2010cfb754b41985b9c93e6e8b3dcd7bd600"
integrity sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==
"@typescript-eslint/utils@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.65.0.tgz#afedd974a0c8deeef553b509df5800bafd615a72"
integrity sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.64.0"
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/scope-manager" "8.65.0"
"@typescript-eslint/types" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/visitor-keys@8.64.0":
version "8.64.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz#7a08421d10e54960733352cd7c95fab1784e8473"
integrity sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==
"@typescript-eslint/visitor-keys@8.65.0":
version "8.65.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz#e3704c13cb4a1c22454c1abf28ff4737e15018c6"
integrity sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==
dependencies:
"@typescript-eslint/types" "8.64.0"
"@typescript-eslint/types" "8.65.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -6368,10 +6368,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.10.43, baseline-browser-mapping@^2.9.19:
version "2.10.43"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz#7b5d11590ce5acdbe4859443e3c940e81ce8c02d"
integrity sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.10.44, baseline-browser-mapping@^2.9.19:
version "2.10.44"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz#2e074675e640b67cb3835c54c11aa2dd9119c0f8"
integrity sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==
batch@0.6.1:
version "0.6.1"
@@ -13029,10 +13029,10 @@ prettier-linter-helpers@^1.0.1:
dependencies:
fast-diff "^1.1.2"
prettier@^3.9.5:
version "3.9.5"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad"
integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==
prettier@^3.9.6:
version "3.9.6"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6"
integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==
pretty-error@^4.0.0:
version "4.0.0"
@@ -14627,10 +14627,10 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
storybook@^10.5.0:
version "10.5.2"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.2.tgz#751aca53cbeea086886d84ceb46c8a29a5f802f3"
integrity sha512-zkYxVZoDMj8njzZc3EH5UyY7885wpi9a1mmWVwFiNHSo+i5r2Go84E2OI1cdOctRymLkNvgs1j5jqAKA0ftBqg==
storybook@^10.5.3:
version "10.5.3"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.3.tgz#72ee7cc02e3b6353eeec9633c2367437cf2ac86e"
integrity sha512-c8Wumu5qz0N2fnzWBxcPzUsY+8BpKBKChNyl4BEh9qhMV6KW587gL8il8emRB+4Hay+zMjDHA7cIeTkl4FKYuw==
dependencies:
"@storybook/global" "^5.0.0"
"@storybook/icons" "^2.0.2"
@@ -15309,15 +15309,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.64.0:
version "8.64.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.64.0.tgz#4984dae4de9dc8bf892acf5c394d0a2a5f08c3e1"
integrity sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==
typescript-eslint@^8.65.0:
version "8.65.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.65.0.tgz#754c953fd6a9a3d36fa54015bdba80a6eb2a4957"
integrity sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==
dependencies:
"@typescript-eslint/eslint-plugin" "8.64.0"
"@typescript-eslint/parser" "8.64.0"
"@typescript-eslint/typescript-estree" "8.64.0"
"@typescript-eslint/utils" "8.64.0"
"@typescript-eslint/eslint-plugin" "8.65.0"
"@typescript-eslint/parser" "8.65.0"
"@typescript-eslint/typescript-estree" "8.65.0"
"@typescript-eslint/utils" "8.65.0"
typescript@~6.0.3:
version "6.0.3"

View File

@@ -111,7 +111,7 @@
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.26.0",
"markdown-to-jsx": "^9.8.2",
"markdown-to-jsx": "^9.9.0",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
@@ -182,9 +182,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.2",
"@storybook/addon-links": "10.5.2",
"@storybook/react-webpack5": "10.5.2",
"@storybook/addon-docs": "10.5.3",
"@storybook/addon-links": "10.5.3",
"@storybook/react-webpack5": "10.5.3",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.46",
@@ -221,7 +221,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.10.43",
"baseline-browser-mapping": "^2.10.44",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
@@ -240,7 +240,7 @@
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.2",
"eslint-plugin-storybook": "10.5.3",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -262,7 +262,7 @@
"oxlint": "^1.74.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"prettier": "3.9.5",
"prettier": "3.9.6",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^16.0.1",
@@ -272,7 +272,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.2",
"storybook": "10.5.3",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -10329,16 +10329,16 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.2.tgz",
"integrity": "sha512-MoBANDsh5qEA14U+JaBoQcYsKbayJDDMopigFN0NdVAsZTdBfVIsL7cnjTFBL6ubB3ifb5M0tCXbScpml1KqiQ==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.3.tgz",
"integrity": "sha512-MI1VDMSMQk78YxjIdt7WlrVOiA3TzTP00lRed1LeXh0fCvA9jxz9YXJI2+XigsLaxCSuOAEf/l35/GTLDMHD8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.5.2",
"@storybook/csf-plugin": "10.5.3",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.5.2",
"@storybook/react-dom-shim": "10.5.3",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -10349,7 +10349,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10358,9 +10358,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.2.tgz",
"integrity": "sha512-PK/wXiALFf88mt4HmDtiZJ6NRvhExSXEM9uFIN+OIHxGqg7Xbp6MB0SPdhsTbMY9720ahiu/DJx5iIzkidcA3w==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.3.tgz",
"integrity": "sha512-mkPq6zru8fN5+46uC1cZEbKW2ws1hh9KvF4g4/Gu8pNbKnvqULPhk0/Bf0ZCtlr7zI7DvcFhyCy3dbvN+2n4Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10373,7 +10373,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "10.5.2",
"storybook": "10.5.3",
"vite": "*",
"webpack": "*"
},
@@ -10393,9 +10393,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.2.tgz",
"integrity": "sha512-TbdYVLuD7gwj1CFsDJhCHUiwfVmzFWzalKEUGy9XgXyNpyOV1CYRsdmRdhaOHgmn2ljQZuTAxSnG7NlElghVaw==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz",
"integrity": "sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10407,7 +10407,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10419,9 +10419,9 @@
}
},
"node_modules/@storybook/addon-links": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.2.tgz",
"integrity": "sha512-9Xk0gTpe8CJNR8a2UfgJtCPIZx1hQGUzYx7iTdfzJFQ9Y79ljGeOdq+d/O1zJ3G2uVqHhjHyD8/V+iolhfhdxg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.3.tgz",
"integrity": "sha512-awu6nBV/MRFv+zu9hIqFrqnKa37LnWfZ3/UHeS68il0QxWfy6uSU1dfpxqiYbCjs9Ct0+bsaO6ZaPamA+jlFLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10434,7 +10434,7 @@
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10528,15 +10528,15 @@
}
},
"node_modules/@storybook/react-webpack5": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.2.tgz",
"integrity": "sha512-nffAS7ipJY0ltaPuM7ELBO0S4SiSLxAR0f9oUrYVbSKpYUVC0r1MzHVTyj/eqzzS6Dwq5yvmvCrwgc3M3Htdmg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.3.tgz",
"integrity": "sha512-5uGVxywkT+/Bge4JwaReIj3TMKeceS6trkFwRc70RJnTWUQtlAbUyodI1OMQifRAs/1190+ptKBl0qOAbDyINw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/builder-webpack5": "10.5.2",
"@storybook/preset-react-webpack": "10.5.2",
"@storybook/react": "10.5.2"
"@storybook/builder-webpack5": "10.5.3",
"@storybook/preset-react-webpack": "10.5.3",
"@storybook/react": "10.5.3"
},
"funding": {
"type": "opencollective",
@@ -10545,7 +10545,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2",
"storybook": "10.5.3",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -10555,13 +10555,13 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.2.tgz",
"integrity": "sha512-iFPMPeRCinW5pQJlcQT1EiNv0+eKXuLu/3t53g/xY9cGY0QfQtAMzxf0c2QIeAnJOxu3BWTrkdL3MKDHuDnPwA==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.3.tgz",
"integrity": "sha512-IY8OlaOFRgsOCA4K+fBmTfEbVArvpqhjc/TYKC7Tq9hgY449stMmj89XNDeviL+ZXc7fL+S80EMiZnCPoF2Acw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.2",
"@storybook/core-webpack": "10.5.3",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"cjs-module-lexer": "^1.2.3",
"css-loader": "^7.1.2",
@@ -10583,7 +10583,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"typescript": {
@@ -10592,9 +10592,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.2.tgz",
"integrity": "sha512-XNzXTJmvAKXpvcrWHDWnIC8jZR4GBgTUH+FF9T5lSmU9ZL46T2wVaJAu2GUXfDLmNwLHZXusbYljbTD7Kswamg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.3.tgz",
"integrity": "sha512-A7DIGq4XcOXkL7g4Dc/XyntlfStvYvBp+iIkblA5mVOJ/I0n7MxI0jgrLoG+thP5jA72HLdSpjedXa3GUQOPrA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10605,17 +10605,17 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.2"
"storybook": "10.5.3"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.2.tgz",
"integrity": "sha512-XV5XV0PqgPEzBwilcEnowqbWkjt7r8qvTm7Zi3vSjrr1Aq6uLpc+9rV51bXsoOgcAFLZv9VQMbAu4B7LzHU/4g==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.3.tgz",
"integrity": "sha512-i93PdwMFAO0hqFhqdrNEhcwBnPb416zELssZTnxjjClJq4qRIGrGxch1NZ33IN4LGaF3fEY68byGuX6AHcHo6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.2",
"@storybook/core-webpack": "10.5.3",
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
"@types/semver": "^7.7.1",
"magic-string": "^0.30.5",
@@ -10632,7 +10632,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"typescript": {
@@ -10641,9 +10641,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.2.tgz",
"integrity": "sha512-XNzXTJmvAKXpvcrWHDWnIC8jZR4GBgTUH+FF9T5lSmU9ZL46T2wVaJAu2GUXfDLmNwLHZXusbYljbTD7Kswamg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.3.tgz",
"integrity": "sha512-A7DIGq4XcOXkL7g4Dc/XyntlfStvYvBp+iIkblA5mVOJ/I0n7MxI0jgrLoG+thP5jA72HLdSpjedXa3GUQOPrA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10654,18 +10654,18 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.2"
"storybook": "10.5.3"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.2.tgz",
"integrity": "sha512-DQkTEvQ3Tn5ndyZnOyNTnYuJWBZdnUsVFuvImtt1H6QyHsZGhl35/3CqRwERa/P10Sw/6vLP5DS+KhDCz1hWbg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.3.tgz",
"integrity": "sha512-d/CK78xgA7DDvqnxkqcYmiTjomE4ch2TWvk0O8/xHQWW6y0nMjKtsZbmUBfZ0QcdYdWq7dErzfbG7YAzxDi7Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.5.2",
"@storybook/react-dom-shim": "10.5.3",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@@ -10678,7 +10678,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2",
"storybook": "10.5.3",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -10694,9 +10694,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.2.tgz",
"integrity": "sha512-TbdYVLuD7gwj1CFsDJhCHUiwfVmzFWzalKEUGy9XgXyNpyOV1CYRsdmRdhaOHgmn2ljQZuTAxSnG7NlElghVaw==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz",
"integrity": "sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10708,7 +10708,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.2"
"storybook": "10.5.3"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -15633,9 +15633,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
"version": "2.10.44",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz",
"integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -20220,18 +20220,18 @@
}
},
"node_modules/eslint-plugin-storybook": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.2.tgz",
"integrity": "sha512-BPTbb8OKNAlQ2XubEQ6H1TeHG9nMygLrHC8cSVTfdIat65qbS568WYgNg//zlJWtZ9ZamyVkCp/na+LBbFPseA==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.3.tgz",
"integrity": "sha512-dreVGgQvTTOvPTrnn71uogs3E7skbODW3Ya1cHxupznzuIofLCzy+7zWJ4ObsEZ1MsOcofQlSaQuV9oJBszLoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "^8.48.0",
"@typescript-eslint/utils": "^8.48.0"
"@typescript-eslint/types": "^8.60.0",
"@typescript-eslint/utils": "^8.60.0"
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "10.5.2"
"storybook": "10.5.3"
}
},
"node_modules/eslint-plugin-testing-library": {
@@ -29085,9 +29085,9 @@
}
},
"node_modules/markdown-to-jsx": {
"version": "9.8.2",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.8.2.tgz",
"integrity": "sha512-rWUuxKB5NsuJmSfUOuXkQ0O5qk0J/Lr3Lk6dzxKoKQI/jeHYlsVfz3zJdMLAhI46hHoXDYERWhtBOiqtWDZ4LA==",
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.9.0.tgz",
"integrity": "sha512-3YRQuriODgdMr9EeY//Ya/104p978bY0nOwmc85IGxHixqOlhNDgq7IJTsLOdmFxjMFKJ6bVzUav30wWVJZOJQ==",
"license": "MIT",
"engines": {
"node": ">= 18"
@@ -33660,9 +33660,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
@@ -38698,9 +38698,9 @@
}
},
"node_modules/storybook": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.2.tgz",
"integrity": "sha512-zkYxVZoDMj8njzZc3EH5UyY7885wpi9a1mmWVwFiNHSo+i5r2Go84E2OI1cdOctRymLkNvgs1j5jqAKA0ftBqg==",
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.3.tgz",
"integrity": "sha512-c8Wumu5qz0N2fnzWBxcPzUsY+8BpKBKChNyl4BEh9qhMV6KW587gL8il8emRB+4Hay+zMjDHA7cIeTkl4FKYuw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -41420,9 +41420,9 @@
}
},
"node_modules/unplugin/node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"bin": {

View File

@@ -196,7 +196,7 @@
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.26.0",
"markdown-to-jsx": "^9.8.2",
"markdown-to-jsx": "^9.9.0",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
@@ -267,9 +267,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.2",
"@storybook/addon-links": "10.5.2",
"@storybook/react-webpack5": "10.5.2",
"@storybook/addon-docs": "10.5.3",
"@storybook/addon-links": "10.5.3",
"@storybook/react-webpack5": "10.5.3",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.46",
@@ -306,7 +306,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.10.43",
"baseline-browser-mapping": "^2.10.44",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
@@ -325,7 +325,7 @@
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.2",
"eslint-plugin-storybook": "10.5.3",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -347,7 +347,7 @@
"oxlint": "^1.74.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"prettier": "3.9.5",
"prettier": "3.9.6",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^16.0.1",
@@ -357,7 +357,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.2",
"storybook": "10.5.3",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",

View File

@@ -457,13 +457,23 @@ export default function transformProps(
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
const labelMapValues = rawLabelMap?.[seriesName];
let displayName: string;
if (groupby.length > 0) {
// When we have groupby, format as "metric, dimension"
// When we have groupby, format as "metric, dimension". Each series
// belongs to the metric recorded in its label-map tuple
// ([metric, ...dimensions]) — always using the first metric would
// prepend it to every other metric's series (#37921). Tuples without
// a metric part fall back to the first metric as before.
const metricDisplayName =
labelMapValues && labelMapValues.length > 1
? getMetricDisplayName(labelMapValues[0], verboseMap)
: MetricDisplayNameA;
const metricPart: string = showQueryIdentifiers
? `${MetricDisplayNameA} (Query A)`
: MetricDisplayNameA;
? `${metricDisplayName} (Query A)`
: metricDisplayName;
displayName = entryName.includes(metricPart)
? entryName
: `${metricPart}, ${entryName}`;
@@ -471,8 +481,6 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
}
const labelMapValues = rawLabelMap?.[seriesName];
if (labelMapValues) {
displayLabelMap[displayName] = labelMapValues;
}
@@ -536,13 +544,23 @@ export default function transformProps(
const seriesEntry = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
let displayName: string;
if (groupbyB.length > 0) {
// When we have groupby, format as "metric, dimension"
// When we have groupby, format as "metric, dimension". Each series
// belongs to the metric recorded in its label-map tuple
// ([metric, ...dimensions]) — always using the first metric would
// prepend it to every other metric's series (#37921). Tuples without
// a metric part fall back to the first metric as before.
const metricDisplayName =
labelMapValuesB && labelMapValuesB.length > 1
? getMetricDisplayName(labelMapValuesB[0], verboseMap)
: MetricDisplayNameB;
const metricPart: string = showQueryIdentifiers
? `${MetricDisplayNameB} (Query B)`
: MetricDisplayNameB;
? `${metricDisplayName} (Query B)`
: metricDisplayName;
displayName = entryName.includes(metricPart)
? entryName
: `${metricPart}, ${entryName}`;
@@ -550,8 +568,6 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
}
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
if (labelMapValuesB) {
displayLabelMapB[displayName] = labelMapValuesB;
}

View File

@@ -1161,3 +1161,83 @@ test('x-axis dedup keeps the forced min label when the endpoints format identica
expect(formatter(min)).toBe('May');
});
test('regression #37921: multi-metric Query A with groupby does not duplicate first metric in series names', () => {
// Regression test for https://github.com/apache/superset/issues/37921
// ("Residual" follow-up to #37055).
//
// When Query A has multiple metrics + at least one Group By dimension,
// the display-name builder in transformProps.ts used to prepend the FIRST
// metric's display name to every series that didn't literally contain it:
// name: `${MetricDisplayNameA}, ${entryName}`
// For series belonging to the *second* metric, this produced a
// cross-contaminated label like `score_one, score_two, A` — the
// user-visible "first metric duplicated" symptom in the legend / tooltip.
// The fix derives each series' metric from its label-map tuple instead.
const multiMetricRows = [
{
'score_one, A': 1,
'score_one, B': 2,
'score_two, A': 3,
'score_two, B': 4,
ds: 599616000000,
},
{
'score_one, A': 5,
'score_one, B': 6,
'score_two, A': 7,
'score_two, B': 8,
ds: 599916000000,
},
];
const multiMetricLabelMap = {
ds: ['ds'],
'score_one, A': ['score_one', 'A'],
'score_one, B': ['score_one', 'B'],
'score_two, A': ['score_two', 'A'],
'score_two, B': ['score_two', 'B'],
};
const queryAData = createTestQueryData(multiMetricRows, {
label_map: multiMetricLabelMap,
});
// Query B keeps the existing single-metric shape — the bug is on
// Query A's path so we just need a valid Query B alongside.
const queryBData = createTestQueryData(defaultQueryRows, {
label_map: defaultLabelMap,
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryAData, queryBData],
formData: {
...formData,
metrics: ['score_one', 'score_two'],
groupby: ['category'],
},
queriesData: [queryAData, queryBData],
});
const transformed = transformProps(chartProps);
const queryASeriesNames = (transformed.echartOptions.series as any[])
.map((s: any) => String(s.name))
.filter((n: string) => n.includes('score_'));
// Each (metric, dim_value) combo from Query A should appear exactly once
// with the *correct* metric prefix — not the first-metric-prepended-to-
// everything-else form. Comparing the sorted array (rather than using
// separate toContain assertions) also catches a regression that emits
// duplicate series for the same name.
expect([...queryASeriesNames].sort()).toEqual(
['score_one, A', 'score_one, B', 'score_two, A', 'score_two, B'].sort(),
);
// And explicitly: no series name should contain *both* metric names —
// that's the smoking gun for the duplication bug.
for (const name of queryASeriesNames) {
expect(name).not.toMatch(/score_one,\s+score_two/);
}
});

View File

@@ -1,85 +0,0 @@
#!/usr/bin/env node
/*
* 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.
*/
// Reduces a webpack `--json` stats file down to the handful of headline
// numbers worth tracking over time, in the flat array format
// benchmark-action/github-action-benchmark expects for its
// "customSmallerIsBetter" tool. The full stats file also includes a
// `modules`/`chunks` graph across ~15k modules, which is enormous and not
// useful for this purpose, so we only ever read `entrypoints`.
//
// Usage: node scripts/bundle-size-summary.js <path-to-stats.json>
const fs = require('fs');
// Entrypoints worth tracking: the two user-facing app shells. `menu`,
// `preamble`, `theme`, and `service-worker` are small, low-variance
// infrastructure chunks, not where bundle bloat actually shows up.
const TRACKED_ENTRYPOINTS = ['spa', 'embedded'];
function entrypointSizeByExt(entrypoint, ext) {
return (entrypoint.assets || [])
.filter(asset => asset.name.endsWith(ext))
.reduce((total, asset) => total + asset.size, 0);
}
function main() {
const statsPath = process.argv[2];
if (!statsPath) {
console.error('Usage: bundle-size-summary.js <path-to-stats.json>');
process.exit(1);
}
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
const { entrypoints } = stats;
if (!entrypoints) {
console.error(
'stats.json has no `entrypoints` key -- was it generated with ' +
'`--stats=normal` (or richer)? `minimal`/`errors-only` stats omit it.',
);
process.exit(1);
}
const results = [];
TRACKED_ENTRYPOINTS.forEach(name => {
const entrypoint = entrypoints[name];
if (!entrypoint) {
console.error(`stats.json is missing the "${name}" entrypoint`);
process.exit(1);
}
results.push({
name: `${name} entrypoint (JS)`,
unit: 'bytes',
value: entrypointSizeByExt(entrypoint, '.js'),
});
results.push({
name: `${name} entrypoint (CSS)`,
unit: 'bytes',
value: entrypointSizeByExt(entrypoint, '.css'),
});
});
console.log(JSON.stringify(results, null, 2));
}
if (require.main === module) {
main();
}
module.exports = { entrypointSizeByExt, main, TRACKED_ENTRYPOINTS };

View File

@@ -1,107 +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.
*/
const fs = require('fs');
const {
entrypointSizeByExt,
main,
} = require('../../scripts/bundle-size-summary');
function mockStats(entrypoints) {
jest
.spyOn(fs, 'readFileSync')
.mockReturnValue(JSON.stringify({ entrypoints }));
}
function mockExit() {
return jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
}
afterEach(() => {
jest.restoreAllMocks();
});
test('entrypointSizeByExt sums only assets matching the given extension', () => {
const entrypoint = {
assets: [
{ name: 'spa.entry.js', size: 100 },
{ name: 'spa.entry.js.map', size: 500 },
{ name: 'spa.entry.css', size: 20 },
],
};
expect(entrypointSizeByExt(entrypoint, '.js')).toBe(100);
expect(entrypointSizeByExt(entrypoint, '.css')).toBe(20);
});
test('entrypointSizeByExt returns 0 when the entrypoint has no assets', () => {
expect(entrypointSizeByExt({}, '.js')).toBe(0);
});
test('main prints byte totals for every tracked entrypoint', () => {
mockStats({
spa: { assets: [{ name: 'spa.js', size: 100 }] },
embedded: { assets: [{ name: 'embedded.js', size: 50 }] },
});
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
main();
const printed = JSON.parse(logSpy.mock.calls[0][0]);
expect(printed).toEqual([
{ name: 'spa entrypoint (JS)', unit: 'bytes', value: 100 },
{ name: 'spa entrypoint (CSS)', unit: 'bytes', value: 0 },
{ name: 'embedded entrypoint (JS)', unit: 'bytes', value: 50 },
{ name: 'embedded entrypoint (CSS)', unit: 'bytes', value: 0 },
]);
});
test('main exits with an error when a tracked entrypoint is missing from stats.json', () => {
mockStats({ spa: { assets: [] } });
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockExit();
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
expect(main).toThrow('process.exit called');
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('missing the "embedded" entrypoint'),
);
});
test('main exits with an error when stats.json has no `entrypoints` key', () => {
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({}));
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockExit();
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
expect(main).toThrow('process.exit called');
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('no `entrypoints` key'),
);
});
test('main prints a usage message and exits when no stats path is given', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockExit();
process.argv = ['node', 'bundle-size-summary.js'];
expect(main).toThrow('process.exit called');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
});

View File

@@ -713,8 +713,8 @@ export function handleChartDataResponse(
// Query is running asynchronously and we must await the results.
// When status is 202, result contains async event data (job_id, channel_id, etc.)
// which differs from QueryData. We cast through unknown to handle this safely.
// The optional signal lets a caller (e.g. StatefulChart) cancel the wait
// when its chart is superseded or unmounted, avoiding leaked listeners.
// The optional signal lets a caller abort the wait (Stop pressed, chart
// superseded or unmounted), cancelling the job and avoiding leaked listeners.
if (useLegacyApi) {
return waitForAsyncData(
result[0] as unknown as Parameters<typeof waitForAsyncData>[0],
@@ -786,15 +786,25 @@ export function exploreJSON(
const [useLegacyApi] = getQuerySettings(formData);
const chartDataRequestCaught = chartDataRequest
.then(({ response, json }) =>
handleChartDataResponse(response, json, useLegacyApi),
handleChartDataResponse(
response,
json,
useLegacyApi,
controller.signal,
),
)
.then(queriesResponse => {
// Drop stale responses: if a newer query has started for this chart,
// its controller will have replaced ours in state, so ignore this
// response to avoid clobbering newer data with older results.
// Drop stale responses: if this request was aborted (Stop, or a newer
// query that aborted ours), or a newer query has since replaced our
// controller in state, ignore the result so we don't clobber newer
// data or a 'stopped' status. Checking the signal is authoritative
// because the reducer nulls out queryController when a query stops.
if (key != null) {
const currentController = getState().charts?.[key]?.queryController;
if (currentController && currentController !== controller) {
if (
controller.signal.aborted ||
(currentController != null && currentController !== controller)
) {
return undefined;
}
}
@@ -853,7 +863,10 @@ export function exploreJSON(
// so a slow earlier request can't mark a newer one as failed.
if (key != null) {
const currentController = getState().charts?.[key]?.queryController;
if (currentController && currentController !== controller) {
if (
controller.signal.aborted ||
(currentController != null && currentController !== controller)
) {
return undefined;
}
}

View File

@@ -134,6 +134,44 @@ describe('asyncEvent middleware', () => {
expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(1);
});
test('rejects with an AbortError and cancels the job when the signal aborts', async () => {
const CANCEL_ENDPOINT = 'glob:*/api/v1/async_event/*/cancel';
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: {} });
const controller = new AbortController();
const promise = asyncEvent.waitForAsyncData(
asyncPendingEvent,
controller.signal,
);
controller.abort();
let error: any = null;
try {
await promise;
} catch (err) {
error = err;
}
expect(error?.name).toBe('AbortError');
// The cancel POST is fire-and-forget; let its microtask flush.
await new Promise(resolve => setTimeout(resolve, 0));
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(1);
});
test('rejects immediately when given an already-aborted signal', async () => {
const CANCEL_ENDPOINT = 'glob:*/api/v1/async_event/*/cancel';
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: {} });
const controller = new AbortController();
controller.abort();
await expect(
asyncEvent.waitForAsyncData(asyncPendingEvent, controller.signal),
).rejects.toMatchObject({ name: 'AbortError' });
// The cancel POST is fire-and-forget; let its microtask flush.
await new Promise(resolve => setTimeout(resolve, 0));
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(1);
});
test('rejects on event error status', async () => {
fetchMock.clearHistory().removeRoutes();
fetchMock.get(EVENTS_ENDPOINT, {

View File

@@ -104,6 +104,17 @@ const fetchCachedData = async (
return { status, data };
};
const cancelAsyncJob = (jobId: string) => {
// Best-effort server-side cancel; the request stops the running Celery task
// so it no longer consumes warehouse resources. Failures are non-fatal: the
// client has already stopped waiting on the job.
SupersetClient.post({
endpoint: `/api/v1/async_event/${jobId}/cancel`,
}).catch(error => {
logging.warn('Failed to cancel async job', jobId, error);
});
};
export const waitForAsyncData = async (
asyncResponse: AsyncEvent,
signal?: AbortSignal,
@@ -122,6 +133,7 @@ export const waitForAsyncData = async (
// Bail immediately if the caller has already aborted (e.g. the chart was
// unmounted before the job started), avoiding a leaked listener.
if (signal?.aborted) {
cancelAsyncJob(jobId);
reject(new DOMException('Aborted', 'AbortError'));
return;
}
@@ -161,11 +173,13 @@ export const waitForAsyncData = async (
}
};
// When the caller aborts (chart superseded/unmounted), stop listening so the
// listener and its retained closure don't leak and keep the poller busy.
// When the caller aborts (Stop pressed, chart superseded/unmounted), stop
// listening so the listener and its retained closure don't leak, and ask the
// server to cancel the job so it stops consuming warehouse resources.
if (signal) {
onAbort = () => {
cleanup();
cancelAsyncJob(jobId);
reject(new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });

View File

@@ -738,14 +738,4 @@ const smp = new SpeedMeasurePlugin({
disable: !measure,
});
// Emits per-asset/entrypoint sizes via `--json` (the default `stats: 'minimal'`
// above omits both). Not `normal`/`detailed` stats: those also serialize the
// full ~15k-module dependency graph, which is hundreds of MB for this app --
// large enough to exceed Node's max string length when read back with
// `fs.readFileSync`. Used by scripts/bundle-size-summary.js in CI.
// e.g. BUNDLE_SIZE_STATS=true npm run build -- --json=stats.json
if (process.env.BUNDLE_SIZE_STATS) {
config.stats = { all: false, assets: true, entrypoints: true };
}
module.exports = smp.wrap(config);

View File

@@ -24,15 +24,15 @@
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.1.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/parser": "^8.63.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
"prettier": "^3.9.5",
"prettier": "^3.9.6",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
"typescript-eslint": "^8.65.0",
"vitest": "^4.1.10"
},
"engines": {
@@ -402,9 +402,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -422,9 +419,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -442,9 +436,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -462,9 +453,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -482,9 +470,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -502,9 +487,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -741,17 +723,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
"integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/type-utils": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/type-utils": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -764,22 +746,176 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.64.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz",
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3"
},
"engines": {
@@ -794,15 +930,145 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz",
"integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==",
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.64.0",
"@typescript-eslint/types": "^8.64.0",
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
@@ -817,14 +1083,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz",
"integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0"
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -835,9 +1101,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz",
"integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -852,15 +1118,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz",
"integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -876,10 +1142,164 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -891,16 +1311,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz",
"integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.64.0",
"@typescript-eslint/tsconfig-utils": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -919,16 +1339,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz",
"integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0"
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -943,13 +1363,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz",
"integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/types": "8.65.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -2075,9 +2495,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2099,9 +2516,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2123,9 +2537,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2147,9 +2558,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2511,9 +2919,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
@@ -2861,16 +3269,95 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz",
"integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==",
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz",
"integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.64.0",
"@typescript-eslint/parser": "8.64.0",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
"integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/type-utils": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"@typescript-eslint/visitor-keys": "8.64.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.64.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz",
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0"
"@typescript-eslint/visitor-keys": "8.64.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": {
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz",
"integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.64.0",
"@typescript-eslint/typescript-estree": "8.64.0",
"@typescript-eslint/utils": "8.64.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@@ -33,15 +33,15 @@
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.1.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/parser": "^8.63.0",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.6.0",
"prettier": "^3.9.5",
"prettier": "^3.9.6",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.63.0",
"typescript-eslint": "^8.65.0",
"vitest": "^4.1.10"
},
"engines": {

View File

@@ -15,14 +15,19 @@
# specific language governing permissions and limitations
# under the License.
import logging
import uuid
from flask import request, Response
from flask_appbuilder import expose
from flask_appbuilder.api import safe
from flask_appbuilder.security.decorators import permission_name, protect
from superset.async_events.async_query_manager import AsyncQueryTokenException
from superset.async_events.async_query_manager import (
AsyncQueryJobException,
AsyncQueryTokenException,
)
from superset.extensions import async_query_manager, event_logger
from superset.utils.core import get_user_id
from superset.views.base_api import BaseSupersetApi, statsd_metrics
logger = logging.getLogger(__name__)
@@ -99,3 +104,76 @@ class AsyncEventsRestApi(BaseSupersetApi):
return self.response_401()
return self.response(200, result=events)
@expose("/<job_id>/cancel", methods=("POST",))
@event_logger.log_this
@protect()
@safe
@statsd_metrics
@permission_name("cancel")
def cancel(self, job_id: str) -> Response:
"""Cancel a running async query job.
---
post:
summary: Cancel a running async query job
description: >-
Revokes the Celery task backing an in-flight async query. The
caller is authorized against the job's original owner (channel and
user), both resolved server-side from the request, so a client
cannot cancel a job it did not submit.
parameters:
- in: path
name: job_id
required: true
description: The job ID returned when the async query was submitted
schema:
type: string
responses:
200:
description: Job cancelled
content:
application/json:
schema:
type: object
properties:
result:
type: object
properties:
job_id:
type: string
status:
type: string
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
try:
uuid.UUID(job_id)
except ValueError:
return self.response_400(message="Invalid job ID")
try:
async_channel_id = async_query_manager.parse_channel_id_from_request(
request
)
except AsyncQueryTokenException:
return self.response_401()
try:
async_query_manager.cancel_job(job_id, async_channel_id, get_user_id())
except AsyncQueryTokenException:
return self.response_403()
except AsyncQueryJobException:
return self.response_404()
return self.response(
200,
result={"job_id": job_id, "status": async_query_manager.STATUS_CANCELLED},
)

View File

@@ -106,6 +106,10 @@ class AsyncQueryManager:
STATUS_RUNNING = "running"
STATUS_ERROR = "error"
STATUS_DONE = "done"
STATUS_CANCELLED = "cancelled"
# Redis key prefix (within the GAQ stream namespace) for the per-job record
# that authorizes cancellation and flags a job as cancelled for the worker.
_JOB_REGISTRY_PREFIX = "job-cancel:"
def __init__(self) -> None:
super().__init__()
@@ -281,10 +285,32 @@ class AsyncQueryManager:
def init_job(self, channel_id: str, user_id: Optional[int]) -> dict[str, Any]:
job_id = str(uuid.uuid4())
self._register_cancellable_job(job_id, channel_id, user_id)
return build_job_metadata(
channel_id, job_id, user_id, status=self.STATUS_PENDING
)
def _job_registry_key(self, job_id: str) -> str:
return f"{self._stream_prefix}{self._JOB_REGISTRY_PREFIX}{job_id}"
def _register_cancellable_job(
self, job_id: str, channel_id: str, user_id: Optional[int]
) -> None:
"""
Persist the identity a later cancel request must match. Keyed by
``job_id`` (also the Celery task id — see ``submit_chart_data_job``) so
the cancel endpoint can authorize the caller against the job's original
owner without trusting the client-supplied id. Expires with the JWT so
it never outlives the job it guards.
"""
if not self._cache:
return
self._cache.set(
self._job_registry_key(job_id),
json.dumps({"channel_id": channel_id, "user_id": user_id}),
ex=self._jwt_expiration_seconds or None,
)
# pylint: disable=too-many-arguments
def submit_explore_json_job(
self,
@@ -307,6 +333,9 @@ class AsyncQueryManager:
response_type,
force,
],
# Use job_id as the Celery task id so the cancel endpoint can revoke
# the running task by the id the client already holds.
task_id=job_metadata["job_id"],
expires=self._jwt_expiration_seconds,
)
return job_metadata
@@ -332,6 +361,9 @@ class AsyncQueryManager:
else job_metadata,
form_data,
],
# Use job_id as the Celery task id so the cancel endpoint can revoke
# the running task by the id the client already holds.
task_id=job_metadata["job_id"],
expires=self._jwt_expiration_seconds,
)
return job_metadata
@@ -383,5 +415,88 @@ class AsyncQueryManager:
logger.debug("********** logging event data to stream %s", scoped_stream_name)
logger.debug(event_data)
# Drop the cancel record before announcing the result, so a cancel that
# arrives once the job is done finds nothing to flag and is reported as
# such instead of contradicting the event below. A cancelled job keeps
# its record until the TTL: the worker still has to recognize the
# SIGUSR1 it is about to receive as a cancellation.
if status in (self.STATUS_DONE, self.STATUS_ERROR):
if job_id := job_metadata.get("job_id"):
self._cache.delete(self._job_registry_key(job_id))
self._cache.xadd(scoped_stream_name, event_data, "*", self._stream_limit)
self._cache.xadd(full_stream_name, event_data, "*", self._stream_limit_firehose)
def is_job_cancelled(self, job_id: str) -> bool:
"""
Whether ``cancel_job`` has flagged this job for cancellation.
Called from the worker's exception handler, so any cache failure is
swallowed and treated as "not cancelled" — a Redis blip must never mask
the original error (e.g. a genuine timeout) with a connection error.
"""
if not self._cache:
return False
try:
raw = self._cache.get(self._job_registry_key(job_id))
if raw is None:
return False
return bool(json.loads(raw).get("cancelled"))
except Exception: # pylint: disable=broad-except
logger.warning(
"Failed to read cancellation flag for job %s", job_id, exc_info=True
)
return False
def cancel_job(self, job_id: str, channel_id: str, user_id: Optional[int]) -> None:
"""
Authorize and cancel a running async job.
The caller's ``channel_id`` and ``user_id`` (resolved server-side from
the request, never taken from the client) must match the job's original
owner. The terminal ``STATUS_CANCELLED`` event is emitted here rather
than by the worker, which never runs for a task revoked while it was
still queued; the worker only logs the cancellation it is told about
through the flag, so a job still gets exactly one terminal event.
:raises AsyncQueryJobException: the job is unknown or already terminal
:raises AsyncQueryTokenException: the caller does not own the job
"""
if not self._cache:
raise CacheBackendNotInitialized("Cache backend not initialized")
key = self._job_registry_key(job_id)
raw = self._cache.get(key)
if raw is None:
raise AsyncQueryJobException("Job not found or already completed")
record = json.loads(raw)
if record.get("channel_id") != channel_id or record.get("user_id") != user_id:
raise AsyncQueryTokenException("Not authorized to cancel this job")
# Flag before revoking so the worker's timeout handler, which may fire
# almost immediately, reliably sees the cancellation. Write only if the
# key still exists (``xx``): if the job finished and cleared its record
# between the read above and here, don't recreate a stale record or
# revoke a task that is already gone — report it as not found instead.
flagged = self._cache.set(
key,
json.dumps({**record, "cancelled": True}),
ex=self._jwt_expiration_seconds or None,
xx=True,
)
if not flagged:
raise AsyncQueryJobException("Job not found or already completed")
# pylint: disable=import-outside-toplevel
from superset.extensions import celery_app
# SIGUSR1 raises SoftTimeLimitExceeded inside the running task rather
# than hard-killing the process, so it unwinds through the task's
# exception handling instead of dying mid-query.
celery_app.control.revoke(job_id, terminate=True, signal="SIGUSR1")
self.update_job(
build_job_metadata(channel_id, job_id, user_id),
self.STATUS_CANCELLED,
)

View File

@@ -99,6 +99,15 @@ class RedisCacheBackend(RedisCache):
"""
return self._cache.set(name, value, ex=ex, px=px, nx=nx, xx=xx)
def get(self, name: str) -> Any:
"""
Get the raw value at key ``name``.
:param name: Key name
:returns: The stored value (bytes), or None if the key is absent
"""
return self._cache.get(name)
def delete(self, *names: str) -> int:
"""
Delete one or more keys.
@@ -283,6 +292,15 @@ class RedisSentinelCacheBackend(RedisSentinelCache):
"""
return self._cache.set(name, value, ex=ex, px=px, nx=nx, xx=xx)
def get(self, name: str) -> Any:
"""
Get the raw value at key ``name``.
:param name: Key name
:returns: The stored value (bytes), or None if the key is absent
"""
return self._cache.get(name)
def delete(self, *names: str) -> int:
"""
Delete one or more keys.

View File

@@ -0,0 +1,247 @@
# 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.
"""
Synthesize a query context from a chart's saved form data (``params``).
A chart's ``query_context`` is normally generated client-side by each viz
plugin's ``buildQuery`` and only persisted when the chart is (re-)saved in
Explore. Charts that predate that behavior keep their ``params`` (form data) but
carry no ``query_context``, so server-side consumers that need to run the query
(e.g. the dashboard Excel export) have nothing to execute.
This module rebuilds a best-effort query context from the form data — columns,
metrics, filters (including free-form SQL and the time range), ordering and time
grain — mirroring the shared parts of the viz plugins' ``buildQuery``. It does
**not** reproduce plugin post-processing (pivot, contribution/percent
transforms, rolling/forecast) or multi-query fan-out, so callers must restrict it
to viz types whose data maps faithfully to a single plain query.
"""
from __future__ import annotations
from typing import Any
from superset.utils import json
def adhoc_filters_to_query_filters(
adhoc_filters: list[dict[str, Any]],
where_only: bool = False,
) -> list[dict[str, Any]]:
"""
Convert ``SIMPLE`` adhoc filters into QueryObject filter clauses.
Adhoc filters use ``{subject, operator, comparator}`` while a query object
expects ``{col, op, val}``; free-form ``SQL`` filters have no ``{col, op,
val}`` equivalent and are handled separately (see
:func:`freeform_where_having`).
By default all ``SIMPLE`` filters are converted (the behavior the MCP
compile/preview path relies on). Pass ``where_only=True`` to convert only
``WHERE``-clause filters, matching the frontend's ``processFilters`` — the
dashboard export uses this so it applies the same rows the chart shows and
does not additionally filter on ``SIMPLE`` ``HAVING`` clauses.
"""
result: list[dict[str, Any]] = []
for flt in adhoc_filters or []:
if flt.get("expressionType") != "SIMPLE":
continue
if where_only and (flt.get("clause") or "WHERE").upper() != "WHERE":
continue
result.append(
{
"col": flt.get("subject"),
"op": flt.get("operator"),
"val": flt.get("comparator"),
}
)
return result
def freeform_where_having(form_data: dict[str, Any]) -> dict[str, str]:
"""
Collect free-form SQL predicates into a query ``extras`` mapping.
Mirrors ``processFilters`` on the frontend: ``SQL`` adhoc filters (and a
legacy top-level ``where``) join into ``extras.where`` / ``extras.having`` by
clause, so a chart restricted by a custom SQL predicate exports the same rows
it displays instead of the full, unrestricted result.
"""
where: list[str] = []
having: list[str] = []
if form_data.get("where"):
where.append(form_data["where"])
for flt in form_data.get("adhoc_filters") or []:
if flt.get("expressionType") == "SQL" and flt.get("sqlExpression"):
clause = (flt.get("clause") or "WHERE").upper()
(having if clause == "HAVING" else where).append(flt["sqlExpression"])
extras: dict[str, str] = {}
if where:
extras["where"] = " AND ".join(f"({clause})" for clause in where)
if having:
extras["having"] = " AND ".join(f"({clause})" for clause in having)
return extras
def columns_from_form_data(form_data: dict[str, Any]) -> list[Any]:
"""
Derive the query's grouping/raw columns from form data.
Handles raw-mode tables (``all_columns``/``columns``), an ``x_axis`` (string
or adhoc column), and ``groupby`` dimensions, de-duplicating while preserving
order.
"""
if form_data.get("query_mode") == "raw" and (
form_data.get("all_columns") or form_data.get("columns")
):
return list(form_data.get("all_columns") or form_data.get("columns") or [])
groupby_columns: list[Any] = form_data.get("groupby") or []
raw_columns: list[Any] = form_data.get("columns") or []
# Prefer explicit raw columns only when they are actually present; a stale
# empty ``columns: []`` key must not shadow the group-by dimensions (which
# would silently drop the grouping and change the aggregation).
columns = raw_columns.copy() if raw_columns else groupby_columns.copy()
x_axis = form_data.get("x_axis")
if isinstance(x_axis, str) and x_axis and x_axis not in columns:
columns.insert(0, x_axis)
elif isinstance(x_axis, dict):
col_name = x_axis.get("column_name")
if col_name and col_name not in columns:
columns.insert(0, col_name)
return columns
def orderby_from_form_data(
form_data: dict[str, Any], metrics: list[Any]
) -> list[list[Any]]:
"""
Derive ordering so a ``row_limit`` returns the chart's top-N, not an
arbitrary N.
Raw-mode tables order by ``order_by_cols`` (stored as JSON ``[col, asc]``
pairs). Aggregate charts order by the configured sort metric
(``timeseries_limit_metric``, or the first metric when ``sort_by_metric`` is
set), otherwise fall back to the first metric descending — matching the
table/pie ``buildQuery`` defaults.
"""
if order_by_cols := form_data.get("order_by_cols") or []:
parsed: list[list[Any]] = []
for col in order_by_cols:
if isinstance(col, str):
try:
col = json.loads(col)
except (TypeError, ValueError):
continue
parsed.append(col)
return parsed
if not metrics:
return []
order_desc = form_data.get("order_desc", True)
sort_metric = form_data.get("timeseries_limit_metric") or (
metrics[0] if form_data.get("sort_by_metric") else None
)
if sort_metric is not None:
return [[sort_metric, not order_desc]]
# No explicit sort metric: default to the first metric, descending.
return [[metrics[0], False]]
def build_query_context_from_form_data(
form_data: dict[str, Any],
datasource: dict[str, Any],
viz_type: str | None = None,
) -> dict[str, Any]:
"""
Build a query-context payload (the JSON shape ``ChartDataQueryContextSchema``
loads) from a chart's form data and datasource reference.
:param form_data: The chart's saved ``params`` parsed to a dict.
:param datasource: ``{"id": <int>, "type": "table"}`` datasource reference.
:param viz_type: The chart's viz type, used for viz-specific handling.
:returns: A single-query query-context dict.
"""
metrics = list(form_data.get("metrics") or [])
# Single-metric charts (e.g. Big Number) store ``metric`` rather than
# ``metrics``.
if not metrics and form_data.get("metric"):
metrics = [form_data["metric"]]
# ``percent_metrics`` are intentionally not carried: the chart shows them as a
# "% of total" produced by contribution post-processing, which this rebuild
# does not apply, so adding them as plain metrics would export raw aggregates
# that don't match the chart.
columns = columns_from_form_data(form_data)
# Only a Big Number *with a trendline* (viz_type ``big_number``) groups by its
# time column; ``big_number_total`` is a single aggregate and must not be
# grouped, or it would return one row per timestamp instead of a total.
promoted_time_column = False
if not columns and viz_type == "big_number" and form_data.get("granularity_sqla"):
columns = [form_data["granularity_sqla"]]
promoted_time_column = True
# SIMPLE adhoc filters (+ legacy top-level ``filters``) become query filters;
# free-form SQL predicates go into ``extras``. Only ``WHERE``-clause SIMPLE
# filters are applied (matching the chart), so the export never filters on a
# ``HAVING`` clause the chart itself ignores.
filters = adhoc_filters_to_query_filters(
form_data.get("adhoc_filters", []), where_only=True
)
for flt in form_data.get("filters") or []:
if isinstance(flt, dict) and flt.get("col") is not None:
filters.append(flt)
extras = freeform_where_having(form_data)
if form_data.get("time_grain_sqla"):
extras["time_grain_sqla"] = form_data["time_grain_sqla"]
# Prefer the modern ``time_range``; fall back to the legacy ``since``/``until``
# pair (older charts store the range that way) before defaulting to no filter.
time_range = form_data.get("time_range")
if not time_range and (form_data.get("since") or form_data.get("until")):
time_range = f"{form_data.get('since') or ''} : {form_data.get('until') or ''}"
time_range = time_range or "No filter"
query: dict[str, Any] = {
"columns": columns,
"metrics": metrics,
"orderby": orderby_from_form_data(form_data, metrics),
"filters": filters,
"time_range": time_range,
}
if extras:
query["extras"] = extras
# ``granularity`` names the temporal column that applies the time range and
# that ``time_grain_sqla`` buckets. Set it when there is a real range to apply,
# or when we've promoted a Big Number trendline's time column (so its time
# grain takes effect even with no active range). Otherwise leave it off — a
# non-temporal ``granularity_sqla`` with no range would be forced through date
# bucketing and fail.
granularity = form_data.get("granularity") or form_data.get("granularity_sqla")
if granularity and (time_range != "No filter" or promoted_time_column):
query["granularity"] = granularity
if form_data.get("row_limit"):
query["row_limit"] = form_data["row_limit"]
return {
"datasource": datasource,
"queries": [query],
"form_data": form_data,
}

View File

@@ -318,7 +318,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
# another temporal filter. A new filter based on the value of
# the granularity will be added later in the code.
# In practice, this is replacing the previous default temporal filter.
if is_adhoc_column(filter_to_remove): # type: ignore
if filter_to_remove and is_adhoc_column(filter_to_remove): # type: ignore
filter_to_remove = filter_to_remove.get("sqlExpression")
if filter_to_remove:

View File

@@ -206,11 +206,9 @@ class QueryCacheManager:
)
query_cache.status = QueryStatus.SUCCESS
query_cache.is_loaded = True
query_cache.is_cached = cache_value is not None
query_cache.is_cached = True
query_cache.sql_rowcount = cache_value.get("sql_rowcount", None)
query_cache.cache_dttm = (
cache_value["dttm"] if cache_value is not None else None
)
query_cache.cache_dttm = cache_value["dttm"]
query_cache.queried_dttm = cache_value.get(
"queried_dttm", cache_value.get("dttm")
)

View File

@@ -1509,6 +1509,16 @@ EXCEL_EXPORT_S3_CLIENT_KWARGS: dict[str, Any] = {}
# a rendered image. Set to None to fall back to the built-in default.
EXCEL_EXPORT_TABLE_VIZ_TYPES: set[str] | None = None
# Viz types whose data export may synthesize a query context from the chart's
# saved form data when no ``query_context`` was persisted (older charts store
# ``params`` but not a query context). Restricted to viz types whose data maps
# faithfully to a single plain query; charts of any other type without a saved
# query context are skipped and listed for the user to re-save in Explore. The
# rebuild does not reproduce plugin post-processing (pivot, rolling, forecast) or
# multi-query charts, so keep this conservative. Set to None to fall back to the
# built-in default.
EXCEL_EXPORT_REBUILD_VIZ_TYPES: set[str] | None = None
# ---------------------------------------------------
# Time grain configurations
# ---------------------------------------------------

View File

@@ -534,6 +534,7 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
],
) -> list[SupersetError]:
errors: list[SupersetError] = []
connect_args: dict[str, Any] = {}
if extra := json.loads(properties.get("extra")): # type: ignore
engine_params = extra.get("engine_params", {})
connect_args = engine_params.get("connect_args", {})

View File

@@ -1235,6 +1235,7 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
all_columns: list[ResultSetColumnType] = []
expanded_columns = []
current_array_level = None
unnested_rows: dict[int, int] = defaultdict(int)
while to_process:
column, level = to_process.popleft()
if column["column_name"] not in [
@@ -1248,7 +1249,7 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
# added by the first. every time we change a level in the nested arrays
# we reinitialize this.
if level != current_array_level:
unnested_rows: dict[int, int] = defaultdict(int)
unnested_rows = defaultdict(int)
current_array_level = level
name = column["column_name"]

View File

@@ -456,18 +456,14 @@ def adhoc_filters_to_query_filters(
Adhoc filters use ``{subject, operator, comparator}`` keys while
``QueryContextFactory`` expects ``{col, op, val}`` (QueryObjectFilterClause).
Delegates to the shared builder so the MCP and dashboard-export paths stay in
sync (single source of truth).
"""
result: list[Dict[str, Any]] = []
for f in adhoc_filters:
if f.get("expressionType") == "SIMPLE":
result.append(
{
"col": f.get("subject"),
"op": f.get("operator"),
"val": f.get("comparator"),
}
)
return result
from superset.common.form_data_query_context import (
adhoc_filters_to_query_filters as _shared,
)
return _shared(adhoc_filters)
def map_table_config(config: TableChartConfig) -> Dict[str, Any]:

View File

@@ -39,26 +39,14 @@ SUPPORTED_FORM_DATA_PREVIEW_FORMATS = frozenset({"ascii", "table", "vega_lite"})
def _build_query_columns(form_data: Dict[str, Any]) -> list[str]:
"""Build query columns list from form_data, including both x_axis and groupby."""
# Table charts in raw mode use all_columns or columns
all_columns = form_data.get("all_columns", [])
raw_columns_field = form_data.get("columns", [])
if form_data.get("query_mode") == "raw" and (all_columns or raw_columns_field):
return list(all_columns or raw_columns_field)
"""Build query columns list from form_data, including both x_axis and groupby.
x_axis_config = form_data.get("x_axis")
groupby_columns: list[str] = form_data.get("groupby") or []
raw_columns: list[str] = form_data.get("columns") or []
Delegates to the shared builder so the MCP and dashboard-export paths stay in
sync (single source of truth).
"""
from superset.common.form_data_query_context import columns_from_form_data
columns = raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
if x_axis_config and isinstance(x_axis_config, str):
if x_axis_config not in columns:
columns.insert(0, x_axis_config)
elif x_axis_config and isinstance(x_axis_config, dict):
col_name = x_axis_config.get("column_name")
if col_name and col_name not in columns:
columns.insert(0, col_name)
return columns
return columns_from_form_data(form_data)
def generate_preview_from_form_data(

View File

@@ -261,6 +261,42 @@ class LoggingMiddleware(Middleware):
dataset_id = params.get("dataset_id")
return agent_id, user_id, dashboard_id, slice_id, dataset_id, params
def _extract_output_ids(self, result: ToolResult) -> tuple[int | None, int | None]:
"""Extract dashboard/chart IDs created by the tool from its response.
Create-style tools (generate_chart, generate_dashboard) don't take
chart_id/dashboard_id as input, so _extract_context_info never sees
them and every retry logs slice_id/dashboard_id=None even on the
attempt that actually persisted the object. Look at the response
body instead, since that's the only place the new ID appears.
Supports both flat ("chart_id"/"dashboard_id") and nested
("chart"/"dashboard" objects with an "id" field) response shapes.
"""
from superset.utils.json import loads as json_loads
try:
data = json_loads(result.content[0].text)
except (AttributeError, IndexError, ValueError, TypeError):
return None, None
if not isinstance(data, dict):
return None, None
slice_id = None
chart = data.get("chart")
if isinstance(chart, dict):
slice_id = chart.get("id")
if slice_id is None:
slice_id = data.get("chart_id")
dashboard_id = None
dashboard = data.get("dashboard")
if isinstance(dashboard, dict):
dashboard_id = dashboard.get("id")
if dashboard_id is None:
dashboard_id = data.get("dashboard_id")
return dashboard_id, slice_id
@staticmethod
def _resolve_tool_name(tool_name: str | None, params: Any) -> str | None:
"""Resolve the underlying tool name from call_tool proxy arguments.
@@ -283,6 +319,129 @@ class LoggingMiddleware(Middleware):
return params["name"]
return None
def _backfill_output_ids(
self,
success: bool,
result: Any,
dashboard_id: int | None,
slice_id: int | None,
) -> tuple[int | None, int | None]:
"""Fill in missing ids from a create tool's response on success.
Create-style tools (generate_chart, generate_dashboard) don't take
the new object's ID as input, so it's missing from params. On a
successful call, pull it from the response instead so retried
creates are distinguishable.
"""
if not success or not isinstance(result, ToolResult):
return dashboard_id, slice_id
output_dashboard_id, output_slice_id = self._extract_output_ids(result)
if dashboard_id is None:
dashboard_id = output_dashboard_id
if slice_id is None:
slice_id = output_slice_id
return dashboard_id, slice_id
@staticmethod
def _build_call_tool_payload(
*,
mcp_call_id: str,
tool_name: str | None,
agent_id: str | None,
params: Any,
method: str,
dashboard_id: int | None,
slice_id: int | None,
dataset_id: int | None,
success: bool,
mcp_tool: str | None,
error_type: str | None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"mcp_call_id": mcp_call_id,
"tool": tool_name,
"agent_id": agent_id,
"params": _sanitize_params(params),
"method": method,
"dashboard_id": dashboard_id,
"slice_id": slice_id,
"dataset_id": dataset_id,
"success": success,
}
if mcp_tool is not None:
payload["mcp_tool"] = mcp_tool
if error_type is not None:
payload["error_type"] = error_type
return payload
def _log_call_tool_result(
self,
*,
context: MiddlewareContext,
tool_name: str | None,
mcp_tool: str | None,
mcp_call_id: str,
agent_id: str | None,
user_id: int | None,
dashboard_id: int | None,
slice_id: int | None,
dataset_id: int | None,
params: Any,
success: bool,
error_type: str | None,
result: Any,
start_time: float,
) -> None:
duration_ms = int((time.time() - start_time) * 1000)
dashboard_id, slice_id = self._backfill_output_ids(
success, result, dashboard_id, slice_id
)
payload = self._build_call_tool_payload(
mcp_call_id=mcp_call_id,
tool_name=tool_name,
agent_id=agent_id,
params=params,
method=context.method,
dashboard_id=dashboard_id,
slice_id=slice_id,
dataset_id=dataset_id,
success=success,
mcp_tool=mcp_tool,
error_type=error_type,
)
if has_app_context():
event_logger.log(
user_id=user_id,
action="mcp_tool_call",
dashboard_id=dashboard_id,
duration_ms=duration_ms,
slice_id=slice_id,
referrer=None,
curated_payload=payload,
)
extra_parts = []
if mcp_tool is not None:
extra_parts.append(f"mcp_tool={mcp_tool}")
if error_type is not None:
extra_parts.append(f"error_type={error_type}")
extra = (", " + ", ".join(extra_parts)) if extra_parts else ""
logger.info(
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
"success=%s, mcp_call_id=%s%s",
tool_name,
agent_id,
user_id,
context.method,
dashboard_id,
slice_id,
dataset_id,
duration_ms,
success,
mcp_call_id,
extra,
)
async def on_call_tool(
self,
context: MiddlewareContext,
@@ -300,6 +459,7 @@ class LoggingMiddleware(Middleware):
start_time = time.time()
success = False
error_type: str | None = None
result: Any = None
try:
result = await call_next(context)
success = not self._is_error_response(result)
@@ -316,53 +476,21 @@ class LoggingMiddleware(Middleware):
success = False
raise
finally:
duration_ms = int((time.time() - start_time) * 1000)
payload: dict[str, Any] = {
"mcp_call_id": mcp_call_id,
"tool": tool_name,
"agent_id": agent_id,
"params": _sanitize_params(params),
"method": context.method,
"dashboard_id": dashboard_id,
"slice_id": slice_id,
"dataset_id": dataset_id,
"success": success,
}
if mcp_tool is not None:
payload["mcp_tool"] = mcp_tool
if error_type is not None:
payload["error_type"] = error_type
if has_app_context():
event_logger.log(
user_id=user_id,
action="mcp_tool_call",
dashboard_id=dashboard_id,
duration_ms=duration_ms,
slice_id=slice_id,
referrer=None,
curated_payload=payload,
)
extra_parts = []
if mcp_tool is not None:
extra_parts.append(f"mcp_tool={mcp_tool}")
if error_type is not None:
extra_parts.append(f"error_type={error_type}")
extra = (", " + ", ".join(extra_parts)) if extra_parts else ""
logger.info(
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
"success=%s, mcp_call_id=%s%s",
tool_name,
agent_id,
user_id,
context.method,
dashboard_id,
slice_id,
dataset_id,
duration_ms,
success,
mcp_call_id,
extra,
self._log_call_tool_result(
context=context,
tool_name=tool_name,
mcp_tool=mcp_tool,
mcp_call_id=mcp_call_id,
agent_id=agent_id,
user_id=user_id,
dashboard_id=dashboard_id,
slice_id=slice_id,
dataset_id=dataset_id,
params=params,
success=success,
error_type=error_type,
result=result,
start_time=start_time,
)
async def on_message(

View File

@@ -84,6 +84,28 @@ def _load_user_from_job_metadata(job_metadata: dict[str, Any]) -> User:
return user
def _handle_soft_time_limit(
job_metadata: dict[str, Any], ex: Exception, activity: str
) -> None:
"""
SoftTimeLimitExceeded is raised both by a genuine timeout and by a
user-initiated cancel (revoke sends SIGUSR1). The cancel endpoint has
already emitted the terminal event for the latter - it has to, since a task
revoked while still queued never reaches this handler - so only a timeout
is reported here, and without one the client would wait forever.
"""
if async_query_manager.is_job_cancelled(job_metadata["job_id"]):
logger.info("Cancelled by the user while %s", activity)
return
logger.warning("A timeout occurred while %s, error: %s", activity, ex)
async_query_manager.update_job(
job_metadata,
async_query_manager.STATUS_ERROR,
errors=[{"message": f"A timeout occurred while {activity}"}],
)
@celery_app.task(name="load_chart_data_into_cache", soft_time_limit=query_timeout)
def load_chart_data_into_cache(
job_metadata: dict[str, Any],
@@ -106,7 +128,7 @@ def load_chart_data_into_cache(
result_url=result_url,
)
except SoftTimeLimitExceeded as ex:
logger.warning("A timeout occurred while loading chart data, error: %s", ex)
_handle_soft_time_limit(job_metadata, ex, "loading chart data")
raise
except Exception as ex:
# Extract SIP-40 style errors when available
@@ -176,9 +198,7 @@ def load_explore_json_into_cache( # pylint: disable=too-many-locals
result_url=result_url,
)
except SoftTimeLimitExceeded as ex:
logger.warning(
"A timeout occurred while loading explore json, error: %s", ex
)
_handle_soft_time_limit(job_metadata, ex, "loading explore json")
raise
except Exception as ex:
if isinstance(ex, SupersetVizException):

View File

@@ -47,6 +47,7 @@ from superset.charts.schemas import ChartDataQueryContextSchema
from superset.commands.chart.data.get_data_command import ChartDataCommand
from superset.commands.distributed_lock.release import ReleaseDistributedLock
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
from superset.common.form_data_query_context import build_query_context_from_form_data
from superset.dashboards.excel_export import email
from superset.dashboards.excel_export.layout import get_charts_in_layout_order
from superset.dashboards.excel_export.screenshot import render_chart_image
@@ -67,6 +68,12 @@ EXPORT_MODE_IMAGES = "images"
# image. Operators can override the set via ``EXCEL_EXPORT_TABLE_VIZ_TYPES``.
TABLE_VIZ_TYPES = {"table", "pivot_table_v2", "pivot_table"}
# Viz types whose missing query context may be rebuilt from saved form data.
# Conservative by default: only charts whose data maps faithfully to a single
# plain query (no post-processing, no multi-query fan-out). Operators can
# override via ``EXCEL_EXPORT_REBUILD_VIZ_TYPES``.
REBUILD_VIZ_TYPES = {"table", "big_number_total", "big_number", "pie"}
EXPORT_SOFT_TIME_LIMIT = 600
EXPORT_HARD_TIME_LIMIT = 660
@@ -96,6 +103,87 @@ def _chart_label(chart: Any) -> str:
return f"{chart.id} - {chart.slice_name or ''}".strip()
def _saved_query_context(raw: Any) -> dict[str, Any] | None:
"""
The chart's saved query context parsed to a dict, or ``None`` when it is
missing or unusable.
Returns ``None`` for a blank value, a string that does not parse as JSON, a
value that parses to something other than an object (e.g. ``"null"``), and an
object with no queries (e.g. ``"{}"`` or ``{"queries": []}``) — all treated
the same as a missing context.
"""
if not raw:
return None
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return None
if not isinstance(parsed, dict) or not parsed.get("queries"):
return None
return parsed
def _rebuild_viz_types() -> set[str]:
"""Viz types eligible for form-data query-context rebuild (config or default).
Only ``None`` falls back to the default; an explicitly configured empty set is
honored so operators can disable the rebuild entirely.
"""
configured = current_app.config.get("EXCEL_EXPORT_REBUILD_VIZ_TYPES")
return REBUILD_VIZ_TYPES if configured is None else configured
# Form-data keys whose behavior needs plugin post-processing or extra queries
# (contribution/time comparison, rolling window, resampling, raw big-number
# aggregation) that the single-query rebuild cannot reproduce. A chart using any
# of these is skipped rather than exported with values that differ from the chart.
_UNSUPPORTED_PROCESSING_KEYS = ("time_compare", "rolling_type", "resample_rule")
def _needs_unsupported_processing(form_data: dict[str, Any]) -> bool:
"""Whether the form data relies on processing the rebuild can't reproduce."""
for key in _UNSUPPORTED_PROCESSING_KEYS:
value = form_data.get(key)
# ``rolling_type`` is often the literal string ``"None"`` when unset.
if value and value != "None":
return True
return form_data.get("aggregation") == "raw"
def _resolve_query_context(chart: Any) -> dict[str, Any] | None:
"""
The query-context payload to run for a chart's data export, or ``None`` when
none can be obtained.
Prefers the chart's saved ``query_context``. When that is missing or empty,
synthesizes one from the chart's saved form data (``params``) — but only for
viz types whose data maps faithfully to a single plain query
(``EXCEL_EXPORT_REBUILD_VIZ_TYPES``) and that don't rely on post-processing or
extra queries the rebuild can't reproduce; other charts return ``None`` so the
caller lists them for re-saving rather than exporting inaccurate data.
"""
if saved := _saved_query_context(chart.query_context):
return saved
if chart.viz_type not in _rebuild_viz_types() or chart.datasource_id is None:
return None
try:
form_data = json.loads(chart.params) if chart.params else {}
except (TypeError, ValueError):
return None
if not isinstance(form_data, dict) or not form_data:
return None
if _needs_unsupported_processing(form_data):
return None
return build_query_context_from_form_data(
form_data,
{"id": chart.datasource_id, "type": chart.datasource_type or "table"},
chart.viz_type,
)
def _record_to_row(record: dict[str, Any], colnames: list[str]) -> list[Any]:
return [record.get(col) for col in colnames]
@@ -131,17 +219,24 @@ def _write_chart_image_sheet(
def _write_chart_sheets(
writer: StreamingXlsxWriter,
chart: Any,
json_body: dict[str, Any],
dashboard_id: int,
active_data_mask: dict[str, Any],
) -> None:
"""
Run a single chart's query and stream its result(s) into the workbook.
Charts may yield more than one query (e.g. mixed-series charts); each becomes
its own sheet. Raises if the chart cannot be exported, so the caller can skip
it and note it in the email.
``json_body`` is the resolved query-context payload (the chart's saved
context or one synthesized from its form data). Charts may yield more than
one query (e.g. mixed-series charts); each becomes its own sheet. Raises if
the chart cannot be exported, so the caller can skip it and note it in the
email.
"""
json_body = json.loads(chart.query_context)
# Shallow-copy before setting our own top-level keys so the caller's payload
# keeps its original result_format/result_type. (The nested ``queries`` are
# mutated in place by apply_dashboard_filter_context below, which is safe here
# because each body is freshly built or parsed per chart.)
json_body = dict(json_body)
# Override any stale saved values: we always want full JSON results.
json_body["result_format"] = ChartDataResultFormat.JSON
json_body["result_type"] = ChartDataResultType.FULL
@@ -196,19 +291,26 @@ def _build_workbook(
try:
for chart in get_charts_in_layout_order(dashboard):
label = _chart_label(chart)
as_image = _renders_as_image(chart, mode)
# Image charts render from their saved params and don't need a query
# context; data (and table) charts still do.
if not as_image and not chart.query_context:
errored.setdefault(email.ERROR_NO_QUERY_CONTEXT, []).append(label)
continue
try:
if as_image:
if _renders_as_image(chart, mode):
# Image charts render from their saved params via the
# webdriver and don't need a query context.
_write_chart_image_sheet(
writer, chart, dashboard.id, active_data_mask, user
)
else:
_write_chart_sheets(writer, chart, dashboard.id, active_data_mask)
# Data charts need a query context: use the saved one, or
# rebuild it from form data for eligible viz types. Skip
# cleanly when none is available rather than failing.
json_body = _resolve_query_context(chart)
if json_body is None:
errored.setdefault(email.ERROR_NO_QUERY_CONTEXT, []).append(
label
)
continue
_write_chart_sheets(
writer, chart, json_body, dashboard.id, active_data_mask
)
except SoftTimeLimitExceeded:
# A soft timeout is a task-level signal, not a per-chart failure:
# let it propagate so the outer handler emails a failure and runs

View File

@@ -35,6 +35,7 @@ from flask_appbuilder.security.sqla import models as ab_models
from flask_appbuilder.security.sqla.models import User
from flask_babel import _
from sqlalchemy.exc import NoResultFound
from werkzeug.exceptions import BadRequest
from superset import appbuilder, dataframe, db, result_set, viz
from superset.common.db_query_status import QueryStatus
@@ -197,6 +198,27 @@ def loads_request_json(request_json_data: str) -> dict[Any, Any]:
return parsed if isinstance(parsed, dict) else {}
def get_request_json_body() -> dict[Any, Any]:
"""Parse the request body as JSON, coercing failures to ``{}``.
``request.is_json`` only inspects the Content-Type header, not whether the
body is actually parseable JSON. Callers reaching ``get_form_data`` from a
non-HTTP-chart-data context (e.g. an MCP tool call rendering
``filter_values()``) can have a request context whose Content-Type claims
JSON but whose body isn't a JSON chart-data payload, which makes Werkzeug
raise ``BadRequest`` from ``request.get_json()``. A well-formed but
non-object JSON body (e.g. ``null``, a scalar, or an array) is coerced to
``{}`` too, since callers treat the result as a mapping.
"""
if not request.is_json:
return {}
try:
data = request.get_json(cache=True)
except BadRequest:
return {}
return data if isinstance(data, dict) else {}
#: Parameter names `url_for` interprets itself rather than appending to the
#: query string. Request-supplied query keys matching these must never be
#: forwarded as `url_for` kwargs.
@@ -345,7 +367,7 @@ def get_form_data(
form_data: dict[str, Any] = initial_form_data or {}
if has_request_context():
json_data = request.get_json(cache=True) if request.is_json else {}
json_data = get_request_json_body()
# chart data API requests are JSON
first_query = (

View File

@@ -17,6 +17,10 @@
from typing import Any, Optional, Type
from unittest import mock
from superset.async_events.async_query_manager import (
AsyncQueryJobException,
AsyncQueryTokenException,
)
from superset.async_events.cache_backend import (
RedisCacheBackend,
RedisSentinelCacheBackend,
@@ -30,12 +34,16 @@ from tests.integration_tests.test_app import app
class TestAsyncEventApi(SupersetTestCase):
UUID = "943c920-32a5-412a-977d-b8e47d36f5a4"
JOB_ID = "10a0bd9a-03c8-4737-9345-f4234ba86512"
def fetch_events(self, last_id: Optional[str] = None):
base_uri = "api/v1/async_event/"
uri = f"{base_uri}?last_id={last_id}" if last_id else base_uri
return self.client.get(uri)
def cancel_event(self, job_id: str):
return self.client.post(f"api/v1/async_event/{job_id}/cancel")
def run_test_with_cache_backend(self, cache_backend_cls: Type[Any], test_func):
app._got_first_request = False
async_query_manager_factory.init_app(app)
@@ -136,3 +144,67 @@ class TestAsyncEventApi(SupersetTestCase):
self.client.set_cookie(app.config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME"], "")
rv = self.fetch_events()
assert rv.status_code == 401
def _test_cancel_logic(self, mock_cache):
with mock.patch.object(async_query_manager, "cancel_job") as mock_cancel:
rv = self.cancel_event(self.JOB_ID)
assert rv.status_code == 200
mock_cancel.assert_called_once()
assert mock_cancel.call_args.args[0] == self.JOB_ID
response = json.loads(rv.data.decode("utf-8"))
assert response["result"] == {"job_id": self.JOB_ID, "status": "cancelled"}
def _test_cancel_invalid_job_id_logic(self, mock_cache):
# job_id lands in a Redis key, so reject anything that isn't a UUID
# before it reaches the cache backend.
with mock.patch.object(async_query_manager, "cancel_job") as mock_cancel:
rv = self.cancel_event("not-a-uuid")
assert rv.status_code == 400
mock_cancel.assert_not_called()
def _test_cancel_forbidden_logic(self, mock_cache):
with mock.patch.object(
async_query_manager,
"cancel_job",
side_effect=AsyncQueryTokenException("nope"),
):
rv = self.cancel_event(self.JOB_ID)
assert rv.status_code == 403
def _test_cancel_not_found_logic(self, mock_cache):
with mock.patch.object(
async_query_manager,
"cancel_job",
side_effect=AsyncQueryJobException("gone"),
):
rv = self.cancel_event(self.JOB_ID)
assert rv.status_code == 404
@mock.patch("uuid.uuid4", return_value=UUID)
def test_cancel_redis_cache_backend(self, mock_uuid4):
self.run_test_with_cache_backend(RedisCacheBackend, self._test_cancel_logic)
@mock.patch("uuid.uuid4", return_value=UUID)
def test_cancel_invalid_job_id(self, mock_uuid4):
self.run_test_with_cache_backend(
RedisCacheBackend, self._test_cancel_invalid_job_id_logic
)
@mock.patch("uuid.uuid4", return_value=UUID)
def test_cancel_forbidden(self, mock_uuid4):
self.run_test_with_cache_backend(
RedisCacheBackend, self._test_cancel_forbidden_logic
)
@mock.patch("uuid.uuid4", return_value=UUID)
def test_cancel_not_found(self, mock_uuid4):
self.run_test_with_cache_backend(
RedisCacheBackend, self._test_cancel_not_found_logic
)
def test_cancel_no_login(self):
app._got_first_request = False
async_query_manager_factory.init_app(app)
rv = self.cancel_event(self.JOB_ID)
assert rv.status_code == 401

View File

@@ -24,6 +24,7 @@ from pytest import fixture, mark, raises # noqa: PT013
from superset import security_manager
from superset.async_events.async_query_manager import (
AsyncQueryJobException,
AsyncQueryManager,
AsyncQueryTokenException,
)
@@ -31,6 +32,7 @@ from superset.async_events.cache_backend import (
RedisCacheBackend,
RedisSentinelCacheBackend,
)
from superset.utils import json
JWT_TOKEN_SECRET = "some_secret" # noqa: S105
JWT_TOKEN_COOKIE_NAME = "superset_async_jwt" # noqa: S105
@@ -306,6 +308,7 @@ def test_submit_chart_data_job_as_guest_user(
},
{},
],
task_id=ANY,
expires=3600,
)
@@ -414,7 +417,174 @@ def test_submit_explore_json_job_as_guest_user(
"json",
False,
],
task_id=ANY,
expires=3600,
)
assert "guest_token" not in job_meta
@fixture
def cancellable_manager():
"""A manager wired to a mock Redis backend for cancellation tests."""
manager = AsyncQueryManager()
manager._jwt_expiration_seconds = 3600
manager._stream_prefix = "async-events-"
manager._cache = mock.Mock(spec=RedisCacheBackend)
return manager
def test_init_job_registers_cancellable_record(cancellable_manager):
"""init_job persists the owner identity a later cancel must match."""
cancellable_manager.init_job("chan-1", 7)
cancellable_manager._cache.set.assert_called_once()
key, value = cancellable_manager._cache.set.call_args.args
assert key.startswith("async-events-job-cancel:")
assert json.loads(value) == {"channel_id": "chan-1", "user_id": 7}
def test_cancel_job_authorized_revokes_task(cancellable_manager):
cancellable_manager._stream_limit = 100
cancellable_manager._stream_limit_firehose = 1000
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
cancellable_manager._cache.set.return_value = True
with mock.patch("superset.extensions.celery_app") as celery_app:
cancellable_manager.cancel_job("job-1", "chan-1", 7)
celery_app.control.revoke.assert_called_once_with(
"job-1", terminate=True, signal="SIGUSR1"
)
# The job is flagged cancelled (conditionally, xx=True) so the worker knows
# what the signal it is about to receive means.
assert cancellable_manager._cache.set.call_args.kwargs["xx"] is True
flagged = json.loads(cancellable_manager._cache.set.call_args.args[1])
assert flagged["cancelled"] is True
def test_cancel_job_emits_the_terminal_event(cancellable_manager):
"""A task revoked before a worker picks it up never reports on itself."""
cancellable_manager._stream_limit = 100
cancellable_manager._stream_limit_firehose = 1000
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
cancellable_manager._cache.set.return_value = True
with mock.patch("superset.extensions.celery_app"):
cancellable_manager.cancel_job("job-1", "chan-1", 7)
scoped_stream, event_data = cancellable_manager._cache.xadd.call_args_list[0].args[
:2
]
assert scoped_stream == "async-events-chan-1"
assert json.loads(event_data["data"]) == {
"channel_id": "chan-1",
"job_id": "job-1",
"user_id": 7,
"status": AsyncQueryManager.STATUS_CANCELLED,
"errors": [],
"result_url": None,
}
# the record has to outlive the event: the worker still needs to recognize
# the signal on its way as a cancellation
cancellable_manager._cache.delete.assert_not_called()
def test_cancel_job_completed_between_read_and_flag(cancellable_manager):
"""If the job's record is cleared after the auth read, don't revoke."""
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
# Conditional (xx) write finds no key: the job finished and cleaned up.
cancellable_manager._cache.set.return_value = None
with (
mock.patch("superset.extensions.celery_app") as celery_app,
raises(AsyncQueryJobException),
):
cancellable_manager.cancel_job("job-1", "chan-1", 7)
celery_app.control.revoke.assert_not_called()
def test_cancel_job_wrong_user_is_rejected(cancellable_manager):
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
with (
mock.patch("superset.extensions.celery_app") as celery_app,
raises(AsyncQueryTokenException),
):
cancellable_manager.cancel_job("job-1", "chan-1", 999)
celery_app.control.revoke.assert_not_called()
def test_cancel_job_wrong_channel_is_rejected(cancellable_manager):
"""A matching user on a different channel still cannot cancel the job."""
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
with (
mock.patch("superset.extensions.celery_app") as celery_app,
raises(AsyncQueryTokenException),
):
cancellable_manager.cancel_job("job-1", "other-chan", 7)
celery_app.control.revoke.assert_not_called()
def test_cancel_job_unknown_raises(cancellable_manager):
cancellable_manager._cache.get.return_value = None
with (
mock.patch("superset.extensions.celery_app") as celery_app,
raises(AsyncQueryJobException),
):
cancellable_manager.cancel_job("job-1", "chan-1", 7)
celery_app.control.revoke.assert_not_called()
def test_is_job_cancelled(cancellable_manager):
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7, "cancelled": True}
)
assert cancellable_manager.is_job_cancelled("job-1") is True
cancellable_manager._cache.get.return_value = json.dumps(
{"channel_id": "chan-1", "user_id": 7}
)
assert cancellable_manager.is_job_cancelled("job-1") is False
cancellable_manager._cache.get.return_value = None
assert cancellable_manager.is_job_cancelled("job-1") is False
def test_is_job_cancelled_swallows_cache_errors(cancellable_manager):
"""A cache failure must not escape and mask the worker's original error."""
cancellable_manager._cache.get.side_effect = RuntimeError("redis down")
assert cancellable_manager.is_job_cancelled("job-1") is False
def test_update_job_clears_registry_before_terminal_event(cancellable_manager):
"""Clearing first is what makes a cancel that lost the race a 404."""
calls = []
cancellable_manager._stream_limit = 100
cancellable_manager._stream_limit_firehose = 1000
cancellable_manager._cache.delete.side_effect = lambda *_: calls.append("delete")
cancellable_manager._cache.xadd.side_effect = lambda *_: calls.append("xadd")
job_metadata = {"channel_id": "chan-1", "job_id": "job-1", "user_id": 7}
cancellable_manager.update_job(job_metadata, AsyncQueryManager.STATUS_DONE)
cancellable_manager._cache.delete.assert_called_once_with(
"async-events-job-cancel:job-1"
)
assert calls == ["delete", "xadd", "xadd"]

View File

@@ -0,0 +1,338 @@
# 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.
from superset.common.form_data_query_context import (
adhoc_filters_to_query_filters,
build_query_context_from_form_data,
columns_from_form_data,
)
DATASOURCE = {"id": 7, "type": "table"}
def test_adhoc_filters_converts_simple_and_drops_custom_sql() -> None:
adhoc = [
{
"expressionType": "SIMPLE",
"subject": "country",
"operator": "==",
"comparator": "US",
},
{"expressionType": "SQL", "sqlExpression": "1 = 1"},
]
assert adhoc_filters_to_query_filters(adhoc) == [
{"col": "country", "op": "==", "val": "US"}
]
assert adhoc_filters_to_query_filters([]) == []
def test_columns_prefers_groupby_and_x_axis() -> None:
form_data = {"groupby": ["region"], "x_axis": "ds"}
assert columns_from_form_data(form_data) == ["ds", "region"]
def test_columns_raw_mode_uses_all_columns() -> None:
form_data = {"query_mode": "raw", "all_columns": ["a", "b"]}
assert columns_from_form_data(form_data) == ["a", "b"]
def test_columns_x_axis_as_adhoc_dict() -> None:
# An x_axis stored as an adhoc column dict contributes its column_name,
# prepended ahead of the groupby dimensions.
form_data = {"groupby": ["region"], "x_axis": {"column_name": "ds"}}
assert columns_from_form_data(form_data) == ["ds", "region"]
def test_columns_x_axis_dict_without_column_name_is_ignored() -> None:
form_data = {
"groupby": ["region"],
"x_axis": {"label": "custom", "sqlExpression": "a+b"},
}
assert columns_from_form_data(form_data) == ["region"]
def test_columns_empty_columns_key_does_not_shadow_groupby() -> None:
# A stale, explicitly-present-but-empty ``columns`` key must not drop the
# group-by dimensions (which would silently change the aggregation).
form_data = {"groupby": ["country"], "columns": []}
assert columns_from_form_data(form_data) == ["country"]
def test_build_context_maps_groupby_metrics_and_filters() -> None:
form_data = {
"groupby": ["country"],
"metrics": ["count"],
"adhoc_filters": [
{
"expressionType": "SIMPLE",
"subject": "year",
"operator": ">",
"comparator": 2000,
},
],
"time_range": "Last year",
"row_limit": 500,
}
ctx = build_query_context_from_form_data(form_data, DATASOURCE)
assert ctx["datasource"] == DATASOURCE
assert ctx["form_data"] == form_data
assert len(ctx["queries"]) == 1
query = ctx["queries"][0]
assert query["columns"] == ["country"]
assert query["metrics"] == ["count"]
assert query["filters"] == [{"col": "year", "op": ">", "val": 2000}]
assert query["time_range"] == "Last year"
assert query["row_limit"] == 500
def test_build_context_big_number_singular_metric_and_default_time_range() -> None:
form_data = {"metric": "sum__sales"}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["metrics"] == ["sum__sales"]
assert query["time_range"] == "No filter"
# No row_limit in form data → not forced into the query.
assert "row_limit" not in query
def test_build_context_merges_legacy_and_adhoc_filters() -> None:
# Legacy charts store simple filters directly under ``filters`` (already in
# QueryObject shape); they are honored alongside adhoc_filters, and malformed
# entries are dropped.
form_data = {
"groupby": ["country"],
"adhoc_filters": [
{
"expressionType": "SIMPLE",
"subject": "year",
"operator": ">",
"comparator": 2000,
},
],
"filters": [
{"col": "region", "op": "==", "val": "EMEA"},
{"not_a_filter": True},
],
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["filters"] == [
{"col": "year", "op": ">", "val": 2000},
{"col": "region", "op": "==", "val": "EMEA"},
]
def test_big_number_trendline_promotes_granularity_sqla_column() -> None:
# A Big Number *with a trendline* (viz_type "big_number") has no
# groupby/columns; its time column (granularity_sqla) becomes the sole column.
form_data = {"metric": "count", "granularity_sqla": "order_date"}
query = build_query_context_from_form_data(
form_data, DATASOURCE, viz_type="big_number"
)["queries"][0]
assert query["columns"] == ["order_date"]
assert query["metrics"] == ["count"]
def test_big_number_total_does_not_promote_granularity_sqla_column() -> None:
# big_number_total is a single aggregate; promoting granularity_sqla to a
# column would turn one total into one row per timestamp.
form_data = {"metric": "count", "granularity_sqla": "order_date"}
query = build_query_context_from_form_data(
form_data, DATASOURCE, viz_type="big_number_total"
)["queries"][0]
assert query["columns"] == []
def test_build_context_sets_granularity_for_time_filtering() -> None:
# Without a `granularity`, the `time_range` is inert downstream, so a legacy
# chart with granularity_sqla + time_range would export its full history.
form_data = {
"metrics": ["count"],
"granularity_sqla": "ds",
"time_range": "Last quarter",
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["granularity"] == "ds"
assert query["time_range"] == "Last quarter"
def test_build_context_prefers_explicit_granularity_over_sqla() -> None:
form_data = {
"metrics": ["count"],
"granularity": "event_time",
"granularity_sqla": "ds",
"time_range": "Last week",
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["granularity"] == "event_time"
def test_build_context_omits_granularity_without_active_time_range() -> None:
# A numeric column saved as granularity_sqla with no active range must not be
# forced through temporal bucketing (which would fail on non-temporal columns).
form_data = {"metrics": ["count"], "granularity_sqla": "year"}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert "granularity" not in query
def test_orderby_defaults_to_first_metric_descending() -> None:
# With a row_limit, ordering must be deterministic so the export returns the
# chart's top-N, not an arbitrary N.
form_data = {"metrics": ["count"], "groupby": ["c"], "row_limit": 10}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["orderby"] == [["count", False]]
def test_orderby_uses_timeseries_limit_metric_and_order_desc() -> None:
form_data = {
"metrics": ["count"],
"groupby": ["c"],
"timeseries_limit_metric": "revenue",
"order_desc": False,
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["orderby"] == [["revenue", True]]
def test_orderby_pie_sort_by_metric() -> None:
form_data = {"metric": "count", "groupby": ["c"], "sort_by_metric": True}
query = build_query_context_from_form_data(form_data, DATASOURCE, viz_type="pie")[
"queries"
][0]
assert query["orderby"] == [["count", False]]
def test_orderby_raw_mode_parses_order_by_cols() -> None:
form_data = {
"query_mode": "raw",
"all_columns": ["a"],
# A malformed entry is skipped rather than raising.
"order_by_cols": ['["a", true]', "not json", ["b", False]],
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["orderby"] == [["a", True], ["b", False]]
def test_sql_filters_and_legacy_where_go_into_extras() -> None:
form_data = {
"groupby": ["c"],
"where": "region = 'EMEA'",
"adhoc_filters": [
{"expressionType": "SQL", "clause": "WHERE", "sqlExpression": "sales > 0"},
{
"expressionType": "SQL",
"clause": "HAVING",
"sqlExpression": "SUM(x) > 5",
},
],
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["extras"]["where"] == "(region = 'EMEA') AND (sales > 0)"
assert query["extras"]["having"] == "(SUM(x) > 5)"
def test_table_excludes_percent_metrics_but_carries_time_grain() -> None:
# percent_metrics are shown as "% of total" via contribution post-processing
# the rebuild can't apply, so they are omitted rather than exported as raw
# aggregates that don't match the chart.
form_data = {
"groupby": ["c"],
"metrics": ["count"],
"percent_metrics": ["pct_total"],
"time_grain_sqla": "P1M",
}
query = build_query_context_from_form_data(form_data, DATASOURCE, viz_type="table")[
"queries"
][0]
assert query["metrics"] == ["count"]
assert query["extras"]["time_grain_sqla"] == "P1M"
def test_simple_having_filter_converted_by_default_but_not_where_only() -> None:
# Default: all SIMPLE filters convert (the behavior MCP relies on).
# where_only=True: SIMPLE HAVING is dropped (matching the chart), which the
# export uses so it doesn't filter on a clause the chart ignores.
adhoc = [
{
"expressionType": "SIMPLE",
"clause": "HAVING",
"subject": "count",
"operator": ">",
"comparator": 5,
}
]
assert adhoc_filters_to_query_filters(adhoc) == [
{"col": "count", "op": ">", "val": 5}
]
assert adhoc_filters_to_query_filters(adhoc, where_only=True) == []
def test_build_context_ignores_simple_having_filter() -> None:
# The export must not apply a SIMPLE HAVING filter the chart itself ignores.
form_data = {
"groupby": ["c"],
"metrics": ["count"],
"adhoc_filters": [
{
"expressionType": "SIMPLE",
"clause": "HAVING",
"subject": "count",
"operator": ">",
"comparator": 5,
},
{
"expressionType": "SIMPLE",
"subject": "region",
"operator": "==",
"comparator": "EMEA",
},
],
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["filters"] == [{"col": "region", "op": "==", "val": "EMEA"}]
def test_big_number_trendline_sets_granularity_without_time_range() -> None:
# A Big Number trendline groups by its time column; granularity must be set so
# time_grain_sqla buckets it even when there's no active time range.
form_data = {
"metric": "count",
"granularity_sqla": "ds",
"time_grain_sqla": "P1M",
}
query = build_query_context_from_form_data(
form_data, DATASOURCE, viz_type="big_number"
)["queries"][0]
assert query["columns"] == ["ds"]
assert query["granularity"] == "ds"
assert query["extras"]["time_grain_sqla"] == "P1M"
def test_time_range_falls_back_to_since_until() -> None:
# Older charts store the range as separate since/until rather than time_range.
form_data = {"metrics": ["count"], "since": "2020-01-01", "until": "2020-12-31"}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["time_range"] == "2020-01-01 : 2020-12-31"

View File

@@ -532,6 +532,42 @@ class TestQueryContextFactory:
assert query_object.columns == ["ds", "other_col"]
def test_apply_granularity_no_filter_to_remove(self):
"""No x-axis and no temporal filters leaves the filters untouched."""
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [{"col": "other_col", "op": "==", "val": "value"}]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [{"col": "other_col", "op": "==", "val": "value"}]
def test_apply_granularity_with_adhoc_temporal_filter(self):
"""An adhoc temporal filter is matched on its SQL expression."""
adhoc_column = {"label": "ds_expr", "sqlExpression": "DATE(ds)"}
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"},
{"col": "DATE(ds)", "op": "TEMPORAL_RANGE", "val": "a : b"},
]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"}
]
def test_apply_filters_with_time_range(self):
"""Test _apply_filters with time_range"""
query_object = Mock(spec=QueryObject)

View File

@@ -197,3 +197,15 @@ class TestPreviewUtilsColumnBuilding:
columns.insert(0, x_axis_config)
assert columns == ["territory", "year"]
def test_build_query_columns_empty_columns_key_keeps_groupby():
"""MCP path: an explicitly empty ``columns`` list no longer shadows ``groupby``.
``_build_query_columns`` delegates to the shared
``superset.common.form_data_query_context.columns_from_form_data``; this pins
the (intentional) behavior change so the export and MCP paths stay in sync.
"""
assert preview_utils._build_query_columns(
{"groupby": ["country"], "columns": []}
) == ["country"]

View File

@@ -216,6 +216,102 @@ class TestLoggingMiddlewareOnCallTool:
assert call_kwargs["slice_id"] == 20
assert call_kwargs["curated_payload"]["dataset_id"] == 30
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_extracts_chart_id_from_response(
self, mock_get_user_id, mock_event_logger
) -> None:
"""generate_chart takes no chart_id as input, so on a successful
create the new chart's ID must be pulled from the response body
instead -- otherwise every retry logs slice_id=None and a
successful attempt can't be told apart from the failed ones.
"""
middleware = LoggingMiddleware()
ctx = _make_context(name="generate_chart", params={"dataset_id": 5})
response_text = (
'{"success": true, "chart": {"id": 123, "slice_name": "My Chart"}}'
)
original_result = ToolResult(
content=[mt.TextContent(type="text", text=response_text)]
)
call_next = AsyncMock(return_value=original_result)
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["slice_id"] == 123
assert call_kwargs["curated_payload"]["slice_id"] == 123
assert call_kwargs["curated_payload"]["success"] is True
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_extracts_dashboard_id_from_response(
self, mock_get_user_id, mock_event_logger
) -> None:
"""generate_dashboard likewise creates an ID that only appears in
the response, not the input params."""
middleware = LoggingMiddleware()
ctx = _make_context(name="generate_dashboard", params={"chart_ids": [1, 2]})
response_text = '{"success": true, "dashboard": {"id": 456}}'
original_result = ToolResult(
content=[mt.TextContent(type="text", text=response_text)]
)
call_next = AsyncMock(return_value=original_result)
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["dashboard_id"] == 456
assert call_kwargs["curated_payload"]["dashboard_id"] == 456
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_does_not_extract_id_on_failed_response(
self, mock_get_user_id, mock_event_logger
) -> None:
"""A failed create (error schema response, no exception raised)
must not report a chart_id -- nothing was actually persisted."""
middleware = LoggingMiddleware()
ctx = _make_context(name="generate_chart", params={"dataset_id": 5})
response_text = (
'{"success": false, "chart": null, '
'"error": {"error_type": "validation_error"}}'
)
original_result = ToolResult(
content=[mt.TextContent(type="text", text=response_text)]
)
call_next = AsyncMock(return_value=original_result)
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["curated_payload"]["success"] is False
assert call_kwargs["slice_id"] is None
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_prefers_input_slice_id_over_response(
self, mock_get_user_id, mock_event_logger
) -> None:
"""When chart_id is already known from input params (e.g.
update_chart), the response body must not override it."""
middleware = LoggingMiddleware()
ctx = _make_context(name="update_chart", params={"chart_id": 111})
response_text = '{"success": true, "chart": {"id": 999}}'
original_result = ToolResult(
content=[mt.TextContent(type="text", text=response_text)]
)
call_next = AsyncMock(return_value=original_result)
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["slice_id"] == 111
class TestLoggingMiddlewareOnMessage:
"""Tests for LoggingMiddleware.on_message()."""
@@ -468,6 +564,40 @@ class TestIsErrorResponse:
assert call_kwargs["curated_payload"]["tool"] == "get_chart_info"
class TestExtractOutputIds:
"""Tests for LoggingMiddleware._extract_output_ids()."""
def test_returns_none_for_non_json_body(self) -> None:
"""A malformed/non-JSON response body must not raise -- the
defensive try/except should fall back to (None, None)."""
middleware = LoggingMiddleware()
result = ToolResult(
content=[mt.TextContent(type="text", text="not valid json {{{")]
)
assert middleware._extract_output_ids(result) == (None, None)
def test_returns_none_for_empty_content(self) -> None:
"""A ToolResult with no content items must not raise."""
middleware = LoggingMiddleware()
assert middleware._extract_output_ids(ToolResult(content=[])) == (
None,
None,
)
def test_returns_none_for_non_dict_json_body(self) -> None:
"""A JSON body that parses but isn't an object (e.g. a bare
list) must not raise and must yield no IDs."""
middleware = LoggingMiddleware()
result = ToolResult(content=[mt.TextContent(type="text", text="[1, 2, 3]")])
assert middleware._extract_output_ids(result) == (None, None)
def test_extracts_both_ids_from_flat_response(self) -> None:
middleware = LoggingMiddleware()
response_text = '{"chart_id": 123, "dashboard_id": 456}'
result = ToolResult(content=[mt.TextContent(type="text", text=response_text)])
assert middleware._extract_output_ids(result) == (456, 123)
class TestMiddlewareChainOrder:
"""Test that the middleware order from server.py logs failures correctly.

View File

@@ -18,6 +18,7 @@ from typing import Any
from unittest import mock
import pytest
from celery.exceptions import SoftTimeLimitExceeded
from flask_babel import lazy_gettext as _
from superset.commands.chart.exceptions import ChartDataQueryFailedError
@@ -63,6 +64,62 @@ def test_load_chart_data_into_cache_with_error(
)
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
def test_load_chart_data_into_cache_cancelled_emits_no_event(
mock_query_context_schema_cls, mock_async_query_manager, mock_security_manager
):
"""A revoke leaves the terminal event to the cancel request that sent it."""
from superset.tasks.async_queries import load_chart_data_into_cache
job_metadata = {"user_id": 1, "job_id": "job-1"}
form_data: dict[str, Any] = {}
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
# Sync Mock: is_job_cancelled is a plain method, but patching the manager
# yields an AsyncMock whose calls would otherwise return truthy coroutines.
mock_async_query_manager.is_job_cancelled = mock.Mock(return_value=True)
mock_query_context_schema_cls.return_value.load.side_effect = (
SoftTimeLimitExceeded()
)
with pytest.raises(SoftTimeLimitExceeded):
load_chart_data_into_cache(job_metadata, form_data)
mock_async_query_manager.is_job_cancelled.assert_called_once_with("job-1")
mock_async_query_manager.update_job.assert_not_called()
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
def test_load_chart_data_into_cache_timeout_emits_error(
mock_query_context_schema_cls, mock_async_query_manager, mock_security_manager
):
"""A genuine timeout reports an error, or the client waits forever."""
from superset.tasks.async_queries import load_chart_data_into_cache
job_metadata = {"user_id": 1, "job_id": "job-1"}
form_data: dict[str, Any] = {}
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
mock_async_query_manager.STATUS_ERROR = "error"
mock_async_query_manager.is_job_cancelled = mock.Mock(return_value=False)
mock_query_context_schema_cls.return_value.load.side_effect = (
SoftTimeLimitExceeded()
)
with pytest.raises(SoftTimeLimitExceeded):
load_chart_data_into_cache(job_metadata, form_data)
mock_async_query_manager.update_job.assert_called_once_with(
job_metadata,
"error",
errors=[{"message": "A timeout occurred while loading chart data"}],
)
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.ChartDataQueryContextSchema")
@@ -373,3 +430,71 @@ def test_load_explore_json_into_cache_falls_back_to_string_for_generic_exception
errors = mock_async_query_manager.update_job.call_args[1]["errors"]
assert errors == ["boom"]
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.get_viz")
@mock.patch("superset.tasks.async_queries.get_datasource_info")
def test_load_explore_json_into_cache_cancelled_emits_no_event(
mock_get_datasource_info,
mock_get_viz,
mock_async_query_manager,
mock_security_manager,
):
"""A revoke leaves the terminal event to the cancel request that sent it."""
from superset.tasks.async_queries import load_explore_json_into_cache
job_metadata = {"user_id": 1, "job_id": "job-1"}
form_data: dict = {}
mock_get_datasource_info.return_value = (1, "table")
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
# Sync Mock: is_job_cancelled is a plain method, but patching the manager
# yields an AsyncMock whose calls would otherwise return truthy coroutines.
mock_async_query_manager.is_job_cancelled = mock.Mock(return_value=True)
viz_obj = mock.MagicMock()
viz_obj.get_payload.side_effect = SoftTimeLimitExceeded()
mock_get_viz.return_value = viz_obj
with pytest.raises(SoftTimeLimitExceeded):
load_explore_json_into_cache(job_metadata, form_data)
mock_async_query_manager.is_job_cancelled.assert_called_once_with("job-1")
mock_async_query_manager.update_job.assert_not_called()
@mock.patch("superset.tasks.async_queries.security_manager")
@mock.patch("superset.tasks.async_queries.async_query_manager")
@mock.patch("superset.tasks.async_queries.get_viz")
@mock.patch("superset.tasks.async_queries.get_datasource_info")
def test_load_explore_json_into_cache_timeout_emits_error(
mock_get_datasource_info,
mock_get_viz,
mock_async_query_manager,
mock_security_manager,
):
"""A genuine timeout reports an error, or the client waits forever."""
from superset.tasks.async_queries import load_explore_json_into_cache
job_metadata = {"user_id": 1, "job_id": "job-1"}
form_data: dict = {}
mock_get_datasource_info.return_value = (1, "table")
mock_security_manager.get_user_by_id.return_value = mock.MagicMock()
mock_async_query_manager.STATUS_ERROR = "error"
mock_async_query_manager.is_job_cancelled = mock.Mock(return_value=False)
viz_obj = mock.MagicMock()
viz_obj.get_payload.side_effect = SoftTimeLimitExceeded()
mock_get_viz.return_value = viz_obj
with pytest.raises(SoftTimeLimitExceeded):
load_explore_json_into_cache(job_metadata, form_data)
mock_async_query_manager.update_job.assert_called_once_with(
job_metadata,
"error",
errors=[{"message": "A timeout occurred while loading explore json"}],
)

View File

@@ -182,6 +182,217 @@ def test_chart_without_query_context_is_skipped(mocks: dict[str, Any]) -> None:
}
@pytest.mark.parametrize(
"raw_context",
[
"", # blank
"null", # parses to None
"{}", # object with no queries
'{"queries": []}', # object with an empty queries list
"not valid json", # unparseable
],
)
def test_chart_with_empty_query_context_is_skipped(
mocks: dict[str, Any], raw_context: str
) -> None:
# A present-but-empty/unusable query context is treated the same as a
# missing one: the chart is listed under "no query context" and the export
# continues, rather than raising mid-export and landing in the general bucket.
good = _chart(10, "Good")
empty = _chart(20, "Empty")
empty.query_context = raw_context
mocks["get_charts_in_layout_order"].return_value = [good, empty]
mocks["ChartDataCommand"].return_value.run.return_value = {
"queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
}
_run()
_, kwargs = mocks["email"].build_success_email.call_args
assert kwargs["errored"] == {mocks["email"].ERROR_NO_QUERY_CONTEXT: ["20 - Empty"]}
# The empty chart is skipped before any query runs; only the good one runs.
mocks["ChartDataCommand"].return_value.run.assert_called_once()
def test_empty_query_context_rebuilt_from_form_data_for_eligible_viz(
mocks: dict[str, Any],
) -> None:
# An eligible viz type (table) with no saved query context is rebuilt from
# its form data and exported instead of being skipped.
good = _chart(10, "Good")
rebuilt = _chart(20, "Rebuilt", viz_type="table")
rebuilt.query_context = None
rebuilt.params = json.dumps({"groupby": ["country"], "metrics": ["count"]})
rebuilt.datasource_id = 5
rebuilt.datasource_type = "table"
mocks["get_charts_in_layout_order"].return_value = [good, rebuilt]
mocks["ChartDataCommand"].return_value.run.return_value = {
"queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
}
_run()
_, kwargs = mocks["email"].build_success_email.call_args
assert kwargs["errored"] == {}
# Both charts ran a query (the saved one and the rebuilt one).
assert mocks["ChartDataCommand"].return_value.run.call_count == 2
def test_empty_query_context_ineligible_viz_is_skipped(
mocks: dict[str, Any],
) -> None:
# A viz type outside the rebuild allowlist (mixed_timeseries — a multi-query
# chart the generic rebuild can't reproduce) is skipped, not exported wrong.
good = _chart(10, "Good")
ineligible = _chart(20, "Ineligible", viz_type="mixed_timeseries")
ineligible.query_context = None
ineligible.params = json.dumps({"groupby": ["x"], "metrics": ["count"]})
ineligible.datasource_id = 5
mocks["get_charts_in_layout_order"].return_value = [good, ineligible]
mocks["ChartDataCommand"].return_value.run.return_value = {
"queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
}
_run()
_, kwargs = mocks["email"].build_success_email.call_args
assert kwargs["errored"] == {
mocks["email"].ERROR_NO_QUERY_CONTEXT: ["20 - Ineligible"]
}
mocks["ChartDataCommand"].return_value.run.assert_called_once()
@pytest.mark.parametrize(
("params", "datasource_id"),
[
("not valid json", 5), # params don't parse → cannot rebuild
("null", 5), # params parse to a non-object → cannot rebuild
('{"groupby": ["x"]}', None), # no datasource to point the query at
],
)
def test_eligible_viz_skipped_when_form_data_unusable(
mocks: dict[str, Any], params: str, datasource_id: int | None
) -> None:
# Even for an allowlisted viz type, a rebuild is only attempted when the form
# data is a usable object and a datasource is known; otherwise the chart is
# skipped rather than raising.
good = _chart(10, "Good")
bad = _chart(20, "Bad", viz_type="table")
bad.query_context = None
bad.params = params
bad.datasource_id = datasource_id
bad.datasource_type = "table"
mocks["get_charts_in_layout_order"].return_value = [good, bad]
mocks["ChartDataCommand"].return_value.run.return_value = {
"queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
}
_run()
_, kwargs = mocks["email"].build_success_email.call_args
assert kwargs["errored"] == {mocks["email"].ERROR_NO_QUERY_CONTEXT: ["20 - Bad"]}
# Only the good chart ran a query; the unusable one never reached execution.
mocks["ChartDataCommand"].return_value.run.assert_called_once()
@pytest.mark.parametrize(
"form_data",
[
{"groupby": ["c"], "metrics": ["m"], "time_compare": ["1 year ago"]},
{"groupby": ["c"], "metrics": ["m"], "rolling_type": "mean"},
{"groupby": ["c"], "metrics": ["m"], "resample_rule": "1D"},
{"columns": ["a", "b"], "aggregation": "raw"},
],
)
def test_eligible_viz_skipped_when_form_data_needs_post_processing(
mocks: dict[str, Any], form_data: dict[str, Any]
) -> None:
# An allowlisted viz type whose form data relies on post-processing or extra
# queries the single-query rebuild can't reproduce (time comparison, rolling,
# resample, raw aggregation) is skipped rather than exported with wrong data.
good = _chart(10, "Good")
fancy = _chart(20, "Fancy", viz_type="table")
fancy.query_context = None
fancy.params = json.dumps(form_data)
fancy.datasource_id = 5
fancy.datasource_type = "table"
mocks["get_charts_in_layout_order"].return_value = [good, fancy]
mocks["ChartDataCommand"].return_value.run.return_value = {
"queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
}
_run()
_, kwargs = mocks["email"].build_success_email.call_args
assert kwargs["errored"] == {mocks["email"].ERROR_NO_QUERY_CONTEXT: ["20 - Fancy"]}
mocks["ChartDataCommand"].return_value.run.assert_called_once()
def test_rebuilt_query_context_payload_carries_query_shape(
mocks: dict[str, Any],
) -> None:
# Assert the actual payload handed to ChartDataQueryContextSchema().load for a
# rebuilt chart: columns, filters, ordering and granularity must survive so the
# exported data matches the chart (not just that a query ran).
chart = _chart(10, "Rebuilt", viz_type="table")
chart.query_context = None
chart.datasource_id = 5
chart.datasource_type = "table"
chart.params = json.dumps(
{
"groupby": ["country"],
"metrics": ["count"],
"granularity_sqla": "ds",
"time_range": "Last quarter",
"row_limit": 25,
"adhoc_filters": [
{
"expressionType": "SIMPLE",
"subject": "year",
"operator": ">",
"comparator": 2000,
}
],
}
)
mocks["get_charts_in_layout_order"].return_value = [chart]
mocks["ChartDataCommand"].return_value.run.return_value = {
"queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
}
_run()
load_args = mocks["ChartDataQueryContextSchema"].return_value.load.call_args
payload = load_args.args[0]
query = payload["queries"][0]
assert query["columns"] == ["country"]
assert query["metrics"] == ["count"]
assert query["filters"] == [{"col": "year", "op": ">", "val": 2000}]
assert query["orderby"] == [["count", False]]
assert query["granularity"] == "ds"
assert query["time_range"] == "Last quarter"
assert query["row_limit"] == 25
@pytest.mark.parametrize(
("configured", "expected"),
[
(None, {"table", "big_number_total", "big_number", "pie"}), # default
(set(), set()), # explicit empty set disables rebuild entirely
({"table"}, {"table"}), # explicit override
],
)
def test_rebuild_viz_types_respects_explicit_empty_set(
configured: set[str] | None, expected: set[str]
) -> None:
from superset.tasks import export_dashboard_excel as module
fake_app = mock.MagicMock()
fake_app.config.get.return_value = configured
with mock.patch.object(module, "current_app", fake_app):
assert module._rebuild_viz_types() == expected
def test_chart_query_error_grouped_as_general_export_continues(
mocks: dict[str, Any],
) -> None:

View File

@@ -0,0 +1,53 @@
# 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.
"""Tests for superset.views.utils module"""
from flask import current_app
from superset.views.utils import get_form_data
def test_get_form_data_handles_non_json_body_with_json_content_type() -> None:
"""get_form_data returns gracefully when Content-Type claims JSON but the
body isn't parseable JSON, instead of letting Werkzeug's BadRequest escape.
This is the shape of the request context an MCP tool call runs in when a
chart/dataset SQL template calls the ``filter_values()`` Jinja macro: the
Content-Type header says ``application/json`` but the body is not a JSON
chart-data payload.
"""
with current_app.test_request_context(
data="not-json-at-all", content_type="application/json"
):
form_data, slc = get_form_data()
assert form_data == {}
assert slc is None
def test_get_form_data_handles_non_dict_json_body() -> None:
"""get_form_data coerces a well-formed but non-object JSON body to {}.
``request.get_json()`` happily returns a scalar or list for valid JSON
that isn't a JSON object (e.g. ``null`` or ``42``). Downstream code treats
the parsed body as a mapping, so a non-dict result must not leak through.
"""
with current_app.test_request_context(data="42", content_type="application/json"):
form_data, slc = get_form_data()
assert form_data == {}
assert slc is None