Compare commits

..
Author SHA1 Message Date
Elizabeth Thompson a42f5a2fa8 feat(dashboard): pluggable Excel export storage backend, with a GCS implementation
superset/utils/s3.py is hardcoded to boto3/AWS S3, so a deployment whose
EXCEL_EXPORT_S3_BUCKET names a native Google Cloud Storage bucket (not an
S3-compatible one) has no way to make dashboard Excel export actually reach
it: uploads and pre-signed download URLs both go through boto3 regardless.

Add EXCEL_EXPORT_STORAGE, an optional config hook following the same
"instance in config" idiom as RESULTS_BACKEND/CUSTOM_SECURITY_MANAGER: set it
to an object implementing the small ExportStorage protocol (upload_file,
generate_download_url) to take over both the task's upload and the download
redirect's URL minting. Left unset, behavior is unchanged -- the existing
boto3/S3 helpers run exactly as before.

Ship GCSExportStorage (superset/utils/gcs.py) as a ready-to-use
implementation for GCS-backed deployments, using the native
google-cloud-storage client (Application Default Credentials) rather than
S3-compatible interop credentials. google-cloud-storage is imported lazily
and is only required if GCSExportStorage is actually configured, via a new
excel-export-gcs extra (mirroring the existing excel-export/boto3 extra).
2026-08-24 19:08:25 +00:00
Gabriel Torres RuizandClaude Fable 5 f94582e2ca fix(dashboard): mention the automatic download in the logged-in export toast
Customer feedback showed the email-only wording made the export read as
an email delivery feature, prompting requests for a direct download that
already exists. Promise both channels, matching actual behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HwFSVNUzsZ4xW6D8Y2n95
2026-08-21 15:17:00 -03:00
Gabriel Torres RuizandClaude Fable 5 8f5283fa0a fix(dashboard): route the export auto-download through navigationUtils redirect
The direct window.location.href assignment from the base branch trips
the navigationUtils invariant scan: it bypasses ensureAppRoot (broken
under subdirectory deployment) and the scheme guard. Use redirect()
instead; tests assert the redirect call rather than the raw sink.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HwFSVNUzsZ4xW6D8Y2n95
2026-08-20 13:55:11 -03:00
Gabriel Torres RuizandClaude Fable 5 a9e680cbe1 fix(dashboard): embedded-aware Excel export UX
Embedded (iframe) sessions get delivery neutral toast copy (no email
promise a guest can never receive), a polling window that outlives the
server task budget so a slow but successful export is not orphaned, and
the image export item hidden (the webdriver cannot render Explore under
a guest identity, so it would burn the whole task budget producing
nothing). The pending toast now mirrors the screenshot download's
repeating noDuplicate info toast for all sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HwFSVNUzsZ4xW6D8Y2n95
2026-08-19 20:10:51 -03:00
Gabriel Torres RuizandClaude Fable 5 44413d8aee fix(dashboard): stamp dashboardId into exported query contexts for guest access
Guest datasource authorization requires form_data.dashboardId to link a
chart to the embedded dashboard (raise_for_access). The browser stamps
it on every interactive request, but the export task replays saved query
contexts that do not carry it, so every chart in a guest export failed
the access check and the workbook came back empty. Stamp the exporting
dashboard's id the same way the browser does; logged-in exports already
carry dashboard scope and are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HwFSVNUzsZ4xW6D8Y2n95
2026-08-19 20:10:51 -03:00
Gabriel Torres RuizandClaude Fable 5 4423f5034b fix(dashboard): resolve guest users in Excel export instead of crashing on g.user.id
GuestUser extends AnonymousUserMixin and has no id attribute, so an
embedded guest triggering export_xlsx crashed with AttributeError (500
Fatal error) on g.user.id before the task was ever enqueued. Pass
user_id=None plus the guest token payload instead, and reconstruct the
guest in the worker via get_guest_user_from_token (the async-queries
pattern) so the export runs under the token's RLS rules and resource
claims rather than an elevated identity. Guests share throttle-lock
slot 0 per dashboard, acquired and released with the same key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HwFSVNUzsZ4xW6D8Y2n95
2026-08-19 16:31:50 -03:00
Elizabeth Thompson 26fabcf77d fix(dashboard): decouple Excel export link lifetime from S3 credential expiry, and support guest/embedded sessions
The Excel export success email links to a pre-signed S3 URL that is only
valid for as long as both its own ExpiresIn and the credentials that signed
it remain valid. Deployments authenticating via short-lived, auto-refreshed
credentials (e.g. an EKS IRSA role assumed through
AssumeRoleWithWebIdentity, which AWS caps at 12 hours) can silently
invalidate the link long before EXCEL_EXPORT_LINK_TTL_SECONDS elapses.

The email now links to a Superset redirect (export_xlsx/download/<job_id>/)
instead of a raw S3 URL. The link's own lifetime is enforced independently
via the key_value store, and a fresh pre-signed URL is generated -- with
then-current credentials -- at click time.

Reusing the same job_id-keyed store also removes a separate limitation:
export_xlsx previously hard-required the requester to have an email address
on file, since email was the only way to deliver the link, which excluded
guest/embedded dashboard sessions entirely. The frontend can now poll a new
export_xlsx_status/<job_id>/ endpoint and auto-download once ready, so an
embedded session (no email) gets a working export too; a logged-in session
gets both the auto-download and, still, the email.

