mirror of
https://github.com/apache/superset.git
synced 2026-07-28 17:42:40 +00:00
Compare commits
32 Commits
ci/fronten
...
hughhhh/ha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808ba6f4b4 | ||
|
|
eef7bc41ac | ||
|
|
5aaf9d337b | ||
|
|
181f5bc9c1 | ||
|
|
e426fb7bc6 | ||
|
|
b5c8d4a5df | ||
|
|
236c9a52fd | ||
|
|
b983161eef | ||
|
|
7b767f6c1d | ||
|
|
526cebac73 | ||
|
|
39f9b7029b | ||
|
|
25a985893d | ||
|
|
ad15c4ddd1 | ||
|
|
a301e7c511 | ||
|
|
dd7bbe0adf | ||
|
|
2e81eb580f | ||
|
|
a970838b5c | ||
|
|
2c061f1e05 | ||
|
|
be012c8faa | ||
|
|
e754b73af2 | ||
|
|
435e7825a6 | ||
|
|
ecc7f726a4 | ||
|
|
0f61b3e884 | ||
|
|
53350ae4c9 | ||
|
|
6d25d5e93e | ||
|
|
670a95215c | ||
|
|
719649fe1a | ||
|
|
de7dfc1585 | ||
|
|
a1212ec089 | ||
|
|
2bbada837b | ||
|
|
de29e984ff | ||
|
|
c7a6c6fc9c |
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -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}}"
|
||||
|
||||
134
.github/workflows/frontend-bundle-size-nightly.yml
vendored
134
.github/workflows/frontend-bundle-size-nightly.yml
vendored
@@ -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
|
||||
6
.github/workflows/issue_creation.yml
vendored
6
.github/workflows/issue_creation.yml
vendored
@@ -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
|
||||
|
||||
77
.github/workflows/superset-docs-verify.yml
vendored
77
.github/workflows/superset-docs-verify.yml
vendored
@@ -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)
|
||||
|
||||
88
.github/workflows/superset-frontend.yml
vendored
88
.github/workflows/superset-frontend.yml
vendored
@@ -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
|
||||
|
||||
@@ -31,8 +31,6 @@ under the License.
|
||||
[](https://github.com/apache/superset/pulls)
|
||||
[](http://bit.ly/join-superset-slack)
|
||||
[](https://superset.apache.org)
|
||||
[](https://superset-storybook.netlify.app)
|
||||
[](https://superset-bundle-analyzer.netlify.app)
|
||||
|
||||
<picture width="500">
|
||||
<source
|
||||
|
||||
13
UPDATING.md
13
UPDATING.md
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
194
docs/yarn.lock
194
docs/yarn.lock
@@ -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"
|
||||
|
||||
162
superset-frontend/package-lock.json
generated
162
superset-frontend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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:'));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
685
superset-websocket/package-lock.json
generated
685
superset-websocket/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
247
superset/common/form_data_query_context.py
Normal file
247
superset/common/form_data_query_context.py
Normal 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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
338
tests/unit_tests/common/test_form_data_query_context.py
Normal file
338
tests/unit_tests/common/test_form_data_query_context.py
Normal 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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"}],
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
53
tests/unit_tests/views/test_utils.py
Normal file
53
tests/unit_tests/views/test_utils.py
Normal 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
|
||||
Reference in New Issue
Block a user