Compare commits

...
Author SHA1 Message Date
rusackasandClaude Sonnet 5 3c3c92e96b fix(security): use self-repository syntax for setup-backend in postgres job
zizmor's self-repository audit flags uses: ./.github/actions/setup-backend/
in the test-postgres job in favor of GitHub's dedicated uses: $/... syntax.
This mirrors the mechanical fix already applied to the analogous
setup-backend references in this same workflow (test-sqlite, #44018) and in
superset-python-presto-hive.yml (#43975), now that the ASF allowlist check
has been bumped to recognize $/ refs (#44014).

Resolves code-scanning alert #2656.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:25:50 -07:00
986b648f1c fix(versioning): resolve dashboard M2M membership by attach/detach windows, not end_transaction_id (#44010)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 17:59:04 -07:00
dependabot[bot]andClaude Code acb29937ae chore(deps): bump the security group across 1 directory with 3 updates (#44030)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-08 17:52:32 -07:00
dependabot[bot]andClaude Code 2875c76d55 chore(deps): bump react-ace from 14.0.1 to 15.0.0 in /superset-frontend (#42833)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-08 17:52:09 -07:00
d0f139c210 feat(deps): bump flask-caching from 2.4.1 to 2.5.0 + bump flask from v2 to v3 (#43875)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-09-08 17:20:53 -07:00
dependabot[bot] 9de50b85e5 chore(deps): bump the security group across 1 directory with 3 updates (#44029)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 17:04:39 -07:00
Joe LiandVitor Avila c510946df5 fix: BigQuery pivot-table Custom SQL row label with a space drops from GROUPING SETS (#43720)
Co-authored-by: Vitor Avila <vitor.avila@preset.io>
2026-09-08 16:58:49 -07:00
Joe LiandClaude Sonnet 5 91f4d01ca2 fix(chart-creation): stop antd v6 Steps from doubling the icon-content gap (#43730)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:57:55 -07:00
Evan Rusackas 3828dcb922 fix(security): suppress zizmor self-repository false positive for cached-dependencies in presto-hive workflow (#43976) 2026-09-08 16:53:30 -07:00
Evan Rusackas 362d3509cb fix(security): suppress zizmor self-repository false positive for cached-dependencies in presto-hive workflow (#43984) 2026-09-08 16:53:23 -07:00
Evan RusackasandClaude Sonnet 5 fa31e9c1c3 fix(security): suppress zizmor self-repository false positive for cached-dependencies (#43972)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:52:42 -07:00
Evan RusackasandClaude Sonnet 5 335a2a78ea fix(dashboard): expand clipped scrollable content before screenshot/PDF export (#43979)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:46:34 -07:00
Evan RusackasandClaude Sonnet 5 3564baabf6 fix(security): suppress zizmor self-repository false positive for setup-backend step (#44018)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:41:46 -07:00
Evan RusackasandClaude 380675662d fix(ci): use self-repository syntax for local actions in presto-hive workflow (#43975)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-08 16:33:28 -07:00
Evan Rusackas 0e6170aaa3 fix(security): use self-repository syntax for setup-backend ref in unit-test workflow (#43970) 2026-09-08 16:31:39 -07:00
Evan Rusackas 9d89dbddc7 fix(security): use self-repository syntax for change-detector action ref (#43971) 2026-09-08 16:31:26 -07:00
Evan Rusackas 02ea5775c1 fix(ci): use self-repository syntax for local supersetbot action ref (#43963) 2026-09-08 16:30:33 -07:00
Evan RusackasandClaude Sonnet 5 83d5d1ccda fix(security): use GitHub self-repository syntax for local action ref (#43961)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:30:08 -07:00
Sam Firkeandrusackas 7b65d22205 chore(tags): TAGGING_SYSTEM to True by default (#39888)
Co-authored-by: rusackas <evan@rusackas.com>
2026-09-08 16:27:37 -07:00
Evan RusackasandClaude Code 1339bdc6ce ci: bump ASF allowlist-check to v1.0.1 for self-repository syntax support (#44014)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-08 15:49:12 -07:00
Evan RusackasandClaude 10866337c5 docs(helm): document Gateway API HTTPRoute support (#43635)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-08 15:33:45 -07:00
Amin Ghadersohi e6d088d6b1 fix(mcp): surface rejected filter columns in get_chart_sql (#43680) 2026-09-08 18:05:37 -04:00
Elizabeth ThompsonandClaude Opus 4.6 5986fe92c2 fix(rls): roll back db.session after RLS failure in get_from_clause (#43883)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 15:04:32 -07:00
dependabot[bot] 04ab089962 chore(deps): bump @swc/html from 1.15.43 to 1.16.2 in /docs in the security group across 1 directory (#44023)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 14:29:15 -07:00
rlei bc35151350 docs(handlebars): correct helper names in the 6.1.0 versioned docs (#44024) 2026-09-08 16:40:44 -04:00
7029c468c4 fix(reports): bound CSV transport retries and sanitize failures (#43977)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-09-08 16:37:25 -04:00
Evan RusackasandClaude 60553b4326 docs(ag-grid): document Show summary support for Raw Records mode (#43905)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-08 13:30:07 -07:00
Evan RusackasandClaude Sonnet 5 85ea6e900a docs(mcp): document MCP_STATELESS_HTTP and its multi-pod tradeoff (#43902)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:29:51 -07:00
a114b4cc46 fix(import): skip redundant has_table check when dataset import has no data URI (#43808)
Co-authored-by: Kunal8954 <your-github-email@example.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:27:40 -07:00
Evan RusackasandClaude Fable 5.1 3ccab2ea7a fix(time-format): share DateWithFormatter and stop reading four-digit years as epoch offsets (#43909)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:24:43 -07:00
Evan RusackasandClaude Opus 4.8 067a356b6f fix(chart): include echart_options in Matrixify render-trigger allowlist (#43946)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 13:24:13 -07:00
rlei 57eafd75e0 docs(handlebars): correct helper names that are not registered (#44020) 2026-09-08 16:10:40 -04:00
c45fc6a31a feat(embedded): diagnose guest-token header size budgets (#43944)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-09-08 14:55:04 -04:00
33c6df77c9 fix(database): don't reattach stored connection details when the effective destination changes (#44003)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Shaitan <105581038+sha174n@users.noreply.github.com>
2026-09-08 11:02:42 -07:00
Rafael BenitezandClaude Opus 4.8 204ccc7ce7 fix(datasets): apply Schema filter on the combined datasource list (#43829)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-08 14:24:27 -03:00
Chandan P 08694d64cb fix(plugin-chart-table): declare DataTable hooks before the early return (#43150) 2026-09-08 10:13:17 -07:00
bouhalimedaziz 056fd218d4 fix: handle missing table element in downloadAsPivotExcel (#39386) 2026-09-08 10:11:03 -07:00
Evan RusackasandClaude Code ad319bd8a3 fix(docs): unbreak Netlify docs preview build (#44001)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-08 10:06:17 -07:00
Joe LiandClaude Sonnet 5 7382ee0bfe fix(dashboard): prevent header metadata bar from overlapping action buttons at narrow widths (#43845)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:34:09 -07:00
Rutger Meuzelaar 7d0cf1acfc docs(scarf-404): replace dead links by the Scarf homepage (#43973) 2026-09-08 21:24:25 +07:00
Enzo Martellucci c4e145b071 fix(sql_lab): raise 400 not 500 on malformed Jinja during CSV export access check (#43866) 2026-09-08 12:27:29 +02:00
dependabot[bot] cdb6c8e0c9 chore(deps-dev): bump the typescript-eslint group in /superset-frontend with 2 updates (#43980)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 01:48:13 -07:00
dependabot[bot] 89786160f9 chore(deps): bump @ant-design/icons from 6.3.3 to 6.3.4 in /docs (#43981)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 01:48:07 -07:00
dependabot[bot] bd3ab30907 chore(deps): bump @ant-design/icons from 6.3.3 to 6.3.4 in /superset-frontend (#43982)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 01:48:03 -07:00
Evan RusackasandClaude Fable 5.1 1b3758d2a4 ci: add ASF GitHub Actions allowlist check (#43844)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:04:10 -07:00
Elizabeth Thompson 1fb6b98941 fix: downgrade md5 namespace-fallback deprecation log to info (#43884) 2026-09-07 15:03:54 -07:00
Elizabeth ThompsonandClaude Opus 4.8 8d6044a147 fix(sqllab): wrap cost-estimate JSON parse to prevent raw JSONDecodeError leak (#43888)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 15:03:50 -07:00
Evan Rusackas 574d121cd8 fix(security): use self-repository uses syntax in supersetbot workflow (#43962) 2026-09-07 12:27:17 -07:00
Evan RusackasandClaude d22069a7af fix(security): use GitHub self-repository syntax for local action refs (#43969)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-07 12:26:41 -07:00
Evan RusackasandClaude Sonnet 5 233f6379ae fix(ci): use self-repository syntax for supersetbot setup in tag-release (#43965)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:26:22 -07:00
Evan RusackasandClaude Sonnet 5 4440887dc2 fix(security): use self-repository syntax for setup-backend action ref (#43968)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:25:42 -07:00
GeidōandClaude Opus 5 53070fa4c0 fix(dashboard): resolve metric Label in Chart Data (View as table) results grid (#43719)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:13:03 +03:00
92bd2ba057 chore(deps): bump @ant-design/icons from 6.3.2 to 6.3.3 in /superset-frontend (#43955)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 17:34:47 +07:00
dependabot[bot] 4eff13b81e chore(deps-dev): bump webpack from 5.110.0 to 5.110.2 in /superset-embedded-sdk (#43953)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 17:34:11 +07:00
dependabot[bot] 06913244de chore(deps-dev): bump webpack from 5.110.1 to 5.110.2 in /docs (#43952)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 02:38:21 -07:00
dependabot[bot] a5377541c9 chore(deps): bump zizmorcore/zizmor-action from 0.6.2 to 0.6.3 (#43954)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 02:38:16 -07:00
dependabot[bot] e5e6d74819 chore(deps-dev): bump webpack from 5.110.1 to 5.110.2 in /superset-frontend (#43956)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 02:38:12 -07:00
dependabot[bot] b8fb5a268c chore(deps): bump zod from 4.4.3 to 4.5.4 in /superset-frontend (#43957)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 02:38:08 -07:00
dependabot[bot] 07052752b6 chore(deps-dev): bump tsx from 4.23.12 to 4.23.13 in /superset-frontend (#43958)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 02:38:05 -07:00
dependabot[bot] 7c9c32651c chore(deps): bump react-error-boundary from 6.1.3 to 6.1.4 in /superset-frontend (#43959)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 02:38:01 -07:00
dependabot[bot] 0db9ca7401 chore(deps): bump @ant-design/icons from 6.3.2 to 6.3.3 in /docs (#43951)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 02:36:03 -07:00
Evan RusackasandClaude Code 7d09490bb8 fix(security): grant SQL Lab query authors an explore access bypass (#39296) (#42590)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-06 23:18:34 -07:00
Evan RusackasandClaude Sonnet 5 df3abe474c docs(theming): document new component sizing tokens and HIDE_NAVBAR_LOGO (#43639)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:17:12 -07:00
Evan RusackasandClaude 87f138cc80 docs(mcp): add theme management tools to AI/MCP tools reference (#43899)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-06 18:03:15 -07:00
c03f3441bd feat(mcp): gauge chart type plugin (#43568)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-06 12:55:35 -04:00
f3896ccf20 fix(explore): say when filter-value suggestions could not be loaded (#43779)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-09-06 10:26:48 -04:00
Joe LiandClaude Sonnet 5 18d277c096 ci(playwright): make embedded and app-root E2E coverage required (#43919)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:17:59 +07:00
fe1b368bcd feat: add dashed line support for derived series (timeseries comparison) on MixedTimeseries chart (#34794)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 14:17:10 -07:00
Nguyen Van VietandClaude Fable 5 1358543827 fix(sqllab): preserve ClickHouse LIMIT BY when applying the row limit (#43578)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 09:51:00 -07:00
190 changed files with 8411 additions and 1373 deletions
@@ -1,23 +0,0 @@
name: Label Draft PRs
on:
pull_request:
types:
- opened
- converted_to_draft
jobs:
label-draft:
runs-on: ubuntu-latest
steps:
- name: Check if the PR is a draft
id: check-draft
uses: actions/github-script@v8
with:
script: |
const isDraft = context.payload.pull_request.draft;
core.setOutput('isDraft', isDraft);
- name: Add `review:draft` Label
if: steps.check-draft.outputs.isDraft == 'true'
uses: actions-ecosystem/action-add-labels@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
labels: "review:draft"
+3 -3
View File
@@ -26,7 +26,7 @@ runs:
- name: Set up QEMU
if: ${{ inputs.build == 'true' }}
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
with:
# Pin the binfmt image to a specific QEMU release. The default
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
@@ -39,12 +39,12 @@ runs:
- name: Set up Docker Buildx
if: ${{ inputs.build == 'true' }}
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Try to login to DockerHub
if: ${{ inputs.login-to-dockerhub == 'true' }}
continue-on-error: true
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ inputs.dockerhub-user }}
password: ${{ inputs.dockerhub-token }}
+46
View File
@@ -0,0 +1,46 @@
# Verifies that every `uses:` ref under .github/ is on the ASF Infrastructure
# GitHub Actions allowlist (apache/infrastructure-actions). An action that is
# not allowlisted fails at "Set up job" with no logs and no notification, so
# this check surfaces the problem at PR time instead. It also warns (without
# failing) when a pinned SHA's allowlist entry is about to expire.
name: ASF Allowlist Check
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/**"
push:
branches:
- "master"
- "[0-9].[0-9]*"
paths:
- ".github/**"
schedule:
# Weekly, so allowlist expirations are surfaced even when nothing under
# .github/ has changed.
- cron: "0 6 * * 1"
permissions:
contents: read
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
asf-allowlist-check:
runs-on: ubuntu-26.04
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check action refs against the ASF allowlist
uses: apache/infrastructure-actions/allowlist-check@df54e48ff76152790f317934c691cfa7fd7a1a46 # allowlist-check/v1.0.1
with:
# Default scan-glob is .github/**/*.yml, which misses .yaml files.
scan-glob: ".github/**/*.y*ml"
@@ -49,4 +49,4 @@ jobs:
run: bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
- name: Check for security issues on GHA workflows
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
+24 -7
View File
@@ -168,7 +168,10 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-26.04
timeout-minutes: 30
# Embedded Tests below adds its own gunicorn boot + spec run on top of
# Required and Soft-delete; 30m was tight even for the two-step shadow
# job this replaced.
timeout-minutes: 40
permissions:
contents: read
pull-requests: read
@@ -176,7 +179,10 @@ jobs:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
# Subdirectory deployment (APPLICATION_ROOT) is a required-to-pass
# dimension, not an optional one, so it runs on every event —
# unlike cypress-matrix above, which only widens on push.
app_root: ["", "/app/prefix"]
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -266,14 +272,25 @@ jobs:
# Scoped to this step: each playwright-run boots its own gunicorn
# with the step's env, so the Required Tests server above keeps
# master's Flask configuration while this one runs with SOFT_DELETE
# on — the same isolation pattern as the Embedded step in
# superset-playwright.yml. Without a flag-on server the
# recently-archived specs skip themselves everywhere and ship zero
# executed E2E coverage; in the Required run above they are
# collected and skipped, which is expected.
# on — the same isolation pattern as the Embedded Tests step below.
# Without a flag-on server the recently-archived specs skip
# themselves everywhere and ship zero executed E2E coverage; in the
# Required run above they are collected and skipped, which is
# expected.
SUPERSET_FEATURE_SOFT_DELETE: "true"
with:
run: playwright-run "${{ matrix.app_root }}" recently-archived/
- name: Run Playwright (Embedded Tests)
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
# Scoped to this step for the same reason as Soft-delete above:
# embedding is a real, required feature, so its Playwright coverage
# now gates merges instead of running only in shadow mode.
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
INCLUDE_EMBEDDED: "true"
with:
run: playwright-run "${{ matrix.app_root }}" embedded
- name: Set safe app root
if: failure()
id: set-safe-app-root
+7 -23
View File
@@ -46,8 +46,10 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests)
# This workflow contains only experimental tests that run in shadow mode
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests),
# including Embedded — it moved out of this workflow because embedding is a
# required feature, not an experimental one. This workflow now contains
# only experimental and mobile tests, which run in shadow mode.
playwright-tests-experimental:
needs: changes
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
@@ -130,10 +132,6 @@ jobs:
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Build embedded SDK
uses: ./.github/actions/cached-dependencies
with:
run: build-embedded-sdk
- name: Install Playwright
uses: ./.github/actions/cached-dependencies
with:
@@ -144,27 +142,13 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
run: playwright-run "${{ matrix.app_root }}" experimental/
- name: Run Playwright (Embedded Tests)
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
# Scope embedded-only env vars to this step. Setting them at the job
# level enabled the EMBEDDED_SUPERSET feature flag inside Flask for
# the preceding "Required Tests" and "Experimental Tests" steps too,
# which loads extra handlers and destabilizes the werkzeug dev
# server under the 2-worker Playwright load. Required Tests should
# match master's Flask configuration.
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
INCLUDE_EMBEDDED: "true"
with:
run: playwright-run "${{ matrix.app_root }}" embedded
- name: Run Playwright (Mobile Tests)
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
# Scoped to this step for the same reason as the embedded flags
# above: the mobile consumption mode should not alter Flask's
# configuration for the required desktop test steps.
# Scoped to this step: setting feature flags at the job level would
# alter Flask's configuration for the preceding Experimental step
# too — the mobile consumption mode should not do that.
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
INCLUDE_MOBILE: "true"
with:
@@ -160,7 +160,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
with:
python-version: ${{ matrix.python-version }}
- name: Setup Postgres
@@ -210,7 +210,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
- name: Install dependencies
uses: ./.github/actions/cached-dependencies
with:
@@ -33,7 +33,7 @@ jobs:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
uses: $/.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -80,9 +80,15 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
- name: Setup Postgres
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: setup-postgres
- name: Start Celery worker
@@ -141,13 +147,19 @@ jobs:
- name: Start hadoop and hive
run: docker compose -f scripts/databases/hive/docker-compose.yml up -d
- name: Setup Python
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
- name: Setup Postgres
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Start Celery worker
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: celery-worker
- name: Python unit tests (PostgreSQL)
@@ -35,7 +35,7 @@ jobs:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
uses: $/.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -60,7 +60,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
with:
python-version: ${{ matrix.python-version }}
- name: Python unit tests
+10 -4
View File
@@ -32,7 +32,7 @@ jobs:
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
uses: $/.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -45,7 +45,13 @@ jobs:
cache-dependency-path: "superset-frontend/package-lock.json"
- name: Install dependencies
if: steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a git submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: npm-install
- name: lint
@@ -68,13 +74,13 @@ jobs:
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
uses: $/.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
- name: Install gettext tools
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
persist-credentials: false
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
uses: $/.github/actions/setup-supersetbot/
- name: Execute custom Node.js script
env:
+3 -3
View File
@@ -66,7 +66,7 @@ jobs:
fetch-depth: 0
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
uses: $/.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -81,7 +81,7 @@ jobs:
package-manager-cache: false
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
uses: $/.github/actions/setup-supersetbot
- name: Execute custom Node.js script
env:
@@ -139,7 +139,7 @@ jobs:
package-manager-cache: false
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
uses: $/.github/actions/setup-supersetbot/
- name: Label the PRs with the right release-related labels
env:
+1 -1
View File
@@ -178,7 +178,7 @@ repos:
name: zizmor (GHA security audit)
entry: zizmor
language: python
additional_dependencies: [zizmor==1.25.2]
additional_dependencies: [zizmor==1.30.0]
files: ^\.github/
types: [yaml]
pass_filenames: false
+28
View File
@@ -24,6 +24,34 @@ assists people when migrating to a new version.
## Next
### Tagging is on by default
`TAGGING_SYSTEM` now ships **on**. The Tags menu entry, the tag columns and
filters on the chart, dashboard and saved-query lists, and the Tags field in the
chart and dashboard property modals are all visible without configuration, and
tags are included in asset export and import.
**What operators should expect:**
- **Implicit tags accrue.** Saving a chart, dashboard, dataset or saved query,
and favoriting an asset, write rows to `tag` and `tagged_object` (`type:chart`,
`editor:<user id>`, `favorited_by:<user id>`). These have always been created
when the flag was on; they are simply no longer opt-in.
- **Exports gain a `tags` key and a `tags.yaml` file.** Chart and dashboard
export bundles carry custom tags. Importers on 6.0 and later understand both;
older importers skip the unrecognized `tags.yaml` file but reject chart and
dashboard YAML that contains a `tags` key, so strip that key before importing
a bundle into Superset 5.x or earlier.
- **The flag is honored at write time.** The tagging SQLA event listeners are
always attached at startup; the ones that create tags check `TAGGING_SYSTEM`
when they fire, so the flag, including a runtime override through
`GET_FEATURE_FLAGS_FUNC` or `IS_FEATURE_ENABLED_FUNC`, takes effect without a
restart. The cleanup listeners run regardless of the flag, so deleting an
asset never leaves orphaned `tagged_object` rows behind.
Set `FEATURE_FLAGS = {"TAGGING_SYSTEM": False}` to restore the previous
behavior. Existing tag rows are left untouched.
### Global Async Queries re-platformed onto the Global Task Framework (breaking)
Global Async Queries (GAQ) no longer runs on its own bespoke async-events
@@ -486,6 +486,39 @@ Log in as an admin user to ensure you have adequate permissions.
This is the best source of information about the problem. In a docker compose deployment, you can do this with a command like `docker logs superset_worker --since 1h`.
### CSV and Excel chart-data request failures
The worker uses the saved query context to POST to the chart-data export endpoint,
falling back to the legacy GET export when a query context cannot be generated.
`ALERT_REPORTS_CSV_REQUEST_TIMEOUT` (60 seconds by default) limits socket operations;
the report execution budget and its delivery/cleanup reserves also cap the request.
Connection and read timeouts are reported as CSV/Excel generation timeouts.
These attachment timeouts are logged at error level and explicitly mark the report
task as failed, while the report execution retains its ERROR state and separate
error-notification history. Other HTTP 408 exception handling is unchanged.
To tolerate short-lived transport failures, operators can opt in with
`ALERT_REPORTS_CSV_REQUEST_RETRY = True` (default: `False`). This permits **one** retry
for transient connection/read failures and HTTP 429, 500, 502, 503, or 504. Other
HTTP statuses are not retried. Backoff is 0.5 seconds, extended to at most 2 seconds
for a numeric `Retry-After`; longer, invalid, or date-based delays are not retried
inline. Both attempts and backoff share the initial request timeout allowance and
respect the remaining execution budget. Unbounded requests are not retried.
A request that consumes its entire timeout does **not** get another full timeout.
Socket timeouts are not wall-clock cancellation: existing report task limits still
interrupt in-flight work. A timed-out server query can continue running, so enabling
retries can increase database load. Leave retries disabled unless appropriate for
your deployment; disable the setting to roll back retry behavior.
Worker diagnostics include schedule/chart identifiers, a fixed endpoint path (no
query string), error category, HTTP status, timeout, elapsed duration, and attempt.
For HTTP errors, at most 4097 response bytes are read to enforce a 4096-byte limit.
Only recognized Superset error types from up to four JSON errors are retained;
free-form messages, extra fields, and non-JSON or oversized bodies are redacted or
omitted. Cookies, authentication headers, URLs, SQL, and query payloads are not
included in these transport diagnostics. HTTP 400 therefore remains a failure to
investigate, not a reason to repeat the same request.
### Check web browser and webdriver installation
To take a screenshot, the worker visits the dashboard or chart using a headless browser, then takes a screenshot. If you are able to send a chart as CSV, XLSX, or text but can't send as PNG, your problem may lie with the browser.
@@ -540,6 +540,8 @@ MCP_STORE_CONFIG = {
When `CACHE_REDIS_URL` is set, the MCP server uses a Redis-backed EventStore for session management, allowing replicas to share state. Without Redis, each pod manages its own in-memory sessions and stateful MCP interactions may fail when requests hit different replicas.
`MCP_STATELESS_HTTP` (default `True`) controls whether requests get a fresh, ephemeral transport per HTTP round trip or a transport that stays alive for the session's lifetime. The default suits multi-pod deployments because it doesn't require session affinity -- any pod can handle any request. Its tradeoff: a client disconnecting mid-tool-call can crash not just its own session but other concurrent sessions on the same worker. Setting it to `False` avoids that, but it requires session-affinity (sticky session) routing on `Mcp-Session-Id` at the mesh/ingress layer, since a session's follow-up requests must land on the same pod that created it. See [`MCP_STATELESS_HTTP`](#core) below.
---
## Configuration Reference
@@ -555,6 +557,7 @@ All MCP settings go in `superset_config.py`. Defaults are defined in `superset/m
| `MCP_SERVICE_URL` | `None` | Public base URL for MCP-generated links (set this when behind a reverse proxy) |
| `MCP_DEBUG` | `False` | Enable debug logging |
| `MCP_DEV_USERNAME` | -- | Superset username for development mode (no auth) |
| `MCP_STATELESS_HTTP` | `True` | Streamable-HTTP session mode. `True` gives each request a fresh, ephemeral transport, torn down as soon as that request completes; a client disconnecting mid-tool-call can crash not just its own session but other concurrent sessions on the same worker. `False` keeps the transport alive for the session's lifetime, avoiding that crash, but requires session-affinity routing on `Mcp-Session-Id` for multi-pod deployments (see [Multi-Pod (Kubernetes)](#multi-pod-kubernetes)). |
| `MCP_RBAC_ENABLED` | `True` | Enforce Superset's role-based access control on MCP tool calls. When `True`, each tool checks that the authenticated user has the required FAB permission before executing. Disable only for testing or trusted-network deployments. |
| `MCP_DISABLED_TOOLS` | `set()` | Set of tool names to remove from the MCP server at startup. Disabled tools are never advertised to AI clients during tool discovery. Useful when a custom extension tool should replace a built-in Superset tool. See [Disabling built-in tools](#disabling-built-in-tools). |
| `MCP_DISABLED_CHART_PLUGINS` | `frozenset()` | Set of chart type plugin names (e.g. `"handlebars"`) to hide from `generate_chart`. Does not affect `get_chart_type_schema`. See [Disabling chart type plugins](#disabling-chart-type-plugins). |
+124
View File
@@ -138,6 +138,18 @@ The existing `APP_NAME` Python config key continues to work for backward compati
Email and alert/report notification subjects are driven by backend settings such as
`EMAIL_REPORTS_SUBJECT_PREFIX` and `APP_NAME`, not by this theme token.
To hide the entire brand area in the navbar (both the logo image and the
brand text), set `HIDE_NAVBAR_LOGO` in `superset_config.py`:
```python
# Hide the entire brand area in the navbar, including the logo image and the
# brand text (brandAppName / APP_NAME). Defaults to False.
HIDE_NAVBAR_LOGO = True
```
`HIDE_NAVBAR_LOGO` is a Python config flag rather than a theme token, so it
cannot be set through the theme CRUD UI or `THEME_DEFAULT`/`THEME_DARK`.
### Migration from Configuration to UI
When `ENABLE_UI_THEME_ADMINISTRATION = True`:
@@ -487,6 +499,118 @@ THEME_DEFAULT = {
This feature provides powerful theming capabilities while maintaining the flexibility of ECharts' extensive configuration options.
## Component Sizing & Style Tokens
:::note
These tokens landed after the Superset 6.1 release and are only available on
`master`; they are not present in any tagged release yet.
:::
Beyond colors and fonts, a handful of Superset-specific tokens let you tune the
sizing, radius, and outline behavior of individual UI components. All of these
tokens are optional — omit them and components fall back to their existing
defaults, so applying them is a zero-visual-change operation until you opt in.
### Button & DropdownButton Sizing
```python
THEME_DEFAULT = {
"token": {
# ... other tokens
"buttonControlHeight": 32, # default button height, in px
"buttonControlHeightSM": 30, # small/dropdown button height, in px
"buttonControlHeightXS": 22, # xsmall button height, in px
"buttonPaddingInline": 18, # horizontal padding, in px
"buttonPaddingInlineSM": 10, # horizontal padding for small buttons, in px
"buttonFontSize": 14,
"buttonBorderRadius": 4,
}
}
```
`buttonControlHeight` and `buttonBorderRadius` also drive the sizing of the
menu-trigger button used by `PageHeaderWithActions`, so a single pair of tokens
keeps page-header icon buttons visually consistent with regular buttons.
For one-off overrides that shouldn't apply to every button in the app, pass a
`styleConfig` prop directly to `Button` or `DropdownButton` instead of setting
a theme token:
```tsx
<Button
styleConfig={{
controlHeight: 40,
paddingInline: 20,
fontSize: 16,
fontWeight: 700,
borderRadius: 8,
ctaMinWidth: 120,
ctaMinHeight: 40,
iconGap: 8,
}}
>
Click me
</Button>
<DropdownButton
styleConfig={{
controlHeight: 32,
fontSize: 14,
fontWeight: 500,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
menu={menuProps}
>
Options
</DropdownButton>
```
`styleConfig` values take precedence over the equivalent theme tokens, which in
turn take precedence over the built-in defaults.
### Label Border Radius
```python
THEME_DEFAULT = {
"token": {
"labelBorderRadius": 4, # defaults to 8px
}
}
```
### Select Option Outline
By default, hovering or navigating to an option in a `Select` dropdown draws a
2px outline in `colorPrimary`. Set `selectOptionActiveOutline` to `False` for a
more subtle hover style with no outline:
```python
THEME_DEFAULT = {
"token": {
"selectOptionActiveOutline": False,
}
}
```
### Dashboard Tile Appearance
Chart tiles on a dashboard (not text/markdown tiles) can be restyled via
`dashboardTile*` tokens. All fall back to the existing look — a
`colorBgContainer` background, a `1px solid colorBorder` border, and a
hairline `box-shadow` while the tile is fading out (e.g. when a filter
makes it irrelevant):
```python
THEME_DEFAULT = {
"token": {
"dashboardTileBg": "#ffffff",
"dashboardTileBorder": "1px solid #e0e0e0",
"dashboardTileBorderRadius": 8,
"dashboardTileBoxShadow": "0 1px 2px rgba(0, 0, 0, 0.08)",
}
}
```
## Advanced Features
- **System Themes**: Manage system-wide default and dark themes via UI or configuration
@@ -215,7 +215,7 @@ If you have a good solution for this, let us know!
:::
:::note
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry
data. Knowing the installation counts for different Superset versions informs the project's
decisions about patching and long-term support. Scarf purges personally identifiable information
(PII) and provides only aggregated statistics.
+49 -1
View File
@@ -87,6 +87,7 @@ The chart will publish appropriate services to expose the Superset UI internally
- Configure the Service as a `LoadBalancer` or `NodePort`
- Set up an `Ingress` for it - the chart includes a definition, but will need to be tuned to your needs (hostname, tls, annotations etc...)
- Set up a Gateway API `HTTPRoute` for it - see [Exposing Superset via Gateway API (HTTPRoute)](#exposing-superset-via-gateway-api-httproute) below
- Run `kubectl port-forward superset-xxxx-yyyy :8088` to directly tunnel one pod's port into your localhost
Depending how you configured external access, the URL will vary. Once you've identified the appropriate URL you can log in with:
@@ -135,7 +136,7 @@ init:
```
:::note
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
There are two independent telemetry channels:
@@ -319,6 +320,53 @@ configOverrides:
AUTH_USER_REGISTRATION_ROLE = "Admin"
```
### Exposing Superset via Gateway API (HTTPRoute)
As an alternative to `Ingress`, the chart can create a [Gateway API](https://gateway-api.sigs.k8s.io/)
`HTTPRoute` that attaches to a Gateway already running in your cluster. This requires the Gateway
API CRDs serving the configured `httproute.apiVersion` (`gateway.networking.k8s.io/v1` by default)
to be installed, along with a Gateway resource for the route to attach to. If the Gateway lives in
a different namespace than the `HTTPRoute` (as in the
example below), its listener's `allowedRoutes` must explicitly permit routes from this release's
namespace, or the `HTTPRoute` will install successfully but never attach.
```yaml
httproute:
enabled: true
parentRefs:
- name: my-gateway
namespace: gateway-system
hostnames:
- superset.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
```
- `httproute.parentRefs` lists the Gateway(s) the route attaches to.
- `httproute.hostnames` matches against the HTTP `Host` header; it's templated, so values like
`{{ .Release.Name }}` can be used.
- `httproute.rules` are routing rules backed by the Superset service; each rule accepts standard
`matches`, `filters`, and `timeouts` fields, and an optional `weight` (defaults to `1`) applied to
its single backend reference. Since each rule maps to one backend, `weight` has no traffic-splitting
effect here; it only matters if you fork the template to add multiple `backendRefs` to a rule.
`timeouts` only joined the Gateway API Standard channel in v1.2, so it requires both v1.2+ CRDs
and a supporting controller; drop it if either predates that.
- If `supersetWebsockets.enabled` is set, an extra rule routing `supersetWebsockets.ingress.path`
(default `/ws`) to the `-ws` service is appended automatically, mirroring the `Ingress` behavior.
WebSocket upgrade support is controller-dependent under Gateway API; check your Gateway
implementation's docs in case it needs an explicit protocol opt-in for global async queries to
keep working behind a Gateway.
- If `supersetMcp.enabled` and `supersetMcp.httproute.enabled` are both set, an extra rule routing
`supersetMcp.httproute.path` to the `-mcp` service is appended as well. Don't expose this route
without first enabling MCP authentication — see the
[MCP Server Deployment & Authentication](/admin-docs/configuration/mcp-server#authentication) doc;
by default the MCP server runs in dev mode with auth disabled.
- Set `httproute.apiVersion` to `gateway.networking.k8s.io/v1beta1` if your cluster's Gateway API
installation hasn't promoted `HTTPRoute` to `v1` yet.
### Enable Alerts and Reports
For this, as per the [Alerts and Reports doc](/admin-docs/configuration/alerts-reports), you will need to:
@@ -215,7 +215,7 @@ If you have a good solution for this, let us know!
:::
:::note
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry
data. Knowing the installation counts for different Superset versions informs the project's
decisions about patching and long-term support. Scarf purges personally identifiable information
(PII) and provides only aggregated statistics.
@@ -135,7 +135,7 @@ init:
```
:::note
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
To opt-out of this data collection in your Helm-based installation, edit the `repository:` line in your `helm/superset/values.yaml` file, replacing `apachesuperset.docker.scarf.sh/apache/superset` with `apache/superset` to pull the image directly from Docker Hub.
:::
@@ -379,6 +379,15 @@ AG Grid supports server-side column filters that query the full dataset — not
AG Grid Interactive Table supports **Time Shift** (time comparison), matching the behavior of the standard Table chart. In the **Advanced Analytics** → **Time Comparison** section of the chart configuration, enter a shift expression (e.g., `1 year ago`, `minus 7 days`) to add comparison columns showing values from the offset period. Dashboard-level time range overrides apply to both the base and comparison periods.
#### Show Summary
The **Show summary** checkbox lives at the top of the **Visual formatting** section in the **Customize** tab, for both **Aggregate** and **Raw Records** query modes. Enabling it pins a summary row to the bottom of the grid whenever there is something to summarize: at least one metric in **Aggregate** mode, or at least one eligible numeric column in **Raw Records** mode. Otherwise no summary row is added.
- In **Aggregate** mode, the summary row applies each metric's own aggregation (or the **Summary aggregation** override, where available) across the full filtered dataset.
- In **Raw Records** mode, the summary row defaults to a server-side `SUM` for each numeric column that's backed by a physical or calculated dataset column; the **Summary aggregation** control can override this to `AVG` as well. Non-numeric cells and columns built from free-form SQL expressions stay blank.
In both modes, the summary is computed across the full result set, independent of the chart's row limit and pagination, and it reflects dashboard and chart-level filters. It does not reflect AG Grid's own server-side column filters (the per-column filter UI in the grid header), which are excluded from the summary query.
### Dynamic Currency Formatting
Chart metric values can display currencies dynamically rather than using a fixed currency code. To enable:
+48
View File
@@ -145,3 +145,51 @@ The following URL parameters can be passed through the `urlParams` option in `da
- **Row-level security** — pass `rls` rules in the guest token request to restrict which rows are visible to the embedded user.
- **Allowed domains** — restrict which host origins can embed a dashboard by setting **Allowed Domains** per-dashboard in the _Embed_ settings modal. Superset checks the request's `Referer` header against this list before serving the embedded view; an empty list allows any origin, so configure this explicitly for production.
- **Redacted errors** — API responses to a guest token report a generic `An error occurred while fetching the data.` instead of the underlying error, since engine errors quote catalog, schema, table and column names. Errors Superset raises itself — access denials, timeouts, payload validation — keep their message, and the full error is always available in the server logs.
## Guest-token request-header size diagnostics
A successful guest-token mint does not guarantee the token can pass through your
deployment's proxies. Limits apply to the **encoded JWT bytes plus header
overhead**, not the number of RLS rules or identifiers. A proxy can reject the
subsequent authentication request before it reaches Superset, including an HTTP
400 HTML response instead of JSON. A 400 alone does not establish a size problem.
Operators can set a deployment-specific diagnostic budget in `superset_config.py`:
```python
# Example only: choose a budget for your complete proxy path.
GUEST_TOKEN_HEADER_MAX_BYTES = 16 * 1024
```
The default is `None` (no budget warnings). Positive integer budgets count UTF-8
bytes of `GUEST_TOKEN_HEADER_NAME`, `: `, the encoded token, and `\r\n`
(four framing bytes). Only sizes **strictly greater** than the budget warn;
equality does not. This is consistent diagnostic accounting, not a prediction of
every proxy's wire-level accounting, HTTP/2 compression, or total-header limits.
Leave a safety margin and validate your actual deployment, including custom
header names. Zero, negative, non-integral, or non-numeric values (including strings and
booleans) disable budget warnings, as do values above JavaScript's maximum safe
integer (2^53 1). Whole-number floats are accepted. Convert environment-variable
strings to integers in deployment configuration to enable the budget.
Issuance audit metadata includes `token_bytes`, `header_bytes`,
`header_budget_bytes`, and `header_budget_exceeded`. Issuance remains HTTP 200
with the same token and response shape. The embedded bootstrap exposes the budget
and configured header name; reload the iframe after changing deployment config.
The embedded client measures initial and refreshed tokens and warns in the
developer console with sizes only. Initial authentication failures get a targeted
suggestion only when the request's token exceeds the budget and the failure has
no status or HTTP 400/431/494; other statuses and ambiguous in-flight
refreshes use the generic error. Refresh warnings do not restart authentication.
These diagnostics do not record JWTs, decoded claims, RLS SQL, or request headers.
[AWS Application Load Balancer quotas](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-limits.html)
list a non-adjustable 16 K single-header limit. Increasing a Superset diagnostic
budget does not increase that limit or add large-token support.
To reduce payload size, replace large inline RLS ID lists with a compact
entitlements-table subquery where supported by your database. Keep the same
tenant/user restrictions, derive identity from your trusted token-issuing
backend, and verify equivalent row access and query performance before rollout.
Do not remove RLS or broaden entitlements to make a token smaller.
+56 -20
View File
@@ -71,17 +71,17 @@ Parses a JSON string into an object that can be used in your template.
---
#### `groupBy`
#### `group`
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by).
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by). The key is passed as a `by` hash argument.
```handlebars
{{#groupBy data 'department'}}
{{#group data by="department"}}
<h3>{{value}}</h3>
{{#each items}}
<p>{{this.name}}</p>
{{/each}}
{{/groupBy}}
{{/group}}
```
---
@@ -90,6 +90,14 @@ Groups an array of objects by a key, powered by [handlebars-group-by](https://gi
Superset also registers all helpers from the [just-handlebars-helpers](https://github.com/leapfrogtechnology/just-handlebars-helpers) library. These include a wide range of comparison, math, string, and conditional helpers. Commonly used ones include:
:::note
These names are specific to `just-handlebars-helpers` and differ from other
Handlebars helper libraries — notably `handlebars-helpers`, which spells the
math helpers `add`, `subtract`, `multiply` and `divide`. Calling a helper that
is not registered raises `Missing helper: "..."`, which renders the chart blank,
so it is worth checking a name against the tables below before using it.
:::
#### Comparison
| Helper | Description | Example |
@@ -97,6 +105,7 @@ Superset also registers all helpers from the [just-handlebars-helpers](https://g
| `eq` | Strict equality | `{{#if (eq status "active")}}` |
| `eqw` | Weak equality | `{{#if (eqw count "5")}}` |
| `neq` | Strict inequality | `{{#if (neq role "admin")}}` |
| `neqw` | Weak inequality | `{{#if (neqw count "5")}}` |
| `lt` | Less than | `{{#if (lt score 50)}}` |
| `lte` | Less than or equal | `{{#if (lte score 100)}}` |
| `gt` | Greater than | `{{#if (gt price 0)}}` |
@@ -114,25 +123,52 @@ Superset also registers all helpers from the [just-handlebars-helpers](https://g
#### String
| Helper | Description | Example |
| ------------ | ----------------------------------- | --------------------------------- |
| `capitalize` | Capitalizes first letter | `{{capitalize name}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `truncate` | Truncates a string | `{{truncate description 100}}` |
| `contains` | Checks if string contains substring | `{{#if (contains tag "urgent")}}` |
| Helper | Description | Example |
| ----------------- | ----------------------------------------------- | ------------------------------ |
| `capitalizeFirst` | Capitalizes the first letter | `{{capitalizeFirst name}}` |
| `capitalizeEach` | Capitalizes the first letter of each word | `{{capitalizeEach title}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `excerpt` | Truncates to a length and appends an ellipsis | `{{excerpt description 100}}` |
| `sprintf` | printf-style formatting | `{{sprintf "%.1f" score}}` |
| `concat` | Concatenates values | `{{concat first " " last}}` |
| `join` | Joins an array with a separator | `{{join tags ", "}}` |
| `first` / `last` | First or last element of an array | `{{first items}}` |
| `newLineToBr` | Converts newlines to `<br>` (needs `{{{ }}}`) | `{{{newLineToBr notes}}}` |
#### Math
| Helper | Description | Example |
| ---------- | -------------- | ----------------------------- |
| `add` | Addition | `{{add a b}}` |
| `subtract` | Subtraction | `{{subtract total discount}}` |
| `multiply` | Multiplication | `{{multiply price quantity}}` |
| `divide` | Division | `{{divide total count}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
| `round` | Round | `{{round value}}` |
| Helper | Description | Example |
| ---------------- | ----------------------- | ------------------------------------ |
| `sum` | Addition | `{{sum a b}}` |
| `difference` | Subtraction | `{{difference total discount}}` |
| `multiplication` | Multiplication | `{{multiplication price quantity}}` |
| `division` | Division | `{{division total count}}` |
| `remainder` | Modulo | `{{remainder index 2}}` |
| `abs` | Absolute value | `{{abs delta}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
`sum` takes exactly two arguments — it adds a pair of numbers and does not total
an array. There is no `round` helper; use `{{sprintf "%.0f" value}}` to round to
a given number of decimal places.
#### Arrays
| Helper | Description | Example |
| ---------- | ---------------------------------- | --------------------------------- |
| `includes` | Whether an array contains a value | `{{#if (includes tags "urgent")}}` |
| `empty` | Whether an array is empty | `{{#if (empty rows)}}` |
| `count` | Number of items in an array | `{{count rows}}` |
`includes` tests array membership. It returns `false` for a string, so it cannot
be used to check for a substring.
#### Formatting
| Helper | Description | Example |
| ---------------- | ---------------------------- | -------------------------------- |
| `formatCurrency` | Formats a number as currency | `{{formatCurrency revenue "$"}}` |
For the full list of available helpers, see the [just-handlebars-helpers documentation](https://github.com/leapfrogtechnology/just-handlebars-helpers).
@@ -305,6 +305,14 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
| `list_databases` | List configured database connections |
| `get_database_info` | Get details about a specific database connection |
### Themes
| Tool | Description |
| ---------------- | ------------------------------------------------------------------------- |
| `list_themes` | Discover themes (antd design-token configurations) with filters |
| `get_theme_info` | Get a theme's tokens (`json_data`) by ID or UUID |
| `create_theme` | Create a reusable theme from antd design tokens (requires write access) |
---
## Troubleshooting
+5 -2
View File
@@ -43,8 +43,11 @@ publish = "build"
ignore = 'if [ -n "$CACHED_COMMIT_REF" ]; then git diff --quiet "$CACHED_COMMIT_REF" HEAD -- . ../README.md; else git fetch --no-tags origin master >/dev/null 2>&1 || true; i=0; while [ "$i" -lt 10 ] && ! git merge-base origin/master HEAD >/dev/null 2>&1; do git fetch --deepen=200 origin master >/dev/null 2>&1 || break; i=$((i+1)); done; BASE="$(git merge-base origin/master HEAD 2>/dev/null || true)"; if [ -z "$BASE" ]; then exit 1; fi; git diff --quiet "$BASE" HEAD -- . ../README.md; fi'
[build.environment]
# Node version matching docs/.nvmrc
NODE_VERSION = "20"
# Node version is intentionally not pinned here: Netlify auto-detects it
# from docs/.nvmrc, which is a symlink to the repo's single source of truth
# at superset-frontend/.nvmrc. Duplicating the version here previously let
# it drift out of sync (stuck on Node 20 after the repo moved to Node 24),
# breaking installs once a dependency required a newer Node engine.
# Yarn version
YARN_VERSION = "1.22.22"
# Increase heap size for webpack bundling of Superset UI components
+2 -2
View File
@@ -43,7 +43,7 @@
"version:remove:components": "node scripts/manage-versions.mjs remove components"
},
"dependencies": {
"@ant-design/icons": "^6.2.5",
"@ant-design/icons": "^6.3.4",
"@docusaurus/core": "^3.10.2",
"@docusaurus/faster": "^3.10.2",
"@docusaurus/plugin-client-redirects": "^3.10.2",
@@ -91,7 +91,7 @@
"oxlint": "^1.80.0",
"oxlint-tsgolint": "^7.0.2001",
"typescript": "7.0.2",
"webpack": "^5.110.1"
"webpack": "^5.110.2"
},
"browserslist": {
"production": [
+6 -6
View File
@@ -98,12 +98,6 @@
"default": false,
"lifecycle": "development",
"description": "Enable Table V2 time comparison feature"
},
{
"name": "TAGGING_SYSTEM",
"default": false,
"lifecycle": "development",
"description": "Enables the tagging system for organizing assets"
}
],
"testing": [
@@ -240,6 +234,12 @@
"description": "Allow users to enable SSH tunneling when creating a DB connection. DB engine must support SSH Tunnels.",
"docs": "https://superset.apache.org/docs/configuration/setup-ssh-tunneling"
},
{
"name": "TAGGING_SYSTEM",
"default": true,
"lifecycle": "testing",
"description": "Enables the tagging system for organizing assets"
},
{
"name": "USE_ANALOGOUS_COLORS",
"default": false,
@@ -215,7 +215,7 @@ If you have a good solution for this, let us know!
:::
:::note
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry
data. Knowing the installation counts for different Superset versions informs the project's
decisions about patching and long-term support. Scarf purges personally identifiable information
(PII) and provides only aggregated statistics.
@@ -135,7 +135,7 @@ init:
```
:::note
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
To opt-out of this data collection in your Helm-based installation, edit the `repository:` line in your `helm/superset/values.yaml` file, replacing `apachesuperset.docker.scarf.sh/apache/superset` with `apache/superset` to pull the image directly from Docker Hub.
:::
@@ -71,17 +71,17 @@ Parses a JSON string into an object that can be used in your template.
---
#### `groupBy`
#### `group`
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by).
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by). The key is passed as a `by` hash argument.
```handlebars
{{#groupBy data 'department'}}
{{#group data by="department"}}
<h3>{{value}}</h3>
{{#each items}}
<p>{{this.name}}</p>
{{/each}}
{{/groupBy}}
{{/group}}
```
---
@@ -90,6 +90,14 @@ Groups an array of objects by a key, powered by [handlebars-group-by](https://gi
Superset also registers all helpers from the [just-handlebars-helpers](https://github.com/leapfrogtechnology/just-handlebars-helpers) library. These include a wide range of comparison, math, string, and conditional helpers. Commonly used ones include:
:::note
These names are specific to `just-handlebars-helpers` and differ from other
Handlebars helper libraries — notably `handlebars-helpers`, which spells the
math helpers `add`, `subtract`, `multiply` and `divide`. Calling a helper that
is not registered raises `Missing helper: "..."`, which renders the chart blank,
so it is worth checking a name against the tables below before using it.
:::
#### Comparison
| Helper | Description | Example |
@@ -97,6 +105,7 @@ Superset also registers all helpers from the [just-handlebars-helpers](https://g
| `eq` | Strict equality | `{{#if (eq status "active")}}` |
| `eqw` | Weak equality | `{{#if (eqw count "5")}}` |
| `neq` | Strict inequality | `{{#if (neq role "admin")}}` |
| `neqw` | Weak inequality | `{{#if (neqw count "5")}}` |
| `lt` | Less than | `{{#if (lt score 50)}}` |
| `lte` | Less than or equal | `{{#if (lte score 100)}}` |
| `gt` | Greater than | `{{#if (gt price 0)}}` |
@@ -114,25 +123,52 @@ Superset also registers all helpers from the [just-handlebars-helpers](https://g
#### String
| Helper | Description | Example |
| ------------ | ----------------------------------- | --------------------------------- |
| `capitalize` | Capitalizes first letter | `{{capitalize name}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `truncate` | Truncates a string | `{{truncate description 100}}` |
| `contains` | Checks if string contains substring | `{{#if (contains tag "urgent")}}` |
| Helper | Description | Example |
| ----------------- | ----------------------------------------------- | ------------------------------ |
| `capitalizeFirst` | Capitalizes the first letter | `{{capitalizeFirst name}}` |
| `capitalizeEach` | Capitalizes the first letter of each word | `{{capitalizeEach title}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `excerpt` | Truncates to a length and appends an ellipsis | `{{excerpt description 100}}` |
| `sprintf` | printf-style formatting | `{{sprintf "%.1f" score}}` |
| `concat` | Concatenates values | `{{concat first " " last}}` |
| `join` | Joins an array with a separator | `{{join tags ", "}}` |
| `first` / `last` | First or last element of an array | `{{first items}}` |
| `newLineToBr` | Converts newlines to `<br>` (needs `{{{ }}}`) | `{{{newLineToBr notes}}}` |
#### Math
| Helper | Description | Example |
| ---------- | -------------- | ----------------------------- |
| `add` | Addition | `{{add a b}}` |
| `subtract` | Subtraction | `{{subtract total discount}}` |
| `multiply` | Multiplication | `{{multiply price quantity}}` |
| `divide` | Division | `{{divide total count}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
| `round` | Round | `{{round value}}` |
| Helper | Description | Example |
| ---------------- | ----------------------- | ------------------------------------ |
| `sum` | Addition | `{{sum a b}}` |
| `difference` | Subtraction | `{{difference total discount}}` |
| `multiplication` | Multiplication | `{{multiplication price quantity}}` |
| `division` | Division | `{{division total count}}` |
| `remainder` | Modulo | `{{remainder index 2}}` |
| `abs` | Absolute value | `{{abs delta}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
`sum` takes exactly two arguments — it adds a pair of numbers and does not total
an array. There is no `round` helper; use `{{sprintf "%.0f" value}}` to round to
a given number of decimal places.
#### Arrays
| Helper | Description | Example |
| ---------- | ---------------------------------- | --------------------------------- |
| `includes` | Whether an array contains a value | `{{#if (includes tags "urgent")}}` |
| `empty` | Whether an array is empty | `{{#if (empty rows)}}` |
| `count` | Number of items in an array | `{{count rows}}` |
`includes` tests array membership. It returns `false` for a string, so it cannot
be used to check for a substring.
#### Formatting
| Helper | Description | Example |
| ---------------- | ---------------------------- | -------------------------------- |
| `formatCurrency` | Formats a number as currency | `{{formatCurrency revenue "$"}}` |
For the full list of available helpers, see the [just-handlebars-helpers documentation](https://github.com/leapfrogtechnology/just-handlebars-helpers).
+85 -146
View File
@@ -222,18 +222,18 @@
resolved "https://registry.yarnpkg.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz#fee56b95427c0b55b216c93d9a7f3473f31615b5"
integrity sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==
"@ant-design/icons-svg@^4.5.0":
version "4.5.0"
resolved "https://registry.yarnpkg.com/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz#7b1c567e489840d747f211d3688949bcba363ad2"
integrity sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==
"@ant-design/icons-svg@^4.6.0":
version "4.6.0"
resolved "https://registry.yarnpkg.com/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz#5f7ebfe2a6b7c871920f73db095bd4ab50d7764d"
integrity sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==
"@ant-design/icons@^6.2.5", "@ant-design/icons@^6.3.2":
version "6.3.2"
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.3.2.tgz#8291dffc53003db9a5df59f80ed758473cd5c8df"
integrity sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==
"@ant-design/icons@^6.3.2", "@ant-design/icons@^6.3.4":
version "6.3.4"
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.3.4.tgz#9a76f7b6b4554a65f25e3e5c439405b96c4ebd71"
integrity sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/icons-svg" "^4.5.0"
"@ant-design/icons-svg" "^4.6.0"
"@rc-component/util" "^1.11.0"
clsx "^2.1.1"
@@ -4969,85 +4969,85 @@
resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9"
integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
"@swc/html-darwin-arm64@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.15.43.tgz#c88069f140ead901724018f96ad709779526a368"
integrity sha512-+PFbHbeeN+zB0zfvR1V1NmvPriuWPI+sijQXpI+wq/nLIujxvtENWjOKVHgouC9TIN/uKmL2zu9HAq6L6YxnPA==
"@swc/html-darwin-arm64@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz#dfe45d70266262a59aaaa0d93740b6161803f192"
integrity sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==
"@swc/html-darwin-x64@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.15.43.tgz#9bc8171494d3b98eac017b5b73f0f63054558626"
integrity sha512-LQJ2U8Oxcx4T1rRF25y4h+/p05nn58FugTe/uGxC5OT3K83c2MftcSZLYaahOu4GVHRZeS1NI94CkSvQV++TVw==
"@swc/html-darwin-x64@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz#72066bda0d843c024dd5230aa013445d10037cde"
integrity sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==
"@swc/html-linux-arm-gnueabihf@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.15.43.tgz#271a5d345719fa2c381df45355f70870faaaf35c"
integrity sha512-DKIen6DuIRO7Xc5gAbgBT5QyRHJGEGXreIdM1VBosYWTGnnrQ//Hwd7bLD6UbT8X8eU1vqvpXwQ1E24QRqRaBQ==
"@swc/html-linux-arm-gnueabihf@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz#efa7dd85b03b941ac173ae2870355f9721993905"
integrity sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==
"@swc/html-linux-arm64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.15.43.tgz#93cd3202f204351e7279efd07abc280fe0b2d193"
integrity sha512-0AuHiyfcE86CZ/CajFIszLzZVzbM2wn5p01oet8Q9RikflCGwyH79Nv9TrAKD1Cx7juUrONzDk+f2b/x73wLTg==
"@swc/html-linux-arm64-gnu@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz#4b6a02b42a40463e9ed9a96e0c42b7095b244f48"
integrity sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==
"@swc/html-linux-arm64-musl@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.15.43.tgz#a6c0c2a1646755b1ee53a6bddbee68674f8edaea"
integrity sha512-TweIdl/g9ugkoiYvcL/qbu+gbglDY3TqNxfXH84WXc4rSqEP20owVlxLya2NjVct8LIP2wDrtutpOwAXWC+Eew==
"@swc/html-linux-arm64-musl@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz#0334e071cb8a01e0423fe7da27204b71f1afed66"
integrity sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==
"@swc/html-linux-ppc64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.15.43.tgz#51319cc1a4184b788613e0e556fc33ff77af0c52"
integrity sha512-4oue1pB38/W6mbudp+w0q1jbwxuwdbdbaOj85ay0pisCs213WkgP+MPN8Zqa5VVPjQnVk2CTY9kmEc74XQI/sA==
"@swc/html-linux-ppc64-gnu@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz#6c0d7293a1f7f7631e975c767755eb5236769193"
integrity sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==
"@swc/html-linux-s390x-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.15.43.tgz#a612115a5f2f6c9df438a52ab2cf8bc4fd3865b1"
integrity sha512-/tceMNvAxK70SKUZtcn3X+K0vcElMGk3i8Sz0CmPdtooso8MZ7WfAvVP1qi3TWgh1rpQ3cC+Al3433AHlET6+w==
"@swc/html-linux-s390x-gnu@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz#853018d7b57377e2f3b23d369a1571862c34d2b9"
integrity sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==
"@swc/html-linux-x64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.15.43.tgz#028980da812e1797316f0a06a758a49aca699318"
integrity sha512-YE7ltlTt5ZFl59GsoHTDrIHnCBY8EDBio66CVj4bqkElFXbE/28xmpVE5ksdGoI5c5aQ/8byUCfHxqzCzQQSVg==
"@swc/html-linux-x64-gnu@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz#a347921e256f6ecb8847956bbacd4a23e1476adb"
integrity sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==
"@swc/html-linux-x64-musl@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.15.43.tgz#363ac7ce3866b664db0c35d78a6a90636f280139"
integrity sha512-nS20HmbOk+dEEzdosJqqxAeyjMIiS5yrCAti8LUf0+dgr4eRmjkH4MlkjfPjf49aayR8o+eMJ1jsDZ7whx4zog==
"@swc/html-linux-x64-musl@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz#ebfbbdb05f2f991a34bc500dce0ca10bfbcdfc3b"
integrity sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==
"@swc/html-win32-arm64-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.15.43.tgz#d0aa3f99c091577aaaa1aaf51a3695c98278564d"
integrity sha512-Yz7aQQhXT/Yc6QcuMDQDZP9jqf2phkVyU+qSu8ZRWEcJgIorrPL6q7YLqMk+MB5PpZyu5XJEODvc1/UVDE1Kyg==
"@swc/html-win32-arm64-msvc@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz#69207d99cd80e55fe1ea722c8c8d5174b3d3cd2a"
integrity sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==
"@swc/html-win32-ia32-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.15.43.tgz#e2cfe4dd26ce8b8c5787ca1fdd69ef6b5dc94822"
integrity sha512-muUgfsSQRZk6YBRuhaGKSLvXy0bV9BW6/mHLI0N/06btWuf0hekoHhIzR7dUmS98NXKCA7Hv+buBPE/0vXUwyA==
"@swc/html-win32-ia32-msvc@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz#9473a17f22c65ec20059533bd8cc78b32c82a711"
integrity sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==
"@swc/html-win32-x64-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.15.43.tgz#ee1a8a7fe4d928595268c214cf17c72867ee8f0f"
integrity sha512-tuLDy4MxPXsLi6jW+ozCdFWO61AoMMnlhePWJxMafefC2Ojm+iILxP2zI2Hgfu6F16y1q7ITdXdpEuqptu5fHw==
"@swc/html-win32-x64-msvc@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz#7c7aeaa21b8848a765be2d4f7ee2ee3fecfd0f52"
integrity sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==
"@swc/html@^1.15.40":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.15.43.tgz#421da1ffc3226d149fd57c73f73755c6e6148427"
integrity sha512-SKbkbdGi9SDO9cTdV+6H0/AYifnb2nDOlz5BlWxlWMXACV3kmX6WwZDo0bBdyGlO/G4jCVWdR5r84qfotU2now==
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.16.2.tgz#91ee34374e4c926c6c8a8f43561688192b8a1725"
integrity sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==
dependencies:
"@swc/counter" "^0.1.3"
optionalDependencies:
"@swc/html-darwin-arm64" "1.15.43"
"@swc/html-darwin-x64" "1.15.43"
"@swc/html-linux-arm-gnueabihf" "1.15.43"
"@swc/html-linux-arm64-gnu" "1.15.43"
"@swc/html-linux-arm64-musl" "1.15.43"
"@swc/html-linux-ppc64-gnu" "1.15.43"
"@swc/html-linux-s390x-gnu" "1.15.43"
"@swc/html-linux-x64-gnu" "1.15.43"
"@swc/html-linux-x64-musl" "1.15.43"
"@swc/html-win32-arm64-msvc" "1.15.43"
"@swc/html-win32-ia32-msvc" "1.15.43"
"@swc/html-win32-x64-msvc" "1.15.43"
"@swc/html-darwin-arm64" "1.16.2"
"@swc/html-darwin-x64" "1.16.2"
"@swc/html-linux-arm-gnueabihf" "1.16.2"
"@swc/html-linux-arm64-gnu" "1.16.2"
"@swc/html-linux-arm64-musl" "1.16.2"
"@swc/html-linux-ppc64-gnu" "1.16.2"
"@swc/html-linux-s390x-gnu" "1.16.2"
"@swc/html-linux-x64-gnu" "1.16.2"
"@swc/html-linux-x64-musl" "1.16.2"
"@swc/html-win32-arm64-msvc" "1.16.2"
"@swc/html-win32-ia32-msvc" "1.16.2"
"@swc/html-win32-x64-msvc" "1.16.2"
"@swc/types@^0.1.28":
version "0.1.28"
@@ -6924,9 +6924,9 @@ color-name@~1.1.4:
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
colord@^2.9.3:
version "2.9.3"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==
version "2.10.0"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.10.0.tgz#56c9050e6b06b4b6c62ddec366a48d65ef57e860"
integrity sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==
colorette@^1.2.0:
version "1.4.0"
@@ -8284,36 +8284,11 @@ escape-string-regexp@^5.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
eslint-scope@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
dependencies:
esrecurse "^4.3.0"
estraverse "^4.1.1"
esprima@~4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esrecurse@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
dependencies:
estraverse "^5.2.0"
estraverse@^4.1.1:
version "4.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
estraverse@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
estree-util-attach-comments@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz#344bde6a64c8a31d15231e5ee9e297566a691c2d"
@@ -9671,9 +9646,9 @@ jiti@^1.20.0:
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
joi@^17.9.2:
version "17.13.4"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.4.tgz#ad6153d97ce558eb3a3b593e0d43eab51df1c474"
integrity sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==
version "17.13.7"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.7.tgz#92e212c50dbbbcb1a1592424f84083eb265cc778"
integrity sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==
dependencies:
"@hapi/hoek" "^9.3.0"
"@hapi/topo" "^5.1.0"
@@ -11137,16 +11112,6 @@ minimist@^1.2.0:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
minimizer-webpack-plugin@^5.6.1:
version "5.6.1"
resolved "https://registry.yarnpkg.com/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz#289922a4c96c4ed1ddb76b8a00bd8074e89a2f7f"
integrity sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==
dependencies:
"@jridgewell/trace-mapping" "^0.3.25"
jest-worker "^27.4.5"
schema-utils "^4.3.0"
terser "^5.31.1"
minimizer-webpack-plugin@^5.7.0:
version "5.8.0"
resolved "https://registry.yarnpkg.com/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.8.0.tgz#744f0e28da888aa1708e2be32b4ddcf2eeb09e58"
@@ -14219,9 +14184,9 @@ svg-parser@^2.0.4:
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
svgo@^3.0.2, svgo@^3.2.0:
version "3.3.4"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.4.tgz#fd2aa10ff585b3bd2b83ce3602f5582bc0718bb5"
integrity sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==
version "3.3.5"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.5.tgz#8a3d9557ab2f386eca7e24760385849554985a1c"
integrity sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==
dependencies:
commander "^7.2.0"
css-select "^5.1.0"
@@ -15163,10 +15128,10 @@ webpack-virtual-modules@^0.6.2:
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
webpack@^5.110.1:
version "5.110.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.110.1.tgz#d662d8ff1866fcb58a6b8af09c86bc6f1b9c5c1d"
integrity sha512-gInQB+jxXxgnZyvPwuzT5NGQmECDqeu85oxcrjinrYHqPoBex0hCAN2SFTJVyPVrK0Pq9E44VFP+e89fAc10/w==
webpack@^5.110.2, webpack@^5.88.1, webpack@^5.95.0:
version "5.110.2"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.110.2.tgz#ef23a0e62fe5e1ba71b033e3505b7f005d8d5c6e"
integrity sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==
dependencies:
"@types/estree" "^1.0.8"
"@types/json-schema" "^7.0.15"
@@ -15188,32 +15153,6 @@ webpack@^5.110.1:
watchpack "^2.5.2"
webpack-sources "^3.5.1"
webpack@^5.88.1, webpack@^5.95.0:
version "5.109.2"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.109.2.tgz#b58dc289561c3282db35c210a99379a836a4c28d"
integrity sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==
dependencies:
"@types/estree" "^1.0.8"
"@types/json-schema" "^7.0.15"
"@webassemblyjs/ast" "^1.14.1"
"@webassemblyjs/wasm-edit" "^1.14.1"
"@webassemblyjs/wasm-parser" "^1.14.1"
acorn "^8.16.0"
browserslist "^4.28.1"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.24.4"
es-module-lexer "^2.1.0"
eslint-scope "5.1.1"
events "^3.2.0"
graceful-fs "^4.2.11"
mime-db "^1.54.0"
minimizer-webpack-plugin "^5.6.1"
neo-async "^2.6.2"
schema-utils "^4.3.3"
tapable "^2.3.0"
watchpack "^2.5.2"
webpack-sources "^3.5.1"
webpackbar@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/webpackbar/-/webpackbar-7.0.0.tgz#7228d32881af2392381b6514499ddea73cdf218a"
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies = [
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.2, <6.0.0",
"flask-caching>=2.4.1, <3",
"flask-caching>=2.5.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
+5 -4
View File
@@ -40,7 +40,7 @@ brotli==1.2.0
# via
# -r requirements/base.in
# flask-compress
cachelib==0.13.0
cachelib==0.17.0
# via
# flask-caching
# flask-session
@@ -105,7 +105,7 @@ et-xmlfile==2.0.0
# via openpyxl
filelock==3.20.3
# via -r requirements/base.in
flask==2.3.3
flask==3.1.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
@@ -124,9 +124,9 @@ flask-appbuilder==5.2.2
# via
# apache-superset (pyproject.toml)
# apache-superset-core
flask-babel==3.1.0
flask-babel==4.0.0
# via flask-appbuilder
flask-caching==2.4.1
flask-caching==2.5.0
# via apache-superset (pyproject.toml)
flask-compress==1.24
# via apache-superset (pyproject.toml)
@@ -218,6 +218,7 @@ markdown-it-py==3.0.0
# via rich
markupsafe==3.0.2
# via
# flask
# jinja2
# mako
# werkzeug
+5 -4
View File
@@ -94,7 +94,7 @@ brotli==1.2.0
# via
# -c requirements/base-constraint.txt
# flask-compress
cachelib==0.13.0
cachelib==0.17.0
# via
# -c requirements/base-constraint.txt
# flask-caching
@@ -247,7 +247,7 @@ filelock==3.20.3
# via
# -c requirements/base-constraint.txt
# virtualenv
flask==2.3.3
flask==3.1.3
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -269,11 +269,11 @@ flask-appbuilder==5.2.2
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
flask-babel==3.1.0
flask-babel==4.0.0
# via
# -c requirements/base-constraint.txt
# flask-appbuilder
flask-caching==2.4.1
flask-caching==2.5.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -524,6 +524,7 @@ markdown-it-py==3.0.0
markupsafe==3.0.2
# via
# -c requirements/base-constraint.txt
# flask
# jinja2
# mako
# werkzeug
+4 -4
View File
@@ -23,7 +23,7 @@
"tscw-config": "^1.1.2",
"typescript": "^7.0.2",
"vitest": "^4.0.18",
"webpack": "^5.110.0",
"webpack": "^5.110.2",
"webpack-cli": "^7.2.3"
}
},
@@ -5260,9 +5260,9 @@
}
},
"node_modules/webpack": {
"version": "5.110.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.1.tgz",
"integrity": "sha512-gInQB+jxXxgnZyvPwuzT5NGQmECDqeu85oxcrjinrYHqPoBex0hCAN2SFTJVyPVrK0Pq9E44VFP+e89fAc10/w==",
"version": "5.110.2",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.2.tgz",
"integrity": "sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -47,7 +47,7 @@
"tscw-config": "^1.1.2",
"typescript": "^7.0.2",
"vitest": "^4.0.18",
"webpack": "^5.110.0",
"webpack": "^5.110.2",
"webpack-cli": "^7.2.3"
},
"repository": {
+169 -194
View File
@@ -215,7 +215,7 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/eslint-plugin": "^8.69.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.5.0",
"babel-loader": "^10.1.1",
@@ -272,11 +272,11 @@
"swc-loader": "^0.2.7",
"ts-jest": "^29.4.12",
"tscw-config": "^1.1.2",
"tsx": "^4.23.12",
"tsx": "^4.23.13",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.110.1",
"webpack": "^5.110.2",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0",
@@ -420,9 +420,9 @@
}
},
"node_modules/@ant-design/icons-svg": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
"integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==",
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz",
"integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==",
"license": "MIT"
},
"node_modules/@ant-design/react-slick": {
@@ -8007,11 +8007,12 @@
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">= 22"
}
},
"node_modules/@mapbox/martini": {
@@ -8027,9 +8028,9 @@
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz",
"integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
@@ -8047,29 +8048,23 @@
"@mapbox/point-geometry": "~0.1.0"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
"license": "ISC"
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.8.5",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz",
"integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==",
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
@@ -8081,10 +8076,16 @@
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.1.tgz",
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
@@ -8097,18 +8098,14 @@
"license": "ISC"
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/vector-tile": "^2.0.4",
"@maplibre/geojson-vt": "^5.0.4",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"pbf": "^4.0.1",
"supercluster": "^8.0.1"
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/@mapbox/point-geometry": {
@@ -8117,21 +8114,10 @@
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@maplibre/vt-pbf/node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
@@ -14995,17 +14981,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
"integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
"integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/type-utils": "8.68.0",
"@typescript-eslint/utils": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0",
"@typescript-eslint/scope-manager": "8.69.0",
"@typescript-eslint/type-utils": "8.69.0",
"@typescript-eslint/utils": "8.69.0",
"@typescript-eslint/visitor-keys": "8.69.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -15018,7 +15004,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.68.0",
"@typescript-eslint/parser": "^8.69.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -15034,16 +15020,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
"integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz",
"integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0",
"@typescript-eslint/scope-manager": "8.69.0",
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0",
"@typescript-eslint/visitor-keys": "8.69.0",
"debug": "^4.4.3"
},
"engines": {
@@ -15059,14 +15045,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
"integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz",
"integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.68.0",
"@typescript-eslint/types": "^8.68.0",
"@typescript-eslint/tsconfig-utils": "^8.69.0",
"@typescript-eslint/types": "^8.69.0",
"debug": "^4.4.3"
},
"engines": {
@@ -15081,14 +15067,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
"integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz",
"integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0"
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/visitor-keys": "8.69.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -15099,9 +15085,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
"integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz",
"integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15116,15 +15102,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
"integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz",
"integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/utils": "8.68.0",
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0",
"@typescript-eslint/utils": "8.69.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -15141,9 +15127,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
"integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz",
"integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15155,16 +15141,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
"integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz",
"integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.68.0",
"@typescript-eslint/tsconfig-utils": "8.68.0",
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/visitor-keys": "8.68.0",
"@typescript-eslint/project-service": "8.69.0",
"@typescript-eslint/tsconfig-utils": "8.69.0",
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/visitor-keys": "8.69.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -15199,16 +15185,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
"integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz",
"integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.68.0"
"@typescript-eslint/scope-manager": "8.69.0",
"@typescript-eslint/types": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -15223,13 +15209,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
"integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
"version": "8.69.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz",
"integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.68.0",
"@typescript-eslint/types": "8.69.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -28781,9 +28767,9 @@
}
},
"node_modules/jest-process-manager/node_modules/joi": {
"version": "17.13.4",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
"version": "17.13.7",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -30783,9 +30769,9 @@
}
},
"node_modules/joi": {
"version": "18.2.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz",
"integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==",
"version": "18.2.8",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
"integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -32288,7 +32274,8 @@
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.isempty": {
"version": "4.4.0",
@@ -32300,7 +32287,8 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
@@ -32575,27 +32563,25 @@
]
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.8.0.tgz",
"integrity": "sha512-+ZkjKTodsVLY0ewQThvXRxXQsclcsSgOm5LlnrBM3G8AloMJKt6Haw8mY4xrOl8T0WMqH6DTYjktZ9zGQXZd4w==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.1",
"@maplibre/maplibre-gl-style-spec": "^26.4.1",
"@maplibre/mlt": "^1.2.1",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"earcut": "^3.2.3",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
@@ -32614,36 +32600,33 @@
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/maplibre-gl/node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/maplibre-gl/node_modules/@maplibre/geojson-vt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
"pbf": "^5.0.0"
}
},
"node_modules/maplibre-gl/node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/maplibre-gl/node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
@@ -38965,9 +38948,9 @@
}
},
"node_modules/react-error-boundary": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.3.tgz",
"integrity": "sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==",
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.4.tgz",
"integrity": "sha512-7FgvbyjCFhu4dtKSZu807cW1MWG2nWAM4bwGqUByAtHVJXtlzoCJX0SSyZRyFrb+aBd7wtJkcOR4DsxXVEuDsA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
@@ -42614,15 +42597,6 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -42715,9 +42689,9 @@
"dev": true
},
"node_modules/svgo": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz",
"integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==",
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz",
"integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -43761,9 +43735,9 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.12",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -45127,9 +45101,9 @@
}
},
"node_modules/webpack": {
"version": "5.110.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.1.tgz",
"integrity": "sha512-gInQB+jxXxgnZyvPwuzT5NGQmECDqeu85oxcrjinrYHqPoBex0hCAN2SFTJVyPVrK0Pq9E44VFP+e89fAc10/w==",
"version": "5.110.2",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.2.tgz",
"integrity": "sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -46922,9 +46896,9 @@
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -47053,7 +47027,7 @@
"tinycolor2": "*"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1 || ^6.0.0",
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.4.1",
"@superset-ui/core": "*",
"@testing-library/dom": "^10.4.1",
@@ -47073,7 +47047,7 @@
"version": "0.20.4",
"license": "Apache-2.0",
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@ant-design/icons": "^6.3.4",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.7",
"@braintree/sanitize-url": "^7.1.2",
@@ -47102,9 +47076,9 @@
"math-expression-evaluator": "^2.0.7",
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-ace": "^15.0.0",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.3",
"react-error-boundary": "^6.1.4",
"react-js-cron": "^6.0.2",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
@@ -47175,13 +47149,13 @@
}
},
"packages/superset-ui-core/node_modules/@ant-design/icons": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz",
"integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==",
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.4.tgz",
"integrity": "sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.1",
"@ant-design/icons-svg": "^4.5.0",
"@ant-design/icons-svg": "^4.6.0",
"@rc-component/util": "^1.11.0",
"clsx": "^2.1.1"
},
@@ -47203,15 +47177,14 @@
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
"integrity": "sha512-z6YAZ20PNf/FqmYEic//G/UK6uw0rn21g58ASgHJHl9rfE4nITQLqthr9rHMVQK4ezwohJbp2dGrZpkq979PYQ==",
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
"integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
"license": "MIT",
"dependencies": {
"ace-builds": "^1.36.3",
"diff-match-patch": "^1.0.5",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"fast-equals": "^5.3.3",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@@ -47414,7 +47387,7 @@
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"zod": "^4.4.3"
"zod": "^4.5.4"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -47593,8 +47566,10 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^5.24.0",
"maplibre-gl": "^6.8.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
},
@@ -47737,7 +47712,7 @@
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"maplibre-gl": "^5.24.0",
"maplibre-gl": "^6.8.0",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.4",
"prop-types": "^15.8.1",
+3 -3
View File
@@ -292,7 +292,7 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/eslint-plugin": "^8.69.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.5.0",
"babel-loader": "^10.1.1",
@@ -349,11 +349,11 @@
"swc-loader": "^0.2.7",
"ts-jest": "^29.4.12",
"tscw-config": "^1.1.2",
"tsx": "^4.23.12",
"tsx": "^4.23.13",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.110.1",
"webpack": "^5.110.2",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0",
@@ -34,7 +34,7 @@
"tinycolor2": "*"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1 || ^6.0.0",
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.4.1",
"@superset-ui/core": "*",
"@testing-library/dom": "^10.4.1",
@@ -49,7 +49,7 @@
"access": "public"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@ant-design/icons": "^6.3.4",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.7",
"@braintree/sanitize-url": "^7.1.2",
@@ -78,9 +78,9 @@
"math-expression-evaluator": "^2.0.7",
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-ace": "^15.0.0",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.3",
"react-error-boundary": "^6.1.4",
"react-js-cron": "^6.0.2",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
@@ -566,6 +566,104 @@ test('should NOT refetch data when other string-based renderTrigger controls cha
});
});
test('should NOT refetch data when echart_options (string-based renderTrigger control) changes', async () => {
// Matches how the Timeseries/MixedTimeseries control panels reference this
// shared control: a bare string, e.g. ['echart_options'].
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [['echart_options']],
},
],
};
jest.mocked(getChartControlPanelRegistry).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
} as unknown as ReturnType<typeof getChartControlPanelRegistry>);
const formDataWithEchartOptions = {
...mockFormData,
echart_options: '{}',
};
const { rerender, getByTestId } = render(
<StatefulChart
formData={formDataWithEchartOptions}
chartType="test_chart"
/>,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
// Edit the ECharts Options field (e.g. from the Customize tab while the
// chart is part of a Matrixify grid cell).
const updatedFormData = {
...formDataWithEchartOptions,
echart_options: '{"title": {"text": "My Chart"}}',
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should NOT refetch data - echart_options is a renderTrigger control
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
// But should re-render with the new formData
expect(getByTestId('super-chart')).toHaveTextContent(
JSON.stringify(updatedFormData),
);
});
});
test('should refetch when a chart overrides a shared renderTrigger control to renderTrigger: false', async () => {
// Matches Country Map's controlPanel.controlOverrides, which sets
// linear_color_scheme to renderTrigger: false because it drives the
// choropleth data query rather than just styling.
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [['linear_color_scheme']],
},
],
controlOverrides: {
linear_color_scheme: {
renderTrigger: false,
},
},
};
jest.mocked(getChartControlPanelRegistry).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
} as unknown as ReturnType<typeof getChartControlPanelRegistry>);
const formDataWithColorScheme = {
...mockFormData,
linear_color_scheme: 'schemeA',
};
const { rerender } = render(
<StatefulChart formData={formDataWithColorScheme} chartType="test_chart" />,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
const updatedFormData = {
...formDataWithColorScheme,
linear_color_scheme: 'schemeB',
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should refetch because this chart's controlOverrides mark the control
// as data-affecting, overriding the shared-control fallback.
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
});
});
test('should refetch when string control is NOT in RENDER_TRIGGER_SHARED_CONTROLS', async () => {
// Control panel with a string control that is NOT in the renderTrigger set
const controlPanelConfig = {
@@ -50,6 +50,9 @@ type LoadingState = 'uninitialized' | 'loading' | 'loaded' | 'error';
* This list is needed because string-based control references (e.g., ['zoomable'])
* cannot be introspected for their renderTrigger property without importing
* sharedControls, which would create a circular dependency.
*
* Keep this list in sync with the `renderTrigger: true` entries in
* @superset-ui/chart-controls's sharedControls.tsx.
*/
const RENDER_TRIGGER_SHARED_CONTROLS = new Set([
'zoomable',
@@ -57,6 +60,11 @@ const RENDER_TRIGGER_SHARED_CONTROLS = new Set([
'time_shift_color',
'y_axis_format',
'currency_format',
'color_picker',
'linear_color_scheme',
'x_axis_time_format',
'x_axis_number_format',
'echart_options',
]);
/**
@@ -111,6 +119,28 @@ function shouldRefetchData(
}
});
// Individual chart types can override a shared control's renderTrigger
// behavior (e.g., Country Map sets `linear_color_scheme` to
// renderTrigger: false because it drives the choropleth query, not just
// styling). Apply those overrides on top of the shared-control fallback
// so such controls still trigger a refetch for that chart type.
const { controlOverrides } = controlPanel;
if (controlOverrides) {
Object.entries(controlOverrides).forEach(([controlName, override]) => {
if (
override &&
typeof override === 'object' &&
'renderTrigger' in override
) {
if ((override as { renderTrigger?: boolean }).renderTrigger) {
renderTriggerControls.add(controlName);
} else {
renderTriggerControls.delete(controlName);
}
}
});
}
// Check which fields changed
const changedFields = Object.keys(nextFormData).filter(
key =>
@@ -604,6 +604,61 @@ test('cleans up event listeners on unmount', async () => {
offSpy.mockRestore();
});
test('re-applies annotations only when their content actually changes across renders (react-ace 15 fast-equals regression guard)', async () => {
// react-ace's componentDidUpdate decides whether to call
// session.setAnnotations() by deep-comparing the new/old `annotations`
// prop (lib/ace.js, using an internal deep-equality helper -- lodash's
// isEqual through react-ace 14.x, fast-equals's deepEqual from 15.0.0
// onward). Superset's own AceEditorProvider/EditorWrapper always pass a
// freshly `.map()`-derived annotations array on every render, so this
// guards the actual behavior Superset relies on: a same-content-but-
// different-reference array must NOT re-trigger setAnnotations (or the
// editor would thrash on every keystroke-driven re-render), while a
// genuinely different array must still update the editor.
const ref = createRef<AceEditor>();
const annotationsV1 = [{ row: 0, column: 0, type: 'error', text: 'oops' }];
const { rerender, container } = render(
<SQLEditor ref={ref as React.Ref<never>} annotations={annotationsV1} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const session = ref.current?.editor?.getSession();
expect(session).toBeDefined();
if (!session) return;
// The initial mount already applies annotations via componentDidMount,
// not componentDidUpdate, so start observing only from the first update.
const setAnnotationsSpy = jest.spyOn(session, 'setAnnotations');
// Same content, new array/object references -- must be a no-op.
const annotationsV1SameContent = [
{ row: 0, column: 0, type: 'error', text: 'oops' },
];
rerender(
<SQLEditor
ref={ref as React.Ref<never>}
annotations={annotationsV1SameContent}
/>,
);
expect(setAnnotationsSpy).not.toHaveBeenCalled();
// Genuinely different content -- must update, with the new value.
const annotationsV2 = [
{ row: 1, column: 2, type: 'warning', text: 'different' },
];
rerender(
<SQLEditor ref={ref as React.Ref<never>} annotations={annotationsV2} />,
);
expect(setAnnotationsSpy).toHaveBeenCalledTimes(1);
expect(setAnnotationsSpy).toHaveBeenCalledWith(annotationsV2);
setAnnotationsSpy.mockRestore();
});
test('does not move autocomplete popup if target container is document.body', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
@@ -0,0 +1,102 @@
/**
* 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.
*/
import { Icons } from '../Icons';
import { Button } from '../Button';
import MetadataBar, { MetadataType } from '../MetadataBar';
import { Menu } from '../Menu';
import { PageHeaderWithActions, PageHeaderWithActionsProps } from '.';
export default {
title: 'Design System/Components/PageHeaderWithActions',
component: PageHeaderWithActions,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'Header used on entity pages (e.g. the dashboard page) combining an editable title with badges, a metadata bar, and page-level actions.',
},
},
},
};
// Mirrors src/dashboard/components/Header's real composition: an editable
// title, the certified badge, and a titlePanelAdditionalItems cluster of a
// refresh button, an auto-refresh indicator, a published-status toggle, and
// a MetadataBar (Last Modified + Editor) -- the same items the real
// dashboard header packs into that space -- so this story reproduces the
// header's real narrow-viewport layout behavior, not just the isolated
// MetadataBar. The real RefreshButton/AutoRefreshIndicator/PublishedStatus
// components live in src/dashboard and depend on this package, so they
// can't be imported here without inverting that dependency; these are
// same-sized stand-ins built from core components instead.
export const DashboardHeader = (args: PageHeaderWithActionsProps) => (
<PageHeaderWithActions {...args} />
);
DashboardHeader.args = {
editableTitleProps: {
title: 'Q3 Executive Revenue and Growth Overview Dashboard',
placeholder: 'Add the name of the dashboard',
onSave: () => {},
canEdit: true,
label: 'Dashboard title',
},
showTitlePanelItems: true,
certificatiedBadgeProps: {
certifiedBy: 'Jane Doe',
details: 'Certified by the BI team',
},
showFaveStar: true,
faveStarProps: { itemId: 1, saveFaveStar: () => {}, isStarred: false },
titlePanelAdditionalItems: [
<Button key="refresh-button" buttonStyle="link" tooltip="Refresh dashboard">
<Icons.ReloadOutlined iconSize="l" />
</Button>,
<Icons.SyncOutlined key="auto-refresh-indicator" iconSize="l" />,
<Button key="published-status" buttonStyle="link">
Published
</Button>,
<MetadataBar
key="metadata-bar"
tooltipPlacement="bottom"
items={[
{
type: MetadataType.LastModified,
value: '2 hours ago',
modifiedBy: 'Jane Doe',
},
{
type: MetadataType.Editor,
createdBy: 'Jane Doe',
editors: ['Jane Doe', 'John Smith'],
createdOn: 'a week ago',
},
]}
/>,
],
rightPanelAdditionalItems: <button type="button">Edit dashboard</button>,
additionalActionsMenu: (
<Menu
items={[{ label: 'Edit properties', key: '1' }]}
data-test="additional-actions-menu"
/>
),
menuDropdownProps: {},
};
@@ -18,7 +18,12 @@
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { PageHeaderWithActions, PageHeaderWithActionsProps } from './index';
import { supersetTheme } from '@apache-superset/core/theme';
import {
buttonsStyles,
PageHeaderWithActions,
PageHeaderWithActionsProps,
} from './index';
import { Menu } from '../Menu';
const defaultProps: PageHeaderWithActionsProps = {
@@ -54,3 +59,15 @@ test('Renders', async () => {
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(defaultProps.menuDropdownProps.onOpenChange).toHaveBeenCalled();
});
test('clips the title panel buttons/metadata cluster instead of letting it overflow into the actions menu', () => {
// jsdom doesn't compute real flexbox layout, so it can't verify the
// overlap itself is fixed; this guards the underlying CSS from
// regressing instead. Without `overflow: hidden`, this wrapper's
// automatic flex minimum size is based on its content rather than 0, so
// it refuses to shrink -- forcing the title to absorb all the space
// pressure until the cluster's content renders outside its box and
// overlaps the actions menu once the title has fully collapsed.
const { styles } = buttonsStyles(supersetTheme);
expect(styles).toMatch(/overflow:\s*hidden/);
});
@@ -99,9 +99,13 @@ const headerStyles = (theme: SupersetTheme) => css`
}
`;
const buttonsStyles = (theme: SupersetTheme) => css`
// Exported only so PageHeaderWithActions.test.tsx can assert on the
// `overflow: hidden` declaration directly; not part of the component's
// public API.
export const buttonsStyles = (theme: SupersetTheme) => css`
display: flex;
align-items: center;
overflow: hidden;
padding-left: ${theme.sizeUnit * 2}px;
& .anticon-star {
@@ -16,15 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
DataRecordValue,
normalizeTimestamp,
TimeFormatFunction,
} from '@superset-ui/core';
import type { DataRecordValue } from '../query/types/QueryResponse';
import type { TimeFormatFunction } from './types';
import normalizeTimestamp from './utils/normalizeTimestamp';
/**
* Extended Date object with a custom formatter, and retains the original input
* when the formatter is simple `String(..)`.
*
* `toString()` never formats an Invalid Date: it returns the original input
* instead. `stringifyTimeInput` relies on that when it falls back to
* `${value}` for an unparseable input, otherwise the two would call each other
* forever.
*/
export default class DateWithFormatter extends Date {
formatter: TimeFormatFunction;
@@ -19,6 +19,7 @@
export { default as TimeFormats, LOCAL_PREFIX } from './TimeFormats';
export { default as TimeFormatter, PREVIEW_TIME } from './TimeFormatter';
export { default as DateWithFormatter } from './DateWithFormatter';
export { DEFAULT_D3_TIME_FORMAT } from './D3FormatConfig';
export {
@@ -28,8 +28,17 @@ export default function stringifyTimeInput(
let time: Date;
if (typeof value === 'string') {
const trimmed = value.trim();
// A bare four-digit string is the ISO 8601 year-only form ("2017"), which
// every engine parses as January 1st of that year. Any other integer
// string is an epoch timestamp in milliseconds that was stringified on
// its way here, e.g. by the pivot table, and is not a valid Date input.
const isYear = /^\d{4}$/.test(trimmed);
const isIntegerString = /^-?\d+$/.test(trimmed);
time = new Date(isIntegerString ? Number(trimmed) : value);
if (isYear) {
time = new Date(trimmed);
} else {
time = new Date(isIntegerString ? Number(trimmed) : value);
}
} else {
time = value instanceof Date ? value : new Date(value);
}
@@ -37,7 +46,9 @@ export default function stringifyTimeInput(
// An input that does not resolve to a valid date - a duration such as
// "00:01:54", for instance - would otherwise be formatted from an Invalid
// Date and render as "NaN:NaN:NaN". Fall back to its own representation,
// as is already done for null and undefined above.
// as is already done for null and undefined above. For a `DateWithFormatter`
// this calls its `toString()`, which returns the original input rather than
// re-entering the formatter; that guard is what keeps the fallback finite.
if (Number.isNaN(time.getTime())) {
return `${value}`;
}
@@ -1,4 +1,4 @@
/*
/**
* 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
@@ -16,11 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getTimeFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import DateWithFormatter from '../../src/utils/DateWithFormatter';
import { formatColumnValue } from '../../src/utils/formatValue';
import { DataColumnMeta } from '../../src/types';
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
const formatter = getTimeFormatter('%H:%M:%S');
@@ -43,18 +39,3 @@ test('retains the original input when the formatter is String', () => {
const value = new DateWithFormatter('00:01:54');
expect(String(value)).toBe('00:01:54');
});
test('renders a duration cell through the column formatter without producing NaN', () => {
// The cell text is produced by formatColumnValue, which hands the wrapped
// value straight to the formatter rather than going through toString().
const column: DataColumnMeta = {
key: 'call_period',
label: 'call_period',
dataType: GenericDataType.Temporal,
formatter,
isNumeric: false,
};
const value = new DateWithFormatter('00:01:54', { formatter });
expect(formatColumnValue(column, value)).toEqual([false, '00:01:54']);
});
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
import stringifyTimeInput from '../../../src/time-format/utils/stringifyTimeInput';
const format = (time: Date) => time.toISOString();
@@ -56,3 +57,25 @@ test('returns unparseable strings unchanged instead of formatting an Invalid Dat
test('returns the representation of a Date that could not be resolved', () => {
expect(stringifyTimeInput(new Date('00:01:54'), format)).toBe('Invalid Date');
});
test('treats a four-digit integer string as a year, not as milliseconds', () => {
// "2017" is the ISO 8601 year-only form. Reading it as an epoch offset
// would silently turn it into two seconds past 1970.
expect(stringifyTimeInput('2017', format)).toBe('2017-01-01T00:00:00.000Z');
expect(stringifyTimeInput(' 1987 ', format)).toBe('1987-01-01T00:00:00.000Z');
// Longer digit strings stay epoch milliseconds.
expect(stringifyTimeInput('1704067200000', format)).toBe(
'2024-01-01T00:00:00.000Z',
);
});
test('returns the original input of an unparseable DateWithFormatter without re-entering the formatter', () => {
// The `${value}` fallback calls `DateWithFormatter.toString()`, which must
// return the input rather than call the formatter again, or the two would
// recurse until the stack overflows.
const formatter = getTimeFormatter('%H:%M:%S');
const value = new DateWithFormatter('00:01:54', { formatter });
expect(stringifyTimeInput(value, time => formatter(time))).toBe('00:01:54');
expect(formatter(value)).toBe('00:01:54');
});
@@ -36,6 +36,7 @@ import {
TimeFormatter,
AgGridChartState,
AgGridFilterModel,
DateWithFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { isEmpty, isEqual, merge } from 'lodash-es';
@@ -45,7 +46,6 @@ import {
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
import {
DataColumnMeta,
@@ -22,6 +22,7 @@ import {
isDefined,
isProbablyHTML,
sanitizeHtml,
DateWithFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
@@ -29,7 +30,6 @@ import {
ValueGetterParams,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { DataColumnMeta, InputColumn } from '../types';
import DateWithFormatter from './DateWithFormatter';
/**
* Format text for cell value.
@@ -25,7 +25,11 @@ import {
CellClassParams,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { useCallback, useMemo } from 'react';
import { DataRecordValue, JsonObject } from '@superset-ui/core';
import {
DataRecordValue,
DateWithFormatter,
JsonObject,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { useTheme } from '@apache-superset/core/theme';
import { ColorFormatters } from '@superset-ui/chart-controls';
@@ -42,7 +46,6 @@ import htmlTextFilterValueGetter, {
htmlTextComparator,
} from './htmlTextFilterValueGetter';
import dateFilterComparator from './dateFilterComparator';
import DateWithFormatter from './DateWithFormatter';
import { getAggFunc } from './getAggFunc';
import { TextCellRenderer } from '../renderers/TextCellRenderer';
import { NumericCellRenderer } from '../renderers/NumericCellRenderer';
@@ -16,34 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getTimeFormatter } from '@superset-ui/core';
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import DateWithFormatter from '../../src/utils/DateWithFormatter';
import { formatColumnValue } from '../../src/utils/formatValue';
import { DataColumnMeta } from '../../src/types';
const formatter = getTimeFormatter('%H:%M:%S');
test('formats a parseable timestamp with the configured formatter', () => {
const value = new DateWithFormatter('2017-02-14T11:22:33Z', { formatter });
expect(String(value)).toBe('11:22:33');
});
test('renders the original value when it is not a parseable timestamp', () => {
// Duration columns hold values like these. They produce an Invalid Date,
// which used to be formatted and rendered as "NaN:NaN:NaN".
['00:01:54', '0 days 00:01:54'].forEach(input => {
const value = new DateWithFormatter(input, { formatter });
expect(Number.isNaN(value.getTime())).toBe(true);
expect(String(value)).toBe(input);
});
});
test('retains the original input when the formatter is String', () => {
const value = new DateWithFormatter('00:01:54');
expect(String(value)).toBe('00:01:54');
});
test('renders a duration cell through the column formatter without producing NaN', () => {
// The cell text is produced by formatColumnValue, which hands the wrapped
// value straight to the formatter rather than going through toString().
@@ -33,7 +33,7 @@
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"zod": "^4.4.3"
"zod": "^4.5.4"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -44,9 +44,14 @@ import {
ValueFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { getOriginalSeries } from '@superset-ui/chart-controls';
import {
getOriginalSeries,
getTimeOffset,
isDerivedSeries,
} from '@superset-ui/chart-controls';
import type { EChartsCoreOption } from 'echarts/core';
import type { SeriesOption } from 'echarts';
import type { LineStyleOption } from 'echarts/types/src/util/types';
import {
DEFAULT_FORM_DATA,
EchartsMixedTimeseriesChartTransformedProps,
@@ -100,7 +105,11 @@ import {
transformSeries,
transformTimeseriesAnnotation,
} from '../Timeseries/transformers';
import { TIMEGRAIN_TO_TIMESTAMP, TIMESERIES_CONSTANTS } from '../constants';
import {
TIMEGRAIN_TO_TIMESTAMP,
TIMESERIES_CONSTANTS,
OpacityEnum,
} from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import {
createSpacedXAxisFormatter,
@@ -453,6 +462,10 @@ export default function transformProps(
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
// Tracks a stable pattern index per time offset so that derived series
// sharing the same comparison window (across both queries A and B) get
// the same dash pattern, mirroring the regular Timeseries transform.
const offsetPatterns: { [key: string]: number } = {};
// The rendered ECharts series names are display names that can diverge from
// the backend `label_map` keys: the metric display name is prepended when
@@ -467,6 +480,22 @@ export default function transformProps(
rawSeriesA.forEach(entry => {
const entryName = String(entry.name || '');
const seriesName = inverted[entryName] || entryName;
const derivedSeries = isDerivedSeries(
entry,
chartProps.rawFormData,
seriesName,
);
const lineStyle: LineStyleOption = {};
if (derivedSeries && timeShiftColor) {
const offset = getTimeOffset(entry, array) || seriesName;
if (!offsetPatterns[offset]) {
offsetPatterns[offset] = Object.keys(offsetPatterns).length + 1;
}
const patternIndex = offsetPatterns[offset];
// use a combination of dash and dot for the line style
lineStyle.type = [(patternIndex % 5) + 1, (patternIndex % 3) + 1];
lineStyle.opacity = OpacityEnum.DerivedSeries;
}
const colorScaleKey = getOriginalSeries(seriesName, array);
const labelMapValues = rawLabelMap?.[seriesName];
@@ -544,6 +573,7 @@ export default function transformProps(
timeShiftColor,
theme,
labelPosition,
lineStyle,
},
);
@@ -556,6 +586,23 @@ export default function transformProps(
rawSeriesB.forEach(entry => {
const entryName = String(entry.name || '');
const seriesEntry = inverted[entryName] || entryName;
const derivedSeries = isDerivedSeries(
entry,
chartProps.rawFormData,
seriesEntry,
);
const lineStyle: LineStyleOption = {};
if (derivedSeries && timeShiftColor) {
const offset = getTimeOffset(entry, array) || seriesEntry;
if (!offsetPatterns[offset]) {
offsetPatterns[offset] = Object.keys(offsetPatterns).length + 1;
}
const patternIndex = offsetPatterns[offset];
// use a combination of dash and dot for the line style
lineStyle.type = [(patternIndex % 5) + 1, (patternIndex % 3) + 1];
lineStyle.opacity = OpacityEnum.DerivedSeries;
}
const colorScaleKey = getOriginalSeries(seriesEntry, array);
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
@@ -634,6 +681,7 @@ export default function transformProps(
timeShiftColor,
theme,
labelPosition: labelPositionB,
lineStyle,
},
);
@@ -21,6 +21,7 @@ import {
AnnotationType,
AnnotationSourceType,
AxisType,
ComparisonType,
DataRecord,
FormulaAnnotationLayer,
IntervalAnnotationLayer,
@@ -1562,6 +1563,7 @@ test('y-axis title position: non-Left sets nameLocation to end', () => {
expect(yAxis[1].nameGap).toEqual(30);
expect(yAxis[1].nameLocation).toEqual('end');
});
describe('EchartsMixedTimeseries tooltip truncation', () => {
const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99';
const marker = '<span style="background-color:#1f77b4;"></span>';
@@ -1763,3 +1765,162 @@ test('hides the ticks on the x axis and both y axes', () => {
expect(yAxis[0].axisTick.show).toBe(false);
expect(yAxis[1].axisTick.show).toBe(false);
});
test('should apply a dashed lineStyle to derived (time comparison) series only', () => {
const queryAData = createTestQueryData(
[
{
sum__num: 100,
'sum__num__1 week ago': 80,
ds: 599616000000,
},
{
sum__num: 150,
'sum__num__1 week ago': 120,
ds: 599916000000,
},
],
{
label_map: {
ds: ['ds'],
sum__num: ['sum__num'],
'sum__num__1 week ago': ['sum__num__1 week ago'],
},
},
);
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryAData, queriesData[1]],
formData: {
...formData,
metrics: ['sum__num'],
groupby: [],
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Values,
timeShiftColor: true,
},
queriesData: [queryAData, queriesData[1]],
});
const transformed = transformProps(chartProps);
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
const mainSeries = series.find(s => s.name === 'sum__num') as
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
| undefined;
const derivedSeries = series.find(s => s.name === 'sum__num__1 week ago') as
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
| undefined;
expect(mainSeries).toBeDefined();
expect(derivedSeries).toBeDefined();
// The primary (non-derived) series should not receive a dash pattern
expect(mainSeries?.lineStyle?.type).toBeUndefined();
// The derived (time comparison) series should receive a dash pattern array
expect(Array.isArray(derivedSeries?.lineStyle?.type)).toBe(true);
});
test('should not apply a dashed lineStyle when comparison_type is not Values', () => {
const queryAData = createTestQueryData(
[
{
sum__num: 100,
'sum__num__1 week ago': 80,
ds: 599616000000,
},
{
sum__num: 150,
'sum__num__1 week ago': 120,
ds: 599916000000,
},
],
{
label_map: {
ds: ['ds'],
sum__num: ['sum__num'],
'sum__num__1 week ago': ['sum__num__1 week ago'],
},
},
);
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryAData, queriesData[1]],
formData: {
...formData,
metrics: ['sum__num'],
groupby: [],
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Difference,
},
queriesData: [queryAData, queriesData[1]],
});
const transformed = transformProps(chartProps);
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
const derivedSeries = series.find(s => s.name === 'sum__num__1 week ago') as
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
| undefined;
expect(derivedSeries).toBeDefined();
expect(derivedSeries?.lineStyle?.type).toBeUndefined();
});
test('should not apply a dashed lineStyle when timeShiftColor is disabled', () => {
const queryAData = createTestQueryData(
[
{
sum__num: 100,
'sum__num__1 week ago': 80,
ds: 599616000000,
},
{
sum__num: 150,
'sum__num__1 week ago': 120,
ds: 599916000000,
},
],
{
label_map: {
ds: ['ds'],
sum__num: ['sum__num'],
'sum__num__1 week ago': ['sum__num__1 week ago'],
},
},
);
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryAData, queriesData[1]],
formData: {
...formData,
metrics: ['sum__num'],
groupby: [],
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Values,
timeShiftColor: false,
},
queriesData: [queryAData, queriesData[1]],
});
const transformed = transformProps(chartProps);
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
const derivedSeries = series.find(s => s.name === 'sum__num__1 week ago') as
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
| undefined;
expect(derivedSeries).toBeDefined();
expect(derivedSeries?.lineStyle?.type).toBeUndefined();
});
@@ -44,6 +44,23 @@ import {
import { PivotData, flatKey } from './utilities';
import { Styles } from './Styles';
/**
* Pivot keys are stringified on their way through `PivotData`, so a temporal
* header holding an epoch timestamp arrives as e.g. "1700000000000". Coerce
* such numeric strings back to numbers so temporal formatters (which expect
* an epoch) render correctly. A bare four-digit string is the ISO 8601
* year-only form ("2017"), which the shared `stringifyTimeInput` in core
* reads as that calendar year; coercing it would turn the year into two
* seconds past 1970, so it is passed through untouched.
*/
const toDateFormatterInput = (value: unknown): unknown =>
typeof value === 'string' &&
value.trim() !== '' &&
!/^\d{4}$/.test(value.trim()) &&
Number.isFinite(Number(value))
? Number(value)
: value;
type ClickCallback = (
e: MouseEvent,
value: unknown,
@@ -989,15 +1006,9 @@ export function TableRenderer(props: TableRendererProps) {
/>
);
};
// Coerce numeric timestamp strings to numbers so temporal formatters
// (which typically expect an epoch) render correctly.
const rawHeaderCellValue = colKey[attrIdx];
const headerCellFormatterValue =
typeof rawHeaderCellValue === 'string' &&
rawHeaderCellValue.trim() !== '' &&
Number.isFinite(Number(rawHeaderCellValue))
? Number(rawHeaderCellValue)
: rawHeaderCellValue;
toDateFormatterInput(rawHeaderCellValue);
const headerCellFormattedValue =
dateFormatters?.[attrName]?.(headerCellFormatterValue) ??
rawHeaderCellValue;
@@ -1263,14 +1274,7 @@ export function TableRenderer(props: TableRendererProps) {
? toggleRowKey(flatRowKeySlice)
: null;
// Coerce numeric timestamp strings to numbers so temporal formatters
// (which typically expect an epoch) render correctly.
const headerFormatterValue =
typeof r === 'string' &&
r.trim() !== '' &&
Number.isFinite(Number(r))
? Number(r)
: r;
const headerFormatterValue = toDateFormatterInput(r);
const headerCellFormattedValue =
dateFormatters?.[settingsRowAttrs[i]]?.(headerFormatterValue) ?? r;
const isActiveHeader = valueCellClassName.includes('active');
@@ -21,6 +21,7 @@ import type { ReactElement } from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import { getTimeFormatter } from '@superset-ui/core';
import { TableRenderer } from '../../src/react-pivottable/TableRenderers';
import {
aggregatorTemplates,
@@ -689,6 +690,44 @@ test('TableRenderer coerces numeric timestamp strings to numbers for row header
expect(screen.getByText('row:red')).toBeInTheDocument();
});
test('TableRenderer passes four-digit year strings through to column header date formatters uncoerced', () => {
const data = [
{ shape: '2017', color: 'blue', value: 1 },
{ shape: '1700000000000', color: 'blue', value: 2 },
];
const props = buildDefaultProps({
data,
rows: ['color'],
cols: ['shape'],
tableOptions: { dateFormatters: { shape: getTimeFormatter('%Y') } },
});
renderWithTheme(<TableRenderer {...props} />);
// "2017" is the ISO year-only form and must render as that year, not as
// 2.017 seconds past the epoch; a stringified epoch still coerces.
expect(screen.getByText('2017')).toBeInTheDocument();
expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.queryByText('1970')).not.toBeInTheDocument();
});
test('TableRenderer passes four-digit year strings through to row header date formatters uncoerced', () => {
const data = [
{ color: '2017', shape: 'circle', value: 1 },
{ color: '1700000000000', shape: 'circle', value: 2 },
];
const props = buildDefaultProps({
data,
rows: ['color'],
cols: ['shape'],
tableOptions: { dateFormatters: { color: getTimeFormatter('%Y') } },
});
renderWithTheme(<TableRenderer {...props} />);
expect(screen.getByText('2017')).toBeInTheDocument();
expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.queryByText('1970')).not.toBeInTheDocument();
});
test('TableRenderer applies cellColorFormatters background and contrast color to column headers', () => {
const cellColorFormatters = {
shape: [
@@ -30,8 +30,10 @@
},
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^5.24.0",
"maplibre-gl": "^6.8.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
},
@@ -103,6 +103,42 @@ const sortTypes = {
alphanumeric: sortAlphanumericCaseInsensitive,
};
// Prefer a stable identifier from original row data; otherwise use a deterministic
// concatenation of visible values (keys sorted so the result does not depend on
// column order).
function stableRowKey<D extends object>(r: Row<D>): string {
const orig = r.original as Record<string, unknown> | undefined;
if (orig) {
const idLike = orig.id ?? orig.ID ?? orig.key ?? orig.uuid;
if (idLike != null) return String(idLike);
}
// Fallback: derive from row.values, sorting the keys so that reordering the
// columns does not change the key.
const v = r.values as Record<string, unknown>;
const keys = Object.keys(v).sort();
return keys.map(k => String(v[k] ?? '')).join('|');
}
// Very small, fast hash for strings (no crypto dependency).
function hashString(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i += 1) {
// oxlint-disable-next-line unicorn/prefer-math-trunc -- | 0 is intentional for 32-bit integer wrapping in hash
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return String(h);
}
function signatureOfRows<D extends object>(rs: Row<D>[]): string {
const keys = rs.map(stableRowKey);
const len = keys.length;
const first = keys[0] ?? '';
const last = keys[len - 1] ?? '';
const digest = hashString(keys.join('\u0001')); // non-printable separator to avoid collisions
return `${len}|${first}|${last}|${digest}`;
}
// Be sure to pass our updateMyData and the skipReset option
export default typedMemo(function DataTable<D extends object>({
tableClassName,
@@ -289,6 +325,46 @@ export default typedMemo(function DataTable<D extends object>({
onFilteredDataChange(rowsRef.current, searchText);
}, [filterValue, onFilteredDataChange, rowSignature]);
// Emit filtered rows to parent in client-side mode (debounced via RAF)
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const rafRef = useRef<number | null>(null);
const lastSigRef = useRef<string>('');
useEffect(() => {
if (serverPagination || typeof onFilteredRowsChange !== 'function') {
return;
}
const sig = signatureOfRows(rows);
if (sig !== lastSigRef.current) {
lastSigRef.current = sig;
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
if (isMountedRef.current) {
// Only emit originals when the signature truly changed
onFilteredRowsChange(rows.map(r => r.original as D));
}
});
}
return () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [rows, serverPagination, onFilteredRowsChange]);
const handleSearchChange = useCallback(
(query: string) => {
if (manualSearch && onSearchChange) {
@@ -472,84 +548,6 @@ export default typedMemo(function DataTable<D extends object>({
onServerPaginationChange(pageNumber, serverPageSize);
}
// Emit filtered rows to parent in client-side mode (debounced via RAF)
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const rafRef = useRef<number | null>(null);
const lastSigRef = useRef<string>('');
// Prefer a stable identifier from original row data; otherwise use a deterministic
// concatenation of visible values (keys sorted so column order changes are detected).
function stableRowKey<D extends object>(r: Row<D>): string {
const orig = r.original as Record<string, unknown> | undefined;
if (orig) {
const idLike =
(orig as any).id ??
(orig as any).ID ??
(orig as any).key ??
(orig as any).uuid;
if (idLike != null) return String(idLike);
}
// Fallback: derive from row.values, but make it stable against column order changes.
const v = r.values as Record<string, unknown>;
const keys = Object.keys(v).sort(); // detect column order changes
return keys.map(k => String(v[k] ?? '')).join('|');
}
// Very small, fast hash for strings (no crypto dependency).
function hashString(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i += 1) {
// oxlint-disable-next-line unicorn/prefer-math-trunc -- | 0 is intentional for 32-bit integer wrapping in hash
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return String(h);
}
function signatureOfRows<D extends object>(rs: Row<D>[]): string {
const keys = rs.map(stableRowKey);
const len = keys.length;
const first = keys[0] ?? '';
const last = keys[len - 1] ?? '';
const digest = hashString(keys.join('\u0001')); // non-printable separator to avoid collisions
return `${len}|${first}|${last}|${digest}`;
}
useEffect(() => {
if (serverPagination || typeof onFilteredRowsChange !== 'function') {
return;
}
const sig = signatureOfRows(rows);
if (sig !== lastSigRef.current) {
lastSigRef.current = sig;
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
if (isMountedRef.current) {
// Only emit originals when the signature truly changed
onFilteredRowsChange(rows.map(r => r.original as D));
}
});
}
return () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [rows, serverPagination, onFilteredRowsChange]);
return (
<div
ref={wrapperRef}
@@ -47,6 +47,7 @@ import {
extractTextFromHTML,
TimeGranularity,
forceHexAlpha,
DateWithFormatter,
} from '@superset-ui/core';
import {
styled,
@@ -98,7 +99,6 @@ import { formatColumnValue } from './utils/formatValue';
import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
import { updateTableOwnState } from './DataTable/utils/externalAPIs';
import getScrollBarSize from './DataTable/utils/getScrollBarSize';
import DateWithFormatter from './utils/DateWithFormatter';
type ValueRange = [number, number];
@@ -36,6 +36,7 @@ import {
SMART_DATE_ID,
TimeFormats,
TimeFormatter,
DateWithFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
@@ -47,7 +48,6 @@ import {
import { isEmpty, merge } from 'lodash-es';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import {
BasicColorFormatterType,
DataColumnMeta,
@@ -1,62 +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.
*/
import {
DataRecordValue,
normalizeTimestamp,
TimeFormatFunction,
} from '@superset-ui/core';
/**
* Extended Date object with a custom formatter, and retains the original input
* when the formatter is simple `String(..)`.
*/
export default class DateWithFormatter extends Date {
formatter: TimeFormatFunction;
input: DataRecordValue;
constructor(
input: DataRecordValue,
{ formatter = String }: { formatter?: TimeFormatFunction } = {},
) {
let value = input;
// assuming timestamps without a timezone is in UTC time
if (typeof value === 'string') {
value = normalizeTimestamp(value);
}
super(value as string);
this.input = input;
this.formatter = formatter;
this.toString = (): string => {
if (this.formatter === String) {
return String(this.input);
}
// Values that are not parseable timestamps - durations such as
// "00:01:54" or "0 days 00:01:54", for instance - produce an Invalid
// Date, and formatting one renders as "NaN:NaN:NaN". Fall back to the
// original value instead.
if (Number.isNaN(this.getTime())) {
return String(this.input);
}
return this.formatter ? this.formatter(this) : Date.toString.call(this);
};
}
}
@@ -22,10 +22,10 @@ import {
getSmallNumberFormatter,
isProbablyHTML,
sanitizeHtml,
DateWithFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { DataColumnMeta } from '../types';
import DateWithFormatter from './DateWithFormatter';
/**
* Format text for cell value.
@@ -0,0 +1,129 @@
/**
* 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.
*/
import '@testing-library/jest-dom';
import { Component, type ReactNode } from 'react';
import { render, screen } from '@superset-ui/core/spec';
import { CellProps, Column, HeaderProps } from 'react-table';
import DataTable from '../../src/DataTable/DataTable';
import { ProviderWrapper } from '../testHelpers';
type DataRow = {
city: string;
firstName: string;
};
interface RenderErrorBoundaryProps {
children: ReactNode;
}
interface RenderErrorBoundaryState {
hasError: boolean;
}
const columns: Column<DataRow>[] = [
{
Header: ({ column }: HeaderProps<DataRow>) => (
<th data-column-name={column.id}>First name</th>
),
Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>,
id: 'firstName',
accessor: 'firstName' as never,
},
{
Header: ({ column }: HeaderProps<DataRow>) => (
<th data-column-name={column.id}>City</th>
),
Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>,
id: 'city',
accessor: 'city' as never,
},
];
const data: DataRow[] = [
{ firstName: 'Michael', city: 'Paris' },
{ firstName: 'Jordan', city: 'London' },
];
// Turns a render-phase throw, such as a Rules of Hooks violation, into a
// readable assertion instead of an unhandled error.
class RenderErrorBoundary extends Component<
RenderErrorBoundaryProps,
RenderErrorBoundaryState
> {
state: RenderErrorBoundaryState = {
hasError: false,
};
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
// data-test is the configured testIdAttribute (see spec/helpers/setup.ts),
// so *ByTestId('render-error') resolves this node.
return <div data-test="render-error">Render error</div>;
}
return this.props.children;
}
}
const renderDataTable = (tableColumns: Column<DataRow>[]) => (
<ProviderWrapper>
<RenderErrorBoundary>
<DataTable<DataRow>
columns={tableColumns}
data={data}
rowCount={data.length}
serverPagination={false}
serverPaginationData={{}}
onServerPaginationChange={jest.fn()}
handleSortByChange={jest.fn()}
sortByFromParent={[]}
onSearchColChange={jest.fn()}
searchOptions={[]}
onFilteredRowsChange={jest.fn()}
sticky={false}
/>
</RenderErrorBoundary>
</ProviderWrapper>
);
test('keeps the hook order stable when the columns disappear', () => {
const { rerender } = render(renderDataTable(columns));
expect(screen.getByText('Michael')).toBeInTheDocument();
rerender(renderDataTable([]));
expect(screen.queryByTestId('render-error')).not.toBeInTheDocument();
expect(screen.getByText('No data found')).toBeInTheDocument();
});
test('keeps the hook order stable when the columns appear', () => {
const { rerender } = render(renderDataTable([]));
expect(screen.getByText('No data found')).toBeInTheDocument();
rerender(renderDataTable(columns));
expect(screen.queryByTestId('render-error')).not.toBeInTheDocument();
expect(screen.getByText('Michael')).toBeInTheDocument();
});
@@ -40,13 +40,13 @@ import {
TimeGranularity,
SMART_DATE_ID,
getTimeFormatterForGranularity,
DateWithFormatter,
} from '@superset-ui/core';
import { CellProps, Column, HeaderProps } from 'react-table';
import DataTable from '../src/DataTable/DataTable';
import TableChart, { sanitizeHeaderId } from '../src/TableChart';
import { GenericDataType } from '@apache-superset/core/common';
import transformProps from '../src/transformProps';
import DateWithFormatter from '../src/utils/DateWithFormatter';
import testData from './testData';
import { ProviderWrapper } from './testHelpers';
@@ -0,0 +1,39 @@
/*
* 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.
*/
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { formatColumnValue } from '../../src/utils/formatValue';
import { DataColumnMeta } from '../../src/types';
const formatter = getTimeFormatter('%H:%M:%S');
test('renders a duration cell through the column formatter without producing NaN', () => {
// The cell text is produced by formatColumnValue, which hands the wrapped
// value straight to the formatter rather than going through toString().
const column: DataColumnMeta = {
key: 'call_period',
label: 'call_period',
dataType: GenericDataType.Temporal,
formatter,
isNumeric: false,
};
const value = new DateWithFormatter('00:01:54', { formatter });
expect(formatColumnValue(column, value)).toEqual([false, '00:01:54']);
});
@@ -49,7 +49,7 @@
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"maplibre-gl": "^5.24.0",
"maplibre-gl": "^6.8.0",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.4",
"prop-types": "^15.8.1",
@@ -35,20 +35,30 @@ jest.mock('src/utils/cachedSupersetGet');
// only need a stand-in that lets us trigger onDrillBy with a distinguishable
// config, so we can assert ChartContextMenu wires it into the modal.
jest.mock('../DrillBy/DrillBySubmenu', () => ({
DrillBySubmenu: ({ onDrillBy }: any) => (
<button
type="button"
data-test="fake-drill-by-submenu"
onClick={() =>
onDrillBy(
{ column_name: 'city', groupby: true },
{ id: 1, columns: [], metrics: [] },
{ filters: [{ col: 'selected_scope' }], groupbyFieldName: 'groupby' },
)
}
>
Fake Drill By
</button>
DrillBySubmenu: ({ onDrillBy, dataset }: any) => (
<>
<button
type="button"
data-test="fake-drill-by-submenu"
onClick={() =>
onDrillBy(
{ column_name: 'city', groupby: true },
{ id: 1, columns: [], metrics: [] },
{
filters: [{ col: 'selected_scope' }],
groupbyFieldName: 'groupby',
},
)
}
>
Fake Drill By
</button>
<div data-test="drillable-columns">
{(dataset?.drillable_columns ?? [])
.map((col: any) => col.column_name)
.join(',')}
</div>
</>
),
}));
@@ -210,3 +220,35 @@ test('drill by modal uses the scope selected in the submenu over the raw context
);
expect(modalConfig.filters).toEqual([{ col: 'selected_scope' }]);
});
test('drill by only offers dimension columns', async () => {
// drill_info returns every column so the results grid can label non-dimension
// ones; narrowing to dimensions is this component's job, not the API's.
mockCachedSupersetGet.mockResolvedValue({
response: {} as Response,
json: {
result: {
columns: [
{ column_name: 'city', verbose_name: 'City', groupby: true },
{ column_name: 'revenue', verbose_name: 'Revenue', groupby: false },
],
metrics: [],
},
},
} as any);
setup({
drillBy: {
filters: [{ col: 'raw_scope', op: '==', val: 'raw' }],
groupbyFieldName: 'groupby',
},
});
userEvent.click(screen.getByTestId('open-context-menu'));
await waitFor(() => {
expect(screen.getByTestId('drillable-columns')).toHaveTextContent('city');
});
expect(screen.getByTestId('drillable-columns')).not.toHaveTextContent(
'revenue',
);
});
@@ -37,7 +37,6 @@ import {
ensureIsArray,
FeatureFlag,
getChartMetadataRegistry,
getExtensionsRegistry,
isFeatureEnabled,
QueryFormData,
} from '@superset-ui/core';
@@ -180,10 +179,6 @@ const ChartContextMenu = (
[],
);
const loadDrillByOptionsExtension = getExtensionsRegistry().get(
'load.drillby.options',
);
const handleCloseDrillByModal = useCallback(() => {
setShowDrillByModal(false);
}, []);
@@ -234,8 +229,9 @@ const ChartContextMenu = (
const filteredColumns = ensureIsArray(dataset.columns).filter(
column =>
// If using an extension, also filter by column.groupby since the extension might not do this
(!loadDrillByOptionsExtension || column.groupby) &&
// Both the API and the extension return every column, since the same
// payload resolves display labels elsewhere. Only dimensions are drillable.
column.groupby &&
!ensureIsArray(
formData[filters?.drillBy?.groupbyFieldName ?? ''],
).includes(column.column_name) &&
@@ -257,7 +253,6 @@ const ChartContextMenu = (
formData.x_axis,
formData[enhancedFilters?.drillBy?.groupbyFieldName ?? ''],
additionalConfig?.drillBy?.excludedColumns,
loadDrillByOptionsExtension,
]);
const showCrossFilters = isDisplayed(ContextMenuItem.CrossFilter);
@@ -31,6 +31,17 @@ import downloadAsPdf from 'src/utils/downloadAsPdf';
import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
jest.mock('src/utils/cachedSupersetGet');
jest.mock('src/explore/components/DataTablesPane', () => ({
ResultsPaneOnDashboard: ({
columnDisplayNames,
}: {
columnDisplayNames?: Record<string, string>;
}) => (
<div data-test="results-pane">
{JSON.stringify(columnDisplayNames ?? {})}
</div>
),
}));
jest.mock('src/utils/downloadAsImage', () =>
jest.fn(() => jest.fn().mockResolvedValue(undefined)),
);
@@ -709,6 +720,109 @@ test('Dataset drill info API call is not made when user lacks drill permissions'
expect(mockCachedSupersetGet).not.toHaveBeenCalled();
});
test('Dataset drill info API call is made when user can only view chart as table', async () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: false,
};
const props = {
...createProps(),
supersetCanExplore: false,
};
// "View as table" has its own permission, so label resolution must not be
// gated behind Drill to detail.
renderWrapper(props, {
Gamma: [
['can_view_chart_as_table', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
});
await waitFor(() =>
expect(mockCachedSupersetGet).toHaveBeenCalledWith({
endpoint: expect.stringContaining(
'/api/v1/dataset/58/drill_info/?q=(dashboard_id:26)',
),
}),
);
});
test('Dataset drill info API call is made for an explore-only user', async () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: false,
};
// "View as table" is offered to `canExplore || canViewTable`, so the fetch that
// feeds its column headers has to cover the same set -- an explore user with
// neither `can_samples` nor `can_view_chart_as_table` opens the same modal.
renderWrapper(createProps(), {
Gamma: [['can_get_drill_info', 'Dataset']],
});
await waitFor(() =>
expect(mockCachedSupersetGet).toHaveBeenCalledWith({
endpoint: expect.stringContaining(
'/api/v1/dataset/58/drill_info/?q=(dashboard_id:26)',
),
}),
);
});
test('Dataset drill info API call is not made without `can_get_drill_info`', async () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: false,
};
const props = {
...createProps(),
supersetCanExplore: false,
};
// The endpoint is guarded by `can_get_drill_info` on Dataset, so requesting
// it without that permission would only ever produce a 403.
renderWrapper(props, {
Gamma: [['can_view_chart_as_table', 'Dashboard']],
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(mockCachedSupersetGet).not.toHaveBeenCalled();
});
test('Results grid receives verbose names for a view-as-table-only user', async () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: false,
};
mockCachedSupersetGet.mockResolvedValue({
response: {} as Response,
json: {
result: {
columns: [{ column_name: 'region', verbose_name: 'Region' }],
metrics: [{ metric_name: 'sum__num', verbose_name: 'Yearly Total' }],
},
},
} as any);
const props = {
...createProps(),
supersetCanExplore: false,
};
renderWrapper(props, {
Gamma: [
['can_view_chart_as_table', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
});
// Let the drill_info request settle the way it would while the dashboard loads.
await waitFor(() => expect(mockCachedSupersetGet).toHaveBeenCalled());
openMenu();
userEvent.click(screen.getByTestId('view-query-menu-item'));
await waitFor(() =>
expect(
JSON.parse(screen.getByTestId('results-pane').textContent as string),
).toEqual({
region: 'Region',
sum__num: 'Yearly Total',
}),
);
});
test('Should show "Embed code" in Share menu when feature flag is enabled and chart has data', async () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: true,
@@ -196,13 +196,22 @@ const SliceHeaderControls = (
.get(props.slice.viz_type)
?.behaviors?.includes(Behavior.InteractiveChart);
const canExplore = props.supersetCanExplore;
const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
const { canDrillToDetail, canGetDrillInfo, canViewQuery, canViewTable } =
usePermissions();
// Single predicate for the "View as table" entry, so the fetch that feeds its
// column headers cannot drift from the set of users who can open it.
const canViewResultsTable = canExplore || canViewTable;
// The dataset's verbose map resolves friendly Labels for both the drill-to-detail
// pane and the results grid, and those are separate permissions — so fetch it for
// either one, as long as the drill_info endpoint itself is readable (it is gated
// by `can_get_drill_info` on Dataset).
const datasetResource = useDatasetDrillInfo(
props.slice.datasource,
props.dashboardId,
props.formData,
!canDrillToDetail,
!canGetDrillInfo || !(canDrillToDetail || canViewResultsTable),
);
const datasetWithVerboseMap =
@@ -594,7 +603,7 @@ const SliceHeaderControls = (
});
}
if (canExplore || canViewTable) {
if (canViewResultsTable) {
newMenuItems.push({
key: MenuKeys.ViewResults,
label: (
@@ -0,0 +1,101 @@
/**
* 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.
*/
import { TextEncoder } from 'util';
import {
measureGuestToken,
guestAuthenticationMessage,
} from './guestTokenDiagnostics';
beforeAll(() => {
Object.assign(global, { TextEncoder });
});
test.each([
[20, true],
[21, false],
[22, false],
[null, false],
[0, false],
[-1, false],
])('header budget %s: exceeded=%s', (budget, exceeded) => {
expect(measureGuestToken('é'.repeat(3), 'X-Custom-É', budget)).toEqual({
tokenBytes: 6,
headerBytes: 21,
headerBudgetBytes: budget && budget > 0 ? budget : null,
headerBudgetExceeded: exceeded,
});
});
test('default header accounting and safe metadata only', () => {
const size = measureGuestToken('secret-token', undefined, 1);
expect(size.headerBytes).toBe(28);
expect(JSON.stringify(size)).not.toContain('secret-token');
expect(guestAuthenticationMessage(size)).toContain('may exceed');
});
test('no size evidence uses generic authentication message', () => {
expect(guestAuthenticationMessage()).not.toContain('may exceed');
expect(guestAuthenticationMessage(measureGuestToken('t'))).not.toContain(
'may exceed',
);
});
test.each([
['16384', null],
['invalid', null],
[true, null],
[false, null],
[[], null],
[{}, null],
[20.5, null],
[NaN, null],
[Infinity, null],
[-Infinity, null],
[2 ** 53, null],
[Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
[16384.0, 16384],
])('normalizes configured budget %p to %p', (configured, expected) => {
const size = measureGuestToken('t', undefined, configured);
expect(size.headerBudgetBytes).toBe(expected);
expect(size.headerBudgetExceeded).toBe(false);
});
test.each([undefined, 400, 431, 494])(
'uses header-size evidence for status %p',
status => {
expect(
guestAuthenticationMessage(measureGuestToken('t', undefined, 1), {
status,
}),
).toContain('may exceed');
expect(
guestAuthenticationMessage(measureGuestToken('t', undefined, 100), {
status,
}),
).not.toContain('may exceed');
},
);
test.each([401, 403, 413, 500])('keeps status %p generic', status => {
expect(
guestAuthenticationMessage(measureGuestToken('t', undefined, 1), {
status,
}),
).not.toContain('may exceed');
});
@@ -0,0 +1,71 @@
/**
* 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.
*/
import { t } from '@apache-superset/core/translation';
export type GuestTokenSize = {
tokenBytes: number;
headerBytes: number;
headerBudgetBytes: number | null;
headerBudgetExceeded: boolean;
};
/** Measure the encoded token without decoding or retaining credentials. */
export function measureGuestToken(
token: string,
headerName = 'X-GuestToken',
budget?: unknown,
): GuestTokenSize {
const encoder = new TextEncoder();
const tokenBytes = encoder.encode(token).length;
// HTTP/1-style accounting: name + ": " + value + CRLF, not wire compression.
const headerBytes = tokenBytes + encoder.encode(headerName).length + 4;
const headerBudgetBytes =
typeof budget === 'number' && Number.isSafeInteger(budget) && budget > 0
? budget
: null;
return {
tokenBytes,
headerBytes,
headerBudgetBytes,
headerBudgetExceeded:
headerBudgetBytes !== null && headerBytes > headerBudgetBytes,
};
}
/** Diagnose from size evidence only; never inspect or log proxy response bodies. */
export function guestAuthenticationMessage(
size?: GuestTokenSize,
error?: unknown,
): string {
const status =
typeof error === 'object' && error !== null && 'status' in error
? error.status
: undefined;
// Explicit auth/server failures have other causes. Some non-JSON proxy
// failures lose their status during parsing, so size remains the evidence.
const possibleHeaderFailure =
status === undefined || status === 400 || status === 431 || status === 494;
return size?.headerBudgetExceeded && possibleHeaderFailure
? t(
'Embedded authentication failed. The guest token may exceed the request-header size limit. Reduce the token payload; large inline RLS lists can be replaced with an entitlements-table lookup.',
)
: t(
'Something went wrong with embedded authentication. Check the dev console for details.',
);
}
+147 -44
View File
@@ -18,7 +18,14 @@
*/
// Mark this file as a module so its top-level declarations stay file-scoped
// (the file has no imports; modules are loaded via require() inside tests).
export {};
import { TextEncoder } from 'util';
Object.assign(global, { TextEncoder });
const mockConfig = {
GUEST_TOKEN_HEADER_NAME: 'X-Custom-Guest',
GUEST_TOKEN_HEADER_MAX_BYTES: 100,
};
// Stable mock references so they survive jest.resetModules() between tests
// (a factory-created jest.fn() would otherwise be replaced on each reset,
@@ -59,13 +66,14 @@ jest.mock('src/components/UiConfigContext', () => ({
// Capture the guestToken handler that start() is wired to, so tests can
// re-trigger the handshake and assert the retry behavior.
const mockSwitchboardInit = jest.fn();
const mockSwitchboard = {
handler: undefined as ((arg: { guestToken: string }) => void) | undefined,
};
jest.mock('@superset-ui/switchboard', () => ({
__esModule: true,
default: {
init: jest.fn(),
init: mockSwitchboardInit,
start: jest.fn(),
defineMethod: (name: string, fn: (arg: { guestToken: string }) => void) => {
if (name === 'guestToken') {
@@ -76,7 +84,8 @@ jest.mock('@superset-ui/switchboard', () => ({
},
}));
jest.mock('src/setup/setupClient', () => jest.fn(), { virtual: true });
const mockSetupClient = jest.fn();
jest.mock('src/setup/setupClient', () => mockSetupClient, { virtual: true });
jest.mock('src/views/store', () => ({
store: {
@@ -113,6 +122,7 @@ jest.mock('react-dom/client', () => ({
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => ({
config: mockConfig,
embedded: { dashboard_id: '123', allowed_domains: [] },
common: {
application_root: '/',
@@ -147,55 +157,148 @@ function sendHandshake() {
);
}
describe('embedded/index.tsx', () => {
beforeEach(() => {
jest.resetModules();
mockSwitchboard.handler = undefined;
mockSetupPlugins.mockReset();
mockSetupAGGridModules.mockReset();
mockLogging.error.mockClear();
mockGetMeWithRole.mockReset();
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
document.body.innerHTML = '<div id="app"></div>';
});
beforeEach(() => {
jest.resetModules();
mockSwitchboard.handler = undefined;
mockSetupPlugins.mockReset();
mockSetupAGGridModules.mockReset();
mockLogging.error.mockClear();
mockGetMeWithRole.mockReset();
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
document.body.innerHTML = '<div id="app"></div>';
});
test('initializes AG Grid modules on bootstrap', async () => {
mockSetupPlugins.mockImplementation(() => undefined);
require('./index');
await flush();
expect(mockSetupAGGridModules).toHaveBeenCalled();
});
test('retries plugin setup after setupPlugins rejects, then bootstraps the user', async () => {
// First plugin setup throws; the second attempt (after a re-handshake) succeeds.
mockSetupPlugins
.mockImplementationOnce(() => {
throw new Error('setupPlugins failed');
})
.mockImplementation(() => undefined);
require('./index');
await flush();
test('initializes AG Grid modules on bootstrap', async () => {
mockSetupPlugins.mockImplementation(() => undefined);
require('./index');
await flush();
sendHandshake();
expect(mockSwitchboard.handler).toBeDefined();
expect(mockSetupAGGridModules).toHaveBeenCalled();
});
// First guest token: plugin setup rejects, start() resets the guard and
// recreates pluginsReady so a retry can re-run setup.
mockSwitchboard.handler!({ guestToken: 'token-1' });
await flush();
expect(mockLogging.error).toHaveBeenCalled();
expect(mockGetMeWithRole).not.toHaveBeenCalled();
// The user gets a visible failure message rather than a blank #app.
expect(document.getElementById('app')!.innerHTML).toContain(
'Something went wrong loading the dashboard',
);
test('retries plugin setup after setupPlugins rejects, then bootstraps the user', async () => {
// First plugin setup throws; the second attempt (after a re-handshake) succeeds.
mockSetupPlugins
.mockImplementationOnce(() => {
throw new Error('setupPlugins failed');
})
.mockImplementation(() => undefined);
// Second guest token retries: plugin setup now succeeds and the user loads.
mockSwitchboard.handler!({ guestToken: 'token-2' });
await flush();
expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
expect(mockGetMeWithRole).toHaveBeenCalled();
});
test.each([
['short', { status: 400, text: '<html>proxy error</html>' }, false],
['short', { status: 401 }, false],
['x'.repeat(100), { status: 401 }, false],
['x'.repeat(100), { status: 500 }, false],
['x'.repeat(100), new SyntaxError('private response body'), true],
])(
'authentication failure uses size evidence, not response content',
async (token, error, targeted) => {
mockGetMeWithRole.mockRejectedValue(error);
require('./index');
await flush();
sendHandshake();
expect(mockSwitchboard.handler).toBeDefined();
// First guest token: plugin setup rejects, start() resets the guard and
// recreates pluginsReady so a retry can re-run setup.
mockSwitchboard.handler!({ guestToken: 'token-1' });
mockSwitchboard.handler!({ guestToken: token });
await flush();
expect(mockLogging.error).toHaveBeenCalled();
expect(mockGetMeWithRole).not.toHaveBeenCalled();
// The user gets a visible failure message rather than a blank #app.
expect(document.getElementById('app')!.innerHTML).toContain(
'Something went wrong loading the dashboard',
expect(
document.getElementById('app')!.textContent?.includes('may exceed'),
).toBe(targeted);
expect(JSON.stringify(mockLogging.error.mock.calls)).not.toContain(
'private response body',
);
expect(JSON.stringify(mockLogging.error.mock.calls)).not.toContain(
'<html>',
);
// Failed authentication still permits the existing retry.
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
mockSwitchboard.handler!({ guestToken: 'replacement' });
await flush();
expect(mockGetMeWithRole).toHaveBeenCalledTimes(2);
},
);
// Second guest token retries: plugin setup now succeeds and the user loads.
mockSwitchboard.handler!({ guestToken: 'token-2' });
await flush();
expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
expect(mockGetMeWithRole).toHaveBeenCalled();
});
test('oversized refresh updates diagnostics without restarting successful auth', async () => {
mockLogging.warn.mockClear();
require('./index');
await flush();
sendHandshake();
mockSwitchboard.handler!({ guestToken: 'short' });
await flush();
mockSwitchboard.handler!({ guestToken: 'x'.repeat(100) });
await flush();
expect(mockGetMeWithRole).toHaveBeenCalledTimes(1);
expect(mockLogging.warn).toHaveBeenLastCalledWith(
'Guest token exceeds configured request-header budget',
{
tokenBytes: 100,
headerBytes: 118,
headerBudgetBytes: 100,
headerBudgetExceeded: true,
},
);
expect(mockSetupClient).toHaveBeenLastCalledWith(
expect.objectContaining({
guestToken: 'x'.repeat(100),
guestTokenHeaderName: 'X-Custom-Guest',
}),
);
});
test('refresh during pending authentication does not misattribute size evidence', async () => {
let rejectRequest: (error: unknown) => void = () => {};
mockGetMeWithRole.mockReturnValue(
new Promise((_resolve, reject) => {
rejectRequest = reject;
}),
);
require('./index');
await flush();
sendHandshake();
mockSwitchboard.handler!({ guestToken: 'x'.repeat(100) });
await flush();
mockSwitchboard.handler!({ guestToken: 'short' });
rejectRequest({ status: 400 });
await flush();
expect(document.getElementById('app')!.textContent).not.toContain(
'may exceed',
);
expect(mockGetMeWithRole).toHaveBeenCalledTimes(1);
// Clearing the guard after failure lets a subsequent token retry authentication.
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
mockSwitchboard.handler!({ guestToken: 'retry' });
await flush();
expect(mockGetMeWithRole).toHaveBeenCalledTimes(2);
});
test('Switchboard does not log credential-bearing message bodies', async () => {
require('./index');
await flush();
sendHandshake();
expect(mockSwitchboardInit).toHaveBeenLastCalledWith(
expect.objectContaining({ debug: false }),
);
});
+33 -13
View File
@@ -49,6 +49,11 @@ import {
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
import { validateMessageEvent } from './originValidation';
import {
measureGuestToken,
guestAuthenticationMessage,
GuestTokenSize,
} from './guestTokenDiagnostics';
// Defer plugin setup until after the language pack loads to prevent t() calls in
// plugin control panel configs from being cached in English before translations are ready.
@@ -177,6 +182,7 @@ if (!window.parent || window.parent === window) {
let displayedUnauthorizedToast = false;
let root: Root | null = null;
let started = false;
let guestTokenSize: GuestTokenSize | undefined;
/**
* If there is a problem with the guest token, we will start getting
@@ -209,8 +215,10 @@ function start() {
endpoint: '/api/v1/me/roles/',
});
return pluginsReady.then(
() =>
getMeWithRole().then(
() => {
// Snapshot at dispatch, not at handshake: plugin loading can overlap refresh.
const requestTokenSize = guestTokenSize;
return getMeWithRole().then(
({ result }) => {
// fill in some missing bootstrap data
// (because at pageload, we don't have any auth yet)
@@ -225,18 +233,18 @@ function start() {
}
root.render(<EmbeddedApp />);
},
err => {
(error: unknown) => {
// something is most likely wrong with the guest token; reset the guard
// so a rehandshake with a valid token can retry.
logging.error(err);
showFailureMessage(
t(
'Something went wrong with embedded authentication. Check the dev console for details.',
),
);
// A refresh while the request is in flight makes attribution ambiguous.
const size =
requestTokenSize === guestTokenSize ? requestTokenSize : undefined;
logging.error('Embedded authentication failed', size);
showFailureMessage(guestAuthenticationMessage(size, error));
started = false;
},
),
);
},
err => {
// setupPlugins() or setupCodeOverrides() threw while preparing plugins;
// reset the guard and recreate pluginsReady so a retry actually re-runs
@@ -258,6 +266,17 @@ function start() {
* Configures SupersetClient with the correct settings for the embedded dashboard page.
*/
function setupGuestClient(guestToken: string) {
guestTokenSize = measureGuestToken(
guestToken,
bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
bootstrapData.config?.GUEST_TOKEN_HEADER_MAX_BYTES,
);
if (guestTokenSize.headerBudgetExceeded) {
logging.warn(
'Guest token exceeds configured request-header budget',
guestTokenSize,
);
}
setupClient({
appRoot: applicationRoot(),
guestToken,
@@ -268,18 +287,19 @@ function setupGuestClient(guestToken: string) {
window.addEventListener('message', function embeddedPageInitializer(event) {
if (!validateMessageEvent(event, bootstrapData.embedded?.allowed_domains)) {
log('ignoring message unrelated to embedded comms', event);
log('ignoring message unrelated to embedded comms');
return;
}
const port = event.ports?.[0];
if (event.data.handshake === 'port transfer' && port) {
log('message port received', event);
log('message port received');
Switchboard.init({
port,
name: 'superset',
debug: debugMode,
// Switchboard debug logs message bodies, including guest-token credentials.
debug: false,
});
Switchboard.defineMethod(
@@ -35,7 +35,11 @@ import {
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import {
FeatureFlag,
isFeatureEnabled,
SupersetClient,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import fetchMock from 'fetch-mock';
@@ -172,10 +176,22 @@ fetchMock.get(ADVANCED_DATA_TYPE_ENDPOINT_INVALID, {
const mockStore = configureStore([thunk]);
const store = mockStore({});
// Either a JSON body ({ result, limit }), a fetch-mock response config
// ({ status, body } / { throws }) so a test can make the server fail, or a
// per-call function for stateful routes. fetch-mock reads this lazily at
// request-match time -- reassigning the variable changes what EARLIER,
// still-unmatched requests resolve with -- so a test whose earlier request
// must still be IN FLIGHT at reassignment time has to use the function
// form. Once the earlier response has settled, plain reassignment is safe.
let columnValuesResponse: unknown = { result: [], limit: 10000 };
let isFeatureEnabledMock: jest.SpyInstance;
beforeEach(() => {
fetchMock.clearHistory();
// Reset the shared route: a prior test's stateful function (with its
// closed-over call counter) must not serve the next test's requests.
columnValuesResponse = { result: [], limit: 10000 };
isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
(featureFlag: FeatureFlag) =>
featureFlag === FeatureFlag.EnableAdvancedDataTypes,
@@ -955,14 +971,16 @@ test('filters the subject select by column verbose_name as well as column_name',
const COLUMN_VALUES_ENDPOINT =
'glob:*/api/v1/datasource/*/column/value/values/*';
let columnValues: { result: unknown[]; limit: number } = {
result: [],
limit: 10000,
};
fetchMock.get(COLUMN_VALUES_ENDPOINT, () => columnValues);
// Route for COLUMN_VALUES_ENDPOINT; the response contract and its
// lazy-read trap are documented at columnValuesResponse's declaration.
fetchMock.get(COLUMN_VALUES_ENDPOINT, (...args: unknown[]) =>
typeof columnValuesResponse === 'function'
? columnValuesResponse(...args)
: columnValuesResponse,
);
const setupWithFilterValues = (result: unknown[], limit = 10000) => {
columnValues = { result, limit };
const setupWithFilterValuesResponse = (response: unknown) => {
columnValuesResponse = response;
const onChange = jest.fn();
const validHandler = jest.fn();
const spy = jest.spyOn(redux, 'useSelector');
@@ -986,12 +1004,17 @@ const setupWithFilterValues = (result: unknown[], limit = 10000) => {
partitionColumn: 'test',
validHandler,
};
render(
const { rerender } = render(
<AdhocFilterEditPopoverSimpleTabContent {...(props as unknown as Props)} />,
);
return props;
return { ...props, rerender };
};
const setupWithFilterValues = (result: unknown[], limit = 10000) =>
setupWithFilterValuesResponse({ result, limit });
const SUGGESTIONS_UNAVAILABLE = /Suggestions could not be loaded/;
const openComparator = async () => {
const comparator = screen.getByRole('combobox', {
name: 'Comparator option',
@@ -1065,7 +1088,7 @@ test('stores the picked value, not the option object', async () => {
test('can remove a value that was saved earlier', async () => {
// Reopening the popover restores the comparator from the saved filter, and
// the value is not in the freshly loaded page. Removing it has to still work.
columnValues = { result: [], limit: 10000 };
columnValuesResponse = { result: [], limit: 10000 };
const onChange = jest.fn();
const validHandler = jest.fn();
jest.spyOn(redux, 'useSelector').mockReturnValue({});
@@ -1115,3 +1138,158 @@ test('does not say the list is partial when it is complete', async () => {
expect(await screen.findByTitle('alpha')).toBeInTheDocument();
expect(screen.queryByText(/Only the first/)).not.toBeInTheDocument();
});
test('says suggestions could not be loaded when the server fails', async () => {
// A failed request used to render exactly like a column with no values,
// which is how a 500 on every semantic view went unreported for months.
const props = setupWithFilterValuesResponse({
status: 500,
body: { message: 'Fatal error' },
});
await openComparator();
expect(await screen.findByText(SUGGESTIONS_UNAVAILABLE)).toBeInTheDocument();
// The note must not cost the user the way through: typing still works.
const comparator = screen.getByRole('combobox', {
name: 'Comparator option',
});
userEvent.type(comparator, 'typed-by-hand');
userEvent.click(await screen.findByTitle('typed-by-hand'));
await waitFor(() => expect(props.onChange).toHaveBeenCalled());
const [filter] = props.onChange.mock.calls.at(-1);
expect(filter.comparator).toEqual(['typed-by-hand']);
});
test('says suggestions could not be loaded when the request gets no answer', async () => {
// A network failure rejects with no response at all (the client retries
// those itself, so it is stubbed above the transport).
jest
.spyOn(SupersetClient, 'get')
.mockRejectedValueOnce(new TypeError('Failed to fetch'));
setupWithFilterValues([]);
await openComparator();
expect(await screen.findByText(SUGGESTIONS_UNAVAILABLE)).toBeInTheDocument();
});
test('stays quiet when the server refuses the request', async () => {
// A 4xx is the caller's problem, not an outage; the picker behaves as before.
fetchMock.clearHistory();
setupWithFilterValuesResponse({
status: 400,
body: { message: 'Column name value does not exist' },
});
await openComparator();
await waitFor(() =>
expect(fetchMock.callHistory.calls(COLUMN_VALUES_ENDPOINT)).toHaveLength(1),
);
expect(await screen.findByText('Type a value here')).toBeInTheDocument();
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
});
test('shows a plain empty list when the server has no values', async () => {
fetchMock.clearHistory();
setupWithFilterValues([]);
await openComparator();
await waitFor(() =>
expect(fetchMock.callHistory.calls(COLUMN_VALUES_ENDPOINT)).toHaveLength(1),
);
expect(await screen.findByText('Type a value here')).toBeInTheDocument();
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
});
test('ignores a stale failing response that loses the race to a newer success', async () => {
// The outage the note exists for is a slow endpoint -- which is exactly
// when a failing response can resolve AFTER a newer search already
// succeeded. The loser must not stamp its note over the winner.
//
// The route is STATEFUL (routed by call count): only the function form
// (see the route comment above) makes the base request genuinely pend
// while the newer one succeeds -- with a plain reassignment only one
// request would ever exist and this test would pass with the staleness
// guard deleted.
let resolveSlowFailure: (value: unknown) => void = () => {};
const firstPending = new Promise(resolve => {
resolveSlowFailure = resolve;
});
let landedCalls = 0;
setupWithFilterValuesResponse(() => {
landedCalls += 1;
return landedCalls === 1
? firstPending
: { result: ['alpha'], limit: 10000 };
});
const comparator = await openComparator();
await waitFor(() => expect(landedCalls).toBe(1));
// A newer request succeeds while the first is still pending.
userEvent.type(comparator, 'al');
expect(
await screen.findByTitle('alpha', {}, { timeout: 3000 }),
).toBeInTheDocument();
expect(landedCalls).toBe(2);
// Now the original request fails -- too late to matter. Flush it all the
// way through explicitly: a waitFor on a negative assertion would pass
// on the first tick, before the late rejection could land. (The single
// macrotask assumes the rejection pipeline is microtask-only; after a
// fetch-mock upgrade, re-run the guard-deleted control to reverify.)
resolveSlowFailure({ status: 500, body: { message: 'Fatal error' } });
await act(async () => {
await firstPending;
await new Promise(resolve => {
setTimeout(resolve, 0);
});
});
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
});
test('does not carry the note to a different column', async () => {
const props = setupWithFilterValuesResponse({
status: 500,
body: { message: 'Fatal error' },
});
await openComparator();
expect(await screen.findByText(SUGGESTIONS_UNAVAILABLE)).toBeInTheDocument();
// The parent applies a subject change by re-rendering with a new filter;
// the note must reset with it -- the new column's own request decides what
// is shown next.
columnValuesResponse = { result: [], limit: 10000 };
props.rerender(
<AdhocFilterEditPopoverSimpleTabContent
{...({
...props,
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'source',
operatorId: Operators.In,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.In].operation,
comparator: [],
clause: Clauses.Where,
}),
} as unknown as Props)}
/>,
);
await waitFor(() =>
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument(),
);
});
test('drops the note once suggestions load again', async () => {
setupWithFilterValuesResponse({
status: 500,
body: { message: 'Fatal error' },
});
const comparator = await openComparator();
expect(await screen.findByText(SUGGESTIONS_UNAVAILABLE)).toBeInTheDocument();
// A new search term is a new request; the server is back. Safe as a
// plain reassignment: the first response has fully settled (the note is
// already on screen), so the lazy read cannot hand it this value.
columnValuesResponse = { result: ['alpha'], limit: 10000 };
userEvent.type(comparator, 'al');
expect(
await screen.findByTitle('alpha', {}, { timeout: 3000 }),
).toBeInTheDocument();
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
});
@@ -85,6 +85,16 @@ const SelectWithLabel = styled(AsyncSelect)<{ labelText: string }>`
// AsyncSelect from asking for a second page.
const COMPARATOR_PAGE_SIZE = 1_000_000;
// SupersetClient rejects with the raw Response, so a refused request carries
// its status. A 4xx is the caller's problem (an unknown column, no access) and
// is not an outage; a 5xx is the server's own failure, and no status at all
// means the request got no answer (network failure, timeout). Only the last
// two are "suggestions unavailable".
const isSuggestionsOutage = (error: unknown): boolean => {
const status = (error as { status?: unknown } | null)?.status;
return typeof status !== 'number' || status >= 500;
};
const toLabeledValue = (value: unknown): LabeledValue => ({
value: value as LabeledValue['value'],
label: optionLabel(value as null | number | boolean | string),
@@ -386,6 +396,12 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
const comparatorSelectRef = useRef<AsyncSelectRef>(null);
const [loadedOptionCount, setLoadedOptionCount] = useState(0);
const [optionsTruncated, setOptionsTruncated] = useState(false);
const [suggestionsUnavailable, setSuggestionsUnavailable] = useState(false);
// Identity of the newest suggestions request. A slow response that loses
// the race -- a failing fetch resolving after a newer search succeeded, or
// after the column changed -- must not stamp its outcome over the current
// one, so every state write below is guarded on still being the latest.
const comparatorRequestRef = useRef(0);
const [hasFocusedComparator, setHasFocusedComparator] =
useState<boolean>(false);
@@ -520,16 +536,20 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
allowNewOptions: true,
ariaLabel: t('Comparator option'),
pageSize: COMPARATOR_PAGE_SIZE,
// A capped list reads as the whole set unless it says otherwise, so an
// absent value looks like a value that does not exist. Only shown when the
// list is actually cut short.
helperText: optionsTruncated
? t(
'Only the first %s values are listed. Type to search all of them, ' +
'or enter a value that is not listed.',
loadedOptionCount,
)
: undefined,
// An empty list reads as "this column has no values" unless it says
// otherwise, so a failed request has to say so -- that silence is how a
// months-long 500 on every semantic view went unreported. Likewise a capped
// list reads as the whole set, so an absent value looks like a value that
// does not exist. Each note is only shown when it applies.
helperText: suggestionsUnavailable
? t('Suggestions could not be loaded. You can still type a value.')
: optionsTruncated
? t(
'Only the first %s values are listed. Type to search all of them, ' +
'or enter a value that is not listed.',
loadedOptionCount,
)
: undefined,
mode:
operatorId && MULTI_OPERATORS.has(operatorId as Operators)
? ('multiple' as const)
@@ -581,6 +601,10 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
return { data: [], totalCount: 0 };
}
const requestId = comparatorRequestRef.current + 1;
comparatorRequestRef.current = requestId;
const isCurrent = () => comparatorRequestRef.current === requestId;
const params = new URLSearchParams();
if (arrayElements) {
params.set('array_elements', 'true');
@@ -615,8 +639,13 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
};
});
setLoadedOptionCount(data.length);
setOptionsTruncated(isDefined(json.limit) && data.length >= json.limit);
if (isCurrent()) {
setLoadedOptionCount(data.length);
setOptionsTruncated(
isDefined(json.limit) && data.length >= json.limit,
);
setSuggestionsUnavailable(false);
}
// The count has to exceed what was returned. AsyncSelect treats
// `loaded >= totalCount` as "that is every value", sets allValuesLoaded
@@ -624,10 +653,18 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
// client-side -- which is the behaviour this whole change exists to
// replace. Pagination is held off by COMPARATOR_PAGE_SIZE instead.
return { data, totalCount: data.length + 1 };
} catch {
setLoadedOptionCount(0);
setOptionsTruncated(false);
return { data: [], totalCount: 0 };
} catch (error) {
if (isCurrent()) {
setLoadedOptionCount(0);
setOptionsTruncated(false);
// The empty page keeps the dropdown, and with it the value the
// user types, in place; the note says why the page is empty.
setSuggestionsUnavailable(isSuggestionsOutage(error));
}
// The count has to exceed the page: an empty page reported as
// complete (0 >= 0) makes AsyncSelect serve every later search from
// it, so the server would never be asked again for this column.
return { data: [], totalCount: 1 };
}
},
[
@@ -640,9 +677,17 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
);
// Options are cached per search term inside AsyncSelect; a different column
// or a switch to element-level suggestions invalidates all of them.
// or a switch to element-level suggestions invalidates all of them, and a
// note about the previous column's request with them.
useEffect(() => {
comparatorSelectRef.current?.clearCache();
// The ref only carries the AsyncSelect handle while the suggestions
// branch is mounted; a column without comparator options renders the
// plain input, so the method itself is optional too.
comparatorSelectRef.current?.clearCache?.();
// Invalidate any in-flight request as well: a slow response for the
// previous column must not resurface the note after this reset.
comparatorRequestRef.current += 1;
setSuggestionsUnavailable(false);
}, [subjectString, arrayElements]);
useEffect(() => {
@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Preset, VizType } from '@superset-ui/core';
import {
ChartLabel,
ChartMetadata,
ChartPlugin,
Preset,
VizType,
} from '@superset-ui/core';
import {
render,
cleanup,
@@ -47,12 +53,31 @@ jest.mock('scroll-into-view-if-needed', () => jest.fn());
jest.useFakeTimers({ advanceTimers: true });
// A minimal plugin carrying a "Featured" label, so tests can assert on the
// badge that VizTypeGallery overlays on its thumbnail.
class FeaturedTestChartPlugin extends ChartPlugin {
constructor() {
super({
metadata: new ChartMetadata({
name: 'Featured Test Chart',
thumbnail: '',
label: ChartLabel.Featured,
tags: ['Featured'],
}),
Chart: () => null,
});
}
}
class MainPreset extends Preset {
constructor() {
super({
name: 'Legacy charts',
plugins: [
new TableChartPlugin().configure({ key: VizType.Table }),
new FeaturedTestChartPlugin().configure({
key: 'featured_test_chart',
}),
new BigNumberTotalChartPlugin().configure({
key: VizType.BigNumberTotal,
}),
@@ -278,6 +303,25 @@ describe('VizTypeControl', () => {
).not.toBeInTheDocument();
});
test('anchors the Featured badge to the bottom-right of the thumbnail image', async () => {
// The badge is positioned relative to the thumbnail image only (not the
// whole tile), so it must hang off the image's bottom-right corner
// rather than its top edge.
await waitForRenderWrapper();
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
const visualizations = screen.getByTestId(getTestId('viz-row'));
const image = await within(visualizations).findByAltText(
'Featured Test Chart',
);
const badgeWrapper = image.nextElementSibling as HTMLElement;
expect(badgeWrapper).toHaveStyleRule('bottom', '4px');
expect(badgeWrapper).toHaveStyleRule('right', '4px');
expect(badgeWrapper).not.toHaveStyleRule('top', expect.anything());
expect(within(badgeWrapper).getByText('FEATURED')).toBeInTheDocument();
});
test('Thumbnail labels expose the full chart name via a title tooltip', async () => {
// Labels are clamped to a fixed two-line block so every tile is the same
// height; the full (possibly truncated) name must stay discoverable through
@@ -344,7 +344,7 @@ const ThumbnailImageWrapper = styled.div`
const ThumbnailLabelWrapper = styled.div`
position: absolute;
right: ${({ theme }) => theme.sizeUnit}px;
top: ${({ theme }) => theme.sizeUnit}px;
bottom: ${({ theme }) => theme.sizeUnit}px;
`;
const TitleLabelWrapper = styled.div`
@@ -17,6 +17,7 @@
* under the License.
*/
import { renderHook, waitFor } from '@testing-library/react';
import { logging } from '@apache-superset/core/utils';
import { Dataset } from 'src/components/Chart/types';
import {
cachedSupersetGet,
@@ -35,6 +36,11 @@ jest.mock('src/utils/cachedSupersetGet', () => ({
},
}));
jest.mock('@apache-superset/core/utils', () => ({
...jest.requireActual('@apache-superset/core/utils'),
logging: { error: jest.fn(), warn: jest.fn() },
}));
// Mock getExtensionsRegistry at module level - returns undefined by default
const mockGetExtensionsRegistry = jest.fn(() => ({ get: () => undefined }));
jest.mock('@superset-ui/core', () => ({
@@ -43,6 +49,7 @@ jest.mock('@superset-ui/core', () => ({
}));
const mockedCachedSupersetGet = jest.mocked(cachedSupersetGet);
const mockedLoggingError = jest.mocked(logging.error);
const mockedSupersetGetCacheDelete = jest.mocked(supersetGetCache.delete);
const mockExtension = jest.fn();
@@ -141,6 +148,22 @@ test('createVerboseMap creates verbose_map from both columns and metrics', () =>
});
});
test('createVerboseMap lets a column win a name collision with a metric', () => {
// A dataset can hold a column and a metric with the same name -- uniqueness is
// only enforced within each list. Both end up in one flat map, so one label has
// to win. Columns win, matching `SqlaTable.data_for_slices`, which builds the
// verbose_map the dashboard's own charts already render with. Otherwise an
// unused metric could relabel a column the chart actually selected.
const dataset = {
columns: [{ column_name: 'revenue', verbose_name: 'Revenue' }],
metrics: [{ metric_name: 'revenue', verbose_name: 'Revenue %' }],
} as Dataset;
const verboseMap = createVerboseMap(dataset);
expect(verboseMap).toEqual({ revenue: 'Revenue' });
});
test('createVerboseMap handles undefined dataset', () => {
const verboseMap = createVerboseMap(undefined);
expect(verboseMap).toEqual({});
@@ -317,6 +340,9 @@ test('useDatasetDrillInfo fetches dataset via extension when extension and formD
mockExtension.mockResolvedValue({
json: { result: mockDataset },
} as any);
mockedCachedSupersetGet.mockResolvedValue({
json: { result: mockDataset },
} as any);
const { result } = renderHook(() =>
useDatasetDrillInfo(123, 456, mockFormData),
@@ -341,9 +367,94 @@ test('useDatasetDrillInfo fetches dataset via extension when extension and formD
},
});
expect(result.current.error).toBeNull();
});
// Verify cachedSupersetGet was NOT called (extension path bypasses REST API)
expect(mockedCachedSupersetGet).not.toHaveBeenCalled();
test('useDatasetDrillInfo labels come from the API even when the extension supplies drill-by options', async () => {
setupExtensionMock();
const mockFormData = { viz_type: 'table', datasource: '123__table' };
// The extension contract only covers drill-by options, so a conforming
// implementation may return dimensions alone.
const extensionResult = {
id: 123,
columns: [{ column_name: 'city', verbose_name: 'City', groupby: true }],
};
// The API is the only source that promises the whole dataset.
const apiResult = {
id: 123,
columns: [
{ column_name: 'city', verbose_name: 'City', groupby: true },
{ column_name: 'revenue', verbose_name: 'Revenue', groupby: false },
],
metrics: [{ metric_name: 'sum__num', verbose_name: 'Yearly Total' }],
};
mockExtension.mockResolvedValue({ json: { result: extensionResult } } as any);
mockedCachedSupersetGet.mockResolvedValue({
json: { result: apiResult },
} as any);
const { result } = renderHook(() =>
useDatasetDrillInfo(123, 456, mockFormData),
);
await waitFor(() => {
expect(result.current.status).toBe('complete');
});
// Labels cover everything the chart may select...
expect(result.current.result?.verbose_map).toEqual({
city: 'City',
revenue: 'Revenue',
sum__num: 'Yearly Total',
});
// ...while drill-by options still come from the extension.
expect(result.current.result?.columns).toEqual(extensionResult.columns);
});
test('useDatasetDrillInfo keeps extension drill-by options when the label fetch fails', async () => {
setupExtensionMock();
const mockFormData = { viz_type: 'table', datasource: '123__table' };
const extensionResult = {
id: 123,
columns: [{ column_name: 'city', verbose_name: 'City', groupby: true }],
};
mockExtension.mockResolvedValue({ json: { result: extensionResult } } as any);
// A deployment may register the extension precisely because the REST endpoint
// is unavailable to it. Drill-by works there today and must keep working.
mockedCachedSupersetGet.mockRejectedValue(new Error('403'));
const { result } = renderHook(() =>
useDatasetDrillInfo(123, 456, mockFormData),
);
await waitFor(() => {
expect(result.current.status).toBe('complete');
});
expect(mockedCachedSupersetGet).toHaveBeenCalled();
expect(result.current.result?.columns).toEqual(extensionResult.columns);
expect(result.current.result?.verbose_map).toEqual({ city: 'City' });
expect(result.current.error).toBeNull();
// The failure is expected and handled in these deployments, so it must not
// log an error on every dashboard load.
expect(mockedLoggingError).not.toHaveBeenCalled();
});
test('useDatasetDrillInfo logs when the API is the only source and it fails', async () => {
// Without an extension the failure is fatal rather than best-effort, so it
// still surfaces in the console.
mockedCachedSupersetGet.mockRejectedValue(new Error('500'));
const { result } = renderHook(() => useDatasetDrillInfo(123, 456));
await waitFor(() => {
expect(result.current.status).toBe('error');
});
expect(mockedLoggingError).toHaveBeenCalled();
});
test('useDatasetDrillInfo handles extension throwing error', async () => {
@@ -46,12 +46,17 @@ export const getDatasetId = (datasetId: string | number): number =>
*/
export const createVerboseMap = (dataset?: Dataset): Record<string, string> => {
const verbose_map: Record<string, string> = {};
ensureIsArray(dataset?.columns).forEach((column: Column) => {
verbose_map[column.column_name] = column.verbose_name || column.column_name;
});
// A name can appear in both lists -- uniqueness is only enforced within each
// one -- so metrics are written first and columns overwrite them. That matches
// `SqlaTable.data_for_slices`, which builds the verbose map the dashboard's own
// charts already render with, and keeps an unused metric from relabelling a
// column the chart actually selected.
ensureIsArray(dataset?.metrics).forEach((metric: Metric) => {
verbose_map[metric.metric_name] = metric.verbose_name || metric.metric_name;
});
ensureIsArray(dataset?.columns).forEach((column: Column) => {
verbose_map[column.column_name] = column.verbose_name || column.column_name;
});
return verbose_map;
};
@@ -81,6 +86,23 @@ export const useDatasetDrillInfo = (
});
return;
}
// `bestEffort` callers recover from a failure themselves, so it is not worth
// logging: a deployment that registers the drill-by extension because this
// endpoint is unreachable would otherwise log on every dashboard load.
const fetchDrillInfo = async ({ bestEffort = false } = {}) => {
const endpoint = `/api/v1/dataset/${getDatasetId(datasetId)}/drill_info/?q=(dashboard_id:${dashboardId})`;
try {
const { json } = await cachedSupersetGet({ endpoint });
return json.result;
} catch (error) {
if (!bestEffort) {
logging.error('Failed to load dataset: ', error);
}
supersetGetCache.delete(endpoint);
throw error;
}
};
const fetchDataset = async () => {
try {
const numericDatasetId = getDatasetId(datasetId);
@@ -88,6 +110,7 @@ export const useDatasetDrillInfo = (
'load.drillby.options',
);
let result;
let labelSource;
if (loadDrillByOptionsExtension && formData) {
const response = await loadDrillByOptionsExtension(
@@ -95,20 +118,23 @@ export const useDatasetDrillInfo = (
formData,
);
result = response?.json?.result;
} else {
const endpoint = `/api/v1/dataset/${numericDatasetId}/drill_info/?q=(dashboard_id:${dashboardId})`;
// The extension contract only covers drill-by options, so a conforming
// implementation may omit metrics and non-dimension columns. Labels
// come from the API, which is the only source that promises the whole
// dataset. If it is unreachable -- a deployment may register the
// extension precisely because it is -- fall back to what the extension
// returned rather than breaking drill-by, which works there today.
try {
const { json } = await cachedSupersetGet({ endpoint });
const { result: datasetResult } = json;
result = datasetResult;
} catch (error) {
logging.error('Failed to load dataset: ', error);
supersetGetCache.delete(endpoint);
throw error;
labelSource = await fetchDrillInfo({ bestEffort: true });
} catch {
labelSource = result;
}
} else {
result = await fetchDrillInfo();
labelSource = result;
}
const verbose_map = createVerboseMap(result);
const verbose_map = createVerboseMap(labelSource);
setResource({
status: ResourceStatus.Complete,
@@ -90,6 +90,7 @@ export const usePermissions = () => {
canDrill,
canDrillBy,
canDrillToDetail,
canGetDrillInfo,
canViewQuery,
canViewTable,
};
@@ -153,6 +153,20 @@ test('renders a select and a VizTypeGallery', async () => {
expect(screen.getByText(/choose chart type/i)).toBeInTheDocument();
});
test('does not double up the vertical Steps icon-to-content gap', async () => {
// antd 6 added its own icon->content gap on `.ant-steps-item-wrapper`
// (column-gap), stacking on top of the pre-existing `margin-right` on
// `.ant-steps-item-icon` and shifting every step's content to the right.
const { container } = await renderComponent();
const styledContainer = container.firstChild;
expect(styledContainer).toHaveStyleRule('column-gap', '0', {
target: '.ant-steps-item-wrapper',
});
expect(styledContainer).toHaveStyleRule('margin-right', '8px', {
target: '.ant-steps-item-icon',
});
});
test('renders dataset help text when user lacks dataset write permissions', async () => {
await renderComponent();
expect(screen.queryByText('Add a dataset')).not.toBeInTheDocument();
@@ -141,6 +141,13 @@ const StyledContainer = styled.div`
display: none;
}
/* antd 6 added its own icon->content gap on this flex wrapper
(column-gap), on top of the .ant-steps-item-icon margin-right below,
doubling the gap. Zero it out so the icon's margin is the only gap. */
&&&& .ant-steps-item-wrapper {
column-gap: 0;
}
&&&& .ant-steps-item-icon {
margin-right: ${theme.marginXS}px;
width: ${theme.sizeUnit * 5}px;
@@ -181,7 +181,10 @@ export interface CommonBootstrapData {
export interface BootstrapData {
user?: BootstrapUser;
common: CommonBootstrapData;
config?: any;
config?: {
GUEST_TOKEN_HEADER_NAME?: string;
GUEST_TOKEN_HEADER_MAX_BYTES?: number | null;
};
embedded?: {
dashboard_id: string;
// Domains allowed to embed this dashboard. An empty/undefined list means
@@ -18,6 +18,7 @@
*/
import type { WorkBook } from 'xlsx';
import { getNumberFormatterRegistry } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import exportPivotExcel from './downloadAsPivotExcel';
const mockWriteFile = jest.fn();
@@ -30,6 +31,14 @@ jest.mock('xlsx', () => {
};
});
jest.mock('@apache-superset/core/utils', () => ({
logging: { error: jest.fn() },
}));
afterEach(() => {
jest.restoreAllMocks();
});
// Renders a single-row pivot table with the given cell values, runs the
// export, and returns the resulting sheet so each test only has to state
// its input cells and assertions.
@@ -114,3 +123,13 @@ test('leaves date-shaped strings as text rather than reinterpreting them as date
expect(sheet.B1).toMatchObject({ t: 's', v: '2024-01-01 13:45:30' });
expect(sheet.C1).toMatchObject({ t: 's', v: 'not-a-date' });
});
test('should log an error and return early when table element is not found', () => {
jest.spyOn(document, 'querySelector').mockReturnValue(null);
exportPivotExcel('.non-existent-selector', 'test-file');
expect(logging.error as jest.Mock).toHaveBeenCalledWith(
'[exportPivotExcel] No element found for selector: ".non-existent-selector"',
);
});
@@ -17,6 +17,7 @@
* under the License.
*/
import { getNumberFormatterRegistry } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { utils, writeFile } from 'xlsx';
import type { WorkSheet } from 'xlsx';
@@ -65,6 +66,12 @@ export default function exportPivotExcel(
fileName: string,
) {
const table = document.querySelector(tableSelector);
if (!table) {
logging.error(
`[exportPivotExcel] No element found for selector: "${tableSelector}"`,
);
return;
}
// `raw: true` keeps every cell as the literal text rendered in the DOM.
// Without it, SheetJS tries to infer numbers/dates from the displayed
// string, which mangles values that were formatted using a non-US
+6 -7
View File
@@ -184,9 +184,6 @@ class SupersetApp(Flask):
app startup.
"""
try:
# Import here to avoid circular import issues
from superset.extensions import feature_flag_manager
# Check if database is up-to-date with migrations
if not self._is_database_up_to_date():
logger.info("Pending database migrations: run 'superset db upgrade'")
@@ -194,11 +191,13 @@ class SupersetApp(Flask):
logger.info("Syncing configuration to database...")
# Register SQLA event listeners for tagging system
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
from superset.tags.core import register_sqla_event_listeners
# Register SQLA event listeners for the tagging system. The
# listeners that create tags check TAGGING_SYSTEM when they fire,
# and the cleanup listeners must run regardless of the flag so a
# deleted object never leaves orphaned `tagged_object` rows behind.
from superset.tags.core import register_sqla_event_listeners
register_sqla_event_listeners()
register_sqla_event_listeners()
# Seed system themes from configuration
from superset.commands.theme.seed import SeedSystemThemesCommand
+29
View File
@@ -44,6 +44,26 @@ class DatabaseExistsValidationError(ValidationError):
)
class DatabaseUpdateUnsafeRebindError(ValidationError):
"""
Marshmallow validation error for an update that would change a
database's effective connection destination while leaving the stored
password/encrypted_extra/SSH tunnel credential masked.
"""
def __init__(self, field_name: str = "sqlalchemy_uri") -> None:
super().__init__(
_(
"This update would change the connection's effective "
"destination (host/port, engine parameters, or SSH tunnel "
"endpoint) while reusing the stored credential. Provide "
"the real password (or SSH tunnel credential) to confirm "
"a connection move."
),
field_name=field_name,
)
class DatabaseRequiredFieldValidationError(ValidationError):
def __init__(self, field_name: str) -> None:
super().__init__(
@@ -174,6 +194,15 @@ class DatabaseSecurityUnsafeError(CommandInvalidError):
message = _("Stopped an unsafe database connection")
class DatabaseTestConnectionUnsafeRebindError(CommandInvalidError):
message = _(
"Testing this connection would change its effective destination "
"(engine parameters or SSH tunnel endpoint) while reusing the stored "
"password. Provide the real password to test a connection whose "
"destination has changed."
)
class DatabaseTestConnectionDriverError(CommandInvalidError):
message = _("Could not load database driver")
@@ -22,7 +22,12 @@ from flask import current_app as app
from superset import db, security_manager
from superset.commands.database.exceptions import DatabaseInvalidError
from superset.commands.database.utils import add_permissions
from superset.commands.database.utils import (
add_permissions,
engine_params_changed,
ssh_tunnel_rebind_unsafe,
uri_identity_changed,
)
from superset.commands.exceptions import ImportFailedError
from superset.constants import PASSWORD_MASK
from superset.databases.ssh_tunnel.models import SSHTunnel
@@ -41,14 +46,19 @@ logger = logging.getLogger(__name__)
def _connection_identity_changed(existing: Database, config: dict[str, Any]) -> bool:
"""Whether the import points the database at a different endpoint."""
try:
stored = make_url_safe(existing.sqlalchemy_uri)._replace(password=None)
incoming = make_url_safe(config["sqlalchemy_uri"])._replace(password=None)
except DatabaseInvalidError:
# An unparseable URI cannot be compared: treat it as a change so
# stored secrets never survive onto it.
if uri_identity_changed(existing.sqlalchemy_uri, config.get("sqlalchemy_uri")):
return True
return stored != incoming
# The URI's host/port aren't the whole story: `extra.engine_params`
# (e.g. `connect_args.host`/`port`) is merged into the actual DBAPI
# connect kwargs and can override them. An import that opens a live
# connection (`add_permissions` -> `get_all_catalog_names`) with a
# rehydrated stored password must not do so against a destination this
# field silently redirected.
submitted_extra = config.get("extra")
if isinstance(submitted_extra, dict):
submitted_extra = json.dumps(submitted_extra)
return engine_params_changed(existing.extra, submitted_extra)
def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> None:
@@ -77,33 +87,13 @@ def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> N
"connection to confirm the change."
)
if ssh_tunnel := config.get("ssh_tunnel"):
existing_tunnel = existing.ssh_tunnel
if existing_tunnel and (
ssh_tunnel.get("server_address") != existing_tunnel.server_address
or ssh_tunnel.get("server_port") != existing_tunnel.server_port
):
has_fresh_credential = any(
ssh_tunnel.get(field) not in (None, PASSWORD_MASK)
for field in ("password", "private_key")
)
# A passphrase-protected private key's stored passphrase is a
# secret in its own right: if the existing tunnel had one, a
# repoint that supplies a fresh private_key but leaves
# private_key_password masked/absent would keep the old
# passphrase attached to the new key rather than requiring the
# importer to confirm it too.
stale_private_key_password = (
existing_tunnel.private_key_password is not None
and ssh_tunnel.get("private_key_password") in (None, PASSWORD_MASK)
)
if not has_fresh_credential or stale_private_key_password:
raise ImportFailedError(
f"Import would change the SSH tunnel endpoint of database "
f"'{existing.database_name}' without providing new tunnel "
"credentials. Re-enter the SSH tunnel credentials to "
"confirm the change."
)
if ssh_tunnel_rebind_unsafe(existing.ssh_tunnel, config.get("ssh_tunnel")):
raise ImportFailedError(
f"Import would change the SSH tunnel endpoint of database "
f"'{existing.database_name}' without providing new tunnel "
"credentials. Re-enter the SSH tunnel credentials to "
"confirm the change."
)
def import_database( # noqa: C901
+36 -5
View File
@@ -26,13 +26,18 @@ from superset.commands.database.exceptions import (
DatabaseSecurityUnsafeError,
DatabaseTestConnectionDriverError,
DatabaseTestConnectionUnexpectedError,
DatabaseTestConnectionUnsafeRebindError,
)
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
SSHTunnelingNotEnabledError,
)
from superset.commands.database.utils import ping
from superset.commands.database.utils import (
engine_params_changed,
ping,
ssh_tunnel_endpoint_changed,
)
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.errors import ErrorLevel, SupersetErrorType
@@ -65,6 +70,8 @@ class TestConnectionDatabaseCommand(BaseCommand):
_model: Optional[Database] = None
_context: dict[str, Any]
_uri: str
_identity_changed: bool
_ssh_tunnel_endpoint_changed: bool
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()
@@ -73,8 +80,27 @@ class TestConnectionDatabaseCommand(BaseCommand):
self._model = DatabaseDAO.get_database_by_name(database_name)
uri = self._properties.get("sqlalchemy_uri", "")
if self._model and uri == self._model.safe_sqlalchemy_uri():
uri = self._model.sqlalchemy_uri_decrypted
self._identity_changed = False
self._ssh_tunnel_endpoint_changed = False
if (model := self._model) is not None:
# A stored password (and, below, encrypted_extra / SSH tunnel
# credentials) must never be rehydrated onto a connection whose
# final effective destination the requester can change. The
# visible `sqlalchemy_uri` is only one part of that destination:
# `extra.engine_params` (merged into the DBAPI connect kwargs,
# e.g. `connect_args.host`/`port`) and the SSH tunnel endpoint
# can both override it after this decision is made.
self._ssh_tunnel_endpoint_changed = ssh_tunnel_endpoint_changed(
model.ssh_tunnel, self._properties.get("ssh_tunnel")
)
self._identity_changed = (
engine_params_changed(model.extra, self._properties.get("extra", "{}"))
or self._ssh_tunnel_endpoint_changed
)
if uri == model.safe_sqlalchemy_uri():
if self._identity_changed:
raise DatabaseTestConnectionUnsafeRebindError()
uri = model.sqlalchemy_uri_decrypted
url = make_url_safe(uri)
@@ -102,7 +128,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
"masked_encrypted_extra",
"{}",
)
if self._model:
if self._model and not self._identity_changed:
serialized_encrypted_extra = (
self._model.db_engine_spec.unmask_encrypted_extra(
self._model.encrypted_extra,
@@ -112,7 +138,12 @@ class TestConnectionDatabaseCommand(BaseCommand):
# collect SSH tunnel info
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
if (
ssh_tunnel_properties
and self._model
and self._model.ssh_tunnel
and not self._ssh_tunnel_endpoint_changed
):
# unmask password while allowing for updated values
ssh_tunnel_properties = unmask_password_info(
ssh_tunnel_properties,
+87
View File
@@ -30,10 +30,18 @@ from superset.commands.database.exceptions import (
DatabaseInvalidError,
DatabaseNotFoundError,
DatabaseUpdateFailedError,
DatabaseUpdateUnsafeRebindError,
MissingOAuth2TokenError,
)
from superset.commands.database.sync_permissions import SyncPermissionsCommand
from superset.commands.database.utils import (
engine_params_changed,
ssh_tunnel_rebind_unsafe,
uri_identity_changed,
)
from superset.constants import PASSWORD_MASK
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.exceptions import OAuth2RedirectError
from superset.models.core import Database
from superset.utils import json
@@ -180,3 +188,82 @@ class UpdateDatabaseCommand(BaseCommand):
database_name,
):
raise DatabaseInvalidError(exceptions=[DatabaseExistsValidationError()])
if self._model:
self._check_no_unsafe_secret_rebind()
def _check_no_unsafe_secret_rebind(self) -> None:
"""
Refuse an update that changes the connection's effective destination
(URI host/port, `extra.engine_params`, or the SSH tunnel endpoint)
while leaving the corresponding stored secret masked.
Without this, an editor could silently redirect the real stored
password/encrypted_extra/SSH tunnel credential to a different
destination -- and since an update persists, every subsequent use of
the database (by any user) would send the real secret there, not
just the editor's own request.
"""
model = self._model
assert model is not None
connection_identity_changed = False
submitted_password: str | None = None
if "sqlalchemy_uri" in self._properties:
submitted_uri = self._properties["sqlalchemy_uri"] or ""
connection_identity_changed = uri_identity_changed(
model.sqlalchemy_uri, submitted_uri
)
try:
submitted_password = make_url_safe(submitted_uri).password
except DatabaseInvalidError:
submitted_password = None
if "extra" in self._properties and engine_params_changed(
model.extra, self._properties["extra"]
):
connection_identity_changed = True
if connection_identity_changed:
# The URI password is only one of the secrets that can silently
# carry over onto a changed destination. `encrypted_extra` (e.g.
# a service-account key or OAuth2 client secret) is reattached
# unconditionally in `run()` via `unmask_encrypted_extra` unless
# we catch it here -- gating on the URI password alone would
# both miss that reuse when a fresh URI password is supplied,
# and wrongly block engines that keep credentials entirely in
# `encrypted_extra` and carry no URI password at all (BigQuery,
# GSheets), since those never have a "fresh" URI password to
# give.
uri_password_reused = model.password is not None and submitted_password in (
None,
PASSWORD_MASK,
)
# encrypted_extra is a blob with per-field masks, so "reused"
# means unmasking the submission against the stored value
# changes nothing -- including not submitting it at all, which
# leaves the old (real) value attached unchanged.
encrypted_extra_reused = model.encrypted_extra not in (
None,
"",
"{}",
) and (
"masked_encrypted_extra" not in self._properties
or model.db_engine_spec.unmask_encrypted_extra(
model.encrypted_extra,
self._properties["masked_encrypted_extra"],
)
== model.encrypted_extra
)
if uri_password_reused or encrypted_extra_reused:
raise DatabaseInvalidError(
exceptions=[DatabaseUpdateUnsafeRebindError()]
)
if "ssh_tunnel" in self._properties and ssh_tunnel_rebind_unsafe(
model.ssh_tunnel, self._properties["ssh_tunnel"]
):
raise DatabaseInvalidError(
exceptions=[DatabaseUpdateUnsafeRebindError(field_name="ssh_tunnel")]
)
+96
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import sqlite3
from contextlib import closing
from typing import Any
from flask import current_app as app
from flask_appbuilder.security.sqla.models import (
@@ -30,14 +31,109 @@ from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from superset import security_manager
from superset.commands.database.exceptions import DatabaseInvalidError
from superset.constants import PASSWORD_MASK
from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import GenericDBException
from superset.models.core import Database
from superset.security.manager import SupersetSecurityManager
from superset.utils import json
from superset.utils.core import timeout
logger = logging.getLogger(__name__)
def uri_identity_changed(existing_uri: str | None, submitted_uri: str | None) -> bool:
"""
Whether two SQLAlchemy URIs differ once their password is stripped --
i.e. whether the effective connection destination (driver, host, port,
database, username, query params) changed.
"""
try:
stored = make_url_safe(existing_uri or "")._replace(password=None)
incoming = make_url_safe(submitted_uri or "")._replace(password=None)
except DatabaseInvalidError:
# An unparseable URI cannot be compared: treat it as a change so a
# stored secret never survives onto it.
return True
return stored != incoming
def engine_params_changed(
existing_extra: str | None, submitted_extra: str | None
) -> bool:
"""
Whether ``submitted_extra`` carries different ``engine_params`` than
``existing_extra``.
``engine_params`` (in particular ``engine_params.connect_args``) is
merged into the actual DBAPI connect kwargs, so it can override the
host/port/etc. carried in the SQLAlchemy URI itself. Any caller that
conditionally reattaches a stored secret (password, encrypted_extra, SSH
tunnel credentials) based on the URI being unchanged must also check
this, or the destination can be silently redirected while the real
secret rides along.
"""
def _engine_params(serialized_extra: str | None) -> dict[str, Any]:
try:
return json.loads(serialized_extra or "{}").get("engine_params", {})
except (json.JSONDecodeError, AttributeError):
# Unparseable/non-dict `extra` cannot be compared: treat it as a
# change so a stored secret never rides along with input that
# can't be verified to leave the connection identity untouched.
return {"__unparseable__": True}
return _engine_params(submitted_extra) != _engine_params(existing_extra)
def ssh_tunnel_endpoint_changed(
existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None
) -> bool:
"""
Whether a submitted SSH tunnel config points at a different endpoint
than the stored tunnel it would otherwise inherit credentials from.
"""
if not submitted_tunnel or not existing_tunnel:
return False
return bool(
submitted_tunnel.get("server_address") != existing_tunnel.server_address
or submitted_tunnel.get("server_port") != existing_tunnel.server_port
)
def ssh_tunnel_rebind_unsafe(
existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None
) -> bool:
"""
Whether a submitted SSH tunnel config repoints the tunnel at a
different endpoint without supplying credentials fresh enough to
justify it -- i.e. whether carrying the stored tunnel secrets over
onto this submission would be unsafe.
"""
if not ssh_tunnel_endpoint_changed(existing_tunnel, submitted_tunnel):
return False
assert submitted_tunnel is not None
assert existing_tunnel is not None
has_fresh_credential = any(
submitted_tunnel.get(field) not in (None, PASSWORD_MASK)
for field in ("password", "private_key")
)
# A passphrase-protected private key's stored passphrase is a secret in
# its own right: if the existing tunnel had one, a repoint that
# supplies a fresh private_key but leaves private_key_password
# masked/absent would keep the old passphrase attached to the new key
# rather than requiring the caller to confirm it too.
stale_private_key_password = (
existing_tunnel.private_key_password is not None
and submitted_tunnel.get("private_key_password") in (None, PASSWORD_MASK)
)
return not has_fresh_credential or stale_private_key_password
def ping(engine: Engine) -> bool:
try:
time_delta = app.config["TEST_DATABASE_CONNECTION_TIMEOUT"]
+45 -2
View File
@@ -27,6 +27,10 @@ from superset.commands.database.exceptions import (
InvalidEngineError,
InvalidParametersError,
)
from superset.commands.database.utils import (
engine_params_changed,
ssh_tunnel_endpoint_changed,
)
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.db_engine_specs import get_engine_spec
@@ -90,11 +94,28 @@ class ValidateDatabaseParametersCommand(BaseCommand):
event_logger.log_with_context(action="validation_error", engine=engine)
raise InvalidParametersError(errors)
# A stored password/encrypted_extra/SSH tunnel credential must never
# be rehydrated onto a connection whose final effective destination
# the caller can change. `parameters` only covers what feeds into
# `sqlalchemy_uri` here -- `extra.engine_params` (merged into the
# actual DBAPI connect kwargs, e.g. `connect_args.host`/`port`) and
# the SSH tunnel endpoint can both override it independently.
identity_changed = False
ssh_tunnel_changed = False
if (model := self._model) is not None:
ssh_tunnel_changed = ssh_tunnel_endpoint_changed(
model.ssh_tunnel, self._properties.get("ssh_tunnel")
)
identity_changed = (
engine_params_changed(model.extra, self._properties.get("extra", "{}"))
or ssh_tunnel_changed
)
serialized_encrypted_extra = self._properties.get(
"masked_encrypted_extra",
"{}",
)
if self._model:
if self._model and not identity_changed:
serialized_encrypted_extra = engine_spec.unmask_encrypted_extra(
self._model.encrypted_extra,
serialized_encrypted_extra,
@@ -110,13 +131,35 @@ class ValidateDatabaseParametersCommand(BaseCommand):
encrypted_extra,
)
if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri():
if identity_changed:
raise InvalidParametersError(
[
SupersetError(
message=__(
"Testing this connection would change its "
"effective destination (engine parameters "
"or SSH tunnel endpoint) while reusing the "
"stored password. Provide the real "
"password to test a connection whose "
"destination has changed."
),
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
level=ErrorLevel.ERROR,
)
]
)
sqlalchemy_uri = self._model.sqlalchemy_uri_decrypted
# Forward the SSH tunnel into the connection test so that
# tunnel-only databases are reached through the tunnel rather
# than directly, mirroring the existing test_connection flow.
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
if (
ssh_tunnel_properties
and self._model
and self._model.ssh_tunnel
and not ssh_tunnel_changed
):
ssh_tunnel_properties = unmask_password_info(
ssh_tunnel_properties,
self._model.ssh_tunnel,
+22 -12
View File
@@ -555,19 +555,29 @@ def import_dataset( # noqa: C901
except SupersetSecurityException as ex:
raise DatasetAccessDeniedError() from ex
try:
table_exists = dataset.database.has_table(
Table(dataset.table_name, dataset.schema, dataset.catalog),
)
except Exception: # pylint: disable=broad-except
# MySQL doesn't play nice with GSheets table names
logger.warning(
"Couldn't check if table %s exists, assuming it does", dataset.table_name
)
table_exists = True
# `has_table` opens a live connection to the target database to run a
# schema-introspection query. Its result is only ever consulted below to
# decide whether to call `load_data`, which itself is a no-op unless
# `data_uri` is set - so for imports that don't carry inline data (the
# common case when bulk-importing dataset *metadata*, e.g. hundreds of
# datasets at once), this was an unconditional, unnecessary round trip
# to every target database on every single dataset, and a major
# contributor to bulk imports timing out.
if data_uri:
try:
table_exists = dataset.database.has_table(
Table(dataset.table_name, dataset.schema, dataset.catalog),
)
except Exception: # pylint: disable=broad-except
# MySQL doesn't play nice with GSheets table names
logger.warning(
"Couldn't check if table %s exists, assuming it does",
dataset.table_name,
)
table_exists = True
if data_uri and (not table_exists or force_data):
load_data(data_uri, dataset, dataset.database)
if not table_exists or force_data:
load_data(data_uri, dataset, dataset.database)
if user:
from superset.subjects.utils import get_user_subject
+102 -10
View File
@@ -70,14 +70,22 @@ class GetCombinedDatasourceListCommand(BaseCommand):
type_filter,
database_id,
semantic_layer_uuid,
schema_filter,
) = self._parse_filters(filters)
source_type = self._resolve_connection_source_type(
source_type,
database_id,
semantic_layer_uuid,
schema_filter,
)
source_type = self._resolve_source_type(source_type, sql_filter, type_filter)
# A connection filter can already resolve to "empty" (e.g. a semantic-layer
# connection combined with a dataset-only schema filter); don't let the
# content-filter resolution override that terminal decision.
if source_type != "empty":
source_type = self._resolve_source_type(
source_type, sql_filter, type_filter, schema_filter
)
if source_type == "empty":
return {"count": 0, "result": []}
@@ -88,6 +96,7 @@ class GetCombinedDatasourceListCommand(BaseCommand):
sql_filter,
database_id,
semantic_layer_uuid,
schema_filter,
)
total_count, rows = DatasourceDAO.paginate_combined_query(
combined, order_column, order_direction, page, page_size
@@ -102,6 +111,7 @@ class GetCombinedDatasourceListCommand(BaseCommand):
source_type: str,
database_id: int | None,
semantic_layer_uuid: str | None,
schema_filter: str | None = None,
) -> str:
# A connection filter implicitly narrows the source type: selecting a
# database ID means "show only datasets", and selecting a semantic layer
@@ -111,6 +121,14 @@ class GetCombinedDatasourceListCommand(BaseCommand):
if database_id is not None:
return "database"
elif semantic_layer_uuid is not None:
# A semantic-layer connection selects only that layer's
# (schema-less) views, so a dataset-only schema filter matches
# nothing: the honest result is empty. Unlike an explicit
# Source="Semantic layer" selection (handled in
# _resolve_source_type), the user never picked a source type
# here, so the "explicit selection wins" rule does not apply.
if schema_filter is not None:
return "empty"
return "semantic_layer"
return source_type
@@ -122,8 +140,11 @@ class GetCombinedDatasourceListCommand(BaseCommand):
sql_filter: bool | None,
database_id: int | None,
semantic_layer_uuid: str | None,
schema_filter: str | None = None,
) -> Any:
ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter, database_id)
ds_q = DatasourceDAO.build_dataset_query(
name_filter, sql_filter, database_id, schema_filter
)
sv_q = DatasourceDAO.build_semantic_view_query(name_filter, semantic_layer_uuid)
if source_type == "database":
@@ -184,12 +205,40 @@ class GetCombinedDatasourceListCommand(BaseCommand):
source_type: str,
sql_filter: bool | None,
type_filter: str | None,
schema_filter: str | None = None,
) -> str:
"""Narrow source_type based on access flags, sql filter, and type filter.
Returns one of: "database", "semantic_layer", "all", or "empty".
"empty" signals that the caller should short-circuit and return no results
(used when the user explicitly requests semantic views but lacks access).
Resolution follows a single precedence order (highest to lowest). This
is what makes a dataset-only filter (schema/sql) combined with a
semantic-view result behave consistently across entry points, with one
deliberate exception noted below:
1. Access a principal never sees a source type it cannot read; a
dataset-only filter applied by a user without dataset access yields
"empty" (nothing to match).
2. Explicit ``Source`` selection an explicit ``source_type`` of
"database"/"semantic_layer" is authoritative and suppresses
otherwise-contradictory cross-type filters (a leftover Schema chip
becomes a no-op rather than a contradiction). This is the one place a
dataset-only filter is intentionally dropped instead of yielding
"empty".
3. Implicit narrowing and content filters honest AND: a filter that
cannot match the resulting rows returns "empty" rather than being
silently dropped. This covers Type="Semantic View" + schema and the
semantic-layer-*connection* + schema route (see
``_resolve_connection_source_type``).
Consequence: "views + schema=X" resolves to "empty" via the Type filter
and via a semantic-layer connection, but an explicit ``Source``="Semantic
layer" selection shows all views with the schema ignored (rule 2). A
views-only user hits rule 1 first, so the same explicit selection yields
"empty" for them access restrictions outrank the explicit-selection
escape hatch. All intended.
"""
if not self._can_read_semantic_views:
# If the user explicitly asked for semantic views but cannot read them,
@@ -199,24 +248,61 @@ class GetCombinedDatasourceListCommand(BaseCommand):
return "empty"
return "database"
if not self._can_read_datasets:
# schema and sql_filter are both dataset-only, so a semantic-views-only
# user matches nothing under AND semantics; return "empty" rather than
# showing views with the filter dropped (mirrors the
# not-can_read_semantic_views branch above and the
# schema/Type="Semantic View" case below).
if schema_filter is not None or sql_filter is not None:
return "empty"
return "semantic_layer"
# An explicit source_type selection ("database" or "semantic_layer") always
# wins. This prevents e.g. Type="Semantic View" from overriding an explicit
# Source="Database" filter and showing inconsistent results.
if source_type in ("database", "semantic_layer"):
return source_type
# sql_filter (physical/virtual toggle) only applies to datasets
if sql_filter is not None:
# sql_filter (physical/virtual toggle) and schema both only apply to
# datasets (semantic views have no schema), so either narrows to datasets.
if sql_filter is not None or schema_filter is not None:
# A schema filter combined with an explicit Type="Semantic View" is
# contradictory: no semantic view has a schema, so under AND semantics
# the honest result is zero rows rather than silently dropping either
# filter. This pair is reachable because the Schema control is not part
# of the frontend cascade. (Via the UI, sql_filter and type_filter come
# from one control and cannot collide; a direct API payload could set
# both, in which case sql_filter wins — see _apply_sql_null_filter.)
if schema_filter is not None and type_filter == "semantic_view":
return "empty"
return "database"
# Explicit semantic-view type filter (only reached when source_type="all")
if type_filter == "semantic_view":
return "semantic_layer"
return source_type
@staticmethod
def _apply_sql_null_filter(
value: Any,
type_filter: str | None,
sql_filter: bool | None,
) -> tuple[str | None, bool | None]:
"""Interpret a ``sql``/``dataset_is_null_or_empty`` filter value.
``"semantic_view"`` selects semantic views; a boolean toggles the
physical/virtual dataset split. Unrecognized values leave both inputs
unchanged, so the caller can pass its current values straight through.
"""
if value == "semantic_view":
return "semantic_view", sql_filter
if isinstance(value, bool):
return type_filter, value
return type_filter, sql_filter
@staticmethod
def _parse_filters(
filters: list[dict[str, Any]],
) -> tuple[str, str | None, bool | None, str | None, int | None, str | None]:
) -> tuple[
str, str | None, bool | None, str | None, int | None, str | None, str | None
]:
"""
Translate raw rison filter dicts into typed query parameters.
@@ -228,6 +314,7 @@ class GetCombinedDatasourceListCommand(BaseCommand):
semantic views
database_id: filter datasets to a specific database ID
semantic_layer_uuid: filter semantic views to a specific semantic layer UUID
schema_filter: filter datasets to a specific schema name
"""
source_type = "all"
name_filter: str | None = None
@@ -235,6 +322,7 @@ class GetCombinedDatasourceListCommand(BaseCommand):
type_filter: str | None = None
database_id: int | None = None
semantic_layer_uuid: str | None = None
schema_filter: str | None = None
for f in filters:
col = f.get("col")
@@ -245,11 +333,12 @@ class GetCombinedDatasourceListCommand(BaseCommand):
source_type = value or "all"
elif col == "table_name" and f.get("opr") == "ct":
name_filter = value
elif col == "sql":
if opr == "dataset_is_null_or_empty" and value == "semantic_view":
type_filter = "semantic_view"
elif opr == "dataset_is_null_or_empty" and isinstance(value, bool):
sql_filter = value
elif col == "sql" and opr == "dataset_is_null_or_empty":
type_filter, sql_filter = (
GetCombinedDatasourceListCommand._apply_sql_null_filter(
value, type_filter, sql_filter
)
)
elif col == "database" and value is not None:
try:
database_id = int(value)
@@ -257,6 +346,8 @@ class GetCombinedDatasourceListCommand(BaseCommand):
pass
elif col == "semantic_layer_uuid" and value is not None:
semantic_layer_uuid = str(value)
elif col == "schema" and opr == "eq" and value is not None:
schema_filter = str(value)
return (
source_type,
@@ -265,4 +356,5 @@ class GetCombinedDatasourceListCommand(BaseCommand):
type_filter,
database_id,
semantic_layer_uuid,
schema_filter,
)

Some files were not shown because too many files have changed in this diff Show More