export_xlsx itself needs a CSRF exemption for this to work end-to-end: it's
a POST route reachable from embedded/guest sessions whose requests carry no
CSRF token, the same reason chart/data is already exempted.
2026-08-16 02:46:09 +00:00
142 changed files with 2596 additions and 6278 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
"dependencies:python":
- changed-files:
- any-glob-to-any-file:
- 'requirements/**'
- 'superset/requirements/**'
- 'superset/translations/requirements.txt'
- 'RELEASING/requirements.txt'
-3
View File
@@ -24,8 +24,6 @@ assists people when migrating to a new version.
## Next
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
### OAuth2 database callback metrics include their outcome
The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
@@ -33,7 +31,6 @@ The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
`DatabaseRestApi.oauth2.error`. Update monitoring rules and dashboards that consume
the old counter to use the outcome-specific replacements.
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
@@ -97,54 +97,6 @@ for more information on how to configure it.
At the very least, you'll want to change `SECRET_KEY` and `SQLALCHEMY_DATABASE_URI`. Continue reading for more about each of these.
## Localizing D3 date and time labels
`BABEL_DEFAULT_LOCALE` controls Superset's application translations, while
`D3_TIME_FORMAT` provides localized date and time names to visualizations that
use the D3 formatter registry, including Calendar Heatmap. Configure both when
you want the application and chart labels to use the same locale.
`D3_TIME_FORMAT` accepts partial overrides. For example, Russian month names
can be configured in `superset_config.py` as follows:
```python
BABEL_DEFAULT_LOCALE = "ru"
D3_TIME_FORMAT = {
"months": [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
],
"shortMonths": [
"Янв",
"Фев",
"Мар",
"Апр",
"Май",
"Июн",
"Июл",
"Авг",
"Сен",
"Окт",
"Ноя",
"Дек",
],
}
```
Restart Superset after changing `superset_config.py` so the frontend receives
the updated formatter configuration.
## Chart-data query timing
Set `CHART_DATA_INCLUDE_TIMING = True` to add an optional versioned timing object
+1 -13
View File
@@ -400,7 +400,7 @@ Once enabled, each user manages their own keys from their profile page:
1. Open the user menu (top-right) and click **Info** to navigate to the User Info page
2. Expand the **API Keys** section
3. Click **+ API Key**
4. Enter a name and optionally select resource scopes
4. Enter a name and (optionally) an expiration date
5. Copy the generated token — it is shown only once
Only users with the `can_read` and `can_write` permissions on `ApiKey` (granted by default to Admins) can manage API keys.
@@ -415,18 +415,6 @@ Authorization: Bearer <your-api-key>
This works for all REST API endpoints and the MCP server. The request is executed with the permissions of the user who created the key.
#### API Key Scopes
The creation dialog can restrict an API key to MCP resource actions such as
`superset:dashboard:read` or `superset:chart:write`. A scope is an additional
restriction: it never grants a permission that the creating user does not
already have through Superset RBAC. Write scopes also cover update and delete
operations for that resource; `superset:sqllab:write` covers SQL execution.
Keys created without scopes retain legacy RBAC-only behavior. The scoped-key
restrictions described here are enforced by the MCP server; regular REST API
routes continue to apply their existing Superset RBAC checks.
#### Use Cases
- **CI/CD pipelines** — automated chart/dashboard exports and imports
+7 -7
View File
@@ -61,9 +61,9 @@
"@storybook/addon-docs": "^10.5.7",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.47",
"antd": "^6.6.0",
"baseline-browser-mapping": "^2.11.13",
"caniuse-lite": "^1.0.30001809",
"antd": "^6.5.4",
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001807",
"docusaurus-plugin-openapi-docs": "^5.1.3",
"docusaurus-theme-openapi-docs": "^5.1.3",
"js-yaml": "^5.2.3",
@@ -89,14 +89,14 @@
"@eslint/js": "^9.39.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"@typescript-eslint/eslint-plugin": "^8.66.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.9.0",
"oxfmt": "^0.63.0",
"oxfmt": "^0.62.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
"typescript-eslint": "^8.66.0",
"webpack": "^5.109.2"
},
"browserslist": {
+32 -44
View File
@@ -3407,26 +3407,22 @@
"nullable": true,
"type": "string"
},
"description": {
"nullable": true,
"type": "string"
},
"editors": {
"items": {
"$ref": "#/components/schemas/DashboardRestApi.get_list.Subject"
},
"type": "array"
},
"id": {
"type": "integer"
},
"is_managed_externally": {
"type": "boolean"
},
"owners": {
"$ref": "#/components/schemas/DashboardRestApi.get_list.User2"
},
"published": {
"nullable": true,
"type": "boolean"
},
"roles": {
"$ref": "#/components/schemas/DashboardRestApi.get_list.Role"
},
"slug": {
"maxLength": 255,
"nullable": true,
@@ -3436,10 +3432,10 @@
"readOnly": true
},
"tags": {
"items": {
"$ref": "#/components/schemas/DashboardRestApi.get_list.Tag"
},
"type": "array"
"$ref": "#/components/schemas/DashboardRestApi.get_list.Tag"
},
"thumbnail_url": {
"readOnly": true
},
"url": {
"readOnly": true
@@ -3448,46 +3444,21 @@
"format": "uuid",
"nullable": true,
"type": "string"
},
"viewers": {
"items": {
"$ref": "#/components/schemas/DashboardRestApi.get_list.Subject1"
},
"type": "array"
}
},
"type": "object"
},
"DashboardRestApi.get_list.Subject": {
"DashboardRestApi.get_list.Role": {
"properties": {
"id": {
"type": "integer"
},
"label": {
"maxLength": 255,
"name": {
"maxLength": 64,
"type": "string"
},
"type": {
"type": "integer"
}
},
"required": ["label", "type"],
"type": "object"
},
"DashboardRestApi.get_list.Subject1": {
"properties": {
"id": {
"type": "integer"
},
"label": {
"maxLength": 255,
"type": "string"
},
"type": {
"type": "integer"
}
},
"required": ["label", "type"],
"required": ["name"],
"type": "object"
},
"DashboardRestApi.get_list.Tag": {
@@ -3540,6 +3511,23 @@
"required": ["first_name", "last_name"],
"type": "object"
},
"DashboardRestApi.get_list.User2": {
"properties": {
"first_name": {
"maxLength": 64,
"type": "string"
},
"id": {
"type": "integer"
},
"last_name": {
"maxLength": 64,
"type": "string"
}
},
"required": ["first_name", "last_name"],
"type": "object"
},
"DashboardRestApi.post": {
"properties": {
"certification_details": {
@@ -16518,7 +16506,7 @@
},
"result": {
"items": {
"$ref": "#/components/schemas/DashboardRestApi.get_list"
"type": "object"
},
"type": "array"
}
+244 -262
View File
@@ -1142,11 +1142,6 @@
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768"
integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==
"@babel/runtime@^8.0.0":
version "8.0.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-8.0.0.tgz#d7bd513e6843662346552c2798ab895716cf97f2"
integrity sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==
"@babel/template@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
@@ -3175,100 +3170,100 @@
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.23.0.tgz#8b66dbfa7b796139e719063fc0e44084e80a1c15"
integrity sha512-gUGJpr+Rn6zMxm5juApV0K3U845i8t47o8k+rbO0BHbi4PoJIfSPeQmrE2dgohQm2g5k6iviNFyXCGqvmaYUpw==
"@oxfmt/binding-android-arm-eabi@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz#136176dc94fdc41e21415cc770d86f5066282e0f"
integrity sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==
"@oxfmt/binding-android-arm-eabi@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz#3f5b9d3ba944f42ad3fa2697b9fef88a8c9d4ce0"
integrity sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==
"@oxfmt/binding-android-arm64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz#10bc42457179210061c801122a64304619e3bdab"
integrity sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==
"@oxfmt/binding-android-arm64@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz#4c7e2c567f645ed051be100318e9e3f716630c1b"
integrity sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==
"@oxfmt/binding-darwin-arm64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz#5f9084d9a760a1836387f8970a7f9d614ec3d909"
integrity sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==
"@oxfmt/binding-darwin-arm64@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz#6c8007ae65ed17f9d1ecc6c680da19ec19276c67"
integrity sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==
"@oxfmt/binding-darwin-x64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz#badd4a02218a9a62319817d5c337b30159a54a21"
integrity sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==
"@oxfmt/binding-darwin-x64@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz#0661a0274e8625921c5a054aeb21a36251946e6b"
integrity sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==
"@oxfmt/binding-freebsd-x64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz#a17261e95c8ebef1f76d8aaac746a64fdb6ba51e"
integrity sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==
"@oxfmt/binding-freebsd-x64@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz#f3345001102ac3e6c2947920d6d1676e9cf97e75"
integrity sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==
"@oxfmt/binding-linux-arm-gnueabihf@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz#baeee34bb08e0769af878623f442e83bc0aacd7a"
integrity sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==
"@oxfmt/binding-linux-arm-gnueabihf@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz#ddc03bc2a899f2071d6706c06dfdec3a7f3e8b5a"
integrity sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==
"@oxfmt/binding-linux-arm-musleabihf@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz#e70d5697ec4b6bb5f87a3f019e01b3f956b8e44b"
integrity sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==
"@oxfmt/binding-linux-arm-musleabihf@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz#5e82208d612c4caf64ada75e129e34d1a9eefb2c"
integrity sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==
"@oxfmt/binding-linux-arm64-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz#638a8ed4f3d256c50aeb6d2c19cfc65792c902e1"
integrity sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==
"@oxfmt/binding-linux-arm64-gnu@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz#eb379bc58aa962e753d58b4cc68ff4081bc19a5d"
integrity sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==
"@oxfmt/binding-linux-arm64-musl@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz#af5a9b787f5233f27a3360ad56235fc1b011f760"
integrity sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==
"@oxfmt/binding-linux-arm64-musl@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz#dc1c62510405e874bf6a53a34f548032eb6dfed7"
integrity sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==
"@oxfmt/binding-linux-ppc64-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz#c1a211206134a5577e355a495989e0d733218d60"
integrity sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==
"@oxfmt/binding-linux-ppc64-gnu@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz#9f9afee327090024db86b70ec81a57ad06bb2f00"
integrity sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==
"@oxfmt/binding-linux-riscv64-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz#4863f0311e5c1b88f75ef822959b3ca4fd938937"
integrity sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==
"@oxfmt/binding-linux-riscv64-gnu@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz#4427d42ee3bad0e55b38dc76fe14a7e5318c360c"
integrity sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==
"@oxfmt/binding-linux-riscv64-musl@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz#ad05a017d12553e2f544743c4940adb552aa1d1c"
integrity sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==
"@oxfmt/binding-linux-riscv64-musl@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz#a117f82909f075cf07c333842d89a5638429e21d"
integrity sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==
"@oxfmt/binding-linux-s390x-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz#2803f539db15bc66db115888fa8f84d6531ed2b9"
integrity sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==
"@oxfmt/binding-linux-s390x-gnu@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz#3fac79fefe7ffc3f0a9393678ebd782aac918fcd"
integrity sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==
"@oxfmt/binding-linux-x64-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz#c22a06a60ae2d6b3de522095e0c50a816040a033"
integrity sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==
"@oxfmt/binding-linux-x64-gnu@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz#8da207bef27941f0265c129d1c7c82c7cf91d1ce"
integrity sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==
"@oxfmt/binding-linux-x64-musl@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz#48d3eeaf8e3757f638cf92de5ee4858befc9c0a3"
integrity sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==
"@oxfmt/binding-linux-x64-musl@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz#e55cf9b7c8c2204fdbb5d4818f8c5ba02aa49360"
integrity sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==
"@oxfmt/binding-openharmony-arm64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz#02be9e140ae35ba30f52bdce27612fece4a01ab3"
integrity sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==
"@oxfmt/binding-openharmony-arm64@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz#4998769ee1b5894efcd6cb99729d5a75f4c09dd1"
integrity sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==
"@oxfmt/binding-win32-arm64-msvc@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz#2226eaf52b6345a2cb926499216b2486cf0dbec2"
integrity sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==
"@oxfmt/binding-win32-arm64-msvc@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz#e09eaabdde76c885c4f8a190518c2eb2de08548a"
integrity sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==
"@oxfmt/binding-win32-ia32-msvc@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz#58d263bb5ecd7330c02f9dcd8cda10f66e42e74b"
integrity sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==
"@oxfmt/binding-win32-ia32-msvc@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz#f36e306308923977365270d8b26290f4ca2fcfa5"
integrity sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==
"@oxfmt/binding-win32-x64-msvc@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz#02a166c8a8049c55d0096d1ba9d8e73f3a4d26a7"
integrity sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==
"@oxfmt/binding-win32-x64-msvc@0.62.0":
version "0.62.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz#bb6545e581d5ee7111084dbabeec7fe548bae418"
integrity sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==
"@parcel/watcher-android-arm64@2.5.6":
version "2.5.6"
@@ -3537,13 +3532,13 @@
dependencies:
"@babel/runtime" "^7.24.4"
"@rc-component/cascader@~1.22.0":
version "1.22.0"
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.22.0.tgz#eec0b6f4d2df5903aa12cfed321a578705926937"
integrity sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==
"@rc-component/cascader@~1.17.0":
version "1.17.0"
resolved "https://registry.yarnpkg.com/@rc-component/cascader/-/cascader-1.17.0.tgz#52c0eceada2c7b4b37ebe822c19a6544b9562edf"
integrity sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==
dependencies:
"@rc-component/select" "~1.10.0"
"@rc-component/tree" "~1.4.0"
"@rc-component/select" "~1.8.0"
"@rc-component/tree" "~1.3.2"
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
@@ -3619,14 +3614,14 @@
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/image@~1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@rc-component/image/-/image-1.10.0.tgz#5d7a82d20e4c91f75875ea64eb1dadd7af676b1d"
integrity sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==
"@rc-component/image@~1.9.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@rc-component/image/-/image-1.9.0.tgz#110785d735d20336afcdbac84e8fbfd059a7a44e"
integrity sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==
dependencies:
"@rc-component/motion" "^1.0.0"
"@rc-component/portal" "^2.1.2"
"@rc-component/util" "^1.11.1"
"@rc-component/util" "^1.10.1"
clsx "^2.1.1"
"@rc-component/input-number@~1.6.2":
@@ -3638,7 +3633,7 @@
"@rc-component/util" "^1.4.0"
clsx "^2.1.1"
"@rc-component/input@~1.3.1":
"@rc-component/input@~1.3.0", "@rc-component/input@~1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@rc-component/input/-/input-1.3.1.tgz#230b8b59cdde8521d50f0eede63ddacb61cc0cd3"
integrity sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==
@@ -3647,27 +3642,15 @@
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/listy@~1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rc-component/listy/-/listy-1.2.3.tgz#e9c8ef4f409c231b44dded37e63ae2875bbe0334"
integrity sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==
"@rc-component/mentions@~1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.10.0.tgz#46b1117cfb0c716b476e97f342555eccc2f41c97"
integrity sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==
dependencies:
"@rc-component/motion" "^1.1.4"
"@rc-component/portal" "^2.0.0"
"@rc-component/resize-observer" "^1.0.0"
"@rc-component/util" "^1.3.1"
"@rc-component/virtual-list" "^1.4.0"
clsx "^2.1.1"
"@rc-component/mentions@~1.11.0":
version "1.11.0"
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.11.0.tgz#cee0c4710f26766ad8550d386cfec5ff86fd58d9"
integrity sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==
dependencies:
"@rc-component/input" "~1.3.1"
"@rc-component/input" "~1.3.0"
"@rc-component/menu" "~1.4.0"
"@rc-component/trigger" "^3.0.0"
"@rc-component/util" "^1.11.1"
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/menu@~1.4.0", "@rc-component/menu@~1.4.1":
@@ -3741,7 +3724,7 @@
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/portal@^2.0.0", "@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0", "@rc-component/portal@^2.2.1":
"@rc-component/portal@^2.1.0", "@rc-component/portal@^2.1.2", "@rc-component/portal@^2.1.3", "@rc-component/portal@^2.2.0", "@rc-component/portal@^2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@rc-component/portal/-/portal-2.2.1.tgz#37c34b4c8cd73f53cc7072c96dd0e9ac332669ec"
integrity sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==
@@ -3789,10 +3772,10 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/select@~1.10.0":
version "1.10.1"
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
"@rc-component/select@~1.8.0", "@rc-component/select@~1.8.2":
version "1.8.2"
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.8.2.tgz#f016992dae5c57186535512d73783e2fc7e4c59e"
integrity sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==
dependencies:
"@rc-component/overflow" "^1.0.0"
"@rc-component/trigger" "^3.0.0"
@@ -3824,10 +3807,10 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/table@~1.11.0":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
"@rc-component/table@~1.10.4":
version "1.10.4"
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.10.4.tgz#8c4e33bc150aa39f579c15426421348a789de326"
integrity sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==
dependencies:
"@rc-component/context" "^2.0.1"
"@rc-component/resize-observer" "^1.0.0"
@@ -3835,10 +3818,10 @@
"@rc-component/virtual-list" "^1.0.1"
clsx "^2.1.1"
"@rc-component/tabs@~1.12.0":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.12.0.tgz#41a1a77ed1afc4f1b8b727003a058c631aceea1b"
integrity sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==
"@rc-component/tabs@~1.11.0":
version "1.11.0"
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.11.0.tgz#c157b2fadcdc2f3ab6c69d0098f73e03c6aa0c12"
integrity sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==
dependencies:
"@rc-component/dropdown" "~1.0.0"
"@rc-component/menu" "~1.4.0"
@@ -3847,13 +3830,13 @@
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/tooltip@~1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.5.0.tgz#422aa0760b310e0a1d0f9f7223e7f0d455de57a2"
integrity sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==
"@rc-component/tooltip@~1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.4.0.tgz#c8cf15c6773218a5a36271467f06e663f99c28e7"
integrity sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==
dependencies:
"@rc-component/trigger" "^3.10.0"
"@rc-component/util" "^1.11.1"
"@rc-component/trigger" "^3.7.1"
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/tour@~2.4.0":
@@ -3866,27 +3849,27 @@
"@rc-component/util" "^1.7.0"
clsx "^2.1.1"
"@rc-component/tree-select@~1.16.0":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
"@rc-component/tree-select@~1.11.0":
version "1.11.0"
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.11.0.tgz#9080cdf1d28f2ddd6d8a4879b7aa90d3170f7db9"
integrity sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==
dependencies:
"@rc-component/select" "~1.10.0"
"@rc-component/tree" "~1.4.0"
"@rc-component/select" "~1.8.0"
"@rc-component/tree" "~1.3.2"
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/tree@~1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.4.0.tgz#c0031180e681389bf0bdcb867a0087525b45c8a9"
integrity sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==
"@rc-component/tree@~1.3.2":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@rc-component/tree/-/tree-1.3.2.tgz#4b0c13564314eff61ca948c18ef923b87c9d7e44"
integrity sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==
dependencies:
"@rc-component/motion" "^1.0.0"
"@rc-component/util" "^1.11.1"
"@rc-component/virtual-list" "^1.2.0"
clsx "^2.1.1"
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15":
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-3.10.1.tgz#cb28e1bc0745a2af6897dd7ec774f9b56dc88f86"
integrity sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==
@@ -3905,7 +3888,7 @@
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.3.1", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f"
integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==
@@ -3923,16 +3906,6 @@
"@rc-component/util" "^1.4.0"
clsx "^2.1.1"
"@rc-component/virtual-list@^1.4.0":
version "1.5.1"
resolved "https://registry.yarnpkg.com/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz#71c5844a8d6bd5b3501dfb66419d3a4612b2bb18"
integrity sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==
dependencies:
"@babel/runtime" "^8.0.0"
"@rc-component/resize-observer" "^1.0.1"
"@rc-component/util" "^1.4.0"
clsx "^2.1.1"
"@redocly/ajv@^8.18.1":
version "8.18.3"
resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.18.3.tgz#a925753d9a33375219f1b2ba91aef320f9929577"
@@ -5685,100 +5658,110 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.67.0", "@typescript-eslint/eslint-plugin@^8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz#52f9f0e47d5a7571c4336e69bfeea581509ef2cf"
integrity sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==
"@typescript-eslint/eslint-plugin@8.66.0", "@typescript-eslint/eslint-plugin@^8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz#76e86aa5a2459fbf5bbd7a839c0dc0cce1d56224"
integrity sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.67.0"
"@typescript-eslint/type-utils" "8.67.0"
"@typescript-eslint/utils" "8.67.0"
"@typescript-eslint/visitor-keys" "8.67.0"
"@typescript-eslint/scope-manager" "8.66.0"
"@typescript-eslint/type-utils" "8.66.0"
"@typescript-eslint/utils" "8.66.0"
"@typescript-eslint/visitor-keys" "8.66.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.67.0", "@typescript-eslint/parser@^8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.67.0.tgz#0158022ec9927e0afcd58a8cc2ad57e01d892f5c"
integrity sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==
"@typescript-eslint/parser@8.66.0", "@typescript-eslint/parser@^8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.66.0.tgz#88e3865ecf73b0118134e7cb831da87a961a57a1"
integrity sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==
dependencies:
"@typescript-eslint/scope-manager" "8.67.0"
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/typescript-estree" "8.67.0"
"@typescript-eslint/visitor-keys" "8.67.0"
"@typescript-eslint/scope-manager" "8.66.0"
"@typescript-eslint/types" "8.66.0"
"@typescript-eslint/typescript-estree" "8.66.0"
"@typescript-eslint/visitor-keys" "8.66.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.67.0.tgz#1552db007ca9206a1c6c7acf49e210bd17a8c56f"
integrity sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==
"@typescript-eslint/project-service@8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.66.0.tgz#828f788895df52d9eb2b543445a3a5a13e35ab4e"
integrity sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.67.0"
"@typescript-eslint/types" "^8.67.0"
"@typescript-eslint/tsconfig-utils" "^8.66.0"
"@typescript-eslint/types" "^8.66.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz#4d4c2da09560d10dd7d947cba2d29d14d25af16d"
integrity sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==
"@typescript-eslint/scope-manager@8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz#4fffcc6ebd0df9fe7983c0256967567ea6f5ac63"
integrity sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==
dependencies:
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/visitor-keys" "8.67.0"
"@typescript-eslint/types" "8.66.0"
"@typescript-eslint/visitor-keys" "8.66.0"
"@typescript-eslint/tsconfig-utils@8.67.0", "@typescript-eslint/tsconfig-utils@^8.67.0":
"@typescript-eslint/tsconfig-utils@8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz#3a89066c507aa30541dc176804685b4b444e1e52"
integrity sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==
"@typescript-eslint/tsconfig-utils@^8.66.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz#f45a3eba6b9132fb47141ec03ce2f275f1ea991d"
integrity sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==
"@typescript-eslint/type-utils@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz#96bed105275559df3bcf0449b73a6414d35c59ce"
integrity sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==
"@typescript-eslint/type-utils@8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz#b2315303eca72fad9afa7be4f58f053c8f2a0479"
integrity sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==
dependencies:
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/typescript-estree" "8.67.0"
"@typescript-eslint/utils" "8.67.0"
"@typescript-eslint/types" "8.66.0"
"@typescript-eslint/typescript-estree" "8.66.0"
"@typescript-eslint/utils" "8.66.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.67.0", "@typescript-eslint/types@^8.67.0":
"@typescript-eslint/types@8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.66.0.tgz#3cacab94d3b564c1d48c56eb37b89f89a6d48479"
integrity sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==
"@typescript-eslint/types@^8.66.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.67.0.tgz#4a8d00cc1faba5c14feabc60f85b7a32652f34b6"
integrity sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==
"@typescript-eslint/typescript-estree@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz#116c3a47c06119c5a050e8851861d6497dd64bc2"
integrity sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==
"@typescript-eslint/typescript-estree@8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz#1a38c3a97dc6c669b66d585d7f90ebc4fbb32a50"
integrity sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==
dependencies:
"@typescript-eslint/project-service" "8.67.0"
"@typescript-eslint/tsconfig-utils" "8.67.0"
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/visitor-keys" "8.67.0"
"@typescript-eslint/project-service" "8.66.0"
"@typescript-eslint/tsconfig-utils" "8.66.0"
"@typescript-eslint/types" "8.66.0"
"@typescript-eslint/visitor-keys" "8.66.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.67.0.tgz#3e478a3d69d330a1fc50c12746cc2ee0732ccfcd"
integrity sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==
"@typescript-eslint/utils@8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.66.0.tgz#e277d67427043cdca2580ee91aa62921e4689969"
integrity sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.67.0"
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/typescript-estree" "8.67.0"
"@typescript-eslint/scope-manager" "8.66.0"
"@typescript-eslint/types" "8.66.0"
"@typescript-eslint/typescript-estree" "8.66.0"
"@typescript-eslint/visitor-keys@8.67.0":
version "8.67.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz#601d40af9acf82a28da2286f3edafc69bba9017f"
integrity sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==
"@typescript-eslint/visitor-keys@8.66.0":
version "8.66.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz#4c494e94745fb2724a4f37a310091e56b644d18a"
integrity sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==
dependencies:
"@typescript-eslint/types" "8.67.0"
"@typescript-eslint/types" "8.66.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -6181,10 +6164,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
antd@^6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
antd@^6.5.4:
version "6.5.4"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.4.tgz#b41665e86a5f46ca761abd3b0abef7460116ca0d"
integrity sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -6193,7 +6176,7 @@ antd@^6.6.0:
"@ant-design/icons" "^6.3.2"
"@ant-design/react-slick" "~2.0.0"
"@babel/runtime" "^7.29.2"
"@rc-component/cascader" "~1.22.0"
"@rc-component/cascader" "~1.17.0"
"@rc-component/checkbox" "~2.0.0"
"@rc-component/collapse" "~1.2.0"
"@rc-component/color-picker" "~3.1.1"
@@ -6201,11 +6184,10 @@ antd@^6.6.0:
"@rc-component/drawer" "~1.4.2"
"@rc-component/dropdown" "~1.0.3"
"@rc-component/form" "~1.8.6"
"@rc-component/image" "~1.10.0"
"@rc-component/image" "~1.9.0"
"@rc-component/input" "~1.3.1"
"@rc-component/input-number" "~1.6.2"
"@rc-component/listy" "~1.2.3"
"@rc-component/mentions" "~1.11.0"
"@rc-component/mentions" "~1.10.0"
"@rc-component/menu" "~1.4.1"
"@rc-component/motion" "^1.3.3"
"@rc-component/mutate-observer" "^2.0.1"
@@ -6217,16 +6199,16 @@ antd@^6.6.0:
"@rc-component/rate" "~1.0.1"
"@rc-component/resize-observer" "^1.1.2"
"@rc-component/segmented" "~1.3.0"
"@rc-component/select" "~1.10.0"
"@rc-component/select" "~1.8.2"
"@rc-component/slider" "~1.1.1"
"@rc-component/steps" "~1.2.2"
"@rc-component/switch" "~1.0.3"
"@rc-component/table" "~1.11.0"
"@rc-component/tabs" "~1.12.0"
"@rc-component/tooltip" "~1.5.0"
"@rc-component/table" "~1.10.4"
"@rc-component/tabs" "~1.11.0"
"@rc-component/tooltip" "~1.4.0"
"@rc-component/tour" "~2.4.0"
"@rc-component/tree" "~1.4.0"
"@rc-component/tree-select" "~1.16.0"
"@rc-component/tree" "~1.3.2"
"@rc-component/tree-select" "~1.11.0"
"@rc-component/trigger" "^3.10.1"
"@rc-component/upload" "~1.1.1"
"@rc-component/util" "^1.12.0"
@@ -6522,10 +6504,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
version "2.11.13"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.9.19:
version "2.11.12"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz#42ac48770bf73d292f60ce8ba4dc5e7ebb242ec3"
integrity sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==
batch@0.6.1:
version "0.6.1"
@@ -6763,10 +6745,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001809:
version "1.0.30001809"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz#e6cf71f14ddfe008f114dd2a846923be3c03a07b"
integrity sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001807:
version "1.0.30001807"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz#a113854941fb45b4c1f51793f4636920489079b4"
integrity sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==
ccount@^2.0.0:
version "2.0.1"
@@ -12262,32 +12244,32 @@ oxc-resolver@^11.19.1:
"@oxc-resolver/binding-win32-arm64-msvc" "11.23.0"
"@oxc-resolver/binding-win32-x64-msvc" "11.23.0"
oxfmt@^0.63.0:
version "0.63.0"
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.63.0.tgz#c7338e6c43a68d5cf8dc61c08b617d77cb54e323"
integrity sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==
oxfmt@^0.62.0:
version "0.62.0"
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.62.0.tgz#9945728022d26dc0a1d5bc486db112e7e340507a"
integrity sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==
dependencies:
tinypool "2.1.0"
optionalDependencies:
"@oxfmt/binding-android-arm-eabi" "0.63.0"
"@oxfmt/binding-android-arm64" "0.63.0"
"@oxfmt/binding-darwin-arm64" "0.63.0"
"@oxfmt/binding-darwin-x64" "0.63.0"
"@oxfmt/binding-freebsd-x64" "0.63.0"
"@oxfmt/binding-linux-arm-gnueabihf" "0.63.0"
"@oxfmt/binding-linux-arm-musleabihf" "0.63.0"
"@oxfmt/binding-linux-arm64-gnu" "0.63.0"
"@oxfmt/binding-linux-arm64-musl" "0.63.0"
"@oxfmt/binding-linux-ppc64-gnu" "0.63.0"
"@oxfmt/binding-linux-riscv64-gnu" "0.63.0"
"@oxfmt/binding-linux-riscv64-musl" "0.63.0"
"@oxfmt/binding-linux-s390x-gnu" "0.63.0"
"@oxfmt/binding-linux-x64-gnu" "0.63.0"
"@oxfmt/binding-linux-x64-musl" "0.63.0"
"@oxfmt/binding-openharmony-arm64" "0.63.0"
"@oxfmt/binding-win32-arm64-msvc" "0.63.0"
"@oxfmt/binding-win32-ia32-msvc" "0.63.0"
"@oxfmt/binding-win32-x64-msvc" "0.63.0"
"@oxfmt/binding-android-arm-eabi" "0.62.0"
"@oxfmt/binding-android-arm64" "0.62.0"
"@oxfmt/binding-darwin-arm64" "0.62.0"
"@oxfmt/binding-darwin-x64" "0.62.0"
"@oxfmt/binding-freebsd-x64" "0.62.0"
"@oxfmt/binding-linux-arm-gnueabihf" "0.62.0"
"@oxfmt/binding-linux-arm-musleabihf" "0.62.0"
"@oxfmt/binding-linux-arm64-gnu" "0.62.0"
"@oxfmt/binding-linux-arm64-musl" "0.62.0"
"@oxfmt/binding-linux-ppc64-gnu" "0.62.0"
"@oxfmt/binding-linux-riscv64-gnu" "0.62.0"
"@oxfmt/binding-linux-riscv64-musl" "0.62.0"
"@oxfmt/binding-linux-s390x-gnu" "0.62.0"
"@oxfmt/binding-linux-x64-gnu" "0.62.0"
"@oxfmt/binding-linux-x64-musl" "0.62.0"
"@oxfmt/binding-openharmony-arm64" "0.62.0"
"@oxfmt/binding-win32-arm64-msvc" "0.62.0"
"@oxfmt/binding-win32-ia32-msvc" "0.62.0"
"@oxfmt/binding-win32-x64-msvc" "0.62.0"
p-cancelable@^3.0.0:
version "3.0.0"
@@ -15485,15 +15467,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.67.0:
version "8.67.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.67.0.tgz#1e92de09ee0ff2d96cc0848f5e9f345ea930d963"
integrity sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==
typescript-eslint@^8.66.0:
version "8.66.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.66.0.tgz#0809b6d25c8a0924690ba30dc1f05607093c11fb"
integrity sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==
dependencies:
"@typescript-eslint/eslint-plugin" "8.67.0"
"@typescript-eslint/parser" "8.67.0"
"@typescript-eslint/typescript-estree" "8.67.0"
"@typescript-eslint/utils" "8.67.0"
"@typescript-eslint/eslint-plugin" "8.66.0"
"@typescript-eslint/parser" "8.66.0"
"@typescript-eslint/typescript-estree" "8.66.0"
"@typescript-eslint/utils" "8.66.0"
typescript@~6.0.3:
version "6.0.3"
+6
View File
@@ -184,6 +184,12 @@ excel = ["xlrd>=2.0.2, <2.1"]
# emails a pre-signed link. boto3 is imported lazily by superset.utils.s3, so
# installing this extra is only required to actually run exports.
excel-export = ["boto3"]
# Alternate dashboard Excel export storage backend (EXCEL_EXPORT_STORAGE =
# GCSExportStorage()) for a deployment whose export bucket is a native Google
# Cloud Storage bucket rather than S3. google-cloud-storage is imported lazily
# by superset.utils.gcs, so this extra is an alternative to excel-export, not
# an addition to it -- pick whichever matches your bucket's provider.
excel-export-gcs = ["google-cloud-storage"]
fastmcp = [
"fastmcp>=3.4.6,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
@@ -18,9 +18,8 @@
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from typing import Any
import isodate
import pyarrow as pa
@@ -91,8 +90,6 @@ class Dimension:
definition: str | None = None
description: str | None = None
grain: Grain | None = None
verbose_name: str | None = field(default=None, compare=False)
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
class AggregationType(str, enum.Enum):
@@ -124,9 +121,6 @@ class Metric:
definition: str
description: str | None = None
aggregation: AggregationType | None = None
verbose_name: str | None = field(default=None, compare=False)
d3format: str | None = field(default=None, compare=False)
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
@dataclass(frozen=True)
@@ -1,97 +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 pyarrow as pa
from superset_core.semantic_layers.types import Dimension, Metric
def test_dimension_metadata_is_not_part_of_identity() -> None:
first = Dimension(
"sales.region",
"region",
pa.utf8(),
verbose_name="Region",
metadata={"display_name": "Region"},
)
second = Dimension(
"sales.region",
"region",
pa.utf8(),
verbose_name="Sales region",
metadata={"display_name": "Sales region"},
)
assert first == second
assert {first, second} == {first}
def test_metric_metadata_is_not_part_of_identity() -> None:
first = Metric(
"sales.total_revenue",
"total_revenue",
pa.float64(),
"SUM(revenue)",
verbose_name="Total revenue",
d3format="$,.2f",
metadata={"unit": {"kind": "currency", "code": "USD"}},
)
second = Metric(
"sales.total_revenue",
"total_revenue",
pa.float64(),
"SUM(revenue)",
verbose_name="Revenue",
d3format=",.0f",
metadata={"unit": {"kind": "currency", "code": "EUR"}},
)
assert first == second
assert {first, second} == {first}
def test_metric_accepts_superset_presentation_fields() -> None:
metric = Metric(
"sales.total_revenue",
"total_revenue",
pa.float64(),
"SUM(revenue)",
verbose_name="Total revenue",
d3format="$,.2f",
)
assert metric.verbose_name == "Total revenue"
assert metric.d3format == "$,.2f"
def test_dimension_accepts_superset_presentation_fields() -> None:
dimension = Dimension(
"sales.region",
"region",
pa.utf8(),
verbose_name="Region",
)
assert dimension.verbose_name == "Region"
def test_metadata_defaults_are_not_shared() -> None:
first = Metric("first", "first", pa.int64(), "COUNT(*)")
second = Metric("second", "second", pa.int64(), "COUNT(*)")
first.metadata["display_name"] = "First"
assert second.metadata == {}
+622 -342
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -158,7 +158,7 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.6.0",
"antd": "^6.5.4",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
@@ -176,7 +176,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.1",
"google-auth-library": "^11.0.0",
"immer": "^11.1.16",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -266,7 +266,7 @@
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^15.0.0",
"@testing-library/user-event": "^12.8.3",
"@types/content-disposition": "^0.5.9",
@@ -277,7 +277,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.2.0",
"@types/node": "^26.1.2",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -289,19 +289,19 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/eslint-plugin": "^8.66.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.13",
"baseline-browser-mapping": "^2.11.12",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.8.1",
"eslint": "^10.8.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
@@ -331,8 +331,8 @@
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"open-cli": "^9.0.0",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"oxfmt": "^0.62.0",
"oxlint": "^1.77.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"process": "^0.11.10",
@@ -349,7 +349,7 @@
"swc-loader": "^0.2.7",
"ts-jest": "^29.4.12",
"tscw-config": "^1.1.2",
"tsx": "^4.23.12",
"tsx": "^4.23.10",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
@@ -103,7 +103,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.2.0",
"@types/node": "^26.1.2",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -107,14 +107,12 @@ const getAllSelectOptions = () =>
const findSelectOption = (text: string) =>
waitFor(() =>
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
within(getElementByClassName('.rc-virtual-list')).getByText(text),
);
const querySelectOption = (text: string) =>
waitFor(() =>
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
text,
),
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
);
const findAllSelectOptions = () =>
@@ -646,7 +644,7 @@ test('does not add a new option if the option already exists', async () => {
await type(option);
await waitFor(() => {
const array = within(
getElementByClassName('.ant-select-dropdown-list'),
getElementByClassName('.rc-virtual-list'),
).getAllByText(option);
expect(array.length).toBe(1);
});
@@ -1400,7 +1398,7 @@ test('appends page>1 results during an active search and discards them when sear
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
// then forwards to onPopupScroll (handlePagination here).
const holder = document.querySelector(
'.ant-select-dropdown-list-holder',
'.rc-virtual-list-holder',
) as HTMLElement | null;
if (!holder) throw new Error('virtual-list holder not rendered');
Object.defineProperty(holder, 'scrollHeight', {
@@ -93,14 +93,12 @@ const deselectAllButtonText = (length: number) =>
const findSelectOption = (text: string) =>
waitFor(() =>
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
within(getElementByClassName('.rc-virtual-list')).getByText(text),
);
const querySelectOption = (text: string) =>
waitFor(() =>
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
text,
),
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
);
const getAllSelectOptions = () =>
@@ -19,71 +19,19 @@
import { t } from '@apache-superset/core/translation';
import { sanitizeHtml } from './html';
export type TooltipTruncationMode = 'off' | 'end' | 'start' | 'middle';
export const TRUNCATION_MAX_CHARS = 40;
const TRUNCATION_STYLE = `
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
`;
const NOWRAP_STYLE = `
white-space: nowrap;
`;
/**
* Shortens plain text so a tooltip label stays readable, placing the ellipsis
* where the caller asked for it.
*
* Only 'start' and 'middle' slice. 'end' is handled by CSS in tooltipHtml, and
* 'off' means no truncation at all, so both return the input untouched.
*
* The input must be plain text. Callers are responsible for truncating before
* any markup (such as the ECharts series marker) is prepended, and before
* sanitization slicing a string that already contains markup would cut into
* a tag.
*/
export function truncateLabel(
text: string,
mode: TooltipTruncationMode = 'end',
): string {
if (
(mode !== 'start' && mode !== 'middle') ||
text.length <= TRUNCATION_MAX_CHARS
) {
return text;
}
const budget = TRUNCATION_MAX_CHARS - 1;
if (mode === 'start') {
return `${text.slice(-budget)}`;
}
const head = Math.ceil(budget / 2);
const tail = Math.floor(budget / 2);
return `${text.slice(0, head)}${text.slice(-tail)}`;
}
function getTruncationStyle(mode: TooltipTruncationMode): string {
if (mode === 'end') {
return TRUNCATION_STYLE;
}
if (mode === 'off') {
return '';
}
// 'start' and 'middle' are already sliced upstream; keep them on one line.
return NOWRAP_STYLE;
}
export function tooltipHtml(
data: string[][],
title?: string,
focusedRow?: number,
truncation: TooltipTruncationMode = 'end',
) {
const truncationStyle = getTruncationStyle(truncation);
const titleRow = title
? `<span style="font-weight: 700;${truncationStyle}">${title}</span>`
? `<span style="font-weight: 700;${TRUNCATION_STYLE}">${title}</span>`
: '';
return sanitizeHtml(`
<div>
@@ -98,7 +46,7 @@ export function tooltipHtml(
const cellStyle = `
text-align: ${j > 0 ? 'right' : 'left'};
padding-left: ${j === 0 ? 0 : 16}px;
${truncationStyle}
${TRUNCATION_STYLE}
`;
return `<td style="${cellStyle}">${cell}</td>`;
});
@@ -16,12 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
sanitizeHtml,
tooltipHtml,
truncateLabel,
TRUNCATION_MAX_CHARS,
} from '@superset-ui/core';
import { sanitizeHtml, tooltipHtml } from '@superset-ui/core';
const TITLE_STYLE =
'style="font-weight: 700;max-width:300px;overflow:hidden;text-overflow:ellipsis;"';
@@ -187,88 +182,3 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
expect(html).toContain('padding-left:16px');
expect(html).toContain('max-width:300px');
});
describe('truncateLabel', () => {
const long = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars
test('returns text unchanged for off and end', () => {
expect(truncateLabel(long, 'off')).toBe(long);
expect(truncateLabel(long, 'end')).toBe(long);
});
test('defaults to end, which does not slice', () => {
expect(truncateLabel(long)).toBe(long);
});
test('truncates the start, keeping the distinguishing suffix', () => {
expect(truncateLabel(long, 'start')).toBe(
'…-us-east-1-service-checkout-latency-p99',
);
expect(truncateLabel(long, 'start')).toHaveLength(TRUNCATION_MAX_CHARS);
});
test('truncates the middle, keeping both ends', () => {
expect(truncateLabel(long, 'middle')).toBe(
'prod-us-east-1-servi…heckout-latency-p99',
);
expect(truncateLabel(long, 'middle')).toHaveLength(TRUNCATION_MAX_CHARS);
});
test('leaves text at or under the limit untouched', () => {
const atLimit = 'x'.repeat(TRUNCATION_MAX_CHARS);
expect(truncateLabel(atLimit, 'start')).toBe(atLimit);
expect(truncateLabel(atLimit, 'middle')).toBe(atLimit);
expect(truncateLabel('short', 'start')).toBe('short');
expect(truncateLabel('', 'middle')).toBe('');
});
test('truncates text one character over the limit', () => {
const overLimit = 'x'.repeat(TRUNCATION_MAX_CHARS + 1);
expect(truncateLabel(overLimit, 'start')).toBe(
`${'x'.repeat(TRUNCATION_MAX_CHARS - 1)}`,
);
});
});
describe('tooltipHtml truncation modes', () => {
const rows = [['label', 'value']];
// sanitizeHtml normalizes spacing inside style attributes, and it does so
// differently across versions, so compare with whitespace stripped.
const styles = (
title: string | undefined,
truncation?: 'off' | 'end' | 'start' | 'middle',
) => removeWhitespaces(tooltipHtml(rows, title, undefined, truncation));
test('emits the 300px cap for end and for the default', () => {
expect(styles('Title', 'end')).toContain('max-width:300px');
expect(tooltipHtml(rows, 'Title')).toBe(
tooltipHtml(rows, 'Title', undefined, 'end'),
);
});
test('emits no truncation style for off', () => {
const html = styles('Title', 'off');
expect(html).not.toContain('max-width');
expect(html).not.toContain('text-overflow');
expect(html).not.toContain('white-space');
});
test.each(['start', 'middle'] as const)(
'emits nowrap instead of a cap for %s',
mode => {
const html = styles('Title', mode);
expect(html).toContain('white-space:nowrap');
expect(html).not.toContain('max-width');
},
);
test('never slices cell text itself, whatever the mode', () => {
const longCell = 'y'.repeat(TRUNCATION_MAX_CHARS + 20);
(['off', 'end', 'start', 'middle'] as const).forEach(mode => {
expect(tooltipHtml([[longCell]], undefined, undefined, mode)).toContain(
longCell,
);
});
});
});
@@ -22,7 +22,7 @@ import { getSequentialSchemeRegistry } from '@superset-ui/core';
import { SupersetTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import CalHeatMapImport from './vendor/cal-heatmap';
import { convertUTCTimestampToLocal, getFormattedUTCTime } from './utils';
import { convertUTCTimestampToLocal } from './utils';
// The vendor file is @ts-nocheck, so its export lacks type info.
// Define a minimal constructor interface for use in this file.
@@ -103,8 +103,6 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
const subDomainTextFormat = showValues
? (_date: Date, value: number) => valueFormatter(value)
: null;
const dateFormatter = (date: Date, format: string) =>
getFormattedUTCTime(date.getTime(), format);
const metricsData = data.data;
@@ -168,7 +166,6 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
itemName: '',
valueFormatter,
timeFormatter,
dateFormatter,
subDomainTextFormat,
});
});
@@ -76,8 +76,6 @@ var CalHeatMap = function () {
timeFormatter: d => d,
dateFormatter: null,
domain: 'hour',
subDomain: 'min',
@@ -1992,14 +1990,10 @@ CalHeatMap.prototype = {
if (typeof format === 'function') {
return format(d);
} else {
var f = d3.time.format(format);
return f(d);
}
if (typeof this.options.dateFormatter === 'function') {
return this.options.dateFormatter(d, format);
}
var f = d3.time.format(format);
return f(d);
},
getSubDomainTitle: function (d) {
@@ -25,11 +25,9 @@ import {
waitFor,
} from 'spec/helpers/testing-library';
import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
import { convertUTCTimestampToLocal } from '../src/utils';
interface MockCalHeatMapConfig {
itemSelector: Element;
dateFormatter?: (date: Date, format: string) => string;
}
type MetricNameInput = string | string[];
@@ -40,7 +38,6 @@ let mockInitCallCount = 0;
let mockThrowOnInitCall: number | null = null;
let mockDestroyCallCount = 0;
let mockDestroyedInstanceIds: string[] = [];
let mockDateFormatter: MockCalHeatMapConfig['dateFormatter'];
const mockTheme = {
colorBgElevated: '#ffffff',
@@ -59,7 +56,6 @@ jest.mock('../src/vendor/cal-heatmap', () => ({
} = require('../src/tooltip');
mockInitCallCount += 1;
mockDateFormatter = config.dateFormatter;
if (mockThrowOnInitCall === mockInitCallCount) {
throw new Error('Mock CalHeatMap init failure');
}
@@ -288,28 +284,9 @@ afterEach(() => {
mockThrowOnInitCall = null;
mockDestroyCallCount = 0;
mockDestroyedInstanceIds = [];
mockDateFormatter = undefined;
document.body.innerHTML = '';
});
test('Calendar provides a timezone-safe date formatter to CalHeatMap', () => {
const calendarOwner = document.createElement('div');
document.body.appendChild(calendarOwner);
Calendar(calendarOwner, {
...createCalendarProps('localized-metric'),
theme: mockTheme,
});
if (!mockDateFormatter) {
throw new Error('Expected Calendar to configure a date formatter');
}
const localDate = new Date(convertUTCTimestampToLocal(Date.UTC(2024, 0, 1)));
expect(mockDateFormatter(localDate, '%Y-%m-%d')).toBe('2024-01-01');
});
test('rerender and unmount clean up only the affected calendar tooltips', () => {
jest.useFakeTimers();
@@ -1,61 +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 CalHeatMapImport from '../src/vendor/cal-heatmap';
type DateFormatter = (date: Date, format: string) => string;
type FunctionalDateFormat = (date: Date) => string;
interface CalHeatMapInstance {
options: {
dateFormatter: DateFormatter | null;
};
formatDate(date: Date, format: string | FunctionalDateFormat): string;
}
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
test('CalHeatMap delegates string date formats to the configured formatter', () => {
const calendar = new CalHeatMap();
const date = new Date(2024, 0, 1);
const dateFormatter = jest.fn<string, [Date, string]>(() => 'Январь');
calendar.options.dateFormatter = dateFormatter;
expect(calendar.formatDate(date, '%B')).toBe('Январь');
expect(dateFormatter).toHaveBeenCalledWith(date, '%B');
});
test('CalHeatMap preserves functional formatters over the configured formatter', () => {
const calendar = new CalHeatMap();
const date = new Date(2024, 0, 1);
const dateFormatter = jest.fn<string, [Date, string]>(() => 'localized');
const functionalFormat = jest.fn<string, [Date]>(() => 'custom');
calendar.options.dateFormatter = dateFormatter;
expect(calendar.formatDate(date, functionalFormat)).toBe('custom');
expect(functionalFormat).toHaveBeenCalledWith(date);
expect(dateFormatter).not.toHaveBeenCalled();
});
test('CalHeatMap keeps the D3 formatter fallback', () => {
const calendar = new CalHeatMap();
const date = new Date(2024, 0, 1);
expect(calendar.formatDate(date, '%B')).toBe('January');
});
@@ -40,7 +40,6 @@ import {
TimeseriesChartDataResponseResult,
TimeseriesDataRecord,
tooltipHtml,
truncateLabel,
ValueFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
@@ -208,7 +207,6 @@ export default function transformProps(
zoomable,
richTooltip,
tooltipSortByMetric,
tooltipTruncation,
xAxisBounds,
xAxisLabelRotation,
xAxisLabelInterval,
@@ -909,19 +907,13 @@ export default function transformProps(
formatter: primarySeries.has(key)
? tooltipFormatter
: tooltipFormatterSecondary,
truncation: tooltipTruncation,
});
rows.push(row);
if (key === focusedSeries) {
focusedRow = rows.length - 1;
}
});
return tooltipHtml(
rows,
truncateLabel(tooltipFormatter(xValue), tooltipTruncation),
focusedRow,
tooltipTruncation,
);
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
},
},
legend: {
@@ -24,7 +24,6 @@ import {
ContributionType,
TimeFormatter,
AxisType,
TooltipTruncationMode,
} from '@superset-ui/core';
import {
BaseChartProps,
@@ -60,7 +59,6 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
timeGrainSqla?: TimeGranularity;
forceMaxInterval?: boolean;
tooltipTimeFormat?: string;
tooltipTruncation?: TooltipTruncationMode;
zoomable: boolean;
richTooltip: boolean;
showQueryIdentifiers?: boolean;
@@ -110,7 +108,6 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
yAxisFormatSecondary: TIMESERIES_DEFAULTS.yAxisFormat,
yAxisTitleSecondary: DEFAULT_TITLE_FORM_DATA.yAxisTitle,
tooltipTimeFormat: TIMESERIES_DEFAULTS.tooltipTimeFormat,
tooltipTruncation: TIMESERIES_DEFAULTS.tooltipTruncation,
xAxisBounds: TIMESERIES_DEFAULTS.xAxisBounds,
xAxisForceCategorical: TIMESERIES_DEFAULTS.xAxisForceCategorical,
xAxisTimeFormat: TIMESERIES_DEFAULTS.xAxisTimeFormat,
@@ -73,7 +73,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
seriesType: EchartsTimeseriesSeriesType.Line,
stack: false,
tooltipTimeFormat: 'smart_date',
tooltipTruncation: 'end',
xAxisTimeFormat: 'smart_date',
xAxisNumberFormat: 'SMART_NUMBER',
truncateXAxis: true,
@@ -30,7 +30,6 @@ import {
DTTM_ALIAS,
ensureIsArray,
tooltipHtml,
truncateLabel,
getCustomFormatter,
getMetricLabel,
getNumberFormatter,
@@ -304,7 +303,6 @@ export default function transformProps(
tooltipSortByMetric,
showTooltipTotal,
showTooltipPercentage,
tooltipTruncation,
truncateXAxis,
truncateYAxis,
xAxis: xAxisOrig,
@@ -1451,7 +1449,6 @@ export default function transformProps(
seriesName: key,
formatter,
marker,
truncation: tooltipTruncation,
});
const annotationRow = annotationLayers.some(
@@ -1485,12 +1482,7 @@ export default function transformProps(
}
rows.push(totalRow);
}
return tooltipHtml(
rows,
truncateLabel(tooltipFormatter(xValue), tooltipTruncation),
focusedRow,
tooltipTruncation,
);
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
},
},
legend: {
@@ -25,7 +25,6 @@ import {
QueryFormMetric,
TimeFormatter,
TimeGranularity,
TooltipTruncationMode,
} from '@superset-ui/core';
import {
BaseChartProps,
@@ -83,7 +82,6 @@ export type EchartsTimeseriesFormData = QueryFormData & {
tooltipTimeFormat?: string;
showTooltipTotal?: boolean;
showTooltipPercentage?: boolean;
tooltipTruncation?: TooltipTruncationMode;
truncateXAxis: boolean;
truncateYAxis: boolean;
yAxisFormat?: string;
@@ -16,13 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { EChartsCoreOption } from 'echarts/core';
import { render, waitFor } from '../../../../spec/helpers/testing-library';
import Echart, {
ECHARTS_HOST_CLASS,
ECHARTS_RENDER_FINISHED_CLASS,
isReportScreenshotMode,
} from './Echart';
import type { EChartsCoreOption } from 'echarts/core';
import Echart, { isReportScreenshotMode } from './Echart';
import type { EchartsProps } from '../types';
type Handler = (params: unknown) => void;
@@ -276,31 +272,3 @@ test('keeps animation enabled when not in report screenshot mode', async () => {
const lastOptions = mockChart.setOption.mock.calls.at(-1)?.[0];
expect(lastOptions.animation).not.toBe(false);
});
test('tags the ECharts canvas host with the readiness-gate class', async () => {
const { container } = render(renderEchart(), {
initialState,
useRedux: true,
});
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
expect(container.querySelector(`.${ECHARTS_HOST_CLASS}`)).not.toBeNull();
});
test('marks the host painted only on the ECharts `finished` event', async () => {
const { container } = render(renderEchart(), {
initialState,
useRedux: true,
});
await waitFor(() => expect(mockChart.setOption).toHaveBeenCalled());
const host = container.querySelector(`.${ECHARTS_HOST_CLASS}`) as HTMLElement;
expect(host).not.toBeNull();
// `setOption` ran during mount, which clears the marker; `finished` has not
// fired yet, so the host must NOT be flagged as painted.
expect(host).not.toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
// Simulate ECharts completing its draw -> the host is flagged painted.
trigger('finished');
expect(host).toHaveClass(ECHARTS_RENDER_FINISHED_CLASS);
});
@@ -138,15 +138,6 @@ export function isReportScreenshotMode(): boolean {
}
}
// Report-screenshot readiness contract (see superset/utils/screenshot_utils.py).
// `echarts-host` marks the canvas host element; `echarts-render-finished` is
// toggled OFF before each setOption and ON in the ECharts `finished` event --
// the only signal that the canvas is fully painted (chartStatus/onRenderSuccess
// both fire pre-paint). The readiness gate treats a host that lacks
// `echarts-render-finished` as not-yet-painted so it never captures a blank chart.
export const ECHARTS_HOST_CLASS = 'echarts-host';
export const ECHARTS_RENDER_FINISHED_CLASS = 'echarts-render-finished';
function Echart(
{
width,
@@ -210,11 +201,6 @@ function Echart(
width,
height,
});
// Paint marker for the report-screenshot readiness gate. `finished`
// is the only event that guarantees the canvas is fully drawn.
chartRef.current.on('finished', () => {
divRef.current?.classList.add(ECHARTS_RENDER_FINISHED_CLASS);
});
}
// did mount
handleSizeChange({ width, height });
@@ -335,9 +321,6 @@ function Echart(
}
)?.dataZoom
: undefined;
// Clear the paint marker before (re)drawing; the `finished` handler
// re-adds it once the new frame is fully rendered.
divRef.current?.classList.remove(ECHARTS_RENDER_FINISHED_CLASS);
chartRef.current?.setOption(themedEchartOptions, {
notMerge,
replaceMerge: notMerge ? undefined : ['series'],
@@ -429,14 +412,7 @@ function Echart(
handleSizeChange({ width, height });
}, [width, height, handleSizeChange]);
return (
<Styles
ref={divRef}
className={ECHARTS_HOST_CLASS}
height={height}
width={width}
/>
);
return <Styles ref={divRef} height={height} width={width} />;
}
export default forwardRef(Echart);
@@ -315,27 +315,6 @@ const tooltipPercentageControl: ControlSetItem = {
},
};
const tooltipTruncationControl: ControlSetItem = {
name: 'tooltipTruncation',
config: {
type: 'SelectControl',
freeForm: false,
label: t('Truncate labels'),
renderTrigger: true,
default: 'end',
clearable: false,
choices: [
['off', t('Off')],
['end', t('End')],
['start', t('Start')],
['middle', t('Middle')],
],
description: t(
'Where to place the ellipsis when a tooltip label is too long. Choose Off to always show the full label, or Start when labels share a common prefix.',
),
},
};
export const richTooltipSection: ControlSetRow[] = [
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
[richTooltipControl],
@@ -343,7 +322,6 @@ export const richTooltipSection: ControlSetRow[] = [
[tooltipPercentageControl],
[tooltipSortByMetricControl],
[tooltipTimeFormatControl],
[tooltipTruncationControl],
];
const sortSeriesType: ControlSetItem = {
@@ -16,13 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
DataRecord,
DTTM_ALIAS,
truncateLabel,
TooltipTruncationMode,
ValueFormatter,
} from '@superset-ui/core';
import { DataRecord, DTTM_ALIAS, ValueFormatter } from '@superset-ui/core';
import type { OptionName, SeriesOption } from 'echarts/types/src/util/types';
import type { TooltipMarker } from 'echarts/types/src/util/format';
import {
@@ -97,16 +91,12 @@ export const formatForecastTooltipSeries = ({
forecastUpper,
marker,
formatter,
truncation = 'end',
}: ForecastValue & {
seriesName: string;
marker: TooltipMarker;
formatter: ValueFormatter;
truncation?: TooltipTruncationMode;
}): string[] => {
// Truncate before sanitizing and before the marker is prepended: slicing a
// string that already contains markup would cut into the marker's tag.
const name = `${marker}${sanitizeHtml(truncateLabel(seriesName, truncation))}`;
const name = `${marker}${sanitizeHtml(seriesName)}`;
let value = typeof observation === 'number' ? formatter(observation) : '';
// Use finite-number checks rather than truthiness so that legitimate
// zero values (e.g. a forecast that crosses zero, or a confidence bound of
@@ -27,7 +27,6 @@ import {
VizType,
ChartDataResponseResult,
TimeGranularity,
TooltipTruncationMode,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
@@ -1296,59 +1295,3 @@ 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>';
const buildTooltip = (tooltipTruncation?: TooltipTruncationMode) => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: {
...formData,
...(tooltipTruncation ? { tooltipTruncation } : {}),
},
queriesData,
});
const { echartOptions } = transformProps(chartProps);
const { formatter } = echartOptions.tooltip as {
formatter: (params: unknown) => string;
};
// richTooltip is false in this fixture, so the trigger is 'item' and the
// formatter receives a single param object rather than an array.
return formatter({
seriesId: longSeriesName,
seriesName: longSeriesName,
value: [599616000000, 1],
marker,
});
};
test('keeps full text with the CSS cap by default', () => {
const html = buildTooltip();
expect(html.replace(/\s/g, '')).toContain('max-width:300px');
expect(html).toContain(longSeriesName);
});
test('removes the cap and keeps full text when off', () => {
const html = buildTooltip('off');
expect(html).not.toContain('max-width');
expect(html).toContain(longSeriesName);
});
test('drops the shared prefix when truncating from the start', () => {
const html = buildTooltip('start');
expect(html).not.toContain('prod-us-east');
expect(html).toContain('latency-p99');
expect(html).toContain('background-color:#1f77b4');
});
test('keeps both ends when truncating the middle', () => {
const html = buildTooltip('middle');
expect(html).toContain('prod-us-east-1-servi…heckout-latency-p99');
expect(html).not.toContain(longSeriesName);
});
});
@@ -32,7 +32,6 @@ import {
TimeseriesAnnotationLayer,
ChartDataResponseResult,
TimeGranularity,
TooltipTruncationMode,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
@@ -2438,94 +2437,3 @@ test('honors the snake_case flag the compare-chart migration stores in params',
[BASE_TIMESTAMP + 300000000, 2],
]);
});
describe('EchartsTimeseries tooltip truncation', () => {
const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99';
const marker = '<span style="background-color:#1f77b4;"></span>';
const buildTooltip = (
tooltipTruncation?: TooltipTruncationMode,
xValue: string | number = 599616000000,
) => {
const chartProps = new ChartProps({
formData: {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'sum__num',
groupby: ['foo'],
viz_type: 'my_viz',
...(tooltipTruncation ? { tooltipTruncation } : {}),
} as SqlaFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ [longSeriesName]: 1, __timestamp: 599616000000 },
{ [longSeriesName]: 3, __timestamp: 599916000000 },
],
},
],
theme: supersetTheme,
});
const { echartOptions } = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
const { formatter } = echartOptions.tooltip as {
formatter: (params: unknown) => string;
};
return formatter([
{
seriesId: longSeriesName,
seriesName: longSeriesName,
value: [xValue, 1],
marker,
},
]);
};
test('applies the CSS cap and keeps full text by default', () => {
const html = buildTooltip();
expect(html).toContain(longSeriesName);
// sanitizeHtml normalizes spacing inside style attributes, so compare with
// whitespace stripped rather than hard-coding one version's formatting.
expect(html.replace(/\s/g, '')).toContain('max-width:300px');
});
test('removes the cap and keeps full text when off', () => {
const html = buildTooltip('off');
expect(html).not.toContain('max-width');
expect(html).toContain(longSeriesName);
});
test('drops the shared prefix when truncating from the start', () => {
const html = buildTooltip('start');
expect(html).not.toContain('prod-us-east');
expect(html).toContain('latency-p99');
expect(html.replace(/\s/g, '')).toContain('white-space:nowrap');
});
test('keeps both ends when truncating the middle', () => {
const html = buildTooltip('middle');
expect(html).toContain('prod-us-east-1-servi…heckout-latency-p99');
expect(html).not.toContain(longSeriesName);
});
test('preserves the echarts marker in every mode', () => {
(['off', 'end', 'start', 'middle'] as const).forEach(mode => {
expect(buildTooltip(mode)).toContain('background-color:#1f77b4');
});
});
test('truncates a long non-temporal x-axis title', () => {
const longCategory = 'prod-us-east-1-service-checkout-cohort-2026';
const html = buildTooltip('start', longCategory);
expect(html).not.toContain(longCategory);
expect(html).toContain('cohort-2026');
});
test('leaves a long title alone in the default mode', () => {
const longCategory = 'prod-us-east-1-service-checkout-cohort-2026';
expect(buildTooltip(undefined, longCategory)).toContain(longCategory);
});
});
@@ -16,11 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
getNumberFormatter,
NumberFormats,
TRUNCATION_MAX_CHARS,
} from '@superset-ui/core';
import { getNumberFormatter, NumberFormats } from '@superset-ui/core';
import { SeriesOption } from 'echarts';
import {
extractForecastSeriesContext,
@@ -415,52 +411,3 @@ test('formatForecastTooltipSeries should skip non-finite forecast values', () =>
}),
).toEqual(['<img>qwerty', '10']);
});
describe('formatForecastTooltipSeries truncation', () => {
const marker =
'<span style="display:inline-block;width:10px;height:10px;background-color:#1f77b4;"></span>';
const longName = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars
const intFormatter = getNumberFormatter(NumberFormats.INTEGER);
const format = (truncation?: 'off' | 'end' | 'start' | 'middle') =>
formatForecastTooltipSeries({
seriesName: longName,
observation: 1,
marker,
formatter: intFormatter,
...(truncation ? { truncation } : {}),
})[0];
test('leaves the name intact by default and for off/end', () => {
expect(format()).toContain(longName);
expect(format('off')).toContain(longName);
expect(format('end')).toContain(longName);
});
test('slices the start of the name without harming the marker', () => {
const cell = format('start');
expect(cell).toContain(marker);
expect(cell).toContain('…-us-east-1-service-checkout-latency-p99');
expect(cell).not.toContain('prod-us-east');
});
test('slices the middle of the name without harming the marker', () => {
const cell = format('middle');
expect(cell).toContain(marker);
expect(cell).toContain('prod-us-east-1-servi…heckout-latency-p99');
});
test('measures the budget against the name, not the marker markup', () => {
// The marker alone is far longer than the budget. If truncation were
// applied to the concatenated cell, a short name would be mangled.
expect(marker.length).toBeGreaterThan(TRUNCATION_MAX_CHARS);
const [cell] = formatForecastTooltipSeries({
seriesName: 'cpu',
observation: 1,
marker,
formatter: intFormatter,
truncation: 'start',
});
expect(cell).toBe(`${marker}cpu`);
});
});
@@ -33,7 +33,7 @@
"mapbox-gl": "^3.28.1",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
"supercluster": "^8.0.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -164,7 +164,7 @@ export async function selectOption(option: string, selectName?: string) {
const item = await waitFor(() =>
within(
// eslint-disable-next-line testing-library/no-node-access
document.querySelector('.ant-select-dropdown-list')!,
document.querySelector('.rc-virtual-list')!,
).getByText(option),
);
await userEvent.click(item);
@@ -17,7 +17,6 @@
* under the License.
*/
import { useState } from 'react';
import fetchMock from 'fetch-mock';
import {
cleanup,
render,
@@ -36,10 +35,6 @@ import { useDrillDetailMenuItems, DrillDetailMenuItemsProps } from './index';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
// Opening the context menu logs an event, and an unmatched request makes
// fetch-mock throw inside the component.
fetchMock.post('glob:*/log/?*', {});
jest.mock(
'../DrillDetail/DrillDetailPane',
() =>
@@ -867,7 +867,7 @@ function DatasourceEditor({
return {
...metric,
certification_details: certificationDetails || details,
warning_markdown: warningMarkdown || metric.warning_markdown || '',
warning_markdown: warningMarkdown || '',
certified_by: certifiedBy || certifiedByMetric,
};
}),
@@ -1,73 +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 fetchMock from 'fetch-mock';
import { screen, userEvent, waitFor } from 'spec/helpers/testing-library';
import {
createProps,
DATASOURCE_ENDPOINT,
setupDatasourceEditorMocks,
cleanupAsyncOperations,
fastRender,
dismissDatasourceWarning,
} from './DatasourceEditor.test.utils';
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
setupDatasourceEditorMocks();
});
afterEach(async () => {
await cleanupAsyncOperations();
fetchMock.clearHistory().removeRoutes();
});
// Certifying a metric fills two adjacent fields in one visit to the expanded
// row. Both are committed through TextControl's debounce, so the second one
// used to land on the item as it looked before the first had been applied,
// leaving the saved metric with details but no certifier.
test('certifying a metric keeps both certified_by and certification_details', async () => {
const testProps = createProps();
fastRender(testProps);
await dismissDatasourceWarning();
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
const expandToggles = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggles[0]);
await userEvent.type(
await screen.findByPlaceholderText('Certified by'),
'Metric Certifier',
);
await userEvent.type(
await screen.findByPlaceholderText('Certification details'),
'Metric cert details',
);
await waitFor(() => {
const { calls } = testProps.onChange.mock;
const savedMetrics = calls[calls.length - 1]?.[0]?.metrics ?? [];
const saved = savedMetrics.find(metric => metric.metric_name === 'count');
expect(saved).toEqual(
expect.objectContaining({
certified_by: 'Metric Certifier',
certification_details: 'Metric cert details',
}),
);
});
});
@@ -1,97 +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 fetchMock from 'fetch-mock';
import { screen, userEvent } from 'spec/helpers/testing-library';
import {
createProps,
DATASOURCE_ENDPOINT,
setupDatasourceEditorMocks,
cleanupAsyncOperations,
fastRender,
dismissDatasourceWarning,
} from './DatasourceEditor.test.utils';
// Stub the Ace-backed control with a plain textarea. Ace spreads its document
// across many spans and keeps only the keystroke buffer in its own textarea,
// so asserting on the value the control receives is less brittle than
// reaching into Ace's DOM.
jest.mock('src/explore/components/controls/TextAreaControl', () => ({
__esModule: true,
default: ({
controlId,
value,
onChange,
}: {
controlId?: string;
value?: string;
onChange?: (value: string) => void;
}) => (
<textarea
data-test={`mock-textarea-${controlId}`}
value={value ?? ''}
onChange={event => onChange?.(event.target.value)}
/>
),
}));
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
setupDatasourceEditorMocks();
});
afterEach(async () => {
await cleanupAsyncOperations();
fetchMock.clearHistory().removeRoutes();
});
// Regression test for #42704. Explore's datasource payload (SqlMetric.data on
// the backend) exposes warning_markdown as a flattened top-level field and
// omits the raw `extra` JSON string that the /api/v1/dataset/{id} endpoint
// backing the Datasets page provides. Deriving warning_markdown purely from
// `extra` therefore dropped the saved text when the modal was opened from
// Explore, leaving the Warning field blank on reopen.
test('keeps a pre-existing top-level warning_markdown when the metric has no extra', async () => {
const baseProps = createProps();
const testProps = {
...baseProps,
datasource: {
...baseProps.datasource,
metrics: [
{
...baseProps.datasource.metrics[0],
warning_markdown: 'existing warning',
extra: undefined,
},
],
},
};
fastRender(testProps);
await dismissDatasourceWarning();
const metricsTab = await screen.findByTestId('collection-tab-Metrics');
await userEvent.click(metricsTab);
const expandToggles = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggles[0]);
expect(
await screen.findByTestId('mock-textarea-warning_markdown'),
).toHaveValue('existing warning');
});
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useCallback, useEffect, useRef } from 'react';
import { ReactNode, useCallback } from 'react';
import { Divider, Form, Typography } from '@superset-ui/core/components';
import { css } from '@apache-superset/core/theme';
import { recurseReactClone } from '../../utils';
@@ -39,24 +39,14 @@ export default function Fieldset({
title = null,
compact = false,
}: FieldsetProps) {
// Controls report their edits asynchronously - TextControl debounces by
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
// earlier render. Spreading that render's `item` rebuilds the whole record
// from a snapshot taken before a sibling field committed, dropping the value
// the user typed first. Reading off a ref merges into the latest commit.
const itemRef = useRef(item);
useEffect(() => {
itemRef.current = item;
}, [item]);
const handleChange = useCallback(
(fieldKey: fieldKeyType, val: any) => {
onChange?.({
...itemRef.current,
...item,
[fieldKey]: val,
});
},
[onChange],
[onChange, item],
);
const propExtender = (field: { props: { fieldKey: fieldKeyType } }) => ({
@@ -135,15 +135,6 @@ describe('dashboardState actions', () => {
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('saveDashboardRequest', () => {
const findDangerToast = (dispatch: jest.Mock) =>
dispatch.mock.calls
.map(call => call[0])
.find(
action =>
action?.type === ADD_TOAST &&
action.payload.toastType === ToastType.Danger,
);
test('should dispatch UPDATE_COMPONENTS_PARENTS_LIST action', () => {
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: false },
@@ -236,89 +227,6 @@ describe('dashboardState actions', () => {
const { body } = putStub.mock.calls[0][0];
expect(body).toBe(JSON.stringify(confirmedDashboardData));
});
test('warns about the overwrite values when a diff is detected', async () => {
const { getState, dispatch } = setup();
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
thunk(dispatch, getState);
await waitFor(() =>
expect(findDangerToast(dispatch)?.payload.text).toBe(
'Please confirm the overwrite values.',
),
);
expect(putStub.mock.calls.length).toBe(0);
});
test('reports the actual error when the overwrite precheck fails', async () => {
getStub.mockRestore();
getStub = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('precheck exploded'));
const { getState, dispatch } = setup();
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
thunk(dispatch, getState);
await waitFor(() =>
expect(findDangerToast(dispatch)?.payload.text).toContain(
'precheck exploded',
),
);
expect(putStub.mock.calls.length).toBe(0);
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('when FeatureFlag.CONFIRM_DASHBOARD_DIFF is disabled', () => {
beforeEach(() => {
mockIsFeatureEnabled.mockImplementation(() => false);
});
afterEach(() => {
mockIsFeatureEnabled.mockRestore();
});
test('never runs the overwrite precheck', async () => {
const { getState, dispatch } = setup();
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
thunk(dispatch, getState);
await waitFor(() => expect(putStub.mock.calls.length).toBe(1));
expect(getStub).not.toHaveBeenCalledWith(
expect.objectContaining({ endpoint: '/api/v1/dashboard/192' }),
);
});
// An unexpected failure used to reach the overwrite-confirm handler,
// which reported it as "Please confirm the overwrite values." even with
// the feature flag off, hiding the real error.
test('reports the actual error when the update throws unexpectedly', async () => {
putStub.mockRestore();
putStub = jest.spyOn(SupersetClient, 'put').mockImplementation(() => {
throw new Error('unexpected boom');
});
const { getState, dispatch } = setup();
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
thunk(dispatch, getState);
await waitFor(() =>
expect(findDangerToast(dispatch)?.payload.text).toContain(
'unexpected boom',
),
);
});
});
test('should navigate to the new dashboard after Save As', async () => {
@@ -471,6 +379,15 @@ describe('dashboardState actions', () => {
// permission-denied copy, while a 403 from outside Superset (reverse proxy,
// WAF, SSO gateway) carries a non-JSON body and must fall back to the
// generic status-derived toast. See #42239.
const findDangerToast = (dispatch: jest.Mock) =>
dispatch.mock.calls
.map(call => call[0])
.find(
action =>
action?.type === ADD_TOAST &&
action.payload.toastType === ToastType.Danger,
);
test('maps a non-JSON 403 save failure to the generic error toast', async () => {
const { getState, dispatch } = setup();
putStub.mockRestore();
@@ -646,7 +646,6 @@ export function saveDashboardRequest(
};
const onError = async (response: Response): Promise<void> => {
logging.error(response);
const { error, message } = await getClientErrorObject(response);
let errorText = t('Sorry, an unknown error occurred');
@@ -690,64 +689,64 @@ export function saveDashboardRequest(
}),
};
const updateDashboard = async (): Promise<JsonObject | void> => {
try {
const response = await SupersetClient.put({
endpoint: `/api/v1/dashboard/${id}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedDashboard),
});
return await onUpdateSuccess(response);
} catch (error) {
return onError(error as Response);
const updateDashboard = (): Promise<JsonObject | void> =>
SupersetClient.put({
endpoint: `/api/v1/dashboard/${id}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedDashboard),
})
.then(response => onUpdateSuccess(response))
.catch(response => onError(response));
return new Promise<void>((resolve, reject) => {
if (
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
) {
// skip overwrite precheck
resolve();
return;
}
};
if (
!isFeatureEnabled(FeatureFlag.ConfirmDashboardDiff) ||
saveType === SAVE_TYPE_OVERWRITE_CONFIRMED
) {
// skip overwrite precheck
return updateDashboard();
}
// precheck for overwrite items
return SupersetClient.get({
endpoint: `/api/v1/dashboard/${id}`,
})
.then((response: JsonObject) => {
// precheck for overwrite items
SupersetClient.get({
endpoint: `/api/v1/dashboard/${id}`,
}).then((response: JsonObject) => {
const dashboard = (response.json as JsonObject).result as JsonObject;
const overwriteConfirmItems = getOverwriteItems(
dashboard,
updatedDashboard,
);
if (overwriteConfirmItems.length === 0) {
return updateDashboard();
if (overwriteConfirmItems.length > 0) {
dispatch(
setOverrideConfirm({
updatedAt: dashboard.changed_on as string,
updatedBy: dashboard.changed_by_name as string,
overwriteConfirmItems:
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
| { overwriteConfirmItems: infer I }
| undefined
? I
: never,
dashboardId: id,
data: updatedDashboard,
}),
);
return reject(overwriteConfirmItems);
}
dispatch(
setOverrideConfirm({
updatedAt: dashboard.changed_on as string,
updatedBy: dashboard.changed_by_name as string,
overwriteConfirmItems:
overwriteConfirmItems as DashboardState['overwriteConfirmMetadata'] extends
| { overwriteConfirmItems: infer I }
| undefined
? I
: never,
dashboardId: id,
data: updatedDashboard,
}),
);
return resolve();
});
})
.then(updateDashboard)
.catch((overwriteConfirmItems: JsonObject[]) => {
const errorText = t('Please confirm the overwrite values.');
dispatch(
logEvent(LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, {
dashboard_id: id,
items: overwriteConfirmItems,
}),
);
dispatch(addDangerToast(t('Please confirm the overwrite values.')));
return undefined;
})
.catch(onError);
dispatch(addDangerToast(errorText));
});
}
// changing the data as the endpoint requires
if (
@@ -27,7 +27,7 @@ import {
} from '@superset-ui/core';
import { Dispatch } from 'redux';
import { RootState } from 'src/dashboard/types';
import { cloneDeep, omit } from 'lodash-es';
import { cloneDeep } from 'lodash-es';
import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions';
import { HYDRATE_DASHBOARD } from './hydrate';
import {
@@ -90,20 +90,12 @@ export const setFilterConfiguration =
});
try {
const response = await updateFilters(filterChanges);
// chartsInScope/tabsInScope are derived from the live layout, and the
// response carries the persisted copy for every filter - including the
// ones this save never touched, whose copy is whatever was stored when
// the dashboard was last saved. Dropping them lets the reducers keep the
// scopes calculateScopes already computed for this session.
const savedFilters = response.result.map(
filter => omit(filter, ['chartsInScope', 'tabsInScope']) as Filter,
);
dispatch({
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: savedFilters,
filterChanges: response.result,
deletedIds: filterChanges.deleted,
});
dispatch(nativeFiltersConfigChanged(savedFilters));
dispatch(nativeFiltersConfigChanged(response.result));
dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters));
} catch (err) {
dispatch({
@@ -18,6 +18,7 @@
*/
import React from 'react';
import {
act,
render,
screen,
userEvent,
@@ -32,8 +33,20 @@ import {
} from '@superset-ui/core';
import { useDownloadMenuItems } from '.';
const mockRedirect = jest.fn();
jest.mock('src/utils/navigationUtils', () => ({
...jest.requireActual('src/utils/navigationUtils'),
redirect: (url: string) => mockRedirect(url),
}));
const mockAddSuccessToast = jest.fn();
const mockAddDangerToast = jest.fn();
const mockAddInfoToast = jest.fn();
let mockIsEmbedded = false;
jest.mock('src/dashboard/util/isEmbedded', () => ({
isEmbedded: () => mockIsEmbedded,
}));
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
@@ -41,6 +54,7 @@ jest.mock('src/components/MessageToasts/withToasts', () => ({
useToasts: () => ({
addSuccessToast: mockAddSuccessToast,
addDangerToast: mockAddDangerToast,
addInfoToast: mockAddInfoToast,
}),
}));
@@ -89,12 +103,17 @@ const MenuWrapperWithProps = (
const originalCreateObjectURL = window.URL.createObjectURL;
const originalRevokeObjectURL = window.URL.revokeObjectURL;
const originalLocation = window.location;
beforeEach(() => {
jest.clearAllMocks();
mockIsEmbedded = false;
// Reset the implementation each test: clearAllMocks resets call history but
// not mockReturnValue, so an override in one test would otherwise leak.
(isFeatureEnabled as jest.Mock).mockReturnValue(false);
// @ts-ignore
delete window.location;
window.location = { href: '' } as Location;
});
// "Export Images to Excel" is gated on the webdriver screenshot feature flags.
@@ -104,6 +123,8 @@ const enableWebDriverScreenshot = () =>
afterEach(() => {
window.URL.createObjectURL = originalCreateObjectURL;
window.URL.revokeObjectURL = originalRevokeObjectURL;
window.location = originalLocation;
jest.useRealTimers();
});
test('Should render all menu items', () => {
@@ -155,8 +176,9 @@ test('Export Data to Excel posts mode "data" and shows a pending toast', async (
endpoint: '/api/v1/dashboard/123/export_xlsx/',
jsonPayload: { active_data_mask: {}, mode: 'data' },
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
);
});
});
@@ -176,12 +198,121 @@ test('Export Images to Excel posts mode "images" and shows a pending toast', asy
endpoint: '/api/v1/dashboard/123/export_xlsx/',
jsonPayload: { active_data_mask: {}, mode: 'images' },
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
);
});
});
test('Export Data to Excel polls status and auto-downloads once ready', async () => {
// A guest/embedded session has no email to be notified at, so completion is
// discovered by polling export_xlsx/status/<job_id>/ instead -- exercised
// here regardless of session type, since the same polling drives the
// auto-download for a regular session too.
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValue({
json: {
status: 'ready',
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
},
} as never);
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
),
);
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(mockSupersetClient.get).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/export_xlsx/status/abc/',
});
expect(mockRedirect).toHaveBeenCalledWith(
'/api/v1/dashboard/export_xlsx/download/abc/',
);
expect(mockAddSuccessToast).toHaveBeenCalledWith(
'Your export is ready and downloading.',
);
});
});
test('Export Data to Excel keeps polling while status is pending', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValue({
json: { status: 'pending' },
} as never);
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
),
);
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(2));
// Still pending -- no terminal toast, and the browser never navigated.
expect(mockAddDangerToast).not.toHaveBeenCalled();
expect(mockRedirect).not.toHaveBeenCalled();
});
test('Export Data to Excel shows an error toast when the export job fails', async () => {
jest.useFakeTimers();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockSupersetClient.get.mockResolvedValue({
json: { status: 'error', message: 'The export could not be built.' },
} as never);
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
{ noDuplicate: true },
),
);
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'The export could not be built.',
);
});
expect(mockRedirect).not.toHaveBeenCalled();
});
test('Export Data to Excel shows an "already in progress" toast when throttled', async () => {
// The throttle response is 202 with a message but no job_id.
mockSupersetClient.post.mockResolvedValue({
@@ -368,3 +499,37 @@ test('Enabled screenshot items should not show tooltip icon', () => {
mockIsFeatureEnabled.mockReset();
});
// ---------------------------------------------------------------------------
// Embedded (guest) behavior: no email channel exists, so the toast must not
// promise one, and the image export (webdriver-rendered, guest cannot open
// Explore) is hidden.
// ---------------------------------------------------------------------------
test('embedded: export toast promises auto-download, not an email', async () => {
mockIsEmbedded = true;
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() =>
expect(mockAddInfoToast).toHaveBeenCalledWith(
'Your export is being generated. Please, do not leave the page.',
{ noDuplicate: true },
),
);
});
test('embedded: Export Images to Excel is hidden even with the webdriver enabled', () => {
mockIsEmbedded = true;
enableWebDriverScreenshot();
render(<MenuWrapper />, { useRedux: true });
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
});
@@ -29,11 +29,13 @@ import {
import { MenuItem } from '@superset-ui/core/components/Menu';
import { parse as parseContentDisposition } from 'content-disposition';
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
import { isEmbedded as isEmbeddedDashboard } from 'src/dashboard/util/isEmbedded';
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import downloadAsImage from 'src/utils/downloadAsImage';
import handleResourceExport from 'src/utils/export';
import { redirect } from 'src/utils/navigationUtils';
import {
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
@@ -43,6 +45,23 @@ import { useToasts } from 'src/components/MessageToasts/withToasts';
import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip';
import { DownloadScreenshotFormat } from './types';
// A guest/embedded session has no email address to be notified at, so rather
// than wait on that notification the frontend polls for completion instead;
// the same polling also drives the auto-download for a regular session,
// which arrives before its export email in practice.
const EXPORT_STATUS_POLL_INTERVAL_MS = 3000;
const EXPORT_STATUS_POLL_TIMEOUT_MS = 5 * 60 * 1000;
// An embedded guest has no email fallback: if the client stops polling, a
// slow-but-successful export is orphaned with no way to retrieve it. Outlive
// the server's hard task budget (11 minutes) instead of racing it.
const EMBEDDED_EXPORT_STATUS_POLL_TIMEOUT_MS = 12 * 60 * 1000;
interface ExportStatusResponse {
status?: 'pending' | 'ready' | 'error';
download_url?: string;
message?: string;
}
export interface UseDownloadMenuItemsProps {
pdfMenuItemTitle: string;
imageMenuItemTitle: string;
@@ -70,8 +89,27 @@ export const useDownloadMenuItems = (
canExportImage,
} = props;
const { addDangerToast, addSuccessToast } = useToasts();
const { addDangerToast, addSuccessToast, addInfoToast } = useToasts();
const dataMask = useSelector((state: RootState) => state.dataMask);
// Embedded (iframe) sessions may have no email address, so they get
// delivery-neutral copy and a poll window that outlives the task budget.
const isEmbedded = isEmbeddedDashboard();
const pollTimeoutMs = isEmbedded
? EMBEDDED_EXPORT_STATUS_POLL_TIMEOUT_MS
: EXPORT_STATUS_POLL_TIMEOUT_MS;
// Mirror the screenshot download's repeating info toast: re-shown on every
// pending poll with noDuplicate, so the reminder persists for the export's
// whole lifetime without stacking.
const addExportPendingToast = () =>
addInfoToast(
isEmbedded
? t('Your export is being generated. Please, do not leave the page.')
: t(
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
),
{ noDuplicate: true },
);
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
const buildActiveDataMask = (): Record<string, { extraFormData: object }> =>
@@ -167,6 +205,56 @@ export const useDownloadMenuItems = (
}
};
const pollExportStatus = (jobId: string, startedAt: number) => {
SupersetClient.get({
endpoint: `/api/v1/dashboard/export_xlsx/status/${jobId}/`,
})
.then(({ json }) => {
const {
status,
download_url: downloadUrl,
message,
} = json as ExportStatusResponse;
if (status === 'ready') {
if (downloadUrl) {
redirect(downloadUrl);
}
addSuccessToast(t('Your export is ready and downloading.'));
return;
}
if (status === 'error') {
addDangerToast(
message || t('Sorry, something went wrong. Try again later.'),
);
return;
}
if (Date.now() - startedAt > pollTimeoutMs) {
addDangerToast(
t('Your export is taking longer than expected. Try again later.'),
);
return;
}
addExportPendingToast();
setTimeout(
() => pollExportStatus(jobId, startedAt),
EXPORT_STATUS_POLL_INTERVAL_MS,
);
})
.catch(error => {
// A transient polling failure shouldn't give up the wait -- the export
// itself may still succeed -- so keep polling until the timeout.
logging.error(error);
if (Date.now() - startedAt > pollTimeoutMs) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
return;
}
setTimeout(
() => pollExportStatus(jobId, startedAt),
EXPORT_STATUS_POLL_INTERVAL_MS,
);
});
};
const onExportXlsx = async (mode: 'data' | 'images') => {
try {
const { json } = await SupersetClient.post({
@@ -175,11 +263,12 @@ export const useDownloadMenuItems = (
});
// The throttle response (an export is already running) returns 202 with a
// message but no job_id; only a freshly enqueued job carries a job_id.
if ((json as { job_id?: string })?.job_id) {
addSuccessToast(
t(
"Your export is being prepared. You'll receive an email when it's ready.",
),
const jobId = (json as { job_id?: string })?.job_id;
if (jobId) {
addExportPendingToast();
setTimeout(
() => pollExportStatus(jobId, Date.now()),
EXPORT_STATUS_POLL_INTERVAL_MS,
);
} else {
addSuccessToast(
@@ -255,8 +344,10 @@ export const useDownloadMenuItems = (
// Image export renders charts through the headless webdriver, so only
// offer it where that infrastructure is available (same signal as the
// PDF/PNG image downloads above); otherwise non-table charts would
// silently come back empty.
...(isWebDriverScreenshotEnabled
// silently come back empty. Embedded sessions are excluded too: the
// webdriver cannot render Explore under a guest identity, so the
// export would burn its whole task budget and produce nothing.
...(isWebDriverScreenshotEnabled && !isEmbedded
? [
{
key: 'export-xlsx-images',
@@ -16,20 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
ChartCustomizationType,
type ChartCustomization,
} from '@superset-ui/core';
import { LabeledValue } from '@superset-ui/core/components';
import { render, screen } from 'spec/helpers/testing-library';
import GroupByFilterCard, {
createLabelSortComparator,
} from './GroupByFilterCard';
jest.mock('src/utils/cachedSupersetGet', () => ({
// Never resolves, pinning the card in its column-loading state.
cachedSupersetGet: jest.fn(() => new Promise(() => {})),
}));
import { createLabelSortComparator } from './GroupByFilterCard';
const apple: LabeledValue = { value: 'a', label: 'Apple' };
const banana: LabeledValue = { value: 'b', label: 'Banana' };
@@ -51,27 +39,3 @@ test('preserves source order when sortAscending is unset', () => {
expect(compare(apple, banana)).toBe(0);
expect(compare(banana, apple)).toBe(0);
});
const groupByCustomization: ChartCustomization = {
id: 'groupby-1',
name: 'Group By',
filterType: 'filter_groupby',
type: ChartCustomizationType.ChartCustomization,
targets: [{ datasetId: 1 }],
scope: { rootPath: [], excluded: [] },
controlValues: {},
defaultDataMask: {},
};
test('renders the column-loading spinner small and muted', async () => {
render(<GroupByFilterCard customizationItem={groupByCustomization} />, {
useRedux: true,
initialState: {
dataMask: {},
nativeFilters: { filters: {} },
},
});
const spinner = await screen.findByTestId('loading-indicator');
expect(spinner).toHaveClass('inline');
expect(spinner).toHaveStyle({ opacity: 0.25, width: '40px' });
});
@@ -645,7 +645,7 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
{loading && (
<div style={{ textAlign: 'center', marginTop: 8 }}>
<Loading position="inline" size="s" muted />
<Loading position="inline" />
</div>
)}
</div>
@@ -76,7 +76,7 @@ const typeIntoSelect = async (text: string) => {
const findOption = (text: string) =>
waitFor(() => {
// eslint-disable-next-line testing-library/no-node-access
const virtualList = document.querySelector('.ant-select-dropdown-list');
const virtualList = document.querySelector('.rc-virtual-list');
if (!virtualList) {
throw new Error('Virtual list not found');
}
@@ -72,13 +72,3 @@ test('omits datasourceType when undefined', () => {
});
expect(target).not.toHaveProperty('datasourceType');
});
test('omits datasourceType when there is no dataset', () => {
// The modal stamps a hidden ``datasourceType`` field on every filter form,
// including dataset-less types. Without a dataset there is nothing for it to
// describe, and emitting it would diverge from the ``{}`` target the import
// and seed paths write.
expect(
buildNativeFilterTarget({ datasourceType: DatasourceType.Table }),
).toEqual({});
});
@@ -33,9 +33,9 @@ export interface TargetFormInputs {
* Build the ``NativeFilterTarget`` carried by a native filter or chart
* customization from its form inputs.
*
* Consolidates what used to live in ``filterTransformer`` and
* ``customizationTransformer`` so changes to the target shape only need to
* happen here.
* Consolidates what used to live in three places ``filterTransformer``,
* ``customizationTransformer``, and ``createHandleSave`` so changes to the
* target shape only need to happen here.
*/
export function buildNativeFilterTarget(
formInputs: TargetFormInputs,
@@ -49,11 +49,7 @@ export function buildNativeFilterTarget(
: formInputs.dataset;
}
// ``datasourceType`` describes the selected dataset, so it only belongs on a
// target that has one. Emitting it for a dataset-less filter (e.g.
// ``filter_time``) would make a UI save serialize a target the import and
// seed paths write as ``{}``.
if (formInputs.dataset != null && formInputs.datasourceType) {
if (formInputs.datasourceType) {
target.datasourceType = formInputs.datasourceType;
}
@@ -1,102 +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 { ChartCustomization, ChartCustomizationType } from '@superset-ui/core';
import { ChartCustomizationsFormItem } from '../types';
import { transformCustomizationForSave } from './customizationTransformer';
const baseFormItem = {
type: ChartCustomizationType.ChartCustomization,
scope: { rootPath: ['ROOT_ID'], excluded: [] },
controlValues: {},
requiredFirst: {},
defaultValue: null,
defaultDataMask: { filterState: {}, extraFormData: {} },
sortMetric: null,
description: '',
// form-only field that must never leak into the saved customization
defaultValueQueriesData: null,
} as unknown as ChartCustomizationsFormItem;
test('serializes a dataset-less customization into a full ChartCustomization', () => {
// Customization plugins declaring ``datasourceCount: 0`` render no dataset
// control, so their form item carries neither ``dataset`` nor ``targets``.
const formItem = {
...baseFormItem,
name: 'Layer visibility',
filterType: 'customization_deckgl_layer_visibility',
} as unknown as ChartCustomizationsFormItem;
const result = transformCustomizationForSave(
'CHART_CUSTOMIZATION-abc',
formItem,
) as ChartCustomization;
expect(result.targets).toEqual([{}]);
expect(result.defaultDataMask).toBeDefined();
expect(result.removed).toBe(false);
expect(result).not.toHaveProperty('defaultValueQueriesData');
});
test('serializes a dataset-backed customization into a full ChartCustomization', () => {
const formItem = {
...baseFormItem,
name: 'Group by',
filterType: 'customization_dynamic_group_by',
dataset: { value: 42, label: 'sales' },
column: 'region',
} as unknown as ChartCustomizationsFormItem;
const result = transformCustomizationForSave(
'CHART_CUSTOMIZATION-def',
formItem,
) as ChartCustomization;
expect(result.targets).toEqual([
{ datasetId: 42, column: { name: 'region' } },
]);
expect(result).not.toHaveProperty('defaultValueQueriesData');
});
test('passes an already-saved ChartCustomization through untouched', () => {
const saved: ChartCustomization = {
id: 'CHART_CUSTOMIZATION-ghi',
name: 'Group by',
filterType: 'customization_dynamic_group_by',
type: ChartCustomizationType.ChartCustomization,
targets: [{ datasetId: 42, column: { name: 'region' } }],
defaultDataMask: { filterState: {}, extraFormData: {} },
controlValues: {},
scope: { rootPath: ['ROOT_ID'], excluded: [] },
description: ' needs trim ',
chartsInScope: [1, 2],
tabsInScope: ['TAB-1'],
};
const result = transformCustomizationForSave(
'CHART_CUSTOMIZATION-ghi',
saved,
) as ChartCustomization;
expect(result.targets).toEqual([
{ datasetId: 42, column: { name: 'region' } },
]);
expect(result.chartsInScope).toEqual([1, 2]);
expect(result.tabsInScope).toEqual(['TAB-1']);
expect(result.description).toBe('needs trim');
});
@@ -69,10 +69,7 @@ function isDividerType(
function isFormInput(
formInputs: ChartCustomizationFormOrSaved,
): formInputs is ChartCustomizationsFormItem {
// Mirrors `filterTransformer`: a saved customization always carries a
// serialized `targets` array, and dataset-less types (e.g. the deck.gl layer
// visibility customization) have no `dataset` to discriminate on.
return !('targets' in formInputs);
return 'dataset' in formInputs && typeof formInputs.dataset === 'object';
}
function transformCustomizationDivider(
@@ -1,156 +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 { DatasourceType, Filter, NativeFilterType } from '@superset-ui/core';
import { NativeFiltersFormItem } from '../types';
import { transformFilterForSave } from './filterTransformer';
const baseFormItem = {
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['ROOT_ID'], excluded: [] },
controlValues: {},
requiredFirst: {},
defaultValue: null,
defaultDataMask: { filterState: {}, extraFormData: {} },
description: '',
// form-only fields that must never leak into the saved filter
defaultValueQueriesData: null,
} as unknown as NativeFiltersFormItem;
test('serializes a dataset-less filter (filter_time) into a full Filter', () => {
// A ``filter_time`` filter has no dataset/column controls, so its form item
// carries neither a ``dataset`` nor a ``targets`` key. It must still be
// transformed like any other native filter rather than persisted verbatim.
const formItem: NativeFiltersFormItem = {
...baseFormItem,
name: 'Time Range',
filterType: 'filter_time',
dependencies: ['NATIVE_FILTER-parent'],
// the modal stamps this on every filter form, dataset or not
datasourceType: DatasourceType.Table,
};
const result = transformFilterForSave(
'NATIVE_FILTER-abc',
formItem,
) as Filter;
// Keys the bug used to strip are present and well-formed. The target matches
// the ``{}`` the import and seed paths write, so one logical filter has one
// serialization regardless of provenance.
expect(result.targets).toEqual([{}]);
expect(result.defaultDataMask).toBeDefined();
expect(result.cascadeParentIds).toEqual(['NATIVE_FILTER-parent']);
// Form-only keys must not leak into the persisted config.
expect(result).not.toHaveProperty('defaultValueQueriesData');
expect(result).not.toHaveProperty('dependencies');
// Empty requiredFirst collapses to undefined instead of the raw form object.
expect(result.requiredFirst).toBeUndefined();
// A dataset-less filter has no sort metric control, so the persisted document
// must not gain a ``sortMetric`` key it never had. Asserted on the serialized
// form because ``undefined`` values survive in the object but not in JSON.
expect(JSON.parse(JSON.stringify(result))).not.toHaveProperty('sortMetric');
expect(result.name).toBe('Time Range');
expect(result.filterType).toBe('filter_time');
});
test('serializes a dataset-backed filter (filter_select) into a full Filter', () => {
const formItem: NativeFiltersFormItem = {
...baseFormItem,
name: 'Region',
filterType: 'filter_select',
dataset: { value: 42, label: 'sales' },
column: 'region',
dependencies: [],
};
const result = transformFilterForSave(
'NATIVE_FILTER-def',
formItem,
) as Filter;
expect(result.targets).toEqual([
{ datasetId: 42, column: { name: 'region' } },
]);
expect(result.defaultDataMask).toBeDefined();
expect(result.cascadeParentIds).toEqual([]);
expect(result).not.toHaveProperty('defaultValueQueriesData');
});
test('passes an already-saved Filter through untouched (aside from trimming)', () => {
// Values coming from the stored filter config map (e.g. cascade-parent
// cleanup) already carry a ``targets`` array and must be preserved as-is.
const savedFilter: Filter = {
id: 'NATIVE_FILTER-ghi',
name: 'Time Range',
filterType: 'filter_time',
type: NativeFilterType.NativeFilter,
targets: [{}],
defaultDataMask: { filterState: {}, extraFormData: {} },
cascadeParentIds: ['NATIVE_FILTER-parent'],
controlValues: {},
scope: { rootPath: ['ROOT_ID'], excluded: [] },
description: ' needs trim ',
chartsInScope: [1, 2],
tabsInScope: ['TAB-1'],
};
const result = transformFilterForSave(
'NATIVE_FILTER-ghi',
savedFilter,
) as Filter;
expect(result.targets).toEqual([{}]);
expect(result.cascadeParentIds).toEqual(['NATIVE_FILTER-parent']);
expect(result.chartsInScope).toEqual([1, 2]);
expect(result.tabsInScope).toEqual(['TAB-1']);
expect(result.description).toBe('needs trim');
});
test('rebuilds a saved filter whose targets were already stripped', () => {
// Dashboards affected by this bug hold ``filter_time`` entries with no
// ``targets``. They no longer match the saved-filter branch, so they take the
// form-item path and are repaired on the next save. ``cascadeParentIds`` is
// read from the form's ``dependencies``, which such an entry does not carry —
// the same write that stripped ``targets`` stripped ``cascadeParentIds`` too.
const strippedFilter = {
id: 'NATIVE_FILTER-jkl',
name: 'Time Range',
filterType: 'filter_time',
type: NativeFilterType.NativeFilter,
scope: { rootPath: ['ROOT_ID'], excluded: [] },
controlValues: { timeShift: false },
description: '',
requiredFirst: { 'NATIVE_FILTER-jkl': true },
defaultValueQueriesData: null,
} as unknown as NativeFiltersFormItem;
const result = transformFilterForSave(
'NATIVE_FILTER-jkl',
strippedFilter,
) as Filter;
expect(result.targets).toEqual([{}]);
expect(result.defaultDataMask).toBeDefined();
expect(result.requiredFirst).toBe(true);
expect(result.cascadeParentIds).toEqual([]);
expect(result).not.toHaveProperty('defaultValueQueriesData');
});
@@ -67,10 +67,7 @@ function isDividerType(
function isFormInput(
formInputs: NativeFilterFormOrSaved,
): formInputs is NativeFiltersFormItem {
// A saved filter always carries a serialized `targets` array; a form item
// never does. Keying this off `dataset` misclassified filter types with no
// dataset control (e.g. `filter_time`) as already saved.
return !('targets' in formInputs);
return 'dataset' in formInputs;
}
function transformDivider(
@@ -118,7 +115,7 @@ function transformFormInput(
adhoc_filters: formInputs.adhoc_filters,
time_range: formInputs.time_range,
granularity_sqla: formInputs.granularity_sqla,
sortMetric: formInputs.sortMetric,
sortMetric: formInputs.sortMetric ?? null,
requiredFirst: formInputs.requiredFirst
? Object.values(formInputs.requiredFirst).find(rf => rf)
: undefined,
@@ -18,15 +18,21 @@
*/
import type { FormInstance } from '@superset-ui/core/components';
import { nanoid } from 'nanoid';
import { getInitialDataMask } from 'src/dataMask/reducer';
import {
FilterConfiguration,
NativeFilterType,
NativeFilterTarget,
Filter,
Divider,
ChartCustomizationType,
ChartCustomizationConfiguration,
ChartCustomization,
ChartCustomizationDivider,
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { buildNativeFilterTarget } from './transformers/buildTarget';
import {
ChartCustomizationsForm,
FilterChangesType,
@@ -95,6 +101,70 @@ export const validateForm = async (
}
};
export const createHandleSave =
(
saveForm: Function,
filterChanges: FilterChangesType,
values: NativeFiltersForm,
filterConfigMap: Record<string, Filter | Divider>,
) =>
async () => {
const transformFilter = (id: string) => {
const formInputs = values.filters?.[id] || filterConfigMap[id];
if (!formInputs) {
return undefined;
}
if (formInputs.type === NativeFilterType.Divider) {
return {
id,
type: NativeFilterType.Divider,
scope: {
rootPath: [DASHBOARD_ROOT_ID],
excluded: [],
},
title: formInputs.title,
description: formInputs.description,
};
}
const target: Partial<NativeFilterTarget> =
buildNativeFilterTarget(formInputs);
return {
id,
adhoc_filters: formInputs.adhoc_filters,
time_range: formInputs.time_range,
controlValues: formInputs.controlValues ?? {},
granularity_sqla: formInputs.granularity_sqla,
...(formInputs.time_grains?.length
? { time_grains: formInputs.time_grains }
: {}),
requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find(
rf => rf,
),
name: formInputs.name,
filterType: formInputs.filterType,
targets: [target],
defaultDataMask: formInputs.defaultDataMask ?? getInitialDataMask(),
cascadeParentIds: formInputs.dependencies || [],
scope: formInputs.scope,
sortMetric: formInputs.sortMetric,
type: formInputs.type,
description: (formInputs.description || '').trim(),
};
};
const transformedModified = filterChanges.modified
.map(transformFilter)
.filter(Boolean);
const newFilterChanges = {
...filterChanges,
modified: transformedModified,
};
await saveForm(newFilterChanges);
};
export const createHandleRemoveItem =
(
setRemovedFilters: (
@@ -214,9 +214,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
await userEvent.type(combobox, 'revenue');
let dropdown = document.querySelector(
'.ant-select-dropdown-list',
) as HTMLElement;
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument();
expect(
within(dropdown).queryByText('User Identifier'),
@@ -228,7 +226,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
await userEvent.clear(combobox);
await userEvent.type(combobox, 'Identifier');
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('User Identifier')).toBeInTheDocument();
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
expect(within(dropdown).queryByText('Creation Date')).not.toBeInTheDocument();
@@ -236,7 +234,7 @@ test('Should filter simple columns by column_name and verbose_name', async () =>
await userEvent.clear(combobox);
await userEvent.type(combobox, '_at');
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Creation Date')).toBeInTheDocument();
expect(within(dropdown).getByText('Last Update')).toBeInTheDocument();
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
@@ -290,9 +288,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
await userEvent.type(combobox, 'revenue');
let dropdown = document.querySelector(
'.ant-select-dropdown-list',
) as HTMLElement;
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Total Sales')).toBeInTheDocument();
expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument();
expect(within(dropdown).queryByText('Net Profit')).not.toBeInTheDocument();
@@ -302,7 +298,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
await userEvent.clear(combobox);
await userEvent.type(combobox, 'Rate');
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Discount Rate')).toBeInTheDocument();
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
expect(within(dropdown).queryByText('Tax Amount')).not.toBeInTheDocument();
@@ -310,7 +306,7 @@ test('Should filter saved expressions by column_name and verbose_name', async ()
await userEvent.clear(combobox);
await userEvent.type(combobox, 'profit');
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Net Profit')).toBeInTheDocument();
expect(within(dropdown).getByText('Profit Margin')).toBeInTheDocument();
expect(within(dropdown).queryByText('Total Sales')).not.toBeInTheDocument();
@@ -207,38 +207,6 @@ describe('AdhocFilter', () => {
expect(adhocFilter10.isValid()).toBe(true);
});
test('is invalid when a comparator-taking operator has no comparator', () => {
// A comparator that was never set, or that was cleared through the value
// Select's clear affordance, is `undefined` rather than `null` or `[]`.
const adhocFilter1 = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'is_intro',
operator: 'IN',
comparator: undefined,
clause: Clauses.Where,
});
expect(adhocFilter1.isValid()).toBe(false);
const adhocFilter2 = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'is_intro',
operator: '==',
comparator: undefined,
clause: Clauses.Where,
});
expect(adhocFilter2.isValid()).toBe(false);
// `false` is a legitimate boolean comparator, not a missing value
const adhocFilter3 = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'is_intro',
operator: '==',
comparator: false,
clause: Clauses.Where,
});
expect(adhocFilter3.isValid()).toBe(true);
});
test('can translate from simple expressions to sql expressions', () => {
const adhocFilter1 = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
@@ -163,10 +163,8 @@ export default class AdhocFilter {
// A non-empty array of values ('IN' or 'NOT IN' clauses)
return this.comparator.length > 0;
}
// A value has been selected or typed. An unset comparator is
// `undefined` rather than `null`: picking a new subject resets it, and
// the value Select's clear affordance emits `undefined` too.
return this.comparator != null;
// A value has been selected or typed
return this.comparator !== null;
}
}
@@ -181,29 +181,6 @@ describe('AdhocFilterEditPopover', () => {
expect(saveButton).toBeDisabled();
});
test('disables save button when a boolean column has no value selected', async () => {
const booleanColumn = { type: 'BOOL', column_name: 'is_intro' };
renderPopover({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
clause: Clauses.Where,
}),
options: [booleanColumn],
datasource: { columns: [booleanColumn], filter_select: false },
});
// Picking the subject resets the comparator to `undefined`; the value
// control is then left untouched, mirroring the reported repro.
await userEvent.click(screen.getByTestId('select-element'));
await userEvent.click(
await screen.findByRole('option', { name: /is_intro/ }),
);
expect(
screen.getByTestId('adhoc-filter-edit-popover-save-button'),
).toBeDisabled();
});
test('initiates resize when resize handle is dragged', async () => {
const onResize = jest.fn();
renderPopover({ onResize });
@@ -340,9 +340,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
await userEvent.type(combobox, 'revenue');
let dropdown = document.querySelector(
'.ant-select-dropdown-list',
) as HTMLElement;
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Gross Revenue')).toBeInTheDocument();
expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument();
expect(within(dropdown).queryByText('Average Price')).not.toBeInTheDocument();
@@ -354,7 +352,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
await userEvent.clear(combobox);
await userEvent.type(combobox, 'Unique');
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Unique Users')).toBeInTheDocument();
expect(within(dropdown).queryByText('Total Count')).not.toBeInTheDocument();
expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument();
@@ -362,7 +360,7 @@ test('Should filter saved metrics by metric_name and verbose_name', async () =>
await userEvent.clear(combobox);
await userEvent.type(combobox, 'total');
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Total Count')).toBeInTheDocument();
expect(within(dropdown).getByText('Total Quantity')).toBeInTheDocument();
expect(within(dropdown).queryByText('Gross Revenue')).not.toBeInTheDocument();
@@ -423,9 +421,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
await userEvent.type(columnCombobox, 'product');
let dropdown = document.querySelector(
'.ant-select-dropdown-list',
) as HTMLElement;
let dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Product Title')).toBeInTheDocument();
expect(
within(dropdown).queryByText('User Identifier'),
@@ -439,7 +435,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
await userEvent.clear(columnCombobox);
await userEvent.type(columnCombobox, 'Modified');
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument();
expect(
within(dropdown).queryByText('User Identifier'),
@@ -449,7 +445,7 @@ test('Should filter columns by column_name and verbose_name in Simple tab', asyn
await userEvent.clear(columnCombobox);
await userEvent.type(columnCombobox, '_at');
dropdown = document.querySelector('.ant-select-dropdown-list') as HTMLElement;
dropdown = document.querySelector('.rc-virtual-list') as HTMLElement;
expect(within(dropdown).getByText('Creation Timestamp')).toBeInTheDocument();
expect(within(dropdown).getByText('Last Modified')).toBeInTheDocument();
expect(
@@ -27,15 +27,8 @@ import {
Input,
Button,
Modal,
Select,
} from '@superset-ui/core/components';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import copyTextToClipboard from 'src/utils/copy';
import {
API_KEY_SCOPE_OPTIONS,
getApiKeyScopesHelpText,
serializeApiKeyScopes,
} from './apiKeyScopes';
interface ApiKeyCreateModalProps {
show: boolean;
@@ -45,7 +38,6 @@ interface ApiKeyCreateModalProps {
interface FormValues {
name: string;
scopes?: string[];
}
export function ApiKeyCreateModal({
@@ -70,13 +62,9 @@ export function ApiKeyCreateModal({
const handleFormSubmit = async (values: FormValues) => {
try {
const scopes = serializeApiKeyScopes(values.scopes);
const response = await SupersetClient.post({
endpoint: '/api/v1/security/api_keys/',
jsonPayload: {
name: values.name,
...(scopes && { scopes }),
},
jsonPayload: values,
});
const key = response.json?.result?.key;
if (!key) {
@@ -95,7 +83,7 @@ export function ApiKeyCreateModal({
return;
}
try {
await copyTextToClipboard(() => Promise.resolve(createdKey));
await navigator.clipboard.writeText(createdKey);
setCopied(true);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
@@ -182,24 +170,6 @@ export function ApiKeyCreateModal({
placeholder={t('e.g., CI/CD Pipeline, Analytics Script')}
/>
</FormItem>
<FormItem
name="scopes"
label={t('MCP scopes')}
help={getApiKeyScopesHelpText()}
>
<Select
name="scopes"
mode="multiple"
allowClear
showSearch
options={API_KEY_SCOPE_OPTIONS}
placeholder={t('Select MCP resource scopes (optional)')}
data-test="api-key-scopes-select"
getPopupContainer={(trigger: HTMLElement) =>
trigger.closest<HTMLElement>('.ant-modal-container') ?? trigger
}
/>
</FormItem>
</FormModal>
);
}
@@ -162,19 +162,6 @@ export function ApiKeyList() {
key: 'status',
render: (_: unknown, record: ApiKey) => getStatusBadge(record),
},
{
title: t('MCP scopes'),
dataIndex: 'scopes',
key: 'scopes',
render: (scopes: string | null) =>
scopes ? (
<Tooltip title={scopes}>
<Tag>{t('%s MCP scopes', scopes.split(',').length)}</Tag>
</Tooltip>
) : (
<Tag>{t('RBAC only')}</Tag>
),
},
{
title: t('Actions'),
key: 'actions',
@@ -1,50 +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 {
API_KEY_SCOPE_OPTIONS,
getApiKeyScopesHelpText,
serializeApiKeyScopes,
} from './apiKeyScopes';
test('offers read and write scopes for every supported resource', () => {
expect(API_KEY_SCOPE_OPTIONS).toHaveLength(32);
expect(API_KEY_SCOPE_OPTIONS).toContainEqual({
label: 'superset:dashboard:read',
value: 'superset:dashboard:read',
});
expect(API_KEY_SCOPE_OPTIONS).toContainEqual({
label: 'superset:sqllab:write',
value: 'superset:sqllab:write',
});
});
test('serializes selected scopes for the FAB API', () => {
expect(
serializeApiKeyScopes(['superset:dashboard:read', 'superset:chart:write']),
).toBe('superset:dashboard:read,superset:chart:write');
expect(serializeApiKeyScopes([])).toBeUndefined();
expect(serializeApiKeyScopes()).toBeUndefined();
});
test('explains that scopes apply to MCP rather than REST APIs', () => {
expect(getApiKeyScopesHelpText()).toContain('MCP resources');
expect(getApiKeyScopesHelpText()).toContain(
'do not restrict REST API requests',
);
});
@@ -1,55 +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 { t } from '@apache-superset/core/translation';
const API_KEY_SCOPE_RESOURCES = [
'annotation',
'chart',
'dashboard',
'database',
'dataset',
'explore',
'query',
'report',
'role',
'rls',
'savedquery',
'sqllab',
'tag',
'task',
'theme',
'user',
] as const;
const API_KEY_SCOPE_ACTIONS = ['read', 'write'] as const;
export const API_KEY_SCOPE_OPTIONS = API_KEY_SCOPE_RESOURCES.flatMap(resource =>
API_KEY_SCOPE_ACTIONS.map(action => {
const value = `superset:${resource}:${action}`;
return { label: value, value };
}),
);
export const serializeApiKeyScopes = (scopes?: string[]) =>
scopes?.length ? scopes.join(',') : undefined;
export const getApiKeyScopesHelpText = () =>
t(
'Limit which MCP resources and actions this key can access. These scopes do not restrict REST API requests and never grant permissions the user does not already have. Leave empty for legacy RBAC-only behavior.',
);
@@ -88,9 +88,9 @@ test('PermissionsField shows a permission matched by its raw name even though th
),
);
expect(
await within(
document.querySelector('.ant-select-dropdown-list')!,
).findByText('stg silver'),
await within(document.querySelector('.rc-virtual-list')!).findByText(
'stg silver',
),
).toBeInTheDocument();
});
+156 -156
View File
@@ -15,24 +15,24 @@
"jsonwebtoken": "^9.0.3",
"lodash-es": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.21.3"
"ws": "^8.21.2"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/eslint__js": "^8.42.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.2.0",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.9.0",
"oxfmt": "^0.63.0",
"oxfmt": "^0.62.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"typescript-eslint": "^8.66.0",
"vitest": "^4.1.10"
},
"engines": {
@@ -310,9 +310,9 @@
}
},
"node_modules/@oxfmt/binding-android-arm-eabi": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz",
"integrity": "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz",
"integrity": "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==",
"cpu": [
"arm"
],
@@ -327,9 +327,9 @@
}
},
"node_modules/@oxfmt/binding-android-arm64": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz",
"integrity": "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz",
"integrity": "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==",
"cpu": [
"arm64"
],
@@ -344,9 +344,9 @@
}
},
"node_modules/@oxfmt/binding-darwin-arm64": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz",
"integrity": "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz",
"integrity": "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==",
"cpu": [
"arm64"
],
@@ -361,9 +361,9 @@
}
},
"node_modules/@oxfmt/binding-darwin-x64": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz",
"integrity": "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz",
"integrity": "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==",
"cpu": [
"x64"
],
@@ -378,9 +378,9 @@
}
},
"node_modules/@oxfmt/binding-freebsd-x64": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz",
"integrity": "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz",
"integrity": "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==",
"cpu": [
"x64"
],
@@ -395,9 +395,9 @@
}
},
"node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz",
"integrity": "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz",
"integrity": "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==",
"cpu": [
"arm"
],
@@ -412,9 +412,9 @@
}
},
"node_modules/@oxfmt/binding-linux-arm-musleabihf": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz",
"integrity": "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz",
"integrity": "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==",
"cpu": [
"arm"
],
@@ -429,9 +429,9 @@
}
},
"node_modules/@oxfmt/binding-linux-arm64-gnu": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz",
"integrity": "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz",
"integrity": "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==",
"cpu": [
"arm64"
],
@@ -449,9 +449,9 @@
}
},
"node_modules/@oxfmt/binding-linux-arm64-musl": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz",
"integrity": "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz",
"integrity": "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==",
"cpu": [
"arm64"
],
@@ -469,9 +469,9 @@
}
},
"node_modules/@oxfmt/binding-linux-ppc64-gnu": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz",
"integrity": "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz",
"integrity": "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==",
"cpu": [
"ppc64"
],
@@ -489,9 +489,9 @@
}
},
"node_modules/@oxfmt/binding-linux-riscv64-gnu": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz",
"integrity": "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz",
"integrity": "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==",
"cpu": [
"riscv64"
],
@@ -509,9 +509,9 @@
}
},
"node_modules/@oxfmt/binding-linux-riscv64-musl": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz",
"integrity": "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz",
"integrity": "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==",
"cpu": [
"riscv64"
],
@@ -529,9 +529,9 @@
}
},
"node_modules/@oxfmt/binding-linux-s390x-gnu": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz",
"integrity": "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz",
"integrity": "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==",
"cpu": [
"s390x"
],
@@ -549,9 +549,9 @@
}
},
"node_modules/@oxfmt/binding-linux-x64-gnu": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz",
"integrity": "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz",
"integrity": "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==",
"cpu": [
"x64"
],
@@ -569,9 +569,9 @@
}
},
"node_modules/@oxfmt/binding-linux-x64-musl": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz",
"integrity": "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz",
"integrity": "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==",
"cpu": [
"x64"
],
@@ -589,9 +589,9 @@
}
},
"node_modules/@oxfmt/binding-openharmony-arm64": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz",
"integrity": "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz",
"integrity": "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==",
"cpu": [
"arm64"
],
@@ -606,9 +606,9 @@
}
},
"node_modules/@oxfmt/binding-win32-arm64-msvc": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz",
"integrity": "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz",
"integrity": "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==",
"cpu": [
"arm64"
],
@@ -623,9 +623,9 @@
}
},
"node_modules/@oxfmt/binding-win32-ia32-msvc": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz",
"integrity": "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz",
"integrity": "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==",
"cpu": [
"ia32"
],
@@ -640,9 +640,9 @@
}
},
"node_modules/@oxfmt/binding-win32-x64-msvc": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz",
"integrity": "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz",
"integrity": "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==",
"cpu": [
"x64"
],
@@ -1044,9 +1044,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1070,17 +1070,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
"integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/type-utils": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"@typescript-eslint/scope-manager": "8.66.0",
"@typescript-eslint/type-utils": "8.66.0",
"@typescript-eslint/utils": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -1093,22 +1093,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.67.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz",
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz",
"integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"@typescript-eslint/scope-manager": "8.66.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0",
"debug": "^4.4.3"
},
"engines": {
@@ -1124,14 +1124,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
"integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.67.0",
"@typescript-eslint/types": "^8.67.0",
"@typescript-eslint/tsconfig-utils": "^8.66.0",
"@typescript-eslint/types": "^8.66.0",
"debug": "^4.4.3"
},
"engines": {
@@ -1146,14 +1146,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
"integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0"
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1164,9 +1164,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
"integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1181,15 +1181,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz",
"integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0",
"@typescript-eslint/utils": "8.66.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -1206,9 +1206,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
"integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1220,16 +1220,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
"integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.67.0",
"@typescript-eslint/tsconfig-utils": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/visitor-keys": "8.67.0",
"@typescript-eslint/project-service": "8.66.0",
"@typescript-eslint/tsconfig-utils": "8.66.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -1248,16 +1248,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
"integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0"
"@typescript-eslint/scope-manager": "8.66.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1272,13 +1272,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
"integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.67.0",
"@typescript-eslint/types": "8.66.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -1689,9 +1689,9 @@
}
},
"node_modules/eslint": {
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -2709,9 +2709,9 @@
}
},
"node_modules/oxfmt": {
"version": "0.63.0",
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.63.0.tgz",
"integrity": "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==",
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.62.0.tgz",
"integrity": "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2727,25 +2727,25 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxfmt/binding-android-arm-eabi": "0.63.0",
"@oxfmt/binding-android-arm64": "0.63.0",
"@oxfmt/binding-darwin-arm64": "0.63.0",
"@oxfmt/binding-darwin-x64": "0.63.0",
"@oxfmt/binding-freebsd-x64": "0.63.0",
"@oxfmt/binding-linux-arm-gnueabihf": "0.63.0",
"@oxfmt/binding-linux-arm-musleabihf": "0.63.0",
"@oxfmt/binding-linux-arm64-gnu": "0.63.0",
"@oxfmt/binding-linux-arm64-musl": "0.63.0",
"@oxfmt/binding-linux-ppc64-gnu": "0.63.0",
"@oxfmt/binding-linux-riscv64-gnu": "0.63.0",
"@oxfmt/binding-linux-riscv64-musl": "0.63.0",
"@oxfmt/binding-linux-s390x-gnu": "0.63.0",
"@oxfmt/binding-linux-x64-gnu": "0.63.0",
"@oxfmt/binding-linux-x64-musl": "0.63.0",
"@oxfmt/binding-openharmony-arm64": "0.63.0",
"@oxfmt/binding-win32-arm64-msvc": "0.63.0",
"@oxfmt/binding-win32-ia32-msvc": "0.63.0",
"@oxfmt/binding-win32-x64-msvc": "0.63.0"
"@oxfmt/binding-android-arm-eabi": "0.62.0",
"@oxfmt/binding-android-arm64": "0.62.0",
"@oxfmt/binding-darwin-arm64": "0.62.0",
"@oxfmt/binding-darwin-x64": "0.62.0",
"@oxfmt/binding-freebsd-x64": "0.62.0",
"@oxfmt/binding-linux-arm-gnueabihf": "0.62.0",
"@oxfmt/binding-linux-arm-musleabihf": "0.62.0",
"@oxfmt/binding-linux-arm64-gnu": "0.62.0",
"@oxfmt/binding-linux-arm64-musl": "0.62.0",
"@oxfmt/binding-linux-ppc64-gnu": "0.62.0",
"@oxfmt/binding-linux-riscv64-gnu": "0.62.0",
"@oxfmt/binding-linux-riscv64-musl": "0.62.0",
"@oxfmt/binding-linux-s390x-gnu": "0.62.0",
"@oxfmt/binding-linux-x64-gnu": "0.62.0",
"@oxfmt/binding-linux-x64-musl": "0.62.0",
"@oxfmt/binding-openharmony-arm64": "0.62.0",
"@oxfmt/binding-win32-arm64-msvc": "0.62.0",
"@oxfmt/binding-win32-ia32-msvc": "0.62.0",
"@oxfmt/binding-win32-x64-msvc": "0.62.0"
},
"peerDependencies": {
"svelte": "^5.0.0",
@@ -3211,16 +3211,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz",
"integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.67.0",
"@typescript-eslint/parser": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0"
"@typescript-eslint/eslint-plugin": "8.66.0",
"@typescript-eslint/parser": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0",
"@typescript-eslint/utils": "8.66.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3520,9 +3520,9 @@
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+7 -7
View File
@@ -23,24 +23,24 @@
"jsonwebtoken": "^9.0.3",
"lodash-es": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.21.3"
"ws": "^8.21.2"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/eslint__js": "^8.42.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.2.0",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.9.0",
"oxfmt": "^0.63.0",
"oxfmt": "^0.62.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"typescript-eslint": "^8.66.0",
"vitest": "^4.1.10"
},
"engines": {
+1 -10
View File
@@ -21,7 +21,7 @@ from functools import partial
from typing import cast
from uuid import UUID
from superset import db, security_manager
from superset import db
from superset.commands.base import BaseCommand
from superset.commands.database.exceptions import DatabaseNotFoundError
from superset.daos.database import DatabaseUserOAuth2TokensDAO
@@ -31,7 +31,6 @@ from superset.exceptions import OAuth2Error
from superset.key_value.types import JsonKeyValueCodec, KeyValueResource
from superset.models.core import Database, DatabaseUserOAuth2Tokens
from superset.superset_typing import OAuth2State
from superset.utils.core import get_user_id
from superset.utils.decorators import on_error, transaction
from superset.utils.oauth2 import decode_oauth2_state
@@ -122,14 +121,6 @@ class OAuth2StoreTokenCommand(BaseCommand):
self._state = decode_oauth2_state(self._parameters["state"])
# Bind the callback to the current session: require an authenticated,
# non-guest user whose id matches the one carried in the state.
user_id = get_user_id()
if user_id is None or security_manager.is_guest_user():
raise OAuth2Error("The OAuth2 callback requires an authenticated user")
if user_id != self._state["user_id"]:
raise OAuth2Error("The OAuth2 state belongs to a different user")
if database := DatabaseUserOAuth2TokensDAO.get_database(
self._state["database_id"]
):
@@ -15,12 +15,9 @@
# specific language governing permissions and limitations
# under the License.
import gzip
import ipaddress
import logging
import os
import re
import socket
from http.client import HTTPConnection, HTTPResponse, HTTPSConnection
from typing import Any
from urllib import request
from urllib.parse import urljoin, urlparse
@@ -50,7 +47,7 @@ from superset.models.helpers import ChildMultipleResultsFound
from superset.sql.parse import Table
from superset.utils import json
from superset.utils.core import get_user
from superset.utils.network import is_safe_host, is_safe_ip
from superset.utils.network import is_safe_host
logger = logging.getLogger(__name__)
@@ -79,47 +76,6 @@ class _ValidatingRedirectHandler(HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _raise_for_unsafe_peer(sock: socket.socket) -> None:
"""
Validate that an established connection's actual peer is publicly
routable, so the address reached matches the policy applied to the host.
"""
peer = sock.getpeername()[0]
if not is_safe_ip(ipaddress.ip_address(peer)):
raise DatasetForbiddenDataURI()
class _PeerValidatingHTTPConnection(HTTPConnection):
"""HTTP connection that validates the peer address on connect."""
def connect(self) -> None:
super().connect()
_raise_for_unsafe_peer(self.sock)
class _PeerValidatingHTTPSConnection(HTTPSConnection):
"""HTTPS connection that validates the peer address after the handshake."""
def connect(self) -> None:
super().connect()
_raise_for_unsafe_peer(self.sock)
class _PeerValidatingHTTPHandler(request.HTTPHandler):
"""Opens HTTP connections through the peer-validating connection class."""
def http_open(self, req: request.Request) -> HTTPResponse:
return self.do_open(_PeerValidatingHTTPConnection, req)
class _PeerValidatingHTTPSHandler(request.HTTPSHandler):
"""Opens HTTPS connections through the peer-validating connection class."""
def https_open(self, req: request.Request) -> HTTPResponse:
context = self._context # type: ignore[attr-defined]
return self.do_open(_PeerValidatingHTTPSConnection, req, context=context)
CHUNKSIZE = 512
VARCHAR = re.compile(r"VARCHAR\((\d+)\)", re.IGNORECASE)
@@ -625,17 +581,7 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
validate_data_uri(data_uri)
logger.info("Downloading data from %s", data_uri)
handlers: list[request.BaseHandler | type[request.BaseHandler]] = [
_ValidatingRedirectHandler
]
if not app.config["DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS"]:
# Also enforce the policy at the socket layer: re-check the peer of
# every connection, including each redirect hop. Disable proxies so the
# connection is made directly to the destination and the peer check
# validates the destination address rather than a proxy's.
handlers.append(request.ProxyHandler({}))
handlers.extend([_PeerValidatingHTTPHandler, _PeerValidatingHTTPSHandler])
opener = request.build_opener(*handlers)
opener = request.build_opener(_ValidatingRedirectHandler)
data = opener.open(data_uri) # pylint: disable=consider-using-with # noqa: S310
if data_uri.endswith(".gz"):
data = gzip.open(data)
+6 -72
View File
@@ -137,29 +137,6 @@ def resolve_executor_user(model: ReportSchedule) -> tuple["User", str]:
return user, username
def _should_build_execution_context(model: ReportSchedule) -> bool:
"""
Whether an execution should run under a :class:`ReportExecutionContext`.
Reports always do their behavior is unchanged. Alerts join them only when
they deliver a rendered PNG/PDF screenshot to recipients, which happens when
``ALERTS_ATTACH_REPORTS`` is enabled. Delivered screenshots must fail closed:
the context selects the fail-closed readiness predicate and disables
partial-tile fallback, so a blank or incomplete capture raises instead of
being delivered.
CSV/text alerts, alerts without the attach flag, the non-delivered
query-context capture, and UI thumbnails are deliberately excluded and keep
their lenient capture contract.
"""
if model.type == ReportScheduleType.REPORT:
return True
return model.report_format in (
ReportDataFormat.PNG,
ReportDataFormat.PDF,
) and feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
def log_report_delivery_phase(
report_context: ReportExecutionContext | None,
recipient_type: ReportRecipientType | None,
@@ -1995,13 +1972,13 @@ class ReportSuccessState(BaseReportState):
try:
self.send()
except Exception as first_ex: # pylint: disable=broad-except
if self._handle_retry_or_error(str(first_ex), first_ex):
except Exception as ex: # pylint: disable=broad-except
if self._handle_retry_or_error(str(ex), ex):
return # retry scheduled — exit cleanly
try:
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=str(first_ex)
ReportState.ERROR, error_message=str(ex)
)
except (ReportScheduleUnexpectedError, SQLAlchemyError) as logging_ex:
# Logging failed (likely StaleDataError), but we still want to
@@ -2014,45 +1991,7 @@ class ReportSuccessState(BaseReportState):
exc_info=True,
)
# Re-raise the original exception, not the logging failure
raise first_ex from logging_ex
# A delivery failure from the Success/Grace path must notify the
# owner just like the first-run path (ReportNotTriggeredErrorState).
# Without this, a schedule whose previous run succeeded would fail
# silently — e.g. once a screenshot capture starts failing closed.
# The error grace period still throttles repeated notifications.
if not self.is_in_error_grace_period():
second_error_message = REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
try:
self.send_error(
f"Error occurred for {self._report_schedule.type}:"
f" {self._report_schedule.name}",
str(first_ex),
)
except SupersetErrorsException as second_ex:
second_error_message = ";".join(
[error.message for error in second_ex.errors]
)
except ReportScheduleUnexpectedError:
# send_error failed due to logging issue; log and continue
# to raise the original error
logger.warning(
"Failed to send error notification due to database issue",
exc_info=True,
)
except Exception as second_ex: # pylint: disable=broad-except
second_error_message = str(second_ex)
finally:
try:
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=second_error_message
)
except ReportScheduleUnexpectedError:
# Logging failed again; log it but don't hide first_ex
logger.warning(
"Failed to log final error state due to database issue",
exc_info=True,
)
raise ex from logging_ex
raise
# send() succeeded — clear retry state and log success. Any execution
@@ -2119,18 +2058,13 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
if not self._model:
raise ReportScheduleExecuteUnexpectedError()
# Reports always run under an execution context; alerts join them
# only when they deliver a rendered screenshot, so a blank/partial
# capture fails closed instead of being delivered. Ownership and
# terminal-error persistence remain report-only recovery semantics.
if _should_build_execution_context(self._model):
if self._model.type == ReportScheduleType.REPORT:
# An invocation that enters on WORKING is a duplicate or stale
# recovery, not the owner that created the active row. Its state
# handler may terminalize a stale execution, but the command
# boundary must never infer ownership from a replayed UUID.
owns_report_working_state = (
self._model.type == ReportScheduleType.REPORT
and self._model.last_state != ReportState.WORKING
self._model.last_state != ReportState.WORKING
)
total_seconds = resolve_report_execution_budget_seconds(
app.config,
@@ -21,7 +21,6 @@ from __future__ import annotations
from typing import Any
from flask_babel import gettext as __
from jinja2.exceptions import TemplateError
from superset import db
from superset.commands.streaming_export.base import BaseStreamingCSVExportCommand
@@ -87,15 +86,6 @@ class StreamingSqlResultExportCommand(BaseStreamingCSVExportCommand):
),
status=403,
) from ex
except TemplateError as ex:
raise SupersetErrorException(
SupersetError(
message=str(ex),
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
level=ErrorLevel.ERROR,
),
status=400,
) from ex
def _get_sql_and_database(self) -> tuple[str, Any, str | None, str | None]:
"""
+1 -46
View File
@@ -58,7 +58,6 @@ from superset.utils.core import (
get_column_name,
get_column_names_from_columns,
get_column_names_from_metrics,
get_user_id,
is_adhoc_column,
is_adhoc_metric,
)
@@ -271,11 +270,6 @@ class QueryContextProcessor:
datasource = self._qc_datasource
extra_cache_keys = datasource.get_extra_cache_keys(query_obj.to_dict())
# Annotation data is cached on the same entry as the dataframe, so the
# key must also bind the annotation sources' security context.
if query_obj and query_obj.annotation_layers:
kwargs["annotation_context"] = self._annotation_cache_context(query_obj)
cache_key = (
query_obj.cache_key(
datasource=datasource.uid,
@@ -289,32 +283,6 @@ class QueryContextProcessor:
)
return cache_key
def _annotation_cache_context(self, query_obj: QueryObject) -> dict[str, Any]:
"""
Cache-key material binding cached annotation data to its security
context.
Annotation payloads are fetched per requesting user and stored on the
same cache entry as the dataframe, so the key also binds the requesting
user and, for chart-backed layers, the RLS clauses of the referenced
chart's datasource.
"""
source_rls: dict[str, list[str] | None] = {}
for layer in query_obj.annotation_layers:
if layer.get("sourceType") not in ("line", "table"):
continue
layer_value = layer.get("value")
chart = (
ChartDAO.find_by_id(layer_value) if layer_value is not None else None
)
annotation_datasource = chart.datasource if chart else None
source_rls[str(layer.get("value"))] = (
security_manager.get_rls_cache_key(annotation_datasource)
if annotation_datasource
else None
)
return {"user_id": get_user_id(), "source_rls": source_rls}
def get_query_result(self, query_object: QueryObject) -> QueryResult:
"""
Returns a pandas dataframe based on the query object.
@@ -668,11 +636,6 @@ class QueryContextProcessor:
if layer["sourceType"] == "NATIVE"
]
layer_ids = [layer["value"] for layer in annotation_layers]
# Enforce the annotation read permission before returning layer records.
if layer_ids and not security_manager.can_access("can_read", "Annotation"):
raise QueryObjectValidationError(
_("You don't have access to annotation layers")
)
layer_objects = {
layer_object.id: layer_object
for layer_object in AnnotationLayerDAO.find_by_ids(layer_ids)
@@ -682,15 +645,6 @@ class QueryContextProcessor:
for layer in annotation_layers:
layer_id = layer["value"]
layer_name = layer["name"]
# A request may reference a layer id that does not exist; treat it
# as a validation error rather than failing on the missing key.
if (layer_object := layer_objects.get(layer_id)) is None:
raise QueryObjectValidationError(
_(
"Annotation layer with ID %(layer_id)s was not found",
layer_id=layer_id,
)
)
columns = [
"start_dttm",
"end_dttm",
@@ -698,6 +652,7 @@ class QueryContextProcessor:
"long_descr",
"json_metadata",
]
layer_object = layer_objects[layer_id]
records = [
{column: getattr(annotation, column) for column in columns}
for annotation in layer_object.annotation
+9
View File
@@ -66,6 +66,7 @@ from superset.tasks.types import ExecutorType
from superset.themes.types import Theme
from superset.utils import core as utils
from superset.utils.encrypt import SQLAlchemyUtilsAdapter
from superset.utils.export_storage import ExportStorage
from superset.utils.log import DBEventLogger
from superset.utils.logging_configurator import DefaultLoggingConfigurator
from superset.utils.version import get_dev_env_label
@@ -368,6 +369,7 @@ WTF_CSRF_ENABLED = True
WTF_CSRF_EXEMPT_LIST = [
"superset.charts.data.api.data",
"superset.dashboards.api.cache_dashboard_screenshot",
"superset.dashboards.api.export_xlsx",
"superset.views.core.log",
"superset.views.datasource.views.samples",
"flask_appbuilder.security.views.acs",
@@ -1537,6 +1539,13 @@ EXCEL_EXPORT_LINK_TTL_SECONDS = 86400
# endpoint_url for S3-compatible stores (MinIO/LocalStack). Credentials
# otherwise resolve through the standard boto3 chain.
EXCEL_EXPORT_S3_CLIENT_KWARGS: dict[str, Any] = {}
# Optional pluggable storage backend (an instance implementing
# superset.utils.export_storage.ExportStorage), the same pattern as
# RESULTS_BACKEND or CUSTOM_SECURITY_MANAGER. When unset, the built-in
# superset.utils.s3 (boto3/AWS S3) backend is used. Set this to e.g.
# GCSExportStorage() (superset.utils.gcs) for a deployment whose
# EXCEL_EXPORT_S3_BUCKET names a native Google Cloud Storage bucket.
EXCEL_EXPORT_STORAGE: ExportStorage | None = None
# Viz types treated as tables in the "Export Images to Excel" mode: these charts
# stay tabular (one worksheet of data) while every other viz type is embedded as
# a rendered image. Set to None to fall back to the built-in default.
+1 -11
View File
@@ -34,7 +34,6 @@ from superset.commands.dashboard.exceptions import (
DashboardUpdateFailedError,
)
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
from superset.dashboards.filter_scope import derive_metadata_scopes
from superset.dashboards.filters import DashboardAccessFilter
from superset.exceptions import SupersetSecurityException
from superset.extensions import db
@@ -548,9 +547,7 @@ class DashboardDAO(BaseDAO[Dashboard]):
cls, id: str
) -> dict[str, list[dict[str, Any]]]:
dashboard = cls.get_by_id_or_slug(id)
metadata = derive_metadata_scopes(
dashboard, json.loads(dashboard.json_metadata or "{}")
)
metadata = json.loads(dashboard.json_metadata or "{}")
native_filter_configuration = metadata.get("native_filter_configuration", [])
tab_filters = defaultdict(list)
@@ -620,13 +617,6 @@ class DashboardDAO(BaseDAO[Dashboard]):
metadata["native_filter_configuration"] = updated_configuration
dashboard.json_metadata = json.dumps(metadata)
# The client rebuilds its in-scope state from this response, so hand
# back derived scopes rather than the stored caches, which are stale
# for every filter the caller did not touch.
updated_configuration = derive_metadata_scopes(dashboard, metadata)[
"native_filter_configuration"
]
return updated_configuration
@classmethod
+134 -21
View File
@@ -91,7 +91,14 @@ from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.commands.purge import PurgeArchivedCommand, SoftDeleteBinding
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO
from superset.dashboards.filter_scope import derive_json_metadata
from superset.dashboards.excel_export.download_link import (
build_download_url,
get_export_status,
PRESIGNED_URL_TTL_SECONDS,
resolve_download_link,
STATUS_ERROR,
STATUS_READY,
)
from superset.dashboards.filters import (
DashboardAccessFilter,
DashboardCertifiedFilter,
@@ -156,8 +163,12 @@ from superset.tasks.thumbnails import (
cache_dashboard_thumbnail,
)
from superset.tasks.utils import get_current_user
from superset.utils import json
from superset.utils.core import parse_boolean_string, sanitize_cookie_token
from superset.utils import json, s3
from superset.utils.core import (
get_user_id,
parse_boolean_string,
sanitize_cookie_token,
)
from superset.utils.file import get_filename
from superset.utils.pdf import build_pdf_from_screenshots
from superset.utils.screenshots import (
@@ -325,6 +336,8 @@ class DashboardRestApi(
"put_colors",
"export_as_example",
"export_xlsx",
"export_xlsx_status",
"download_xlsx",
"list_versions",
"get_version",
"activity",
@@ -349,6 +362,9 @@ class DashboardRestApi(
# menu item on it) instead of the ``can_export_xlsx`` FAB would otherwise
# derive from the method name.
"export_xlsx": "export",
# Polling status of an export you already requested is the same
# capability as requesting it, not a distinct permission.
"export_xlsx_status": "export",
"purge": "write",
}
@@ -418,8 +434,7 @@ class DashboardRestApi(
result:
type: array
items:
$ref: >-
#/components/schemas/{{self.__class__.__name__}}.get_list
type: object
400:
$ref: '#/components/responses/400'
401:
@@ -655,12 +670,6 @@ class DashboardRestApi(
schema = self.dashboard_get_response_schema
result = schema.dump(dash)
if json_metadata := result.get("json_metadata"):
# The stored scope caches (``chartsInScope``, ``tabsInScope``,
# ``chart_configuration``) go stale as soon as the layout changes;
# derive them so callers see the same document the dashboard client
# computes for itself.
result["json_metadata"] = derive_json_metadata(dash, json_metadata)
if "charts" in result:
# Only name the member charts the caller can access, consistent with
# the per-object narrowing applied to the dashboard's datasets and
@@ -1731,8 +1740,11 @@ class DashboardRestApi(
summary: Export dashboard chart data to Excel
description: >-
Enqueues an async task that writes each chart's data to its own
worksheet, uploads the .xlsx to S3, and emails the requesting user a
pre-signed download link. Returns immediately with a job id.
worksheet, uploads the .xlsx to S3, and records a download link.
The requesting user is emailed the link when they have an address
on file; either way the returned job id can be polled at
export_xlsx/status/<job_id>/ for status and, once ready, the
download link.
parameters:
- in: path
schema:
@@ -1795,12 +1807,9 @@ class DashboardRestApi(
except SupersetSecurityException:
return self.response_403()
# Email delivery is the only result channel, so an account with an email
# address is required; embedded guest users are excluded in this version.
if isinstance(g.user, GuestUser) or not getattr(g.user, "email", None):
return self.response_400(
message="Excel export requires an account with an email address."
)
# A requester with no email on file (e.g. an embedded/guest session)
# still gets a usable export: they poll export_xlsx_status/<job_id>/
# for the download link instead of relying on an email notification.
if not dashboard.slices:
return self.response_400(message="Dashboard has no charts to export.")
@@ -1809,7 +1818,12 @@ class DashboardRestApi(
# otherwise) so the guard works across the web server and workers and is
# not a no-op under the default cache. The task releases it when it
# settles; the TTL is the backstop if that release is ever lost.
lock_params = export_lock_params(g.user.id, dashboard.id)
# A guest/embedded requester has no DB-backed user id (GuestUser carries
# no ``id`` attribute at all), so all guests share lock slot 0 for the
# dashboard; the task reconstructs the guest (with the token's RLS rules
# and resource claims) from the token payload passed alongside.
user_id = get_user_id()
lock_params = export_lock_params(user_id or 0, dashboard.id)
try:
AcquireDistributedLock(
EXPORT_LOCK_NAMESPACE,
@@ -1827,10 +1841,15 @@ class DashboardRestApi(
export_dashboard_excel.apply_async(
kwargs={
"dashboard_id": dashboard.id,
"user_id": g.user.id,
"user_id": user_id,
"active_data_mask": payload.get("active_data_mask", {}),
"job_id": job_id,
"mode": payload.get("mode", "data"),
"guest_token": (
getattr(g.user, "guest_token", None)
if user_id is None
else None
),
},
task_id=job_id,
)
@@ -1842,6 +1861,100 @@ class DashboardRestApi(
raise
return self.response(202, job_id=job_id)
@expose("/export_xlsx/status/<uuid:job_id>/", methods=("GET",))
@protect()
@safe
@statsd_metrics
def export_xlsx_status(self, job_id: uuid.UUID) -> WerkzeugResponse:
"""Poll the status of an in-flight or completed Excel export.
---
get:
summary: Poll the status of a dashboard Excel export job
description: >-
For a session with no email address to be notified at (e.g. an
embedded/guest session), the frontend polls this endpoint with the
job_id from the export_xlsx response instead of waiting for an
email. Behind the same @protect() as the export request itself,
unlike the login-free download_xlsx redirect (which also has to
work when clicked from a plain email link, possibly with no
active session at all).
parameters:
- in: path
schema:
type: string
format: uuid
name: job_id
description: The job_id from the export_xlsx response
responses:
200:
description: >-
Job status: {"status": "pending"} while still running,
{"status": "ready", "download_url": "..."} once the file is
available, or {"status": "error", "message": "..."} if the
export failed.
401:
$ref: '#/components/responses/401'
"""
payload = get_export_status(job_id)
if payload is None:
return self.response(200, status="pending")
if payload.get("status") == STATUS_READY:
return self.response(
200, status=STATUS_READY, download_url=build_download_url(job_id)
)
if payload.get("status") == STATUS_ERROR:
return self.response(
200, status=STATUS_ERROR, message=payload.get("message")
)
return self.response(200, status="pending")
@expose("/export_xlsx/download/<uuid:job_id>/", methods=("GET",))
@safe
@statsd_metrics
def download_xlsx(self, job_id: uuid.UUID) -> WerkzeugResponse:
"""Redirect to a freshly pre-signed S3 URL for a completed Excel export.
---
get:
summary: Download a completed dashboard Excel export
description: >-
Intentionally requires no login, matching a raw pre-signed S3
URL's own access model: the unguessable job_id, emailed only to
the original requester (or handed to their own session via
export_xlsx_status), is the credential. The dashboard access
check already ran once, when the export was requested -- see
security_manager.raise_for_access in export_xlsx. A fresh
pre-signed URL is generated at click time (instead of the one
baked into the export at completion time) so the link's promised
lifetime is independent of how long the signing credentials
themselves remain valid.
parameters:
- in: path
schema:
type: string
format: uuid
name: job_id
description: The job_id from the export_xlsx response
responses:
302:
description: Redirect to a pre-signed S3 download URL
410:
description: The link is unknown, expired, or the export failed
"""
resolved = resolve_download_link(job_id)
if resolved is None:
return self.response(410, message="This download link has expired.")
bucket, key = resolved
storage = current_app.config.get("EXCEL_EXPORT_STORAGE")
if storage is not None:
download_url = storage.generate_download_url(
bucket, key, PRESIGNED_URL_TTL_SECONDS
)
else:
download_url = s3.generate_presigned_url(
bucket, key, PRESIGNED_URL_TTL_SECONDS
)
return redirect(download_url)
@expose("/<pk>/cache_dashboard_screenshot/", methods=("POST",))
@validate_feature_flags(["THUMBNAILS", "ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS"])
@protect()
@@ -0,0 +1,139 @@
# 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.
"""
Status tracking and long-lived download links for dashboard Excel exports.
A raw S3 pre-signed URL is only valid for as long as *both* its own
``ExpiresIn`` window and the credentials that signed it remain valid.
Deployments whose S3 client authenticates via short-lived, auto-refreshed
credentials (e.g. an EKS IRSA role assumed through
``AssumeRoleWithWebIdentity``, which AWS caps at 12 hours and many clusters
default to far less) can silently invalidate a pre-signed URL long before the
``EXCEL_EXPORT_LINK_TTL_SECONDS`` window promised in the export email elapses,
since the *credentials'* session -- not just the URL's own ``ExpiresIn`` --
bounds how long it actually works.
To keep that promise regardless of credential lifetime, the email links to a
small Superset redirect endpoint instead of a raw S3 URL. The link's own
lifetime is enforced by this module via the ``key_value`` store's
``expires_on`` (independent of any credential session), and the actual
pre-signed URL is generated fresh -- with then-current credentials -- at click
time, valid only long enough to complete a single download.
The redirect endpoint (``download_xlsx``) intentionally requires no login: a
pre-signed S3 URL never did either, and the access-control decision for the
underlying dashboard was already enforced once, when the export was
originally requested (see ``security_manager.raise_for_access`` in
``superset.dashboards.api.export_xlsx``). The unguessable key emailed only to
that requester's own address is the same "possession of the link is the
credential" model the raw pre-signed URL had; this module just re-signs it
closer to when it is actually used.
Every entry is keyed by ``job_id`` -- the same id the ``export_xlsx`` POST
response hands back -- rather than a separately-generated identifier, so a
caller that only has the job id (e.g. a polling frontend for a session with
no email on file, such as an embedded/guest dashboard) can resolve both
status and, once ready, a download link from that one id.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from superset.daos.key_value import KeyValueDAO
from superset.key_value.types import JsonKeyValueCodec, KeyValueResource
from superset.utils.urls import headless_url
RESOURCE = KeyValueResource.EXCEL_EXPORT_DOWNLOAD
CODEC = JsonKeyValueCodec()
# The fresh pre-signed URL generated at click time only needs to outlive the
# redirect and the browser/S3 handshake that follows it, not the link's own
# multi-hour lifetime.
PRESIGNED_URL_TTL_SECONDS = 300
DOWNLOAD_PATH = "/api/v1/dashboard/export_xlsx/download/{job_id}/"
STATUS_READY = "ready"
STATUS_ERROR = "error"
def _sweep_and_upsert(
job_id: UUID, value: dict[str, Any], expires_at: datetime
) -> None:
# Lazily sweep expired entries each time one is written; there is no
# dedicated cleanup job, so this resource keeps itself tidy on write.
# upsert (not create) so a retried/duplicate write for the same job_id
# overwrites cleanly instead of colliding on the primary key.
KeyValueDAO.delete_expired_entries(RESOURCE)
KeyValueDAO.upsert_entry(
resource=RESOURCE,
value=value,
codec=CODEC,
key=job_id,
expires_on=expires_at,
)
def build_download_url(job_id: UUID) -> str:
"""The browser-facing URL that redirects to a freshly pre-signed S3 URL
for ``job_id``, once its export is ready."""
return headless_url(DOWNLOAD_PATH.format(job_id=job_id), user_friendly=True)
def create_download_link(
job_id: UUID, bucket: str, key: str, expires_at: datetime
) -> str:
"""Record that ``job_id``'s export succeeded and is downloadable from
``key`` in ``bucket`` until ``expires_at``, and return the download URL
(used in the success email).
``expires_at`` should be a naive datetime in the same timezone convention
``KeyValueEntry.is_expired()`` compares against (naive ``datetime.now()``).
"""
_sweep_and_upsert(
job_id,
{"status": STATUS_READY, "bucket": bucket, "key": key},
expires_at,
)
return build_download_url(job_id)
def mark_export_failed(job_id: UUID, message: str, expires_at: datetime) -> None:
"""Record that ``job_id``'s export failed, so a polling client can
distinguish "failed" from "still running" instead of retrying a missing
key forever. ``message`` is shown to whoever is polling, so keep it
generic rather than an internal exception string.
"""
_sweep_and_upsert(job_id, {"status": STATUS_ERROR, "message": message}, expires_at)
def get_export_status(job_id: UUID) -> dict[str, Any] | None:
"""The stored status payload for ``job_id``, or ``None`` if it is unknown
(still running, or never existed) or has expired."""
return KeyValueDAO.get_value(RESOURCE, job_id, CODEC)
def resolve_download_link(job_id: UUID) -> tuple[str, str] | None:
"""The ``(bucket, object_key)`` for a *ready* download, or ``None`` if it
is missing, expired, still running, or errored."""
payload = get_export_status(job_id)
if payload is None or payload.get("status") != STATUS_READY:
return None
return payload["bucket"], payload["key"]
-275
View File
@@ -1,275 +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.
"""Derive filter-scope caches in ``json_metadata`` from the dashboard layout.
``chartsInScope`` / ``tabsInScope`` on a native filter, and the ``chartsInScope``
lists inside ``chart_configuration`` / ``global_chart_configuration``, are
denormalized caches of the authoritative ``scope`` plus ``position_json``. They
are written when a dashboard is saved and are never revisited afterwards, so a
dashboard that has charts added or removed - or that was seeded, exported or
imported - carries scope arrays naming charts it does not contain.
The dashboard client already ignores the stored values and recomputes them from
the live layout on every load, which is why the JSON Metadata panel and
``GET /api/v1/dashboard/{id}`` disagreed on a dashboard nobody had ever saved.
Deriving them on read makes the API agree with the client and keeps integrations
that read ``native_filter_configuration`` from receiving dangling chart ids.
The rules mirror the client (``superset-frontend/src/dashboard/util``):
``calculateScopes``, ``getChartIdsInFilterScope``, ``findTabsWithChartsInScope``
and ``getCrossFiltersConfiguration``.
"""
from __future__ import annotations
import re
from typing import Any, TYPE_CHECKING
from superset.utils import json
if TYPE_CHECKING:
from superset.models.dashboard import Dashboard
CHART_TYPE = "CHART"
TAB_TYPE = "TAB"
NATIVE_FILTER_DIVIDER_PREFIX = "NATIVE_FILTER_DIVIDER-"
DIVIDER_TYPES = frozenset({"DIVIDER", "CHART_CUSTOMIZATION_DIVIDER"})
# ``chart-<chartId>-layer-<layerIndex>``, the per-layer scope keys a deck.gl
# multi-layer chart contributes to ``scope.selectedLayers``.
LAYER_SELECTION_RE = re.compile(r"^chart-(\d+)-layer-(\d+)$")
ChartLayoutItems = dict[int, list[dict[str, Any]]]
def build_chart_layout_items(position_data: dict[str, Any]) -> ChartLayoutItems:
"""Map each chart id in the layout to the layout items that render it."""
chart_layout_items: ChartLayoutItems = {}
for item in position_data.values():
if not isinstance(item, dict) or item.get("type") != CHART_TYPE:
continue
chart_id = item.get("meta", {}).get("chartId")
if isinstance(chart_id, int):
chart_layout_items.setdefault(chart_id, []).append(item)
return chart_layout_items
def get_chart_ids_in_scope(
scope: dict[str, Any],
chart_ids: list[int],
chart_layout_items: ChartLayoutItems,
) -> list[int]:
"""Charts covered by ``scope``, in ``chart_ids`` order."""
excluded = set(scope.get("excluded") or [])
root_path = set(scope.get("rootPath") or [])
def in_scope(chart_id: int) -> bool:
if chart_id in excluded:
return False
return any(
parent in root_path
for layout_item in chart_layout_items.get(chart_id, [])
for parent in layout_item.get("parents") or []
)
selected_layers = scope.get("selectedLayers") or []
if not selected_layers:
return [chart_id for chart_id in chart_ids if in_scope(chart_id)]
# A layer selection targets its chart directly, and suppresses the
# rootPath/excluded test for that chart.
charts_with_layer_selections = set()
targeted: list[int] = []
chart_id_set = set(chart_ids)
for selection_key in selected_layers:
if match := LAYER_SELECTION_RE.match(str(selection_key)):
chart_id = int(match.group(1))
charts_with_layer_selections.add(chart_id)
if chart_id in chart_id_set and chart_id not in targeted:
targeted.append(chart_id)
return targeted + [
chart_id
for chart_id in chart_ids
if chart_id not in charts_with_layer_selections
and chart_id not in targeted
and in_scope(chart_id)
]
def get_tabs_in_scope(
charts_in_scope: list[int],
chart_layout_items: ChartLayoutItems,
) -> list[str]:
"""Tabs holding at least one of ``charts_in_scope``."""
tabs_in_scope: list[str] = []
seen: set[str] = set()
for chart_id in charts_in_scope:
for layout_item in chart_layout_items.get(chart_id, []):
for parent in layout_item.get("parents") or []:
if parent.startswith(f"{TAB_TYPE}-") and parent not in seen:
seen.add(parent)
tabs_in_scope.append(parent)
return tabs_in_scope
def _is_divider(item: dict[str, Any]) -> bool:
return (
str(item.get("id", "")).startswith(NATIVE_FILTER_DIVIDER_PREFIX)
or item.get("type") in DIVIDER_TYPES
)
def _derive_item_scopes(
items: list[Any],
chart_ids: list[int],
chart_layout_items: ChartLayoutItems,
) -> list[Any]:
"""Restamp ``chartsInScope`` / ``tabsInScope`` on scoped config items.
Items without a usable ``scope`` are returned untouched: legacy chart
customizations target a chart directly and only gain a ``scope`` once the
client migrates them, so overwriting their cache here would drop targeting
the client still needs.
"""
derived = []
for item in items:
if not isinstance(item, dict):
derived.append(item)
continue
if _is_divider(item):
derived.append({**item, "chartsInScope": [], "tabsInScope": []})
continue
scope = item.get("scope")
if not isinstance(scope, dict) or not isinstance(scope.get("excluded"), list):
derived.append(item)
continue
charts_in_scope = get_chart_ids_in_scope(scope, chart_ids, chart_layout_items)
derived.append(
{
**item,
"chartsInScope": charts_in_scope,
"tabsInScope": get_tabs_in_scope(charts_in_scope, chart_layout_items),
}
)
return derived
def _derive_cross_filter_scopes(
metadata: dict[str, Any],
chart_ids: list[int],
chart_layout_items: ChartLayoutItems,
) -> None:
global_config = metadata.get("global_chart_configuration")
global_charts_in_scope = chart_ids
if isinstance(global_config, dict) and isinstance(global_config.get("scope"), dict):
global_charts_in_scope = get_chart_ids_in_scope(
global_config["scope"], chart_ids, chart_layout_items
)
metadata["global_chart_configuration"] = {
**global_config,
"chartsInScope": global_charts_in_scope,
}
chart_configuration = metadata.get("chart_configuration")
if not isinstance(chart_configuration, dict):
return
derived_configuration = {}
for key, config in chart_configuration.items():
try:
chart_id = int(key)
except (TypeError, ValueError):
derived_configuration[key] = config
continue
# Config for a chart no longer on the dashboard is dead weight; the
# client drops it on load for the same reason.
if chart_id not in chart_layout_items:
continue
if not isinstance(config, dict):
derived_configuration[key] = config
continue
cross_filters = config.get("crossFilters")
if not isinstance(cross_filters, dict):
derived_configuration[key] = config
continue
scope = cross_filters.get("scope")
if isinstance(scope, dict):
charts_in_scope = get_chart_ids_in_scope(
scope, chart_ids, chart_layout_items
)
else:
# Anything that is not an explicit scope object points at the
# dashboard-wide scope, which never includes the emitting chart.
charts_in_scope = [cid for cid in global_charts_in_scope if cid != chart_id]
derived_configuration[key] = {
**config,
"crossFilters": {**cross_filters, "chartsInScope": charts_in_scope},
}
metadata["chart_configuration"] = derived_configuration
def derive_scopes(
metadata: dict[str, Any],
position_data: dict[str, Any],
chart_ids: list[int],
) -> dict[str, Any]:
"""Return ``metadata`` with every derived scope cache recomputed.
``chart_ids`` orders the resulting ``chartsInScope`` arrays and should be the
dashboard's chart ids as the client sees them, so that the API and the JSON
Metadata panel produce byte-identical documents.
"""
derived = dict(metadata)
chart_layout_items = build_chart_layout_items(position_data)
for key in ("native_filter_configuration", "chart_customization_config"):
config = derived.get(key)
if isinstance(config, list):
derived[key] = _derive_item_scopes(config, chart_ids, chart_layout_items)
_derive_cross_filter_scopes(derived, chart_ids, chart_layout_items)
return derived
def derive_metadata_scopes(
dashboard: Dashboard,
metadata: dict[str, Any],
) -> dict[str, Any]:
"""``derive_scopes`` for a dashboard model's parsed ``json_metadata``."""
return derive_scopes(
metadata,
dashboard.position,
[slc.id for slc in dashboard.slices],
)
def derive_json_metadata(dashboard: Dashboard, json_metadata: str) -> str:
"""``derive_metadata_scopes`` over a raw ``json_metadata`` string.
Metadata that does not parse as a JSON object is handed back untouched -
reading a dashboard is not the place to start rejecting documents that have
always been served as-is.
"""
try:
metadata = json.loads(json_metadata)
except (TypeError, ValueError):
return json_metadata
if not isinstance(metadata, dict):
return json_metadata
return json.dumps(derive_metadata_scopes(dashboard, metadata))
+4 -4
View File
@@ -83,7 +83,7 @@ The tables below (generated via `python superset/db_engine_specs/lib.py`) summar
| Databricks (legacy) | 70 | Supported | Partial | Supported | Partial | Partial | Not supported |
| StarRocks | 69 | Supported | Partial | Supported | Partial | Partial | Partial |
| SingleStore | 68 | Supported | Partial | Supported | Not supported | Partial | Not supported |
| ClickHouse Connect (Superset) | 62 | Supported | Partial | Supported | Partial | Partial | Not supported |
| ClickHouse Connect (Superset) | 61 | Supported | Partial | Partial | Partial | Partial | Not supported |
| Google Sheets | 61 | Supported | Partial | Supported | Supported | Partial | Partial |
| Aurora MySQL (Data API) | 59 | Supported | Partial | Supported | Partial | Partial | Not supported |
| MariaDB | 59 | Supported | Partial | Supported | Partial | Partial | Not supported |
@@ -91,7 +91,7 @@ The tables below (generated via `python superset/db_engine_specs/lib.py`) summar
| OceanBase | 59 | Supported | Partial | Supported | Partial | Partial | Not supported |
| MotherDuck | 58 | Supported | Partial | Supported | Not supported | Partial | Not supported |
| KustoSQL | 54 | Supported | Partial | Supported | Partial | Partial | Not supported |
| ClickHouse | 52 | Supported | Partial | Supported | Partial | Partial | Not supported |
| ClickHouse | 51 | Supported | Partial | Partial | Partial | Partial | Not supported |
| Databend | 51 | Supported | Partial | Supported | Partial | Partial | Not supported |
| Apache Drill | 50 | Supported | Partial | Supported | Partial | Partial | Partial |
| Apache Druid | 47 | Partial | Partial | Supported | Partial | Partial | Not supported |
@@ -293,8 +293,8 @@ The tables below (generated via `python superset/db_engine_specs/lib.py`) summar
| Aurora MySQL (Data API) | True | True | True | True | True | True | True | True |
| Aurora PostgreSQL (Data API) | True | True | True | True | True | True | True | True |
| Azure Synapse | True | True | True | True | True | True | True | True |
| ClickHouse | True | True | True | True | True | True | True | True |
| ClickHouse Connect (Superset) | True | True | True | True | True | True | True | True |
| ClickHouse | False | True | True | True | True | True | True | True |
| ClickHouse Connect (Superset) | False | True | True | True | True | True | True | True |
| CockroachDB | True | True | True | True | True | True | True | True |
| Couchbase | True | True | True | True | False | True | True | True |
| CrateDB | True | True | True | True | True | True | True | True |
+2 -21
View File
@@ -165,31 +165,12 @@ def get_available_engine_specs() -> dict[type[BaseEngineSpec], set[str]]: # noq
except Exception as ex: # pylint: disable=broad-except
logger.debug("Unable to load SQLAlchemy dialect %s: %s", ep.name, ex)
else:
# A third-party entry point can load successfully yet not resolve to
# a usable dialect. Validate the same dialect contract as the native
# loop so malformed connectors are neither advertised nor allowed to
# abort the whole enumeration.
backend = getattr(dialect, "name", None)
if (
not isinstance(dialect, type)
or not issubclass(dialect, DefaultDialect)
or not isinstance(backend, (str, bytes))
or not hasattr(dialect, "driver")
or dialect.driver == "adodbapi"
):
logger.warning(
"Skipping SQLAlchemy dialect entry point %r: %r did not "
"resolve to a usable dialect (%r)",
ep.name,
ep.value,
dialect,
)
continue
backend = dialect.name
if isinstance(backend, bytes):
backend = backend.decode()
backend = backend_replacements.get(backend, backend)
driver = dialect.driver
driver = getattr(dialect, "driver", dialect.name)
if isinstance(driver, bytes):
driver = driver.decode()
drivers[backend].add(driver)
-1
View File
@@ -112,7 +112,6 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
_time_grain_expressions = {
None: "{col}",
"PT1S": "toStartOfSecond(toDateTime64({col}, 3))",
"PT1M": "toStartOfMinute(toDateTime({col}))",
"PT5M": "toDateTime(intDiv(toUInt32(toDateTime({col})), 300)*300)",
"PT10M": "toDateTime(intDiv(toUInt32(toDateTime({col})), 600)*600)",
+2 -6
View File
@@ -26,12 +26,8 @@ if TYPE_CHECKING:
# Matches only the static asset endpoint:
# /api/v1/extensions/<publisher>/<name>/<path:file>, where the file portion may
# contain nested segments (worker / WASM / chunk subfolders).
# Does not match the list (/), get (/<publisher>/<name>), or info (/_info)
# endpoints, nor the per-user storage endpoints under
# /<publisher>/<name>/storage/, whose responses must keep ``Vary: Cookie``.
_ASSET_PATH_RE: re.Pattern[str] = re.compile(
r"^/api/v1/extensions/[^/]+/[^/]+/(?!storage/).+$"
)
# Does not match the list (/), get (/<publisher>/<name>), or info (/_info) endpoints.
_ASSET_PATH_RE: re.Pattern[str] = re.compile(r"^/api/v1/extensions/[^/]+/[^/]+/.+$")
class ExtensionCacheMiddleware:
+2 -8
View File
@@ -92,16 +92,10 @@ class ExtensionStorageRestApi(BaseApi):
route_base = "/api/v1/extensions"
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses.
Stored values are scoped to the requesting user, so responses are
marked non-cacheable.
"""
"""Helper method to create JSON responses."""
from flask import jsonify
response = jsonify(kwargs)
response.cache_control.no_store = True
return response, status_code
return jsonify(kwargs), status_code
def response_404(self, message: str = "Not found") -> Response:
"""Helper method to create 404 responses."""
+2 -36
View File
@@ -1272,34 +1272,6 @@ def get_dataset_id_from_context(metric_key: str) -> int:
raise SupersetTemplateException(exc_message)
def guest_user_can_access_dataset(dataset: SqlaTable) -> bool:
"""
Whether the current guest (embedded) user may read the given dataset.
Guest access is granted per dashboard, so the dataset must back at least
one chart on a dashboard the guest token covers; a ``datasets`` allowlist
on the token further restricts the reachable IDs.
:param dataset: a dataset resolved without the DAO base filter.
:returns: whether the guest user may read the dataset.
"""
guest_user = security_manager.get_current_guest_user_if_guest()
if not guest_user:
return False
allowed_datasets: list[int] | None = guest_user.guest_token.get("datasets")
if allowed_datasets is not None and (
not isinstance(allowed_datasets, list) or dataset.id not in allowed_datasets
):
return False
return any(
security_manager.has_guest_access(dashboard)
for slc in dataset.slices
for dashboard in slc.dashboards
)
def metric_macro(
env: Environment,
context: dict[str, Any],
@@ -1322,9 +1294,8 @@ def metric_macro(
if not dataset_id:
dataset_id = get_dataset_id_from_context(metric_key)
# Embedded (guest) user access is validated at the dashboard level, so the
# regular DAO filter is bypassed for them and dashboard-level scope is
# enforced explicitly below.
# Embedded user access is validated at the dashboard level, so we bypass
# the regular DAO filter for them
dataset = DatasetDAO.find_by_id(
dataset_id,
skip_base_filter=security_manager.is_guest_user(),
@@ -1332,11 +1303,6 @@ def metric_macro(
if not dataset:
raise DatasetNotFoundError(f"Dataset ID {dataset_id} not found.")
# With the base filter skipped, scope a guest to datasets reachable through
# a dashboard their token grants; reuse the not-found error for consistency.
if security_manager.is_guest_user() and not guest_user_can_access_dataset(dataset):
raise DatasetNotFoundError(f"Dataset ID {dataset_id} not found.")
metrics: dict[str, str] = {
metric.metric_name: metric.expression for metric in dataset.metrics
}
+1
View File
@@ -42,6 +42,7 @@ class KeyValueFilter(TypedDict, total=False):
class KeyValueResource(StrEnum):
APP = "app"
DASHBOARD_PERMALINK = "dashboard_permalink"
EXCEL_EXPORT_DOWNLOAD = "excel_export_download"
EXPLORE_PERMALINK = "explore_permalink"
METASTORE_CACHE = "superset_metastore_cache"
LOCK = "lock"
+25 -75
View File
@@ -68,11 +68,6 @@ from superset.mcp_service.session_scope import _mcp_session_token
from superset.mcp_service.utils.error_sanitization import (
sanitize_for_log as _sanitize_for_log,
)
from superset.security.api_key_scopes import (
get_resource_scope,
METHOD_PERMISSION_SCOPE_ACTION,
RESOURCE_SCOPE_NAME as RESOURCE_SCOPE_NAME,
)
from superset.security.guest_token import GuestUser
if TYPE_CHECKING:
@@ -131,24 +126,19 @@ class MCPNoAuthSourceError(ValueError):
# is a privileged, write-class operation and therefore requires the write
# scope. When introducing a new method permission, add it here.
_METHOD_TO_REQUIRED_SCOPE = {
method: f"superset:{action}"
for method, action in METHOD_PERMISSION_SCOPE_ACTION.items()
"read": "superset:read",
# "get" is the read-class permission FAB registers on its security API
# views (User/Role) — those views have no can_read, so tools targeting
# them declare method_permission_name="get".
"get": "superset:read",
"write": "superset:write",
"delete": "superset:write",
# SQL execution (execute_sql, get_chart_sql) runs arbitrary queries and is
# treated as a write-class privileged operation for scope purposes.
"execute_sql_query": "superset:write",
}
def _required_resource_scope(
class_permission_name: str, method_permission_name: str
) -> str | None:
"""Compute the ``superset:<resource>:<action>`` scope string for a tool.
Returns None if either the resource or the action isn't mapped — callers
must treat that as "no per-resource scope available," not as a grant;
the flat ``_METHOD_TO_REQUIRED_SCOPE`` fallback still applies in that case
(see ``_token_scope_allows``).
"""
return get_resource_scope(class_permission_name, method_permission_name)
def _get_token_scopes() -> set[str] | None:
"""Return the set of scopes on the current JWT access token, or None.
@@ -164,13 +154,8 @@ def _get_token_scopes() -> set[str] | None:
try:
access_token = get_access_token()
except Exception: # noqa: BLE001 - fail closed on token-context errors
logger.exception("Unable to resolve MCP access-token scopes")
# ``None`` means that no scoped credential was presented and enables
# legacy RBAC-only behavior. An empty set instead makes every scope
# check fail, so an unexpected context error cannot erase restrictions
# carried by a credential.
return set()
except Exception: # noqa: BLE001 - no JWT context for this request
return None
if access_token is None:
return None
@@ -182,21 +167,12 @@ def _get_token_scopes() -> set[str] | None:
return {str(s) for s in scopes}
def _token_scope_allows(
method_permission_name: str, class_permission_name: str | None = None
) -> bool:
def _token_scope_allows(method_permission_name: str) -> bool:
"""Return whether the current token's scopes permit the given method.
Back-compat: returns True (allow) when the token carries no scopes or there
is no JWT context, so deployments not using scopes keep RBAC-only behavior.
Only when the token advertises scopes is the mapped required scope enforced.
The per-resource scope (``superset:<resource>:<action>``, derived via
``_required_resource_scope``) is an ALTERNATIVE grant path alongside the
flat method scope: a token carrying either the flat scope
(e.g. ``superset:read``) or the matching per-resource scope
(e.g. ``superset:dashboard:read``) is allowed, so already-issued
flat-scoped tokens keep working unchanged.
"""
token_scopes = _get_token_scopes()
if token_scopes is None:
@@ -214,15 +190,7 @@ def _token_scope_allows(
method_permission_name,
)
return False
if required_scope in token_scopes:
return True
if class_permission_name is not None:
resource_scope = _required_resource_scope(
class_permission_name, method_permission_name
)
if resource_scope is not None and resource_scope in token_scopes:
return True
return False
return required_scope in token_scopes
class MCPPermissionDeniedError(PermissionError):
@@ -266,20 +234,12 @@ def _log_scope_denial(
cyclomatic complexity in check.
"""
required_scope = _METHOD_TO_REQUIRED_SCOPE.get(method_permission_name)
resource_scope = _required_resource_scope(
class_permission_name, method_permission_name
)
scope_desc = (
resource_scope
or required_scope
or f"unmapped method permission '{method_permission_name}'"
)
if log_denial:
logger.warning(
"Scope denied for user %s: token lacks required scope "
"'%s' for %s on %s (tool: %s)",
_sanitize_for_log(g.user.username),
scope_desc,
required_scope,
permission_str,
class_permission_name,
func.__name__,
@@ -288,7 +248,7 @@ def _log_scope_denial(
logger.debug(
"Tool hidden for user %s: token lacks required scope '%s' (tool: %s)",
_sanitize_for_log(g.user.username),
scope_desc,
required_scope,
func.__name__,
)
@@ -394,13 +354,8 @@ def check_tool_permission( # noqa: C901
)
return False
method_permission_name = getattr(func, METHOD_PERMISSION_ATTR, "read")
class_permission_name = getattr(func, CLASS_PERMISSION_ATTR, None)
# Token capabilities and user RBAC are independent restrictions.
# Disabling RBAC must not discard scopes explicitly carried by a key.
if not current_app.config.get("MCP_RBAC_ENABLED", True):
return _token_scope_allows(method_permission_name, class_permission_name)
return True
if not hasattr(g, "user") or not g.user:
if log_denial:
@@ -413,6 +368,7 @@ def check_tool_permission( # noqa: C901
)
return False
class_permission_name = getattr(func, CLASS_PERMISSION_ATTR, None)
if not class_permission_name:
# No RBAC configured for this tool; allow by default. This is a
# supported configuration (a protected tool may intentionally
@@ -426,17 +382,9 @@ def check_tool_permission( # noqa: C901
"class_permission_name; allowing access without an RBAC check",
func.__name__,
)
if not _token_scope_allows(method_permission_name):
if log_denial:
logger.warning(
"Scope denied for permission-less tool %s: token lacks "
"flat scope for method %s",
func.__name__,
method_permission_name,
)
return False
return True
method_permission_name = getattr(func, METHOD_PERMISSION_ATTR, "read")
permission_str = f"{PERMISSION_PREFIX}{method_permission_name}"
has_permission = security_manager.can_access(
@@ -451,9 +399,7 @@ def check_tool_permission( # noqa: C901
# advertises scopes. Tokens/deployments that don't use scopes (API keys,
# scope-less JWTs, dev-mode) fall through to RBAC-only behavior — see
# ``_token_scope_allows``.
if has_permission and not _token_scope_allows(
method_permission_name, class_permission_name
):
if has_permission and not _token_scope_allows(method_permission_name):
_log_scope_denial(
func,
method_permission_name,
@@ -516,7 +462,7 @@ def is_tool_visible_to_current_user(tool: Any) -> bool:
return False
if not current_app.config.get("MCP_RBAC_ENABLED", True):
return check_tool_permission(tool_func, log_denial=False)
return True
from superset.mcp_service.privacy import (
tool_requires_data_model_metadata_access,
@@ -529,6 +475,10 @@ def is_tool_visible_to_current_user(tool: Any) -> bool:
):
return False
class_permission_name = getattr(tool_func, CLASS_PERMISSION_ATTR, None)
if not class_permission_name:
return True
return check_tool_permission(tool_func, log_denial=False)
except (AttributeError, RuntimeError, ValueError):
@@ -113,19 +113,15 @@ class CompositeTokenVerifier(TokenVerifier):
)
self._api_key_prefixes = tuple(valid)
def _validate_api_key_sync(self, token: str) -> tuple[str, list[str]] | None:
"""Validate an API key against FAB and return (username, scopes).
def _validate_api_key_sync(self, token: str) -> str | None:
"""Validate an API key against FAB and return the user's username.
Runs synchronously inside a thread executor. Pushes a fresh Flask
app context so that FAB's SecurityManager can access the database.
``scopes`` is the key's own ``ApiKey.scopes`` column, parsed from
FAB's comma-separated string storage format into a list (empty list
if the key has no scopes set, matching the "no scopes advertised"
convention used elsewhere in this module and in ``auth.py``).
Returns ``None`` if the key is invalid, FAB does not support
``validate_api_key``, or an unexpected error occurs (fail closed).
Returns the username on success, or ``None`` if the key is invalid,
FAB does not support ``validate_api_key``, or an unexpected error
occurs (fail closed).
"""
if self._app is None:
return None
@@ -139,21 +135,12 @@ class CompositeTokenVerifier(TokenVerifier):
)
return None
user = sm.validate_api_key(token)
if user is None:
return None
username = user.username
scopes_str = (
sm.get_api_key_scopes(token)
if hasattr(sm, "get_api_key_scopes")
else None
)
scopes = (
[s.strip() for s in scopes_str.split(",") if s.strip()]
if scopes_str
else []
)
token = "" # noqa: S105 -- unbind raw token, defense-in-depth
return username, scopes
username = user.username if user else None
# Unbind the local reference so this frame no longer points at
# the raw token (defense-in-depth). Python does not zero the
# underlying string memory on rebind.
token = "" # noqa: S105
return username
except Exception: # noqa: BLE001 — catch-all: DB errors, FAB internals, etc.
logger.warning(
"API key transport validation failed unexpectedly; rejecting token",
@@ -181,25 +168,21 @@ class CompositeTokenVerifier(TokenVerifier):
if any(token.startswith(prefix) for prefix in self._api_key_prefixes):
if self._app is not None:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
username = await loop.run_in_executor(
None, self._validate_api_key_sync, token
)
if result is None:
if username is None:
logger.debug(
"API key rejected at transport layer (invalid or expired)"
)
return None
username, key_scopes = result
logger.debug(
"API key validated at transport layer for user=%s", username
)
return AccessToken(
token=token,
client_id="api_key",
# Preserve the key's own scopes exactly. An empty list
# means "no scopes advertised" and therefore retains the
# RBAC-only behavior for existing unscoped API keys.
scopes=key_scopes,
scopes=list(self.required_scopes or []),
claims={
API_KEY_PASSTHROUGH_CLAIM: True,
API_KEY_VALIDATED_USERNAME_CLAIM: username,
@@ -207,11 +190,10 @@ class CompositeTokenVerifier(TokenVerifier):
)
# No app configured: fall back to prefix-only pass-through so
# ``_resolve_user_from_api_key`` handles DB validation. Without an
# app there is no DB access here, so the key's own ApiKey.scopes
# cannot be read — the verifier-global required_scopes are used
# instead. Authorization is still enforced downstream via
# ``check_tool_permission`` (RBAC).
# ``_resolve_user_from_api_key`` handles DB validation.
# NOTE: ``MCP_REQUIRED_SCOPES`` is intentionally not enforced for
# API-key auth — FAB API keys do not carry scopes. Authorization is
# enforced downstream via ``check_tool_permission`` (RBAC).
logger.debug("API key token detected (prefix match), passing through")
return AccessToken(
token=token,
+4 -3
View File
@@ -653,9 +653,10 @@ def _build_composite_verifier(
if api_key_enabled:
if required_scopes := app.config.get("MCP_REQUIRED_SCOPES", []):
logger.warning(
"MCP_REQUIRED_SCOPES=%r is configured, but API key tokens use "
"the scopes stored on each key instead. Unscoped API keys "
"retain legacy RBAC-only behavior.",
"MCP_REQUIRED_SCOPES is configured but API key tokens bypass "
"scope enforcement. API key holders gain access regardless of "
"MCP_REQUIRED_SCOPES=%r. Enforce per-key authorization via FAB "
"roles/RBAC instead.",
required_scopes,
)
raw_prefixes: str | Sequence[str] = app.config.get(
@@ -30,7 +30,7 @@ from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.auth import _token_scope_allows, MCPPermissionDeniedError
from superset.mcp_service.auth import MCPPermissionDeniedError
from superset.mcp_service.common.schema_discovery import (
CHART_DEFAULT_COLUMNS,
CHART_SEARCH_COLUMNS,
@@ -235,10 +235,9 @@ async def get_schema(
from superset import security_manager
rbac_allows = not current_app.config.get(
"MCP_RBAC_ENABLED", True
) or security_manager.can_access("can_read", class_permission)
if not (rbac_allows and _token_scope_allows("read", class_permission)):
if current_app.config.get("MCP_RBAC_ENABLED", True) and not (
security_manager.can_access("can_read", class_permission)
):
user_str = getattr(getattr(g, "user", None), "username", None)
logger.warning(
"get_schema RBAC denied: user=%s type=%s view=%s",
-77
View File
@@ -1,77 +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.
"""Canonical resource and action mappings for scoped API keys."""
# Map FAB method permissions used by MCP tools to the coarser actions supported
# by API-key scopes. Keep this explicit so an unknown permission fails closed.
METHOD_PERMISSION_SCOPE_ACTION: dict[str, str] = {
"read": "read",
"get": "read",
"write": "write",
"update": "write",
"delete": "write",
"execute_sql_query": "write",
}
# Map MCP/FAB class permission names to stable public resource slugs. These
# cannot be derived by lowercasing because several names contain spaces or use
# public spellings that differ from their internal class names.
RESOURCE_SCOPE_NAME: dict[str, str] = {
"Annotation": "annotation",
"Chart": "chart",
"Dashboard": "dashboard",
"Database": "database",
"Dataset": "dataset",
"Explore": "explore",
"Query": "query",
"ReportSchedule": "report",
"Role": "role",
"Row Level Security": "rls",
"SavedQuery": "savedquery",
"SQLLab": "sqllab",
"Tag": "tag",
"Task": "task",
"Theme": "theme",
"User": "user",
}
RESOURCE_SCOPE_CLASS: dict[str, str] = {
resource: class_name for class_name, resource in RESOURCE_SCOPE_NAME.items()
}
RESOURCE_SCOPE_ACTIONS: frozenset[str] = frozenset(
METHOD_PERMISSION_SCOPE_ACTION.values()
)
SCOPE_ACTION_METHOD_PERMISSIONS: dict[str, tuple[str, ...]] = {
action: tuple(
method
for method, mapped_action in METHOD_PERMISSION_SCOPE_ACTION.items()
if mapped_action == action
)
for action in RESOURCE_SCOPE_ACTIONS
}
def get_resource_scope(
class_permission_name: str, method_permission_name: str
) -> str | None:
"""Return the resource scope required by a FAB class/method permission."""
resource = RESOURCE_SCOPE_NAME.get(class_permission_name)
action = METHOD_PERMISSION_SCOPE_ACTION.get(method_permission_name)
if resource is None or action is None:
return None
return f"superset:{resource}:{action}"
+27 -237
View File
@@ -17,7 +17,6 @@
# pylint: disable=too-many-lines
"""A set of constants and methods to manage permissions and security"""
import datetime
import logging
import re
import time
@@ -37,7 +36,7 @@ from urllib.parse import quote
from flask import current_app, Flask, g, has_app_context, Request, Response
from flask_appbuilder import Model
from flask_appbuilder.api import expose, permission_name, protect, safe
from flask_appbuilder.api import expose, protect, safe
from flask_appbuilder.models.filters import BaseFilter
from flask_appbuilder.security.manager import AUTH_REMOTE_USER
from flask_appbuilder.security.sqla.apis import GroupApi, RoleApi, UserApi
@@ -395,11 +394,8 @@ class SupersetUserApi(UserApi):
"""
Overriding the UserApi to sync Subject rows, filter excluded users,
handle deletion constraints, and add audit logging.
The Subject sync happens in ``pre_add``/``pre_update``, which FAB calls
*before* the commit that ``self.datamodel.add``/``edit`` issues -- so the
sync rides that same commit rather than needing one of its own after the
fact.
UserApi has custom post/put that bypass hooks, so we override them
and sync after the parent method succeeds.
"""
base_filters = [["username", ExcludeUsersFilter, lambda: []]]
@@ -419,45 +415,6 @@ class SupersetUserApi(UserApi):
"changed_on",
]
def pre_add(self, item: Model) -> None:
"""Hash the password (FAB's own ``pre_add``), then sync the user's
``Subject`` row before FAB's own commit.
``UserApi.post`` calls ``pre_add`` *before* ``self.datamodel.add``,
which is what actually issues the commit -- so flushing the new user
here (to obtain its id) and syncing its ``Subject`` row alongside it
means both writes ride the same transaction and commit together,
instead of the subject sync needing a second, separate commit after
the fact.
"""
super().pre_add(item)
from superset.daos.user import UserDAO
self.datamodel.session.add(item)
self.datamodel.session.flush()
UserDAO._sync_subject(item)
def pre_update(self, item: Model, data: dict[str, Any]) -> None:
"""Same reasoning as ``pre_add``: ``UserApi.put`` calls ``pre_update``
before ``self.datamodel.edit`` commits, so the subject sync lands in
that same transaction.
"""
super().pre_update(item, data)
from superset.daos.user import UserDAO
UserDAO._sync_subject(item)
if data.get("password"):
# An admin-initiated password change via this endpoint must
# invalidate the target account's other outstanding sessions,
# the same as the self-service ``/me/`` path and the two
# password-reset views.
from superset.security.session_invalidation import (
invalidate_sessions_for_user,
)
invalidate_sessions_for_user(item.id)
@expose("/", methods=["POST"])
@protect()
@safe
@@ -473,7 +430,17 @@ class SupersetUserApi(UserApi):
500:
description: Server error
"""
return super().post()
response = super().post()
if response.status_code == 201:
from superset.daos.user import UserDAO
user_id = response.json.get("id")
if user_id:
user = self.datamodel.session.get(self.datamodel.obj, user_id)
if user:
UserDAO._sync_subject(user)
self.datamodel.session.commit() # pylint: disable=consider-using-transaction
return response
@expose("/<pk>", methods=["PUT"])
@protect()
@@ -497,42 +464,15 @@ class SupersetUserApi(UserApi):
500:
description: Server error
"""
return super().put(pk)
response = super().put(pk)
if response.status_code == 200:
from superset.daos.user import UserDAO
@expose("/<int:pk>/sessions", methods=["DELETE"])
@protect()
@permission_name("put")
@safe
def terminate_sessions(self, pk: int) -> Response:
"""Terminate a user's outstanding sessions without disabling their account.
---
delete:
parameters:
- in: path
name: pk
schema:
type: integer
responses:
200:
description: Sessions terminated
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
from superset.security.session_invalidation import invalidate_sessions_for_user
user = self.datamodel.get(pk, self._base_filters)
if not user:
return self.response_404()
invalidate_sessions_for_user(user.id)
self.datamodel.session.commit() # pylint: disable=consider-using-transaction
_log_audit_event(
"UserSessionsTerminated",
{"target_username": user.username, "target_user_id": user.id},
)
return self.response(200, message="User sessions terminated.")
user = self.datamodel.get(pk, self._base_filters)
if user:
UserDAO._sync_subject(user)
self.datamodel.session.commit() # pylint: disable=consider-using-transaction
return response
def pre_delete(self, item: Model) -> None:
from superset.daos.user import UserDAO
@@ -814,24 +754,15 @@ def _native_filter_query_modified(
query: Any, allowed_columns: set[str], allowed_metrics: set[str]
) -> bool:
"""Whether a single query in a native-filter request reads beyond its targets."""
# Columns, group-by, and series columns may only reference target column(s);
# adhoc (free-form SQL) columns cannot be validated, so reject them.
for key in ("columns", "groupby", "series_columns"):
# Columns and group-by may only reference target column(s); adhoc (free-form
# SQL) columns cannot be validated, so reject them.
for key in ("columns", "groupby"):
for col in getattr(query, key, None) or []:
if not isinstance(col, str) or col not in allowed_columns:
return True
for metric in getattr(query, "metrics", None) or []:
if not _native_filter_term_allowed(metric, allowed_columns, allowed_metrics):
return True
# A series-limit metric ranks the top-N groups in the inner query, so it is
# a value-returning term and is validated like a metric. ``QueryObject``
# renames the deprecated ``timeseries_limit_metric`` payload key onto this
# attribute, so both spellings are covered.
series_limit_metric = getattr(query, "series_limit_metric", None)
if series_limit_metric and not _native_filter_term_allowed(
series_limit_metric, allowed_columns, allowed_metrics
):
return True
# order-by entries are ``(expression, asc)`` pairs.
for order in getattr(query, "orderby", None) or []:
expr = order[0] if isinstance(order, (list, tuple)) and order else order
@@ -853,9 +784,8 @@ def _native_filter_request_modified(query_context: "QueryContext") -> bool:
A native filter may only read the column(s) it targets on the dashboard it
belongs to. The request is treated as modified (and therefore rejected for
guest users) when it cannot be tied to a native filter on the requesting
dashboard, or when any value-returning term (column, group-by, series
column, metric, series-limit metric, or order-by) references something
other than a target column, a simple
dashboard, or when any value-returning term (column, group-by, metric, or
order-by) references something other than a target column, a simple
aggregate over a target column, or the filter's configured sort metric.
Free-form SQL terms and saved metrics other than the configured sort metric
are rejected. Row-restricting clauses (``filter``/``extras``) are not
@@ -1625,23 +1555,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
bypassed. We distinguish the two by comparing the acting user
(``g.user``) against the target ``userid``: they match for a
self-service reset and differ for an admin reset.
Also stamps the session-invalidation epoch for the target user, so
any session for the account that predates this reset stops working --
regardless of which of the two paths triggered it.
"""
super().reset_password(userid, password)
# pylint: disable=import-outside-toplevel
from superset import db
from superset.security.session_invalidation import invalidate_sessions_for_user
invalidate_sessions_for_user(int(userid))
# ``super().reset_password`` (FAB's ``update_user``) already committed
# its own change in a separate transaction, so the epoch stamp above
# needs its own commit too, rather than riding an existing one.
db.session.commit() # pylint: disable=consider-using-transaction
acting_user = getattr(g, "user", None)
acting_user_id = getattr(acting_user, "id", None)
# ``userid`` arrives as a string (the ``pk`` request arg) on the admin
@@ -4381,15 +4297,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
child_slice_id=slice_id,
parent_slice=parent_slc,
)
# Bind the request to the child
# chart's own datasource, mirroring
# the direct-chart leg above.
and (
child_slc := self.session.query(Slice)
.filter(Slice.id == slice_id)
.one_or_none()
)
and child_slc.datasource == datasource
)
)
)
@@ -5019,123 +4926,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
raw_token, secret, algorithms=[algo], audience=audience
)
def get_api_key_scopes(self, api_key_string: str) -> Optional[str]:
"""Return the ``scopes`` value for a validated API key.
FAB's ``validate_api_key`` resolves the matching ``ApiKey`` row
internally (by lookup hash) but only returns the associated
``User`` the row's ``scopes`` column is otherwise unreachable by
callers. This repeats the same cheap, indexed lookup so MCP's
``CompositeTokenVerifier`` can propagate per-key scopes instead of
silently falling back to verifier-global scopes. Call only after
``validate_api_key`` has already succeeded for this token this
method does not itself verify the key hash or active status.
"""
lookup = self._compute_lookup_hash(api_key_string) # type: ignore[attr-defined]
api_key = (
self.session.query(self.api_key_model) # type: ignore[attr-defined]
.filter(self.api_key_model.lookup_hash == lookup)
.one_or_none()
)
return api_key.scopes if api_key else None
def _validate_requested_api_key_scopes(
self, user: Any, scopes: Optional[str]
) -> None:
"""Raise if ``scopes`` would grant a user more than their own RBAC.
Enforces the "intersection, never broader" rule confirmed for this
feature: a user must never be able to mint a token scoped beyond
what their own role already permits, even if they hand-author the
scopes string themselves at issuance time.
Per-resource scopes (``superset:<resource>:<action>``) are checked
against the user's actual ``can_<method>`` RBAC grant for that
resource. Flat scopes (``superset:read``/``superset:write``, the
pre-per-resource form) can only be self-issued by Admins a flat
scope grants a method across every resource, and there's no single
RBAC check that soundly proves a non-Admin has that for "every
resource," so it's rejected for anyone else rather than guessed at.
Unrecognized scope strings are rejected outright (fail closed).
NOTE: this only prevents the request from being honored; it does
not (yet) produce a clean 400 response, since FAB's ``ApiKeyApi``
has no validation hook this can plug into without replacing the API
registration entirely. Raising here surfaces as a 500 via FAB's
``@safe`` decorator until that's addressed — tracked as a known
follow-up, not silently accepted.
"""
if not scopes:
return
# pylint: disable-next=import-outside-toplevel
from superset.security.api_key_scopes import (
RESOURCE_SCOPE_ACTIONS,
RESOURCE_SCOPE_CLASS,
SCOPE_ACTION_METHOD_PERMISSIONS,
)
admin_role_name = get_conf()["AUTH_ROLE_ADMIN"]
is_admin = any(
role.name == admin_role_name for role in getattr(user, "roles", [])
)
for raw_scope in scopes.split(","):
scope = raw_scope.strip()
if not scope:
continue
parts = scope.split(":")
if len(parts) == 3 and parts[0] == "superset":
_, resource_slug, action = parts
class_permission_name = RESOURCE_SCOPE_CLASS.get(resource_slug)
if class_permission_name is None:
raise ValueError(
f"Requested scope '{scope}' names an unrecognized "
f"resource '{resource_slug}'"
)
if action not in RESOURCE_SCOPE_ACTIONS:
raise ValueError(
f"Requested scope '{scope}' names an unrecognized "
f"action '{action}'"
)
if any(
self._has_view_access(user, f"can_{method}", class_permission_name)
for method in SCOPE_ACTION_METHOD_PERMISSIONS[action]
):
continue
raise ValueError(
f"Requested scope '{scope}' exceeds the issuing user's "
"own permissions"
)
if (
len(parts) == 2
and parts[0] == "superset"
and parts[1] in RESOURCE_SCOPE_ACTIONS
and is_admin
):
continue
raise ValueError(
f"Requested scope '{scope}' is not a recognized "
"superset:<resource>:<action> scope, or requires Admin to "
"self-issue as a flat scope"
)
def create_api_key(
self,
user: Any,
name: str,
scopes: Optional[str] = None,
expires_on: Optional[datetime.datetime] = None,
) -> Optional[dict[str, Any]]:
"""Create a new API key, enforcing the scope-intersection rule.
Thin wrapper around FAB's ``SecurityManager.create_api_key`` — see
``_validate_requested_api_key_scopes`` for the actual check. FAB's
base implementation is otherwise unchanged.
"""
self._validate_requested_api_key_scopes(user, scopes)
return super().create_api_key( # type: ignore[misc]
user=user, name=name, scopes=scopes, expires_on=expires_on
)
@staticmethod
def is_guest_user(user: Optional[Any] = None) -> bool:
# pylint: disable=import-outside-toplevel
+1 -29
View File
@@ -41,7 +41,7 @@ from typing import Any, Optional
from flask import flash, session
from flask_babel import gettext as __
from flask_login import current_user, logout_user
from sqlalchemy import event, inspect, or_
from sqlalchemy import event, inspect
from sqlalchemy.exc import IntegrityError
from werkzeug.wrappers import Response
@@ -163,20 +163,9 @@ def invalidate_user_sessions(connection: Any, user_id: int) -> None:
)
def _stamp_existing() -> int:
# Guard against two concurrent writers regressing the epoch: a
# transaction that computed an earlier ``now`` can reach this UPDATE
# after one with a later ``now`` has already committed. Only apply
# the write when it would advance (or initialize) the stored value,
# so the epoch is monotonic regardless of commit order.
return connection.execute(
table.update()
.where(table.c.user_id == user_id)
.where(
or_(
table.c.sessions_invalidated_at.is_(None),
table.c.sessions_invalidated_at < now,
)
)
.values(sessions_invalidated_at=now, changed_on=now)
).rowcount
@@ -198,23 +187,6 @@ def invalidate_user_sessions(connection: Any, user_id: int) -> None:
_stamp_existing()
def invalidate_sessions_for_user(user_id: int) -> None:
"""Stamp the invalidation epoch for ``user_id`` from ordinary application code.
Convenience wrapper around ``invalidate_user_sessions`` for callers that
don't have the raw ``Connection`` the ``after_update`` event listener
receives -- e.g. a password-change flow. The stamp is written through the
current session's own connection, so it participates in whatever
transaction the caller's other pending changes belong to; it is not
committed here, so the caller's own commit (or the next flush that
triggers one) is what makes it durable.
"""
# pylint: disable=import-outside-toplevel
from superset.extensions import db
invalidate_user_sessions(db.session.connection(), user_id)
def _stamp_epoch_on_disable(_mapper: Any, connection: Any, target: Any) -> None:
history = inspect(target).attrs.active.history
# Only act when ``active`` actually changed to False — ignore the
+9 -25
View File
@@ -324,9 +324,7 @@ class SemanticView(AuditMixinNullable, Model):
MetricMetadata(
metric_name=metric.name,
expression=metric.definition,
verbose_name=metric.verbose_name,
description=metric.description,
d3format=metric.d3format,
)
for metric in self.implementation.get_metrics()
]
@@ -359,7 +357,6 @@ class SemanticView(AuditMixinNullable, Model):
is_dttm=pa.types.is_date(dimension.type)
or pa.types.is_time(dimension.type)
or pa.types.is_timestamp(dimension.type),
verbose_name=dimension.verbose_name,
description=dimension.description,
expression=None,
extra=json.dumps(
@@ -375,19 +372,6 @@ class SemanticView(AuditMixinNullable, Model):
@property
def data(self) -> ExplorableData:
dimensions = self._unique_dimensions
metrics = list(self.implementation.get_metrics())
verbose_map = {
**{metric.name: metric.verbose_name or metric.name for metric in metrics},
**{
dimension.name: dimension.verbose_name or dimension.name
for dimension in dimensions
},
}
column_formats = {
metric.name: metric.d3format for metric in metrics if metric.d3format
}
return {
# core
"id": self.id,
@@ -415,16 +399,16 @@ class SemanticView(AuditMixinNullable, Model):
"python_date_format": None,
"type": str(dimension.type),
"type_generic": get_column_type(dimension.type),
"verbose_name": dimension.verbose_name,
"verbose_name": None,
"warning_markdown": None,
}
for dimension in dimensions
for dimension in self._unique_dimensions
],
"metrics": [
{
"certification_details": None,
"certified_by": None,
"d3format": metric.d3format,
"d3format": None,
"description": metric.description,
"expression": metric.definition,
"id": None,
@@ -433,14 +417,14 @@ class SemanticView(AuditMixinNullable, Model):
"metric_name": metric.name,
"warning_markdown": None,
"warning_text": None,
"verbose_name": metric.verbose_name,
"verbose_name": None,
}
for metric in metrics
for metric in self.implementation.get_metrics()
],
"database": {},
"parent": {"name": self.semantic_layer.name},
# UI features
"verbose_map": verbose_map,
"verbose_map": {},
"order_by_choices": [],
"filter_select": True,
"filter_select_enabled": True,
@@ -452,11 +436,11 @@ class SemanticView(AuditMixinNullable, Model):
"description": self.description,
"table_name": self.name,
"column_types": [
get_column_type(dimension.type) for dimension in dimensions
get_column_type(dimension.type) for dimension in self._unique_dimensions
],
"column_names": [dimension.name for dimension in dimensions],
"column_names": [dimension.name for dimension in self._unique_dimensions],
# rare
"column_formats": column_formats,
"column_formats": {},
"datasource_name": self.name,
"perm": self.perm,
"offset": self.offset,
+1 -6
View File
@@ -129,12 +129,7 @@ class TaskContext(CoreTaskContext):
"""
from superset.daos.tasks import TaskDAO
# Internal executor path: load the running task itself, keyed on a
# UUID this instance already holds, not a user-requested lookup;
# see TaskFilter for the request-scoped vs. internal-plumbing split.
fresh_task = TaskDAO.find_one_or_none(
uuid=self._task_uuid, skip_base_filter=True
)
fresh_task = TaskDAO.find_one_or_none(uuid=self._task_uuid)
if not fresh_task:
raise ValueError(f"Task {self._task_uuid} not found")
+6 -14
View File
@@ -167,12 +167,6 @@ class TaskWrapper(Generic[P]):
return value is discarded.
Direct calls execute synchronously, .schedule() runs async via Celery.
The status-refresh reads below pass ``skip_base_filter=True`` to
``TaskDAO.find_one_or_none`` because they read back the task this
executor itself submitted, keyed on the UUID it already holds -- not
a task requested by a user. See ``TaskFilter`` for the request-scoped
vs. internal-plumbing split.
"""
def __init__(
@@ -384,7 +378,7 @@ class TaskWrapper(Generic[P]):
task.uuid,
)
# Return task in current state (caller can check status)
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid, skip_base_filter=True)
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid)
return refreshed if refreshed else task
def _execute_inline(
@@ -428,7 +422,7 @@ class TaskWrapper(Generic[P]):
set_ended_at=True,
).run()
# Refresh to get updated task
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid, skip_base_filter=True)
refreshed = TaskDAO.find_one_or_none(uuid=task.uuid)
return refreshed if refreshed else task
# Atomic transition: PENDING → IN_PROGRESS (set started_at for duration
@@ -447,7 +441,7 @@ class TaskWrapper(Generic[P]):
self.name,
task_uuid,
)
refreshed = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
refreshed = TaskDAO.find_one_or_none(uuid=task_uuid)
return refreshed if refreshed else task
# Update cached status (no DB read needed - we just wrote IN_PROGRESS)
@@ -526,7 +520,7 @@ class TaskWrapper(Generic[P]):
)
# Refresh once at end to return current state
final_task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
return final_task if final_task else task
except Exception as ex:
@@ -548,7 +542,7 @@ class TaskWrapper(Generic[P]):
)
# Refresh once at end to return current state
final_task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
return final_task if final_task else task
finally:
@@ -558,9 +552,7 @@ class TaskWrapper(Generic[P]):
# Publish completion notification for any waiters
# Use final_task if set by try/except, otherwise refresh (fallback)
if final_task is None:
final_task = TaskDAO.find_one_or_none(
uuid=task_uuid, skip_base_filter=True
)
final_task = TaskDAO.find_one_or_none(uuid=task_uuid)
if final_task and final_task.status in TERMINAL_STATES:
TaskManager.publish_completion(task_uuid, final_task.status)
+85 -21
View File
@@ -16,8 +16,10 @@
# under the License.
"""
Celery task that exports every chart on a dashboard to a single multi-sheet
``.xlsx`` file, uploads it to S3, and emails the requesting user a pre-signed
download link.
``.xlsx`` file, uploads it to S3, and records a download link (see
``superset.dashboards.excel_export.download_link``) that emails to the
requesting user when they have an address on file, and/or is resolved by
polling ``GET .../export_xlsx/status/<job_id>/`` when they don't.
In ``"data"`` mode the task re-runs each chart's saved query context under the
requesting user, applies the live dashboard filter state, and streams the results
@@ -33,6 +35,7 @@ import copy
import logging
import os
import tempfile
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
@@ -53,9 +56,14 @@ from superset.common.form_data_query_context import (
is_raw_query_mode,
)
from superset.dashboards.excel_export import email
from superset.dashboards.excel_export.download_link import (
create_download_link,
mark_export_failed,
)
from superset.dashboards.excel_export.layout import get_charts_in_layout_order
from superset.dashboards.excel_export.screenshot import render_chart_image
from superset.extensions import celery_app
from superset.security.guest_token import GuestToken
from superset.utils import json, s3
from superset.utils.core import override_user
from superset.utils.excel_streaming import StreamingXlsxWriter
@@ -299,6 +307,13 @@ def _write_chart_sheets(
json_body["result_type"] = ChartDataResultType.FULL
json_body.pop("force", None)
# Guest authorization links a chart to its dashboard through
# ``form_data.dashboardId`` (raise_for_access); saved contexts don't carry
# it, so stamp it the way the browser does on interactive requests.
form_data = dict(json_body.get("form_data") or {})
form_data["dashboardId"] = dashboard_id
json_body["form_data"] = form_data
filter_context = get_dashboard_filter_context(
dashboard_id=dashboard_id,
chart_id=chart.id,
@@ -400,19 +415,49 @@ def _build_workbook(
return errored
def _send_failure_email(
user: Any, dashboard_title: str, requested_at: datetime
_GENERIC_FAILURE_MESSAGE = (
"An error occurred while generating the file. Please try again, or "
"contact your administrator if the problem persists."
)
def _handle_export_failure(
user: Any, dashboard_title: str, requested_at: datetime, job_id: str, ttl: int
) -> None:
if not (user and getattr(user, "email", None)):
return
"""Notify the requester their export failed: email them if they have an
address on file, and record a pollable failure status either way (a
session with no email, e.g. an embedded/guest dashboard, has no other way
to learn the export failed than polling ``export_xlsx/status/<job_id>/``).
"""
if user and getattr(user, "email", None):
try:
email.send_export_email(
user.email,
email.build_subject(dashboard_title, success=False),
email.build_failure_email(dashboard_title, requested_at),
)
except Exception: # pylint: disable=broad-except
logger.exception("Failed to send export failure email")
try:
email.send_export_email(
user.email,
email.build_subject(dashboard_title, success=False),
email.build_failure_email(dashboard_title, requested_at),
expires_at = datetime.now(tz=timezone.utc) + timedelta(seconds=ttl)
mark_export_failed(
uuid.UUID(job_id),
_GENERIC_FAILURE_MESSAGE,
expires_at.replace(tzinfo=None),
)
except Exception: # pylint: disable=broad-except
logger.exception("Failed to send export failure email")
logger.exception("Failed to record export failure status for %s", job_id)
def _upload_export_file(tmp_path: str, bucket: str, key: str) -> None:
"""Upload the generated workbook via EXCEL_EXPORT_STORAGE if configured
(e.g. a GCS backend for a deployment whose bucket isn't S3), else the
built-in boto3/S3 helper."""
storage = current_app.config.get("EXCEL_EXPORT_STORAGE")
if storage is not None:
storage.upload_file(tmp_path, bucket, key)
else:
s3.upload_file_to_s3(tmp_path, bucket, key)
@celery_app.task(
@@ -425,30 +470,45 @@ def _send_failure_email(
def export_dashboard_excel(
self: Any, # pylint: disable=unused-argument
dashboard_id: int,
user_id: int,
user_id: int | None,
active_data_mask: dict[str, Any],
job_id: str,
mode: str = EXPORT_MODE_DATA,
guest_token: GuestToken | None = None,
) -> None:
"""
Export a dashboard's charts to an ``.xlsx`` and email a download link.
Export a dashboard's charts to an ``.xlsx`` and record a download link.
:param dashboard_id: The dashboard to export
:param user_id: The requesting user (the task runs with their permissions)
:param user_id: The requesting user (the task runs with their permissions),
or ``None`` for a guest/embedded requester
:param active_data_mask: Live dashboard filter state keyed by native filter id
:param job_id: Correlation id, also the Celery task id and S3 object name
:param mode: ``"data"`` streams every chart's tabular result; ``"images"``
embeds non-table charts as rendered images and keeps tables tabular
:param guest_token: The guest token payload when the requester is an
embedded guest; the guest user is reconstructed from it so the export
runs under the token's RLS rules and resource claims, never under an
elevated identity
"""
# pylint: disable=import-outside-toplevel
from superset.models.dashboard import Dashboard
requested_at = datetime.now(tz=timezone.utc)
user = security_manager.get_user_by_id(user_id)
user = None
dashboard_title = ""
tmp_path: str | None = None
ttl = current_app.config["EXCEL_EXPORT_LINK_TTL_SECONDS"]
try:
# Resolve the user inside the protected block: if this raises (e.g. the
# guest role lookup fails), the ``finally`` below must still release the
# lock the API acquired, and the failure status must still be recorded
# for pollers.
if user_id is not None:
user = security_manager.get_user_by_id(user_id)
elif guest_token:
user = security_manager.get_guest_user_from_token(guest_token)
with override_user(user, force=False):
dashboard = (
db.session.query(Dashboard).filter_by(id=dashboard_id).one_or_none()
@@ -471,11 +531,14 @@ def export_dashboard_excel(
f"{current_app.config['EXCEL_EXPORT_S3_KEY_PREFIX']}"
f"{dashboard_id}/{job_id}.xlsx"
)
ttl = current_app.config["EXCEL_EXPORT_LINK_TTL_SECONDS"]
s3.upload_file_to_s3(tmp_path, bucket, key)
download_url = s3.generate_presigned_url(bucket, key, ttl)
_upload_export_file(tmp_path, bucket, key)
expires_at = datetime.now(tz=timezone.utc) + timedelta(seconds=ttl)
# KeyValueEntry.expires_on comparisons use naive datetime.now(), so
# the stored expiry must be naive UTC too, not tz-aware.
download_url = create_download_link(
uuid.UUID(job_id), bucket, key, expires_at.replace(tzinfo=None)
)
if user and getattr(user, "email", None):
try:
@@ -497,17 +560,18 @@ def export_dashboard_excel(
logger.exception("Failed to send export success email")
except SoftTimeLimitExceeded:
logger.warning("Dashboard excel export %s timed out", job_id)
_send_failure_email(user, dashboard_title, requested_at)
_handle_export_failure(user, dashboard_title, requested_at, job_id, ttl)
raise
except Exception:
logger.exception("Dashboard excel export %s failed", job_id)
_send_failure_email(user, dashboard_title, requested_at)
_handle_export_failure(user, dashboard_title, requested_at, job_id, ttl)
raise
finally:
try:
ReleaseDistributedLock(
EXPORT_LOCK_NAMESPACE,
export_lock_params(user_id, dashboard_id),
# Must mirror the key the API acquired: guests share slot 0.
export_lock_params(user_id or 0, dashboard_id),
).run()
except Exception: # pylint: disable=broad-except
# Best-effort: the lock's TTL is the backstop if this fails.
+4 -19
View File
@@ -33,35 +33,20 @@ class TaskFilter(BaseFilter): # pylint: disable=too-few-public-methods
owned and shared tasks. Unsubscribing removes visibility.
Admins see all tasks without filtering.
This filter applies to request-scoped reads only -- the REST API and
the MCP task tools -- where a task's visibility to the requesting
principal matters. Internal task-executor and scheduler code that
reads back the state of a task it already owns (e.g. polling for the
terminal status of the task it is currently executing) calls the DAO
with ``skip_base_filter=True`` instead: that code isn't presenting
task data to a user, and the UUID it operates on is never
caller-supplied, so the visibility check doesn't apply.
"""
def apply(self, query: Query, value: Any) -> Query:
"""Apply the filter to the query."""
from flask import has_request_context
from sqlalchemy import and_, false, select
from sqlalchemy import and_, select
from superset import security_manager
from superset.models.task_subscribers import TaskSubscriber
from superset.models.tasks import Task
# If user is admin or no user_id, return unfiltered query.
# This typically applies to background tasks and system operations
user_id = get_user_id()
if not user_id:
# Within a request, a principal without a user id gets no tasks;
# background jobs run outside a request context and are unfiltered.
if has_request_context():
return query.filter(false())
return query
if security_manager.is_admin():
if not user_id or security_manager.is_admin():
return query
is_subscribed = (
+3 -10
View File
@@ -259,15 +259,10 @@ class TaskManager:
return remaining if remaining > 0 else 0
def get_task() -> "Task | None":
# Reads back the task named by the caller's own task_uuid, not
# a user-requested lookup; see TaskFilter for the
# request-scoped vs. internal-plumbing split.
if app and not has_app_context():
with app.app_context():
return TaskDAO.find_one_or_none(
uuid=task_uuid, skip_base_filter=True
)
return TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
return TaskDAO.find_one_or_none(uuid=task_uuid)
return TaskDAO.find_one_or_none(uuid=task_uuid)
# Check current state first
task = get_task()
@@ -483,9 +478,7 @@ class TaskManager:
"""
from superset.daos.tasks import TaskDAO
# Internal control-flow check on the task the executor is already
# running, not a user-facing lookup; see TaskFilter.
task = TaskDAO.find_one_or_none(uuid=task_uuid, skip_base_filter=True)
task = TaskDAO.find_one_or_none(uuid=task_uuid)
return task is not None and task.status in ABORT_STATES
@classmethod
+3 -7
View File
@@ -311,11 +311,7 @@ def execute_task( # noqa: C901
# Convert string UUID to native UUID (Celery deserializes as string)
native_uuid = UUID(task_uuid)
# Internal executor path: load the task Celery was dispatched to run,
# keyed on the UUID passed at enqueue time, not a user-requested
# lookup; see TaskFilter for the request-scoped vs. internal-plumbing
# split. The refreshes below load the same task for the same reason.
task = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
task = TaskDAO.find_one_or_none(uuid=native_uuid)
if not task:
logger.error("Task %s not found in metastore", task_uuid)
return {"status": "error", "message": "Task not found"}
@@ -350,7 +346,7 @@ def execute_task( # noqa: C901
task_type,
task_uuid,
)
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid)
return {
"status": refreshed.status if refreshed else "unknown",
"task_uuid": task_uuid,
@@ -493,7 +489,7 @@ def execute_task( # noqa: C901
)
# Refresh to get final status for return value and completion notification
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid, skip_base_filter=True)
refreshed = TaskDAO.find_one_or_none(uuid=native_uuid)
final_status = refreshed.status if refreshed else "unknown"
# Publish completion notification for any waiters (e.g., sync callers)
+58
View File
@@ -0,0 +1,58 @@
# 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.
"""
Pluggable storage backend interface for dashboard Excel export artifacts.
Defining ``EXCEL_EXPORT_STORAGE`` in ``superset_config.py`` (an instance of a
class implementing this protocol, the same pattern as ``RESULTS_BACKEND`` or
``CUSTOM_SECURITY_MANAGER``) swaps out where the export task uploads the
generated ``.xlsx`` and how the download redirect mints a fresh, time-limited
URL for it. When unset, ``superset.utils.s3`` (boto3/AWS S3) is used.
This module has no dependency on any storage SDK: it is safe to import (e.g.
from ``superset/config.py``) regardless of which storage extras, if any, are
installed. A concrete implementation -- such as a hypothetical
``GCSExportStorage`` for deployments where the export bucket is a native
Google Cloud Storage bucket rather than S3 -- imports its own SDK lazily, the
same way ``superset.utils.s3`` only imports ``boto3`` inside the functions
that need it.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class ExportStorage(Protocol):
"""Where the export task uploads a file, and how a download link resolves
it back to a fresh, time-limited URL at click time.
The two operations run at very different times against the same object:
``upload_file`` once, when the export finishes; ``generate_download_url``
every time the (possibly long-lived) download link is clicked, so its
credentials never need to outlive the link itself. See
``superset.dashboards.excel_export.download_link`` for why the link is a
Superset redirect rather than a raw storage URL.
"""
def upload_file(self, local_path: str, bucket: str, key: str) -> None:
"""Upload a local file to ``bucket``/``key``."""
def generate_download_url(self, bucket: str, key: str, expires_in: int) -> str:
"""A time-limited URL for downloading ``bucket``/``key``, valid for
``expires_in`` seconds from now."""
+84
View File
@@ -0,0 +1,84 @@
# 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.
"""
``ExportStorage`` implementation backed by Google Cloud Storage, for
deployments where the dashboard Excel export bucket is a native GCS bucket
rather than S3.
Set ``EXCEL_EXPORT_STORAGE = GCSExportStorage()`` in ``superset_config.py`` to
use this instead of the default ``superset.utils.s3`` (boto3/AWS) backend.
Authentication uses Application Default Credentials (a service account key,
workload identity, etc.) via the standard ``google-cloud-storage`` resolution
chain -- there is no separate credential config here.
"""
from __future__ import annotations
from datetime import timedelta
from typing import Any
def _get_client() -> Any:
"""Build a GCS client using Application Default Credentials."""
# Imported lazily, mirroring superset.utils.s3._get_s3_client: importing
# this module (which happens at config-load time if EXCEL_EXPORT_STORAGE
# is set) should not require google-cloud-storage unless an export
# actually runs.
try:
from google.cloud import storage # pylint: disable=import-outside-toplevel
except ImportError as ex:
raise ImportError(
"google-cloud-storage is required for GCSExportStorage but is not "
"installed. Install it with "
"`pip install apache-superset[excel-export-gcs]`."
) from ex
return storage.Client()
class GCSExportStorage:
"""``ExportStorage`` backed by Google Cloud Storage.
See ``superset.utils.export_storage.ExportStorage`` for the interface this
implements.
"""
def upload_file(self, local_path: str, bucket: str, key: str) -> None:
"""
Upload a local file to GCS.
:param local_path: Path to the file on local disk
:param bucket: Destination GCS bucket
:param key: Destination GCS blob name
"""
_get_client().bucket(bucket).blob(key).upload_from_filename(local_path)
def generate_download_url(self, bucket: str, key: str, expires_in: int) -> str:
"""
Generate a time-limited signed URL for downloading a GCS object.
:param bucket: The GCS bucket
:param key: The GCS blob name
:param expires_in: URL lifetime in seconds
:returns: A v4 signed URL
"""
blob = _get_client().bucket(bucket).blob(key)
return blob.generate_signed_url(
version="v4",
expiration=timedelta(seconds=expires_in),
method="GET",
)

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