mirror of
https://github.com/apache/superset.git
synced 2026-09-09 00:34:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce8582717 | ||
|
|
b0f3630d62 | ||
|
|
1315f5ff6b | ||
|
|
974f36f94f | ||
|
|
9e9a3d06ad | ||
|
|
5c4c8b831f | ||
|
|
1e76fb6098 | ||
|
|
4850082247 | ||
|
|
3b492233c7 | ||
|
|
8963613e92 | ||
|
|
0ac1a6fade | ||
|
|
630982e269 | ||
|
|
8fb5b8f2fb | ||
|
|
a3aba2a16a | ||
|
|
b2fd72559a | ||
|
|
0820a0ac14 | ||
|
|
334700fc33 | ||
|
|
ed4d413d77 | ||
|
|
724d9045c6 | ||
|
|
01b8eb50a0 | ||
|
|
c77efa77ed | ||
|
|
fb12d4ae12 | ||
|
|
a49581dd76 | ||
|
|
ccfaa2302a | ||
|
|
b8cdebcaba | ||
|
|
c1ca29fe09 | ||
|
|
ff90a60b09 | ||
|
|
b4325e2ce3 | ||
|
|
6760573432 | ||
|
|
db0af5c610 | ||
|
|
67a417812d | ||
|
|
9fb02296d8 | ||
|
|
ee03ee1719 | ||
|
|
0d025daaf7 | ||
|
|
c3109899e2 | ||
|
|
5c04256c05 | ||
|
|
45d11da815 | ||
|
|
317e80f6a8 | ||
|
|
37d65ef731 | ||
|
|
75e4014ac1 | ||
|
|
fee0f9e160 |
Submodule .github/actions/comment-on-pr deleted from 85a56be792
Submodule .github/actions/latest-tag deleted from 6d22a6738f
@@ -23,10 +23,8 @@ on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
|
||||
# No `paths:` filter on purpose, matching enforce-single-migration-head: a
|
||||
# required check that never runs for a given PR blocks that PR forever. The
|
||||
# job is ~10s, so it fires on every PR rather than guessing which file edits
|
||||
# can move the spec.
|
||||
# Deliberately unfiltered by `paths`: a required check that does not run on a
|
||||
# PR blocks it from merging forever.
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
@@ -46,26 +44,56 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
with:
|
||||
# base.txt pins apispec, which decides the generated output: 6.10.0
|
||||
# renders marshmallow 4's unknown=RAISE as "additionalProperties":
|
||||
# false while the pinned 6.6.1 does not. Regenerating off-pin
|
||||
# produces a spec no CI run can reproduce.
|
||||
# The generated output depends on the pinned apispec version.
|
||||
requirements-type: base
|
||||
- name: Regenerate the spec
|
||||
env:
|
||||
# No SUPERSET_CONFIG_PATH: the published spec documents the routes a
|
||||
# default deployment registers. A config enabling feature flags adds
|
||||
# paths that would 404 for everyone who has not enabled them.
|
||||
# No config file: the spec documents what a default deployment
|
||||
# registers, so feature flags must stay off.
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: "sqlite:///:memory:"
|
||||
FLASK_APP: "superset.app:create_app()"
|
||||
run: superset update-api-docs
|
||||
- name: Assert the published spec is up to date
|
||||
env:
|
||||
SPEC: docs/static/resources/openapi.json
|
||||
run: |
|
||||
if ! git diff --quiet -- docs/static/resources/openapi.json; then
|
||||
echo "::error::docs/static/resources/openapi.json is stale."
|
||||
echo "Regenerate it on the pinned requirements, with no config file:"
|
||||
echo " SUPERSET__SQLALCHEMY_DATABASE_URI='sqlite:///:memory:' \\"
|
||||
echo " FLASK_APP='superset.app:create_app()' superset update-api-docs"
|
||||
git diff --stat -- docs/static/resources/openapi.json
|
||||
exit 1
|
||||
if git diff --quiet -- "$SPEC"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Staged to a file, not piped: `head` closing the pipe would
|
||||
# SIGPIPE-kill `git diff` under pipefail and abort this step.
|
||||
diff_file="$RUNNER_TEMP/openapi.diff"
|
||||
git diff -- "$SPEC" > "$diff_file"
|
||||
|
||||
regen="SUPERSET__SQLALCHEMY_DATABASE_URI='sqlite:///:memory:' FLASK_APP='superset.app:create_app()' superset update-api-docs"
|
||||
|
||||
echo "::error::$SPEC is stale. Regenerate it on the pinned requirements:"
|
||||
echo "$regen"
|
||||
git diff --stat -- "$SPEC"
|
||||
|
||||
# Summaries cap at 1 MiB, well under a full regeneration.
|
||||
{
|
||||
echo '### OpenAPI spec is stale'
|
||||
echo
|
||||
git diff --stat -- "$SPEC"
|
||||
echo
|
||||
echo 'Regenerate with:'
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "$regen"
|
||||
echo '```'
|
||||
echo
|
||||
echo '```diff'
|
||||
head -300 "$diff_file"
|
||||
echo '```'
|
||||
if [ "$(wc -l < "$diff_file")" -gt 300 ]; then
|
||||
echo
|
||||
echo '_Truncated at 300 lines; see the job log for the full diff._'
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "::group::Full diff"
|
||||
cat "$diff_file"
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
|
||||
@@ -213,6 +213,29 @@ jobs:
|
||||
docker images $IMAGE_TAG
|
||||
docker history $IMAGE_TAG
|
||||
|
||||
- name: WebSocket server smoke test
|
||||
if: contains(fromJson('["lean", "dev"]'), matrix.build_preset)
|
||||
shell: bash
|
||||
run: |
|
||||
# The realtime WebSocket server is bundled in the official image and
|
||||
# launched via an alternate entrypoint; verify the bundled Node runtime
|
||||
# starts it and it serves /health. (A JWT secret >= 32 bytes is required
|
||||
# or the server refuses to start; no Redis is needed for /health.)
|
||||
# Both presets are checked because docker-compose-non-dev.yml runs the
|
||||
# websocket service from the dev target.
|
||||
docker run -d --name superset-ws \
|
||||
-e JWT_SECRET="ci-smoke-test-secret-ci-smoke-test-secret" \
|
||||
-e PORT=8080 -p 8080:8080 \
|
||||
"$IMAGE_TAG" /app/docker/entrypoints/run-websocket.sh
|
||||
ok=""
|
||||
for _ in $(seq 1 20); do
|
||||
if curl -sf http://localhost:8080/health; then echo "ws /health OK"; ok=1; break; fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs superset-ws || true
|
||||
docker rm -f superset-ws || true
|
||||
[ "$ok" = "1" ] || { echo "::error::websocket /health did not come up"; exit 1; }
|
||||
|
||||
- name: docker-compose sanity check
|
||||
if: matrix.build_preset == 'dev'
|
||||
shell: bash
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Tags
|
||||
on:
|
||||
release:
|
||||
types: [published] # This makes it run only when a new released is published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
latest-release:
|
||||
name: Add/update tag to new release
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Check for latest tag
|
||||
id: latest-tag
|
||||
env:
|
||||
RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
source ./scripts/tag_latest_release.sh "$RELEASE_TAG_NAME" --dry-run
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Run latest-tag
|
||||
uses: ./.github/actions/latest-tag
|
||||
if: steps.latest-tag.outputs.SKIP_TAG != 'true'
|
||||
with:
|
||||
description: Superset latest release
|
||||
tag-name: latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -15,18 +15,12 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
[submodule ".github/actions/latest-tag"]
|
||||
path = .github/actions/latest-tag
|
||||
url = https://github.com/EndBug/latest-tag
|
||||
[submodule ".github/actions/pr-lint-action"]
|
||||
path = .github/actions/pr-lint-action
|
||||
url = https://github.com/morrisoncole/pr-lint-action
|
||||
[submodule ".github/actions/cached-dependencies"]
|
||||
path = .github/actions/cached-dependencies
|
||||
url = https://github.com/apache-superset/cached-dependencies
|
||||
[submodule ".github/actions/comment-on-pr"]
|
||||
path = .github/actions/comment-on-pr
|
||||
url = https://github.com/unsplash/comment-on-pr
|
||||
[submodule ".github/actions/chart-testing-action"]
|
||||
path = .github/actions/chart-testing-action
|
||||
url = https://github.com/helm/chart-testing-action
|
||||
|
||||
+10
-1
@@ -64,10 +64,19 @@ repos:
|
||||
hooks:
|
||||
- id: oxfmt-frontend
|
||||
name: oxfmt (frontend)
|
||||
entry: bash -c 'cd superset-frontend && files=(); for f in "$@"; do files+=("${f#superset-frontend/}"); done; npx oxfmt --write --no-error-on-unmatched-pattern -- "${files[@]}"' --
|
||||
entry: ./scripts/oxfmt.sh superset-frontend
|
||||
language: system
|
||||
pass_filenames: true
|
||||
files: ^superset-frontend/.*\.(js|jsx|ts|tsx|css|scss|sass|json)$
|
||||
- id: oxfmt-websocket
|
||||
name: oxfmt (websocket)
|
||||
entry: ./scripts/oxfmt.sh superset-websocket
|
||||
language: system
|
||||
pass_filenames: true
|
||||
# JSON is excluded: superset-websocket/.oxfmtrc.json ignores *.json, so
|
||||
# passing them here would only ever be a no-op (notably for the tracked
|
||||
# package-lock.json).
|
||||
files: ^superset-websocket/.*\.(js|ts)$
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: oxlint-frontend
|
||||
|
||||
+37
@@ -104,6 +104,30 @@ RUN if [ "${BUILD_TRANSLATIONS}" = "true" ]; then \
|
||||
rm -rf /app/superset/translations/*/*/*.[po,mo];
|
||||
|
||||
|
||||
######################################################################
|
||||
# superset-websocket builds the realtime WebSocket (Node) server that
|
||||
# ships in the official image, launched via docker/entrypoints/run-websocket.sh
|
||||
######################################################################
|
||||
FROM node:24-trixie-slim AS superset-websocket
|
||||
|
||||
# Harden `npm ci` against transient npm-registry network blips (e.g. ECONNRESET).
|
||||
ENV npm_config_fetch_retries=5 \
|
||||
npm_config_fetch_retry_mintimeout=20000 \
|
||||
npm_config_fetch_retry_maxtimeout=120000 \
|
||||
npm_config_fetch_timeout=600000
|
||||
|
||||
WORKDIR /app/superset-websocket
|
||||
|
||||
# Install against the lockfile first (cached until it changes), then bundle the
|
||||
# TypeScript server into a single self-contained CJS file (esbuild inlines every
|
||||
# dependency), so the runtime image needs only the Node binary and dist/ — no
|
||||
# node_modules to ship.
|
||||
COPY superset-websocket/package.json superset-websocket/package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
COPY superset-websocket/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
######################################################################
|
||||
# Base python layer
|
||||
######################################################################
|
||||
@@ -221,6 +245,19 @@ RUN rm superset/translations/*/*/*.po
|
||||
COPY --from=superset-node /app/superset/translations superset/translations
|
||||
COPY --from=python-translation-compiler /app/translations_mo superset/translations
|
||||
|
||||
# --- Realtime WebSocket server (part of the official image) ---------------
|
||||
# The realtime transport (superset-websocket) is a Node service, bundled by
|
||||
# esbuild into a single self-contained file. Copy the Node runtime plus that
|
||||
# bundle so every image built from this stage can launch it via an alternate
|
||||
# entrypoint (docker/entrypoints/run-websocket.sh) rather than needing a separate
|
||||
# image. This lives here rather than in a single downstream stage so the lean and
|
||||
# dev images both ship it — docker-compose-non-dev.yml runs the websocket service
|
||||
# from the dev target.
|
||||
RUN /app/docker/apt-install.sh libstdc++6
|
||||
COPY --from=superset-websocket /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=superset-websocket --chown=superset:superset \
|
||||
/app/superset-websocket/dist /app/superset-websocket/dist
|
||||
|
||||
HEALTHCHECK CMD /app/docker/docker-healthcheck.sh
|
||||
CMD ["/app/docker/entrypoints/run-server.sh"]
|
||||
EXPOSE ${SUPERSET_PORT}
|
||||
|
||||
@@ -125,7 +125,6 @@ Here are some of the major database solutions that are supported:
|
||||
<a href="https://superset.apache.org/docs/databases/supported/apache-pinot" title="Apache Pinot"><img src="docs/static/img/databases/apache-pinot.svg" alt="Apache Pinot" width="76" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/apache-solr" title="Apache Solr"><img src="docs/static/img/databases/apache-solr.png" alt="Apache Solr" width="79" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/apache-spark-sql" title="Apache Spark SQL"><img src="docs/static/img/databases/apache-spark.png" alt="Apache Spark SQL" width="75" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/ascend" title="Ascend"><img src="docs/static/img/databases/ascend.webp" alt="Ascend" width="117" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/aurora-mysql-data-api" title="Aurora MySQL (Data API)"><img src="docs/static/img/databases/mysql.png" alt="Aurora MySQL (Data API)" width="77" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/aurora-postgresql-data-api" title="Aurora PostgreSQL (Data API)"><img src="docs/static/img/databases/postgresql.svg" alt="Aurora PostgreSQL (Data API)" width="76" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/azure-data-explorer" title="Azure Data Explorer"><img src="docs/static/img/databases/kusto.png" alt="Azure Data Explorer" width="40" height="40" /></a>
|
||||
|
||||
+10
@@ -84,6 +84,16 @@ The `sql_lab` role is *additive*: it grants the SQL Lab permission set on top of
|
||||
|
||||
Deployments may grant or revoke individual view-menu permissions, which shifts the boundary for that deployment but does not redefine the model. Any custom role created by an operator inherits the same principle: its capabilities are whatever the operator has explicitly granted it. The Public principal follows the same rule: operators may grant the Public role read access to specific datasets or dashboards (typically for anonymous reporting use cases), which shifts the boundary for that deployment without redefining the model.
|
||||
|
||||
### Async Execution and Realtime Notifications
|
||||
|
||||
Asynchronous execution paths do not create a separate data-access capability. A background task is a continuation of an already-authorized action, such as reading chart data or executing SQL through SQL Lab. The initiating route, command, or scheduler must enforce the same route-level and object-level checks the synchronous path would enforce before it creates the task, and the worker must execute under the initiating principal's effective identity when row-level security, impersonation, embedded guest-token scope, or similar controls affect the result.
|
||||
|
||||
Task metadata is itself a request-scoped resource. Non-admin users and embedded guests may read or cancel only tasks they are subscribed to or that otherwise represent work they are entitled to observe; Admin may observe and manage tasks as part of the trusted operational boundary. A bug that lets a principal create, read, join, cancel, or receive task state for work outside the role and capability matrix is in scope.
|
||||
|
||||
Realtime transports, including WebSocket delivery backed by Redis or Valkey Pub/Sub, are notification mechanisms rather than authorization sources. WebSocket notification access is controlled by `can_read` on the `Realtime` resource. The broadcast scope is authenticated-global, not public: it reaches every authenticated realtime socket, and an anonymous request receives no realtime principal, no JWT cookie, and therefore no socket, so it never receives these messages (true anonymous/Public-role realtime is not offered and would require a separate, restricted model). Broadcast Pub/Sub messages, such as list-view entity-change events, must be context-free nudges; sensitive or authoritative state must not be published on the broadcast scope. Targeted Pub/Sub messages may carry task state only when the producer names routing keys derived from an authorized identity, such as a task subscriber's principal channel (or a per-tab channel derived from it); the producer validates every routing key against the task's own subscriber principals before publishing, and the websocket server forwards the payload only to sockets bound to those keys. Full data and result payloads must still be fetched through the normal protected REST API or cache-read path. Redis Streams used for task completion, dependency, and lock-release signalling are likewise coordination signals; the metastore or cache entry they wake a consumer to read remains the source of truth.
|
||||
|
||||
The realtime notification permission is distinct from the permission to read the underlying object. It controls whether a principal receives push notifications, not whether they may read the object once they call the protected REST API. Existing websocket connections are authorized by the JWT accepted at upgrade time; permission revocation after token minting is bounded by `WEBSOCKET_JWT_EXPIRATION_SECONDS` plus the websocket server's socket-check interval. Redis Streams are internal server-to-server coordination primitives and should not be directly exposed as an end-user subscription surface.
|
||||
|
||||
### Vulnerability Scope
|
||||
|
||||
The test for whether a finding is in scope is a single question:
|
||||
|
||||
+141
@@ -24,6 +24,144 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Global Async Queries re-platformed onto the Global Task Framework (breaking)
|
||||
|
||||
Global Async Queries (GAQ) no longer runs on its own bespoke async-events
|
||||
plumbing. Async chart data is now executed as Global Task Framework (GTF) tasks
|
||||
(one task per `QueryObject`), the browser learns of completion by polling
|
||||
`GET /api/v1/task/status_changes` (optionally accelerated by the WebSocket
|
||||
transport below) and re-issuing the original `/chart/data` request against the
|
||||
now-warm per-query cache, and the realtime WebSocket server is a generic,
|
||||
feature-agnostic task push transport rather than a GAQ-specific event tail.
|
||||
|
||||
Breaking removals (no deprecation window):
|
||||
|
||||
- The `/api/v1/async_event/` REST API, `AsyncQueryManager`, and the
|
||||
`qc-<hash>` query-context descriptor replay endpoint
|
||||
(`GET /api/v1/chart/data/<cache_key>`) are removed. Any client that consumed a
|
||||
`result_url` from a `202` response must move to the re-request model (the
|
||||
built-in frontend already does).
|
||||
- The following config keys are removed: `GLOBAL_ASYNC_QUERIES_CACHE_BACKEND`,
|
||||
`GLOBAL_ASYNC_QUERIES_TRANSPORT`, `GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL`,
|
||||
`GLOBAL_ASYNC_QUERIES_REDIS_STREAM_PREFIX`,
|
||||
`GLOBAL_ASYNC_QUERIES_REDIS_STREAM_LIMIT`,
|
||||
`GLOBAL_ASYNC_QUERIES_REDIS_STREAM_LIMIT_FIREHOSE`,
|
||||
`GLOBAL_ASYNC_QUERIES_REGISTER_REQUEST_HANDLERS`,
|
||||
`GLOBAL_ASYNC_QUERIES_JWT_*`, and
|
||||
`GLOBAL_ASYNC_QUERY_MANAGER_CLASS`. The coordinator (locks, GTF, and now GAQ)
|
||||
uses `DISTRIBUTED_COORDINATION_CONFIG` exclusively.
|
||||
|
||||
Enabling async chart data in the new flow:
|
||||
|
||||
```python
|
||||
# feature flag: makes async chart data available (auto-enables GLOBAL_TASK_FRAMEWORK)
|
||||
FEATURE_FLAGS = {"GLOBAL_ASYNC_QUERIES": True}
|
||||
|
||||
# a Redis connection for distributed coordination (locks, GTF signalling,
|
||||
# and the realtime pub/sub); required for async execution in production
|
||||
DISTRIBUTED_COORDINATION_CONFIG = {
|
||||
"CACHE_TYPE": "RedisCache",
|
||||
"CACHE_REDIS_HOST": "localhost",
|
||||
"CACHE_REDIS_PORT": 6379,
|
||||
"CACHE_REDIS_DB": 0,
|
||||
}
|
||||
```
|
||||
|
||||
Async is now **opt-in per request**: `GLOBAL_ASYNC_QUERIES` only makes async
|
||||
*available*; whether a given `/chart/data` request runs async is decided by an
|
||||
`async_mode` request flag (endpoint default `false`, so programmatic API clients
|
||||
keep the synchronous `200` flow unless they opt in). The built-in frontend
|
||||
resolves the `async_mode` it sends from a policy chain — per-dashboard override →
|
||||
deployment default `GLOBAL_ASYNC_QUERIES_DEFAULT` (default `true`) → the feature
|
||||
flag — so the UI keeps its existing async behavior by default.
|
||||
|
||||
**Embedded (guest token) async requires explicit role grants.** Async chart-data
|
||||
completion is observed through `GET /api/v1/task/status_changes` (gated by
|
||||
`can_read Task`) and, when the WebSocket transport is enabled, over the socket
|
||||
(gated by `can_read Realtime`). An authenticated Gamma user has `can_read Task` by
|
||||
default; the default guest role (`Public`) does **not**. So an embedded guest only
|
||||
runs async when the operator grants its role `can_read Task` (and `can_read
|
||||
Realtime` for the socket) — otherwise the request transparently falls back to the
|
||||
synchronous `200` flow rather than returning a `202` the guest could never resolve.
|
||||
|
||||
Enabling the realtime WebSocket transport (optional; when enabled it becomes the
|
||||
completion transport for async chart-data — see the note on the interval poll):
|
||||
|
||||
> **Note:** the realtime WebSocket transport is opt-in (`WEBSOCKET_ENABLE`
|
||||
> defaults to `False`). When it is **disabled**, async chart-data completion is
|
||||
> driven entirely by the `status_changes` interval poll (the source of truth).
|
||||
> When it is **enabled**, completion is delivered over the socket and the
|
||||
> recurring interval poll does not run; a one-shot `status_changes` catch-up on
|
||||
> waiter registration and on socket reconnect reconciles anything missed while
|
||||
> disconnected. The socket accelerates delivery over the authoritative
|
||||
> `status_changes` API rather than replacing it: Redis Pub/Sub is best-effort
|
||||
> (at-most-once, no replay), so a disconnect is reconciled by the catch-up on
|
||||
> reconnect/registration. In the rare case a `task.status` is missed while the
|
||||
> socket stays open, the request's give-up runs one final `status_changes` read
|
||||
> before timing out — so a chart whose query actually finished still resolves; only
|
||||
> if that read can't confirm completion does the request end in a bounded error (a
|
||||
> page reload re-establishes state).
|
||||
|
||||
```python
|
||||
WEBSOCKET_ENABLE = True
|
||||
WEBSOCKET_URL = "ws://<same-host>:8080/"
|
||||
WEBSOCKET_JWT_SECRET = "<output of: openssl rand -base64 42>"
|
||||
```
|
||||
|
||||
The built-in Gamma role receives `can_read Realtime`; grant that permission to
|
||||
custom roles that should receive websocket notifications.
|
||||
|
||||
Run the `superset-websocket` Node server on the **same browser-visible host**
|
||||
(so its JWT channel cookie is shared) and point its `redis` config at the same
|
||||
instance as `DISTRIBUTED_COORDINATION_CONFIG`, plus `jwtSecret` /
|
||||
`jwtCookieName` matching the Flask config (`WEBSOCKET_JWT_SECRET` /
|
||||
`WEBSOCKET_JWT_COOKIE_NAME`, default `superset-ws-token`). During websocket JWT
|
||||
secret rotation, set the websocket server's `previousJwtSecret` /
|
||||
`PREVIOUS_JWT_SECRET` to the old key while Flask continues minting cookies with
|
||||
`WEBSOCKET_JWT_SECRET`. The server is bundled in the official Superset image
|
||||
and launched via an alternate entrypoint — no separate image is required:
|
||||
`docker run <superset-image> /app/docker/entrypoints/run-websocket.sh` (or the
|
||||
opt-in `websocket` profile in `docker compose`). It **subscribes** to a single
|
||||
Redis Pub/Sub channel, `realtime`, which carries a self-describing
|
||||
`{topic, scope, routes, payload}` envelope (both the broadcast `entity.changed`
|
||||
nudges and the targeted `task.status` messages), and forwards `{topic, payload}`
|
||||
to browsers after routing — so a Redis ACL for the websocket server must allow
|
||||
subscribing to `realtime` (this replaces the earlier `entity-changes:*` /
|
||||
`task-status` channels); see `superset-websocket/README.md`.
|
||||
|
||||
Orphaned GTF tasks (a worker killed mid-execution) are now detected and cleaned
|
||||
up server-side. While a worker holds a task it writes a liveness heartbeat
|
||||
(`tasks.last_heartbeat`, every `GTF_TASK_HEARTBEAT_INTERVAL` seconds, default
|
||||
`15`); a dedicated `reap_orphaned_tasks` Celery beat job reaps any active task
|
||||
whose heartbeat is older than `GTF_ORPHAN_TASK_TIMEOUT` (default `60`) — revoking
|
||||
its Celery job, marking it `FAILURE` so waiters unblock, and (on engines that
|
||||
support query cancellation) cancelling the abandoned warehouse query out-of-band.
|
||||
Enable the `reap_orphaned_tasks` beat schedule on a short interval (e.g. every
|
||||
minute); it is separate from `prune_tasks` (a heavier retention delete run
|
||||
infrequently). The heartbeat write is issued out-of-band and deliberately does
|
||||
not advance `changed_on`.
|
||||
|
||||
Async chart-data query tasks are now cancellable: a per-query timeout
|
||||
(`GLOBAL_ASYNC_QUERIES_QUERY_TIMEOUT`, default `None` = unbounded) or a user
|
||||
cancel aborts the task, and on database engines that support query cancellation
|
||||
(e.g. PostgreSQL, MySQL, Snowflake, Redshift) the abort also cancels the running
|
||||
warehouse query over a fresh connection — including when the worker died (the
|
||||
reaper cancels it). Engines without cancel support are unaffected — the task is
|
||||
still freed, but the query runs to completion.
|
||||
|
||||
- Calculated (expression) dataset columns are now wrapped in parentheses when
|
||||
compiled to SQL (`(<expression>)`), in `SELECT`, `GROUP BY`, `ORDER BY`,
|
||||
`COUNT(DISTINCT ...)`, and the series-limit (top-N) prequery/JOIN paths. This
|
||||
fixes a correctness bug where a bare boolean operator (e.g. `OR`) inside a
|
||||
calculated column used as a series dimension leaked into the surrounding
|
||||
operator precedence (`state = 'CA' OR state = 'NY' = 1` mis-parsing as
|
||||
`state = 'CA' OR (state = 'NY' = 1)`). Query results are otherwise unchanged,
|
||||
but the generated SQL text for calculated-column queries differs; deployments
|
||||
that key on the exact compiled SQL (custom result-cache keys, logging, or SQL
|
||||
diffing) may observe the added parentheses. Physical columns are unaffected,
|
||||
as are calculated columns used as a temporal (time/x-axis) dimension, which
|
||||
resolve through a separate time-grain path (`get_timestamp_expression`).
|
||||
|
||||
- **[BREAKING] `SemanticLayer` and `SemanticView` are now classified in the
|
||||
Flask-AppBuilder role sets**, so `sync_role_definitions` (run on
|
||||
`superset init` and on startup) stops granting the built-in **Gamma** role
|
||||
@@ -63,6 +201,9 @@ payload. Clients must display the new impact and obtain renewed confirmation
|
||||
before retrying. Preview or recheck failures fail closed rather than treating
|
||||
unknown impact as zero. Chart and dashboard purge endpoints are unchanged.
|
||||
|
||||
- The dashboard datasource-based visibility fallback now fails closed: a dashboard whose member charts’ datasources cannot be resolved (deleted datasource rows, missing `datasource_id`, or unsupported datasource types) is no longer accessible to users without explicit editor/viewer rights, and a dashboard composed of semantic-view charts now requires `datasource_access` on (at least one of) its semantic views or their parent semantic layer — previously any authenticated user could open such a dashboard’s shell. Because the fallback now considers every member chart rather than only table-backed ones, a user holding `datasource_access` on any single member datasource — including a semantic view or its parent layer — can open a mixed dashboard that previously denied them. Dashboards with no charts remain accessible, and dashboards with explicit viewers are unaffected. Conversely, holders of `all_datasource_access` now see every published no-viewer dashboard in the dashboard list — including chart-less ones previously hidden by the inner joins — matching what the object-level gate already allowed them to open.
|
||||
- As the `ascend.io` platform shut down, its DB integration has been removed. Any existing users
|
||||
using `ascend://` DB protocol should switch to compatible `impala://` or preferentially remove the connection.
|
||||
- `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.
|
||||
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity.
|
||||
|
||||
|
||||
@@ -137,6 +137,41 @@ services:
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
# Realtime WebSocket transport, launched from the official image via its
|
||||
# alternate entrypoint (no separate image needed). Opt-in — start it with
|
||||
# `docker compose --profile websocket up`. To actually use it, the Superset
|
||||
# app must also set WEBSOCKET_ENABLE=true, WEBSOCKET_URL, and a matching
|
||||
# WEBSOCKET_JWT_SECRET (== the JWT_SECRET below) in docker/.env-local.
|
||||
superset-websocket:
|
||||
build:
|
||||
<<: *common-build
|
||||
container_name: superset_websocket
|
||||
profiles:
|
||||
- websocket
|
||||
# Neither a volume mount nor the root user is needed: the entrypoint and the
|
||||
# Node bundle it runs are both baked into the image, and the server is
|
||||
# configured entirely through the environment below.
|
||||
command: ["/app/docker/entrypoints/run-websocket.sh"]
|
||||
environment:
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
PORT: 8080
|
||||
JWT_COOKIE_NAME: superset-ws-token
|
||||
# Dev-only default; must match the app's WEBSOCKET_JWT_SECRET and be
|
||||
# replaced with a strong secret (>= 32 bytes) outside local development.
|
||||
JWT_SECRET: ${WEBSOCKET_JWT_SECRET:-dev-only-websocket-secret-change-me!}
|
||||
# Optional verify-only old key for websocket JWT secret rotation.
|
||||
PREVIOUS_JWT_SECRET: ${WEBSOCKET_PREVIOUS_JWT_SECRET:-}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8080:8080
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
# Overrides the image-level HEALTHCHECK, which probes the Superset app.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/health"]
|
||||
|
||||
volumes:
|
||||
superset_home:
|
||||
external: false
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
#
|
||||
HYPHEN_SYMBOL='-'
|
||||
|
||||
STATSD_ARGS=()
|
||||
STATSD_HOST="${SERVER_STATSD_HOST//[[:space:]]/}"
|
||||
if [ -n "${STATSD_HOST}" ]; then
|
||||
STATSD_PORT="${SERVER_STATSD_PORT//[[:space:]]/}"
|
||||
STATSD_PORT="${STATSD_PORT:-8125}"
|
||||
STATSD_ARGS=(--statsd-host "${STATSD_HOST}:${STATSD_PORT}" --statsd-prefix "${SERVER_STATSD_PREFIX:-superset}")
|
||||
fi
|
||||
|
||||
exec gunicorn \
|
||||
--bind "${SUPERSET_BIND_ADDRESS:-0.0.0.0}:${SUPERSET_PORT:-8088}" \
|
||||
--access-logfile "${ACCESS_LOG_FILE:-$HYPHEN_SYMBOL}" \
|
||||
@@ -33,4 +41,5 @@ exec gunicorn \
|
||||
--max-requests-jitter ${WORKER_MAX_REQUESTS_JITTER:-0} \
|
||||
--limit-request-line ${SERVER_LIMIT_REQUEST_LINE:-0} \
|
||||
--limit-request-field_size ${SERVER_LIMIT_REQUEST_FIELD_SIZE:-0} \
|
||||
"${STATSD_ARGS[@]}" \
|
||||
"${FLASK_APP}"
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Launch the realtime WebSocket server (superset-websocket) bundled in the
|
||||
# official image. Run it with:
|
||||
#
|
||||
# docker run <superset-image> /app/docker/entrypoints/run-websocket.sh
|
||||
#
|
||||
# Configure via environment variables — see superset-websocket/src/config.ts for
|
||||
# the authoritative, complete set (Redis connection, logging, connection limits,
|
||||
# StatsD, etc.). The values that MUST match the Flask app's config are:
|
||||
# JWT_SECRET == WEBSOCKET_JWT_SECRET
|
||||
# JWT_COOKIE_NAME == WEBSOCKET_JWT_COOKIE_NAME (default superset-ws-token)
|
||||
# REALTIME_CHANNEL_PREFIX == Flask REALTIME_CHANNEL_PREFIX (default empty; set a
|
||||
# per-deployment value on both sides to isolate a shared Redis/Valkey)
|
||||
# Optional rotation setting:
|
||||
# PREVIOUS_JWT_SECRET == old WEBSOCKET_JWT_SECRET accepted for verification
|
||||
# and the Redis connection (REDIS_HOST/REDIS_PORT/...) must point at the same
|
||||
# instance as the app's DISTRIBUTED_COORDINATION_CONFIG.
|
||||
set -e
|
||||
|
||||
# Run from a writable directory so that opting into file logging with the
|
||||
# default relative LOG_FILENAME (LOG_TO_FILE=true) writes somewhere the
|
||||
# unprivileged `superset` user can create files, rather than the read-only /app.
|
||||
# The config.json lookup is unaffected (it resolves relative to the bundle).
|
||||
cd "${SUPERSET_HOME:-/app/superset_home}"
|
||||
|
||||
exec node /app/superset-websocket/dist/index.cjs start
|
||||
@@ -15,8 +15,8 @@
|
||||
"db": 0,
|
||||
"ssl": false
|
||||
},
|
||||
"redisStreamPrefix": "async-events-",
|
||||
"jwtAlgorithms": ["HS256"],
|
||||
"jwtSecret": "CHANGE-ME-IN-PRODUCTION-GOTTA-BE-LONG-AND-SECRET",
|
||||
"jwtCookieName": "async-token"
|
||||
"previousJwtSecret": "",
|
||||
"jwtCookieName": "superset-ws-token"
|
||||
}
|
||||
|
||||
@@ -97,6 +97,37 @@ This setting only applies to requests detected as native filter option queries.
|
||||
over the per-chart/dataset/database timeouts, but not over an explicit per-request
|
||||
`custom_cache_timeout` override (e.g. "Force refresh").
|
||||
|
||||
## Async Query Result Cache TTL
|
||||
|
||||
When [Global Async Queries](/admin-docs/configuration/configuring-superset#feature-flags) is
|
||||
enabled, a chart-data request that runs asynchronously does not return the result inline. Instead the
|
||||
query executes on a background task that **writes the result to the data cache**, and the browser
|
||||
then re-issues the same request to read that result back out of the cache once the task succeeds.
|
||||
|
||||
This read-back is what makes the result-cache TTL matter for correctness, not just performance: if
|
||||
the effective TTL is shorter than the full async round trip (task execution + the client's poll
|
||||
interval + the re-fetch), the entry can be **evicted before the client reads it**, leaving the chart
|
||||
stuck re-running instead of loading. To prevent this, async requests floor their result-cache TTL to
|
||||
`GLOBAL_ASYNC_QUERIES_MIN_CACHE_TTL` (seconds, default `300` — five minutes):
|
||||
|
||||
```python
|
||||
GLOBAL_ASYNC_QUERIES_MIN_CACHE_TTL = 300 # seconds
|
||||
```
|
||||
|
||||
How the floor interacts with the timeouts above:
|
||||
|
||||
- It applies **only to async execution**. Synchronous `/chart/data` requests keep their normal
|
||||
chart/dataset/database/`DATA_CACHE_CONFIG` timeout even when Global Async Queries is enabled.
|
||||
- A **longer** effective TTL from that chain is kept as-is — the floor only raises TTLs that are
|
||||
shorter than it.
|
||||
- A TTL of `0` ("cache forever") is left untouched.
|
||||
|
||||
Tuning guidance: raise this value if your workload's async round trip can exceed five minutes (very
|
||||
long-running queries or slow warehouses), otherwise those charts may intermittently fail to load. Be
|
||||
aware of the trade-off — because the floor can raise an async result's TTL above a shorter cache
|
||||
retention policy, it keeps async results in the cache longer and modestly increases cache
|
||||
(Redis/Valkey) usage. Do not lower it below your worst-case async round trip.
|
||||
|
||||
## Limiting Cached Result Size
|
||||
|
||||
Very large chart or SQL query results can flood the cache backend (Redis/Memcached), evicting many
|
||||
@@ -316,14 +347,25 @@ high-performance distributed operations. This configuration enables:
|
||||
|
||||
- **Distributed locking**: Moves lock operations from the metadata database to Redis, improving
|
||||
performance and reducing metastore load
|
||||
- **Real-time event notifications**: Enables instant pub/sub messaging for task abort signals and
|
||||
completion notifications instead of polling-based approaches
|
||||
- **Event-driven notifications**: Task completion and abort signals are delivered over Redis
|
||||
**Streams**, so waiters (sync join-and-wait, task-dependency DAGs, abort listeners) wake when a
|
||||
signal lands instead of polling the metadata database. Because stream entries are persisted, a
|
||||
waiter that reads slightly late, reconnects, or fails over still receives the signal. Without this
|
||||
backend, these operations poll the metadata database instead.
|
||||
|
||||
:::note
|
||||
This requires Redis or Valkey specifically—it uses Redis-specific features (pub/sub, `SET NX EX`)
|
||||
that are not available in general Flask-Caching backends.
|
||||
This requires Redis or Valkey specifically—it uses Redis-specific features (Streams, pub/sub,
|
||||
`SET NX EX`) that are not available in general Flask-Caching backends.
|
||||
:::
|
||||
|
||||
Each signal stream keeps only its latest entry and is given a TTL, so signal streams for tasks that
|
||||
are never awaited do not accumulate in Redis/Valkey. Set the retention window with
|
||||
`DISTRIBUTED_COORDINATION_SIGNAL_TTL` (seconds, default 24 hours):
|
||||
|
||||
```python
|
||||
DISTRIBUTED_COORDINATION_SIGNAL_TTL = 24 * 60 * 60
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The distributed coordination uses Flask-Caching style configuration for consistency with other cache
|
||||
@@ -366,9 +408,8 @@ DISTRIBUTED_COORDINATION_CONFIG = {
|
||||
}
|
||||
```
|
||||
|
||||
By default, connections opened for `DISTRIBUTED_COORDINATION_CONFIG` (as well as
|
||||
`GLOBAL_ASYNC_QUERIES_CACHE_BACKEND`, which uses the same `RedisCache`/`RedisSentinelCache`
|
||||
backend) have no socket timeout. This can be overridden with `CACHE_REDIS_SOCKET_TIMEOUT` and
|
||||
By default, connections opened for `DISTRIBUTED_COORDINATION_CONFIG` have no socket
|
||||
timeout. This can be overridden with `CACHE_REDIS_SOCKET_TIMEOUT` and
|
||||
`CACHE_REDIS_SOCKET_CONNECT_TIMEOUT`, both in seconds:
|
||||
|
||||
```python
|
||||
|
||||
@@ -50,13 +50,34 @@ Superset can be configured to log events to [StatsD](https://github.com/statsd/s
|
||||
if desired. Most endpoints hit are logged as
|
||||
well as key events like query start and end in SQL Lab.
|
||||
|
||||
To setup StatsD logging, it’s a matter of configuring the logger in your `superset_config.py`.
|
||||
If not already present, you need to ensure that the `statsd`-package is installed in Superset's python environment.
|
||||
Superset can also collect gunicorn [metrics](https://gunicorn.org/instrumentation/).
|
||||
To enable these, the following environment variables should be set:
|
||||
|
||||
```bash
|
||||
SERVER_STATSD_HOST=localhost
|
||||
SERVER_STATSD_PORT=8125
|
||||
SERVER_STATSD_PREFIX=superset
|
||||
```
|
||||
|
||||
To setup StatsD logging for Superset, it’s a matter of configuring the logger in your `superset_config.py`.
|
||||
|
||||
```python
|
||||
import os
|
||||
from superset.stats_logger import StatsdStatsLogger
|
||||
STATS_LOGGER = StatsdStatsLogger(host='localhost', port=8125, prefix='superset')
|
||||
|
||||
try:
|
||||
STATSD_PORT = int(os.environ.get("SERVER_STATSD_PORT", "8125"))
|
||||
except ValueError:
|
||||
STATSD_PORT = 8125
|
||||
|
||||
STATS_LOGGER = StatsdStatsLogger(
|
||||
host=os.environ.get("SERVER_STATSD_HOST", "localhost"),
|
||||
port=STATSD_PORT,
|
||||
prefix=os.environ.get("SERVER_STATSD_PREFIX", "superset"),
|
||||
)
|
||||
```
|
||||
|
||||
[statsd](https://pypi.org/project/statsd/) in version ~3.3.0 must be installed.
|
||||
|
||||
Note that it’s also possible to implement your own logger by deriving
|
||||
`superset.stats_logger.BaseStatsLogger`.
|
||||
|
||||
@@ -183,13 +183,14 @@ https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotat
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
|
||||
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
|
||||
| `GLOBAL_ASYNC_QUERIES_JWT_SECRET` | Signs the async-query channel JWT | Forged async-query tokens | Quarterly + post-incident |
|
||||
| `WEBSOCKET_JWT_SECRET` | Signs the realtime websocket channel JWT cookie | Forged websocket tokens → unauthorized realtime notifications | Quarterly + post-incident |
|
||||
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
|
||||
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |
|
||||
|
||||
Notes:
|
||||
|
||||
- Rotating `GUEST_TOKEN_JWT_SECRET` or `GLOBAL_ASYNC_QUERIES_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
|
||||
- Rotating `GUEST_TOKEN_JWT_SECRET` or `WEBSOCKET_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
|
||||
- `WEBSOCKET_JWT_SECRET` can be rotated without disconnecting live sockets: set the outgoing value as `PREVIOUS_JWT_SECRET` on the websocket server so it keeps verifying old cookies, then remove it once they have aged out.
|
||||
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
|
||||
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.
|
||||
|
||||
|
||||
@@ -570,15 +570,6 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>AsyncEventsRestApi</strong> (1 endpoints) — Real-time event streaming via Server-Sent Events (SSE).</summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | [Read off of the Redis events stream](/developer-docs/api/read-off-of-the-redis-events-stream) | `/api/v1/async_event/` |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>OpenApi</strong> (1 endpoints) — Access the OpenAPI specification.</summary>
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ PENDING ──→ IN_PROGRESS ────→ SUCCESS
|
||||
| `IN_PROGRESS` | Executing |
|
||||
| `ABORTING` | Abort/timeout triggered, abort handlers running |
|
||||
| `SUCCESS` | Completed successfully |
|
||||
| `FAILURE` | Failed with error or abort/cleanup handler exception |
|
||||
| `FAILURE` | Failed with error, abort/cleanup handler exception, orphan reaping, or worker self-fence |
|
||||
| `ABORTED` | Cancelled by user/admin |
|
||||
| `TIMED_OUT` | Exceeded configured timeout |
|
||||
|
||||
@@ -152,10 +152,57 @@ Use the tuple format `(current, total)` whenever possible. It provides the riche
|
||||
|
||||
#### Payload
|
||||
|
||||
The `payload` parameter stores custom metadata that can help users understand what the task is doing. Each call to `update_task()` replaces the previous payload completely.
|
||||
The `payload` parameter stores custom metadata that can help users understand what the task is doing. Each call to `update_task()` merges into the existing payload (top-level keys are added or overwritten; keys you don't pass are preserved), so a task can build up its payload incrementally across calls.
|
||||
|
||||
In the Task List UI, when a payload is defined, an info icon appears in the **Details** column. Users can hover over it to see the JSON content.
|
||||
|
||||
#### Forcing an Immediate Write
|
||||
|
||||
By default `update_task()` throttles database writes (batching frequent updates to limit metastore load, at most one write per `TASK_PROGRESS_UPDATE_THROTTLE_INTERVAL` seconds, default 2). Pass `immediate=True` to bypass throttling and write synchronously:
|
||||
|
||||
```python
|
||||
ctx.update_task(payload={"result_cache_key": key}, immediate=True)
|
||||
```
|
||||
|
||||
Use this only when another consumer must observe the update as soon as the task finishes — for example, a dependent task that reads a prerequisite's payload the moment the dependency gate releases. For ordinary progress reporting, prefer the default throttled behavior.
|
||||
|
||||
#### Task state: public properties, private state, and results
|
||||
|
||||
A task's state lives in three tiers:
|
||||
|
||||
1. **Public `properties`** — named runtime state and execution config
|
||||
(`is_abortable`, `progress_*`, `dedupe_count`, `execution_mode`, `timeout`,
|
||||
`error_message`). Returned by the Task REST API and shown in the Task List UI.
|
||||
2. **Private properties** — internal state that is surfaced to API consumers
|
||||
**only in debug mode** (otherwise the whole `private` key is stripped). It has
|
||||
two structurally isolated namespaces so a task type's freeform key can never
|
||||
collide with a framework key:
|
||||
- `private.framework` — framework-owned named keys common to every task: the
|
||||
Celery job id the orphan reaper revokes (`celery_task_id`) plus error debug
|
||||
(`exception_type`, `stack_trace`). Written only by the framework via
|
||||
`task.update_framework_private({...})`.
|
||||
- `private.task` — freeform, task-type-specific internal handles (e.g. the
|
||||
chart-data query task's engine cancel handle,
|
||||
`cancel_query_id`/`cancel_database_id`). Written by task/execution code via
|
||||
`task.update_task_private({...})`.
|
||||
- `private.subscription` — a
|
||||
[subscription policy](#per-client-subscriptions-subscription-policies)'s
|
||||
per-client bookkeeping (e.g. chart-data's per-tab consumer list). Written
|
||||
only from the policy hooks via `TaskDAO.merge_subscription_state(task, {...})`;
|
||||
the executor never writes it, and its whole-blob property writes carry the
|
||||
row's current value through instead of overwriting it.
|
||||
All namespaces merge independently (a write to one never clobbers another).
|
||||
3. **Results (`payload`)** — end-user-facing task output (intermediate/final):
|
||||
e.g. a `cache_key` or an engine tracking URL. Set via
|
||||
`ctx.update_task(payload=...)` and rendered in the Task List info bubble. In
|
||||
debug mode the bubble shows the `private` state in a separate section below.
|
||||
|
||||
Rule of thumb: user-facing status → top-level `properties`; user-facing output →
|
||||
`payload`; framework plumbing → `private.framework`; task-specific internal
|
||||
handles → `private.task`; subscription-policy bookkeeping →
|
||||
`private.subscription`.
|
||||
|
||||
|
||||
### Handlers
|
||||
|
||||
Register handlers to run cleanup logic or respond to abort requests:
|
||||
@@ -244,6 +291,64 @@ The framework automatically skips execution if a task was aborted while pending:
|
||||
Always implement an abort handler for long-running tasks. This allows users to cancel unneeded tasks and free up worker capacity for other operations.
|
||||
:::
|
||||
|
||||
### Per-client subscriptions (subscription policies)
|
||||
|
||||
The framework subscribes tasks at **principal grain**: one subscriber row per
|
||||
authenticated user (or embedded guest). The abort-vs-unsubscribe decision above
|
||||
counts principals. For most task types that is exactly right.
|
||||
|
||||
Some task types need a finer grain than the principal. The canonical case is
|
||||
async chart-data: a single `SHARED` task is deduplicated across every request
|
||||
for the same query, so one user viewing the same chart in **two browser tabs** is
|
||||
a single principal with a single subscriber row. If either tab's cancel (an
|
||||
explicit cancel, or the navigate-away teardown) were treated as *the* principal
|
||||
leaving, it would abort the shared task and kill the other tab's still-pending
|
||||
query.
|
||||
|
||||
A **subscription policy** lets a task type refine this without the framework
|
||||
knowing anything about tabs (or any other per-client grain). Register one on the
|
||||
`@task` decorator:
|
||||
|
||||
```python
|
||||
from superset_core.tasks.subscription import TaskSubscriptionPolicy
|
||||
|
||||
class MyConsumerPolicy(TaskSubscriptionPolicy):
|
||||
def on_subscribe(self, task, *, principal, client_ref):
|
||||
# Record this client (e.g. append f"{principal}:{client_ref}" to a list
|
||||
# via TaskDAO.merge_subscription_state(task, {...})). Called after the
|
||||
# framework has ensured the principal's subscriber row.
|
||||
...
|
||||
|
||||
def on_unsubscribe(self, task, *, principal, client_ref) -> bool:
|
||||
# Drop this client. Return True if the principal now has no client left
|
||||
# (the framework then proceeds with its normal principal-grain rule:
|
||||
# unsubscribe the principal, and abort if it was the last subscriber);
|
||||
# return False to keep the principal subscribed because another of its
|
||||
# clients is still watching.
|
||||
...
|
||||
|
||||
@task(name="my_task", scope=TaskScope.SHARED, subscription_policy=MyConsumerPolicy())
|
||||
def my_task() -> None:
|
||||
...
|
||||
```
|
||||
|
||||
Both hooks run in the web request process, inside the lock that serializes
|
||||
concurrent submit/cancel for the task, so an implementation can safely
|
||||
read-modify-write its bookkeeping without extra locking against other
|
||||
submits/cancels. Keep that bookkeeping under `private.subscription` and write it
|
||||
with `TaskDAO.merge_subscription_state(task, {...})`: the executor does not hold
|
||||
the submit/cancel lock and keeps writing the task's properties while it runs, so
|
||||
the helper merges under a row lock and the executor's own writes preserve that
|
||||
namespace, where a plain `task.update_task_private({...})` would be overwritten
|
||||
by the executor's next write and silently drop a client that joined
|
||||
mid-execution. `client_ref` is the caller's
|
||||
opaque per-client id (for chart-data, the browser tab id sent as `tab_id` on the
|
||||
request); it is **not** an authorization token — the framework authorizes the
|
||||
calling principal before the policy runs, and the policy only ever records or
|
||||
removes entries scoped to that principal. A task type with no policy, or a
|
||||
request with no `client_ref`, keeps plain principal-grain behavior. An admin
|
||||
**Force abort** always aborts, bypassing the policy.
|
||||
|
||||
## Timeouts
|
||||
|
||||
Set a timeout to automatically abort tasks that run too long:
|
||||
@@ -333,6 +438,48 @@ assert task.uuid == task2.uuid # True
|
||||
print(task2.status) # "success" (terminal status)
|
||||
```
|
||||
|
||||
## Task Dependencies
|
||||
|
||||
Tasks can declare prerequisite tasks, forming a directed acyclic graph (DAG). Pass the prerequisite `Task` objects (returned by `.schedule()`) via `depends_on`:
|
||||
|
||||
```python
|
||||
from superset_core.tasks.types import TaskOptions
|
||||
|
||||
totals = totals_task.schedule(options=TaskOptions(task_key="totals_123"))
|
||||
|
||||
# `dependent` only runs once `totals` has finished successfully.
|
||||
dependent = dependent_task.schedule(
|
||||
options=TaskOptions(depends_on=[totals])
|
||||
)
|
||||
```
|
||||
|
||||
Passing the `Task` object is the canonical pattern. For convenience, a prerequisite's `UUID` (or UUID string) is also accepted where you don't hold the `Task` itself.
|
||||
|
||||
**Semantics (`all_success`).** A task runs only once **every** direct prerequisite has reached a terminal `SUCCESS`. If **any** prerequisite ends in a non-`SUCCESS` terminal state (`FAILURE`, `ABORTED`, or `TIMED_OUT`), the dependent does **not** run and is transitioned to `FAILURE`. This propagates transitively: because a failed dependent is itself non-`SUCCESS`, its own dependents fail in turn, so a failure anywhere short-circuits everything downstream.
|
||||
|
||||
**Scheduling model (non-blocking defer).** All tasks in a DAG are enqueued immediately. When a dependent is dequeued before its prerequisites are terminal, it does **not** hold its worker slot: it is re-enqueued via a Celery retry with a short, growing backoff (roughly 1s, 3s, 5s… capped) and the worker moves on to other work. While waiting, the task remains `PENDING` (shown as "waiting on N prerequisites" in the Task List). Each defer emits the `gtf.task.dag_deferred` metric.
|
||||
|
||||
:::note
|
||||
A deferred dependent carries no heartbeat and no Celery job id until it is actually claimed (its prerequisites met), so the orphan reaper never mistakes a waiting task for abandoned work.
|
||||
:::
|
||||
|
||||
Cycles (including self-dependencies) are rejected at schedule time. Dependency edges are removed automatically when either endpoint task is pruned.
|
||||
|
||||
**Reading a prerequisite's output.** A dependent reads the payloads its prerequisites published via `ctx.get_dependency_payloads()`, which returns the prerequisites' payloads in dependency-edge order. Pair it with the prerequisite writing its result with `ctx.update_task(payload=..., immediate=True)` so the value is flushed (not held in the write-throttle buffer) by the time the dependency gate releases the dependent:
|
||||
|
||||
```python
|
||||
@task
|
||||
def totals_task() -> None:
|
||||
ctx = get_context()
|
||||
# immediate=True so the dependent observes this the moment the gate releases.
|
||||
ctx.update_task(payload={"result_cache_key": key}, immediate=True)
|
||||
|
||||
@task
|
||||
def dependent_task() -> None:
|
||||
ctx = get_context()
|
||||
upstream = ctx.get_dependency_payloads() # [{"result_cache_key": ...}, ...]
|
||||
```
|
||||
|
||||
## Task Scopes
|
||||
|
||||
```python
|
||||
@@ -355,6 +502,10 @@ def system_task(): ...
|
||||
| `SHARED` | All subscribers | Last subscriber cancels; others unsubscribe |
|
||||
| `SYSTEM` | Admins only | Admin cancels |
|
||||
|
||||
For `SHARED` tasks, "last subscriber" is at principal grain by default; a task
|
||||
type can refine cancel to a finer per-client (e.g. per browser tab) grain with a
|
||||
[subscription policy](#per-client-subscriptions-subscription-policies).
|
||||
|
||||
## Task Cleanup
|
||||
|
||||
Completed tasks accumulate in the database over time. Configure a scheduled prune job to automatically remove old tasks:
|
||||
@@ -375,8 +526,32 @@ The prune job only removes tasks in terminal states (`SUCCESS`, `FAILURE`, `ABOR
|
||||
|
||||
See `superset/config.py` for a complete example configuration.
|
||||
|
||||
### Orphan Reaping
|
||||
|
||||
A task whose worker dies mid-execution (OOM kill, crash, lost broker message) would otherwise stay `IN_PROGRESS` forever. To prevent this, a worker writes a liveness heartbeat while it holds a task, and a dedicated `reap_orphaned_tasks` beat job reaps orphans:
|
||||
|
||||
- **Heartbeat** — every `GTF_TASK_HEARTBEAT_INTERVAL` seconds (default 15) the executing worker refreshes `tasks.last_heartbeat`. This write is deliberately out-of-band and does not update `changed_on`.
|
||||
- **Reaping** — `reap_orphaned_tasks` marks any active task whose heartbeat is older than `GTF_ORPHAN_TASK_TIMEOUT` (default 60) as `FAILURE` so waiters and dependents unblock, revokes its Celery job so a redelivered copy (with `task_acks_late`) will not run, and — on engines that support query cancellation, when the dead worker had captured a cancel handle — cancels the abandoned warehouse query out-of-band. A task still being worked on keeps a fresh heartbeat and is never reaped, so this never interferes with a live worker's cooperative abort/cleanup.
|
||||
- **Self-fencing** — the reaper handles a *dead* worker, but a worker that is alive yet cut off from the metastore (network partition, metastore outage) would keep running a query the reaper has already marked `FAILURE`. To avoid that wasted work, if a worker's heartbeat writes keep failing for longer than `GTF_ORPHAN_TASK_TIMEOUT` — the same window the reaper uses — the worker fails the task from the inside, cancelling any in-flight query. A single failed write is tolerated; only a sustained outage spanning the orphan window fences, so a transient blip never kills a healthy task. There is no handover to another worker: the task simply fails.
|
||||
|
||||
Enable the `reap_orphaned_tasks` beat schedule on a short interval (e.g. every minute) so orphaned tasks — and their warehouse queries — do not linger; it is separate from `prune_tasks` (a heavier retention delete that runs infrequently). Keep `GTF_ORPHAN_TASK_TIMEOUT` comfortably larger than the heartbeat interval (≥ ~3×) so a brief pause or CPU-bound stretch is not mistaken for a dead worker.
|
||||
|
||||
```python
|
||||
# In your superset_config.py, add to your Celery beat schedule:
|
||||
CELERY_CONFIG.beat_schedule["reap_orphaned_tasks"] = {
|
||||
"task": "reap_orphaned_tasks",
|
||||
"schedule": crontab(minute="*", hour="*"), # Run every minute
|
||||
}
|
||||
```
|
||||
|
||||
Unlike `prune_tasks`, the reaper takes no kwargs — it reads `GTF_ORPHAN_TASK_TIMEOUT` from config.
|
||||
|
||||
:::note Cancelling the underlying query
|
||||
For long-running work backed by an external query, register an `on_abort` handler that cancels it (this is how async chart-data query tasks cancel the warehouse query on engines that support cancellation). Without such a handler an abort/timeout frees the task but cannot stop the external work.
|
||||
:::
|
||||
|
||||
:::tip Distributed Coordination for Faster Notifications
|
||||
By default, abort detection and sync join-and-wait use database polling. Configure `DISTRIBUTED_COORDINATION_CONFIG` to enable Redis pub/sub for real-time notifications. See [Distributed Coordination Backend](/admin-docs/configuration/cache#signal-cache-backend) for configuration details.
|
||||
By default, abort detection and sync join-and-wait poll the task row in the metadata database (every `TASK_ABORT_POLLING_DEFAULT_INTERVAL` seconds, default 10). Configure `DISTRIBUTED_COORDINATION_CONFIG` (Redis/Valkey) and these become event-driven: completion and abort are signalled over Redis **Streams**, so a waiter wakes when the signal lands instead of polling the database. Because stream entries are persisted, a waiter that reads slightly late, reconnects, or fails over still receives the signal. Each signal stream keeps only its latest entry and is given a TTL, so streams for tasks that are never awaited do not accumulate; set the retention window with `DISTRIBUTED_COORDINATION_SIGNAL_TTL` (default 24h). See [Distributed Coordination Backend](/admin-docs/configuration/cache#signal-cache-backend) for configuration details.
|
||||
:::
|
||||
|
||||
## API Reference
|
||||
@@ -387,19 +562,24 @@ By default, abort detection and sync join-and-wait use database polling. Configu
|
||||
@task(
|
||||
name: str | None = None,
|
||||
scope: TaskScope = TaskScope.PRIVATE,
|
||||
timeout: int | None = None
|
||||
timeout: int | None = None,
|
||||
subscription_policy: TaskSubscriptionPolicy | None = None,
|
||||
)
|
||||
```
|
||||
|
||||
- `name`: Task identifier (defaults to function name)
|
||||
- `scope`: `PRIVATE`, `SHARED`, or `SYSTEM`
|
||||
- `timeout`: Default timeout in seconds (can be overridden via `TaskOptions`)
|
||||
- `subscription_policy`: Optional per-client subscription policy that refines the
|
||||
principal-grain cancel decision (see
|
||||
[Per-client subscriptions](#per-client-subscriptions-subscription-policies))
|
||||
|
||||
### TaskContext Methods
|
||||
|
||||
| Method | Description |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `update_task(progress, payload)` | Update progress and/or custom payload |
|
||||
| `update_task(progress, payload, immediate=False)` | Update progress and/or custom payload (`immediate=True` bypasses write throttling) |
|
||||
| `get_dependency_payloads()` | Return prerequisite tasks' payloads, in dependency-edge order |
|
||||
| `on_cleanup(handler)` | Register cleanup handler |
|
||||
| `on_abort(handler)` | Register abort handler (makes task abortable) |
|
||||
|
||||
@@ -409,13 +589,15 @@ By default, abort detection and sync join-and-wait use database polling. Configu
|
||||
TaskOptions(
|
||||
task_key: str | None = None,
|
||||
task_name: str | None = None,
|
||||
timeout: int | None = None
|
||||
timeout: int | None = None,
|
||||
depends_on: list[Task | UUID | str] | None = None
|
||||
)
|
||||
```
|
||||
|
||||
- `task_key`: Deduplication key (also used as display name if `task_name` is not set)
|
||||
- `task_name`: Human-readable display name for the Task List UI
|
||||
- `timeout`: Timeout in seconds (overrides decorator default)
|
||||
- `depends_on`: Prerequisite tasks to wait for before running. Pass the scheduled `Task` objects (canonical); a `UUID` or UUID string is also accepted (see [Task Dependencies](#task-dependencies))
|
||||
|
||||
:::tip
|
||||
Provide a descriptive `task_name` for better readability in the Task List UI. While `task_key` is used for deduplication and may be technical (e.g., `chart_export_123`), `task_name` can be user-friendly (e.g., `"Export Sales Chart 123"`).
|
||||
|
||||
@@ -159,7 +159,6 @@ single source of truth. The README.md retains its own static copy
|
||||
<a href="/user-docs/databases/supported/apache-pinot" title="Apache Pinot"><img src="/img/databases/apache-pinot.svg" alt="Apache Pinot" width="76" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/apache-solr" title="Apache Solr"><img src="/img/databases/apache-solr.png" alt="Apache Solr" width="79" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/apache-spark-sql" title="Apache Spark SQL"><img src="/img/databases/apache-spark.png" alt="Apache Spark SQL" width="75" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/ascend" title="Ascend"><img src="/img/databases/ascend.webp" alt="Ascend" width="117" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/aurora-mysql-data-api" title="Aurora MySQL (Data API)"><img src="/img/databases/mysql.png" alt="Aurora MySQL (Data API)" width="77" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/aurora-postgresql-data-api" title="Aurora PostgreSQL (Data API)"><img src="/img/databases/postgresql.svg" alt="Aurora PostgreSQL (Data API)" width="76" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/azure-data-explorer" title="Azure Data Explorer"><img src="/img/databases/kusto.png" alt="Azure Data Explorer" width="40" height="40" /></a>
|
||||
|
||||
@@ -287,7 +287,6 @@ def add_missing_operation_ids(spec: dict[str, Any]) -> int:
|
||||
TAG_DESCRIPTIONS = {
|
||||
"Advanced Data Type": "Advanced data type operations and conversions.",
|
||||
"Annotation Layers": "Manage annotation layers and annotations for charts.",
|
||||
"AsyncEventsRestApi": "Real-time event streaming via Server-Sent Events (SSE).",
|
||||
"Available Domains": "Get available domains for the Superset instance.",
|
||||
"CSS Templates": "Manage CSS templates for custom dashboard styling.",
|
||||
"CacheRestApi": "Cache management and invalidation operations.",
|
||||
|
||||
@@ -93,7 +93,6 @@ const CATEGORY_GROUPS = {
|
||||
'User',
|
||||
'Menu',
|
||||
'Available Domains',
|
||||
'AsyncEventsRestApi',
|
||||
'OpenApi',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"Apache Doris",
|
||||
"Apache Kylin",
|
||||
"Apache Phoenix",
|
||||
"Ascend",
|
||||
"Azure Data Explorer",
|
||||
"Azure Synapse",
|
||||
"ClickHouse",
|
||||
@@ -168,7 +167,6 @@
|
||||
"base"
|
||||
],
|
||||
"Cloud Data Warehouses": [
|
||||
"Ascend",
|
||||
"Azure Synapse",
|
||||
"Cloudflare D1",
|
||||
"Databend",
|
||||
@@ -180,7 +178,6 @@
|
||||
"YugabyteDB"
|
||||
],
|
||||
"Hosted Open Source": [
|
||||
"Ascend",
|
||||
"Cloudflare D1",
|
||||
"Databricks",
|
||||
"Google Sheets",
|
||||
@@ -1741,87 +1738,6 @@
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false
|
||||
},
|
||||
"Ascend": {
|
||||
"time_grains": {
|
||||
"SECOND": true,
|
||||
"FIVE_SECONDS": false,
|
||||
"THIRTY_SECONDS": false,
|
||||
"MINUTE": true,
|
||||
"FIVE_MINUTES": false,
|
||||
"TEN_MINUTES": false,
|
||||
"FIFTEEN_MINUTES": false,
|
||||
"THIRTY_MINUTES": false,
|
||||
"HALF_HOUR": false,
|
||||
"HOUR": true,
|
||||
"SIX_HOURS": false,
|
||||
"DAY": true,
|
||||
"WEEK": true,
|
||||
"WEEK_STARTING_SUNDAY": false,
|
||||
"WEEK_STARTING_MONDAY": false,
|
||||
"WEEK_ENDING_SATURDAY": false,
|
||||
"WEEK_ENDING_SUNDAY": false,
|
||||
"MONTH": true,
|
||||
"QUARTER": true,
|
||||
"QUARTER_YEAR": false,
|
||||
"YEAR": true
|
||||
},
|
||||
"module": "superset.db_engine_specs.ascend",
|
||||
"limit_method": 1,
|
||||
"limit_clause": true,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"alias_in_select": true,
|
||||
"alias_in_orderby": true,
|
||||
"time_groupby_inline": false,
|
||||
"alias_to_source_column": false,
|
||||
"order_by_not_in_select": true,
|
||||
"expressions_in_orderby": false,
|
||||
"cte_in_subquery": true,
|
||||
"max_column_name": null,
|
||||
"sql_comments": true,
|
||||
"escaped_colons": true,
|
||||
"masked_encrypted_extra": false,
|
||||
"column_type_mapping": false,
|
||||
"function_names": false,
|
||||
"user_impersonation": false,
|
||||
"file_upload": true,
|
||||
"get_extra_table_metadata": false,
|
||||
"dbapi_exception_mapping": false,
|
||||
"custom_errors": false,
|
||||
"dynamic_schema": false,
|
||||
"catalog": false,
|
||||
"dynamic_catalog": false,
|
||||
"ssh_tunneling": true,
|
||||
"query_cancelation": true,
|
||||
"get_metrics": false,
|
||||
"where_latest_partition": false,
|
||||
"expand_data": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false,
|
||||
"score": 38,
|
||||
"max_score": 201,
|
||||
"documentation": {
|
||||
"description": "Ascend.io is a data automation platform for building data pipelines.",
|
||||
"logo": "ascend.webp",
|
||||
"homepage_url": "https://www.ascend.io/",
|
||||
"categories": [
|
||||
"Cloud Data Warehouses",
|
||||
"Analytical Databases",
|
||||
"Hosted Open Source"
|
||||
],
|
||||
"pypi_packages": ["impyla"],
|
||||
"connection_string": "ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true",
|
||||
"category": "Other Databases"
|
||||
},
|
||||
"engine": "ascend",
|
||||
"engine_name": "Ascend",
|
||||
"engine_aliases": [],
|
||||
"default_driver": null,
|
||||
"supports_file_upload": true,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false
|
||||
},
|
||||
"Aurora MySQL": {
|
||||
"time_grains": {
|
||||
"SECOND": true,
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
Vendored
+36
-220
@@ -1053,26 +1053,21 @@
|
||||
},
|
||||
"ChartDataAsyncResponseSchema": {
|
||||
"properties": {
|
||||
"channel_id": {
|
||||
"description": "Unique session async channel ID",
|
||||
"cursor": {
|
||||
"description": "Status-changes recovery cursor captured before any task was created. The client polls `/api/v1/task/status_changes` from it and is guaranteed to observe each task's completion.",
|
||||
"type": "string"
|
||||
},
|
||||
"job_id": {
|
||||
"description": "Unique async job ID",
|
||||
"type": "string"
|
||||
},
|
||||
"result_url": {
|
||||
"description": "Unique result URL for fetching async query data",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "Status value for async job",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"description": "Requesting user ID",
|
||||
"tab_id": {
|
||||
"description": "The per-client (e.g. browser-tab) id echoed back when the caller advertised one, so a later cancel detaches exactly that client. Absent when the caller supplied none.",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"task_ids": {
|
||||
"description": "UUIDs of the scheduled GTF tasks (one per QueryObject that missed the cache), in query order. The client polls `/api/v1/task/status_changes`, aggregates these tasks' statuses, and re-issues this request once they all succeed.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -1523,6 +1518,11 @@
|
||||
},
|
||||
"ChartDataQueryContextSchema": {
|
||||
"properties": {
|
||||
"async_mode": {
|
||||
"description": "Opt this request into asynchronous execution on the Global Task Framework (requires the GLOBAL_ASYNC_QUERIES feature flag). When true the response is HTTP 202 with the query task ids to poll; when absent or false the query runs synchronously (HTTP 200). Default: `false`.",
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"custom_cache_timeout": {
|
||||
"description": "Override the default cache timeout",
|
||||
"nullable": true,
|
||||
@@ -1536,6 +1536,11 @@
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"force_nonce": {
|
||||
"description": "Forced-refresh idempotency token for a single-query request: the async task's UUID (as returned in the 202 `task_ids`). Sent on the synchronous read-back of a forced refresh so it reads the result the task warmed instead of recomputing; concurrent refreshes joining the same shared task read back under the same token. Multi-query requests set the per-query `force_nonce` on each query instead. Ignored when `force` is false.",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"form_data": {
|
||||
"nullable": true
|
||||
},
|
||||
@@ -1564,6 +1569,11 @@
|
||||
"post_processed",
|
||||
"drill_detail"
|
||||
]
|
||||
},
|
||||
"tab_id": {
|
||||
"description": "Opaque per-browser-tab id (see the frontend `getTabId`). On an async request it ref-counts this tab as a consumer of the shared chart-data task so a cancel/navigate-away from one tab doesn't abort a task another tab still awaits. Read by the API as a request-level routing hint; not part of the query context.",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -1621,6 +1631,11 @@
|
||||
"nullable": true,
|
||||
"type": "array"
|
||||
},
|
||||
"force_nonce": {
|
||||
"description": "Per-query forced-refresh idempotency token: the async task's UUID (as returned in the 202 `task_ids`, in query order). Sent on the synchronous read-back of a forced refresh so it reads the result the task warmed instead of recomputing. Because the token is the task's identity, concurrent refreshes joining the same shared task read back under the same token. Ignored when `force` is false.",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"granularity": {
|
||||
"description": "Name of temporal column used for time filtering. ",
|
||||
"nullable": true,
|
||||
@@ -8888,6 +8903,11 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"report_format": {
|
||||
"maxLength": 50,
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"retry_max_attempts": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -15493,153 +15513,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/async_event/": {
|
||||
"get": {
|
||||
"description": "Reads off of the Redis events stream, using the user's JWT token and optional query params for last event received.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Last ID received by the client",
|
||||
"in": "query",
|
||||
"name": "last_id",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"channel_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"errors": {
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"job_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"result_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Async event results"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Read off of the Redis events stream",
|
||||
"tags": [
|
||||
"AsyncEventsRestApi"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/async_event/{job_id}/cancel": {
|
||||
"post": {
|
||||
"description": "Revokes the Celery task backing an in-flight async query. The caller is authorized against the job's original owner (channel and user), both resolved server-side from the request, so a client cannot cancel a job it did not submit.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The job ID returned when the async query was submitted",
|
||||
"in": "path",
|
||||
"name": "job_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Job cancelled"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/400"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/403"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Cancel a running async query job",
|
||||
"tags": [
|
||||
"AsyncEventsRestApi"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/available_domains/": {
|
||||
"get": {
|
||||
"responses": {
|
||||
@@ -16061,63 +15934,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/chart/data/{cache_key}": {
|
||||
"get": {
|
||||
"description": "Takes a query context cache key and returns payload data response for the given query.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "cache_key",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ChartDataResponseSchema"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Query result"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/400"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/403"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/components/responses/422"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/500"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Return payload data response for the given query",
|
||||
"tags": [
|
||||
"Charts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/chart/export/": {
|
||||
"get": {
|
||||
"parameters": [
|
||||
|
||||
@@ -50,7 +50,6 @@ are compatible with Superset.
|
||||
| [Apache Pinot](/user-docs/6.0.0/configuration/databases#apache-pinot) | `pip install pinotdb` | `pinot://BROKER:5436/query?server=http://CONTROLLER:5983/` |
|
||||
| [Apache Solr](/user-docs/6.0.0/configuration/databases#apache-solr) | `pip install sqlalchemy-solr` | `solr://{username}:{password}@{hostname}:{port}/{server_path}/{collection}` |
|
||||
| [Apache Spark SQL](/user-docs/6.0.0/configuration/databases#apache-spark-sql) | `pip install pyhive` | `hive://hive@{hostname}:{port}/{database}` |
|
||||
| [Ascend.io](/user-docs/6.0.0/configuration/databases#ascendio) | `pip install impyla` | `ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true` |
|
||||
| [Azure MS SQL](/user-docs/6.0.0/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://UserName@presetSQL:TestPassword@presetSQL.database.windows.net:1433/TestSchema` |
|
||||
| [ClickHouse](/user-docs/6.0.0/configuration/databases#clickhouse) | `pip install clickhouse-connect` | `clickhousedb://{username}:{password}@{hostname}:{port}/{database}` |
|
||||
| [CockroachDB](/user-docs/6.0.0/configuration/databases#cockroachdb) | `pip install cockroachdb` | `cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable` |
|
||||
@@ -188,16 +187,6 @@ Repeat this process for each type of database you want Superset to connect to.
|
||||
|
||||
### Database-specific Instructions
|
||||
|
||||
#### Ascend.io
|
||||
|
||||
The recommended connector library to Ascend.io is [impyla](https://github.com/cloudera/impyla).
|
||||
|
||||
The expected connection string is formatted as follows:
|
||||
|
||||
```
|
||||
ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true
|
||||
```
|
||||
|
||||
#### Apache Doris
|
||||
|
||||
The [sqlalchemy-doris](https://pypi.org/project/pydoris/) library is the recommended way to connect to Apache Doris through SQLAlchemy.
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"Apache Doris",
|
||||
"Apache Kylin",
|
||||
"Apache Phoenix",
|
||||
"Ascend",
|
||||
"Azure Data Explorer",
|
||||
"Azure Synapse",
|
||||
"ClickHouse",
|
||||
@@ -165,7 +164,6 @@
|
||||
"base"
|
||||
],
|
||||
"Cloud Data Warehouses": [
|
||||
"Ascend",
|
||||
"Azure Synapse",
|
||||
"Cloudflare D1",
|
||||
"Databend",
|
||||
@@ -177,7 +175,6 @@
|
||||
"YugabyteDB"
|
||||
],
|
||||
"Hosted Open Source": [
|
||||
"Ascend",
|
||||
"Cloudflare D1",
|
||||
"Databricks",
|
||||
"Google Sheets",
|
||||
@@ -1738,87 +1735,6 @@
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false
|
||||
},
|
||||
"Ascend": {
|
||||
"time_grains": {
|
||||
"SECOND": true,
|
||||
"FIVE_SECONDS": false,
|
||||
"THIRTY_SECONDS": false,
|
||||
"MINUTE": true,
|
||||
"FIVE_MINUTES": false,
|
||||
"TEN_MINUTES": false,
|
||||
"FIFTEEN_MINUTES": false,
|
||||
"THIRTY_MINUTES": false,
|
||||
"HALF_HOUR": false,
|
||||
"HOUR": true,
|
||||
"SIX_HOURS": false,
|
||||
"DAY": true,
|
||||
"WEEK": true,
|
||||
"WEEK_STARTING_SUNDAY": false,
|
||||
"WEEK_STARTING_MONDAY": false,
|
||||
"WEEK_ENDING_SATURDAY": false,
|
||||
"WEEK_ENDING_SUNDAY": false,
|
||||
"MONTH": true,
|
||||
"QUARTER": true,
|
||||
"QUARTER_YEAR": false,
|
||||
"YEAR": true
|
||||
},
|
||||
"module": "superset.db_engine_specs.ascend",
|
||||
"limit_method": 1,
|
||||
"limit_clause": true,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"alias_in_select": true,
|
||||
"alias_in_orderby": true,
|
||||
"time_groupby_inline": false,
|
||||
"alias_to_source_column": false,
|
||||
"order_by_not_in_select": true,
|
||||
"expressions_in_orderby": false,
|
||||
"cte_in_subquery": true,
|
||||
"max_column_name": null,
|
||||
"sql_comments": true,
|
||||
"escaped_colons": true,
|
||||
"masked_encrypted_extra": false,
|
||||
"column_type_mapping": false,
|
||||
"function_names": false,
|
||||
"user_impersonation": false,
|
||||
"file_upload": true,
|
||||
"get_extra_table_metadata": false,
|
||||
"dbapi_exception_mapping": false,
|
||||
"custom_errors": false,
|
||||
"dynamic_schema": false,
|
||||
"catalog": false,
|
||||
"dynamic_catalog": false,
|
||||
"ssh_tunneling": true,
|
||||
"query_cancelation": true,
|
||||
"get_metrics": false,
|
||||
"where_latest_partition": false,
|
||||
"expand_data": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false,
|
||||
"score": 38,
|
||||
"max_score": 201,
|
||||
"documentation": {
|
||||
"description": "Ascend.io is a data automation platform for building data pipelines.",
|
||||
"logo": "ascend.webp",
|
||||
"homepage_url": "https://www.ascend.io/",
|
||||
"categories": [
|
||||
"Cloud Data Warehouses",
|
||||
"Analytical Databases",
|
||||
"Hosted Open Source"
|
||||
],
|
||||
"pypi_packages": ["impyla"],
|
||||
"connection_string": "ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true",
|
||||
"category": "Other Databases"
|
||||
},
|
||||
"engine": "ascend",
|
||||
"engine_name": "Ascend",
|
||||
"engine_aliases": [],
|
||||
"default_driver": null,
|
||||
"supports_file_upload": true,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false
|
||||
},
|
||||
"Aurora MySQL": {
|
||||
"time_grains": {
|
||||
"SECOND": true,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
title: Ascend
|
||||
sidebar_label: Ascend
|
||||
description: 'Ascend.io is a data automation platform for building data pipelines.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
{/*
|
||||
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 { DatabasePage } from '@site/src/components/databases';
|
||||
|
||||
export const databaseInfo = {
|
||||
engine: 'ascend',
|
||||
engine_name: 'Ascend',
|
||||
module: 'ascend',
|
||||
documentation: {
|
||||
description:
|
||||
'Ascend.io is a data automation platform for building data pipelines.',
|
||||
logo: 'ascend.webp',
|
||||
homepage_url: 'https://www.ascend.io/',
|
||||
pypi_packages: ['impyla', 'impyla'],
|
||||
connection_string:
|
||||
'ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true',
|
||||
default_port: 21050,
|
||||
categories: [
|
||||
'CLOUD_DATA_WAREHOUSES',
|
||||
'ANALYTICAL_DATABASES',
|
||||
'HOSTED_OPEN_SOURCE',
|
||||
],
|
||||
},
|
||||
time_grains: {
|
||||
SECOND: true,
|
||||
FIVE_SECONDS: false,
|
||||
THIRTY_SECONDS: false,
|
||||
MINUTE: true,
|
||||
FIVE_MINUTES: false,
|
||||
TEN_MINUTES: false,
|
||||
FIFTEEN_MINUTES: false,
|
||||
THIRTY_MINUTES: false,
|
||||
HALF_HOUR: false,
|
||||
HOUR: true,
|
||||
SIX_HOURS: false,
|
||||
DAY: true,
|
||||
WEEK: true,
|
||||
WEEK_STARTING_SUNDAY: false,
|
||||
WEEK_STARTING_MONDAY: false,
|
||||
WEEK_ENDING_SATURDAY: false,
|
||||
WEEK_ENDING_SUNDAY: false,
|
||||
MONTH: true,
|
||||
QUARTER: true,
|
||||
QUARTER_YEAR: false,
|
||||
YEAR: true,
|
||||
},
|
||||
score: 38,
|
||||
max_score: 201,
|
||||
joins: true,
|
||||
subqueries: true,
|
||||
supports_dynamic_schema: false,
|
||||
supports_catalog: false,
|
||||
supports_dynamic_catalog: false,
|
||||
ssh_tunneling: true,
|
||||
supports_file_upload: true,
|
||||
query_cancelation: true,
|
||||
query_cost_estimation: false,
|
||||
sql_validation: false,
|
||||
user_impersonation: false,
|
||||
};
|
||||
|
||||
<DatabasePage name="Ascend" database={databaseInfo} />
|
||||
@@ -159,7 +159,6 @@ single source of truth. The README.md retains its own static copy
|
||||
<a href="/user-docs/databases/supported/apache-pinot" title="Apache Pinot"><img src="/img/databases/apache-pinot.svg" alt="Apache Pinot" width="76" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/apache-solr" title="Apache Solr"><img src="/img/databases/apache-solr.png" alt="Apache Solr" width="79" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/apache-spark-sql" title="Apache Spark SQL"><img src="/img/databases/apache-spark.png" alt="Apache Spark SQL" width="75" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/ascend" title="Ascend"><img src="/img/databases/ascend.webp" alt="Ascend" width="117" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/aurora-mysql-data-api" title="Aurora MySQL (Data API)"><img src="/img/databases/mysql.png" alt="Aurora MySQL (Data API)" width="77" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/aurora-postgresql-data-api" title="Aurora PostgreSQL (Data API)"><img src="/img/databases/postgresql.svg" alt="Aurora PostgreSQL (Data API)" width="76" height="40" /></a>
|
||||
<a href="/user-docs/databases/supported/azure-data-explorer" title="Azure Data Explorer"><img src="/img/databases/kusto.png" alt="Azure Data Explorer" width="40" height="40" /></a>
|
||||
|
||||
@@ -128,7 +128,6 @@ Here are some of the major database solutions that are supported:
|
||||
<a href="https://superset.apache.org/docs/databases/supported/apache-pinot" title="Apache Pinot"><img src="docs/static/img/databases/apache-pinot.svg" alt="Apache Pinot" width="76" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/apache-solr" title="Apache Solr"><img src="docs/static/img/databases/apache-solr.png" alt="Apache Solr" width="79" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/apache-spark-sql" title="Apache Spark SQL"><img src="docs/static/img/databases/apache-spark.png" alt="Apache Spark SQL" width="75" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/ascend" title="Ascend"><img src="docs/static/img/databases/ascend.webp" alt="Ascend" width="117" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/aurora-mysql-data-api" title="Aurora MySQL (Data API)"><img src="docs/static/img/databases/mysql.png" alt="Aurora MySQL (Data API)" width="77" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/aurora-postgresql-data-api" title="Aurora PostgreSQL (Data API)"><img src="docs/static/img/databases/postgresql.svg" alt="Aurora PostgreSQL (Data API)" width="76" height="40" /></a>
|
||||
<a href="https://superset.apache.org/docs/databases/supported/azure-data-explorer" title="Azure Data Explorer"><img src="docs/static/img/databases/kusto.png" alt="Azure Data Explorer" width="40" height="40" /></a>
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.22.6 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.22.7 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -112,7 +112,10 @@ extraEnv: {}
|
||||
# GUNICORN_KEEPALIVE: 2
|
||||
# SERVER_LIMIT_REQUEST_LINE: 0
|
||||
# SERVER_LIMIT_REQUEST_FIELD_SIZE: 0
|
||||
|
||||
# See: https://superset.apache.org/docs/configuration/event-logging/#statsd-logging
|
||||
# SERVER_STATSD_HOST: localhost
|
||||
# SERVER_STATSD_PORT: 8125
|
||||
# SERVER_STATSD_PREFIX: superset
|
||||
# OAUTH_HOME_DOMAIN: ..
|
||||
# # If a whitelist is not set, any address that can use your OAuth2 endpoint will be able to login.
|
||||
# # this includes any random Gmail address if your OAuth2 Web App is set to External.
|
||||
|
||||
+7
-7
@@ -50,7 +50,7 @@ dependencies = [
|
||||
"flask-cors>=6.0.5, <7.0",
|
||||
"croniter>=6.2.4",
|
||||
"cron-descriptor",
|
||||
"cryptography>=50.0.0, <51.0.0",
|
||||
"cryptography>=50.0.1, <51.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <4.0.0",
|
||||
"flask-appbuilder>=5.2.2, <6.0.0",
|
||||
@@ -82,7 +82,7 @@ dependencies = [
|
||||
# https://github.com/apache/superset/issues/33162
|
||||
"marshmallow>=3.0, <5",
|
||||
"marshmallow-union>=0.1.15.post1",
|
||||
"msgpack>=1.2.0, <1.3",
|
||||
"msgpack>=1.2.2, <1.3",
|
||||
"nh3>=0.3.7, <0.4",
|
||||
"numpy>=1.23.5, <2.5",
|
||||
"packaging",
|
||||
@@ -96,7 +96,7 @@ dependencies = [
|
||||
"pgsanity",
|
||||
"Pillow>=12.3.0, <13", # raise floor to match resolved pin; closes SCA false-positive on 11.x-range CVEs already fixed in 12.3.0
|
||||
"polyline>=2.0.4, <3.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pydantic>=2.13.5",
|
||||
"pyparsing>=3.3.2, <4",
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
@@ -110,7 +110,7 @@ dependencies = [
|
||||
"shillelagh[gsheetsapi]>=1.4.5, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.2",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"slack_sdk>=3.44.0, <4",
|
||||
"sqlalchemy>=2.0.52, <2.1",
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
@@ -142,7 +142,7 @@ bigquery = [
|
||||
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
|
||||
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
|
||||
"sqlalchemy-bigquery>=1.17.2",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
"google-cloud-bigquery>=3.44.0",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.7.2, <2.0"]
|
||||
# The `cockroachdb` PyPI package (last released 2021) is abandoned and its
|
||||
@@ -273,7 +273,7 @@ tdengine = [
|
||||
"taospy>=2.8.10",
|
||||
"taos-ws-py>=0.7.0"
|
||||
]
|
||||
teradata = ["teradatasql>=20.0.0.66"]
|
||||
teradata = ["teradatasql>=20.0.0.67"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
|
||||
@@ -288,7 +288,7 @@ development = [
|
||||
"docker",
|
||||
"flask-testing",
|
||||
"freezegun",
|
||||
"grpcio>=1.82.1",
|
||||
"grpcio>=1.83.1",
|
||||
"openapi-spec-validator",
|
||||
"parameterized",
|
||||
"pip",
|
||||
|
||||
@@ -26,7 +26,7 @@ filelock>=3.20.3,<4.0.0
|
||||
brotli>=1.2.0,<2.0.0
|
||||
numexpr>=2.9.0
|
||||
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
|
||||
cryptography>=50.0.0,<51.0.0
|
||||
cryptography>=50.0.1,<51.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.4.1,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
|
||||
@@ -84,7 +84,7 @@ cron-descriptor==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
croniter==6.2.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
cryptography==50.0.0
|
||||
cryptography==50.0.1
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -236,7 +236,7 @@ marshmallow-union==0.1.15.post1
|
||||
# via apache-superset (pyproject.toml)
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
msgpack==1.2.1
|
||||
msgpack==1.2.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
msgspec==0.19.0
|
||||
# via flask-session
|
||||
@@ -297,11 +297,11 @@ pyasn1-modules==0.4.2
|
||||
# via google-auth
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
pydantic==2.13.4
|
||||
pydantic==2.13.5
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
pydantic-core==2.46.4
|
||||
pydantic-core==2.46.5
|
||||
# via pydantic
|
||||
pygeohash==3.2.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -378,7 +378,7 @@ six==1.17.0
|
||||
# python-dateutil
|
||||
# rfc3339-validator
|
||||
# wtforms-json
|
||||
slack-sdk==3.43.0
|
||||
slack-sdk==3.44.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlalchemy==2.0.52
|
||||
# via
|
||||
|
||||
@@ -179,7 +179,7 @@ croniter==6.2.4
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
cryptography==50.0.0
|
||||
cryptography==50.0.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -360,7 +360,7 @@ google-auth-oauthlib==1.2.1
|
||||
# via
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
google-cloud-bigquery==3.43.0
|
||||
google-cloud-bigquery==3.45.0
|
||||
# via
|
||||
# apache-superset
|
||||
# pandas-gbq
|
||||
@@ -384,7 +384,7 @@ greenlet==3.5.5
|
||||
# sqlalchemy
|
||||
griffelib==2.0.2
|
||||
# via fastmcp-slim
|
||||
grpcio==1.83.0
|
||||
grpcio==1.83.1
|
||||
# via
|
||||
# apache-superset
|
||||
# google-api-core
|
||||
@@ -559,7 +559,7 @@ more-itertools==10.8.0
|
||||
# via
|
||||
# jaraco-classes
|
||||
# jaraco-functools
|
||||
msgpack==1.2.1
|
||||
msgpack==1.2.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -731,7 +731,7 @@ pycparser==2.22
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# cffi
|
||||
pydantic==2.13.4
|
||||
pydantic==2.13.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -740,7 +740,7 @@ pydantic==2.13.4
|
||||
# mcp
|
||||
# openapi-pydantic
|
||||
# pydantic-settings
|
||||
pydantic-core==2.46.4
|
||||
pydantic-core==2.46.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# pydantic
|
||||
@@ -943,7 +943,7 @@ six==1.17.0
|
||||
# python-dateutil
|
||||
# rfc3339-validator
|
||||
# wtforms-json
|
||||
slack-sdk==3.43.0
|
||||
slack-sdk==3.44.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 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.
|
||||
|
||||
# Format the passed files with oxfmt, from within an npm workspace.
|
||||
#
|
||||
# Usage: scripts/oxfmt.sh <workspace-dir> [file...]
|
||||
#
|
||||
# Paths are passed in repo-relative (as pre-commit provides them) and rewritten
|
||||
# relative to the workspace, since oxfmt resolves its config from the working
|
||||
# directory.
|
||||
|
||||
set -e
|
||||
|
||||
workspace_dir="$1"
|
||||
shift
|
||||
|
||||
if [[ -z "$workspace_dir" ]]; then
|
||||
echo "Error: no workspace directory given" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
script_dir="$(dirname "$(realpath "$0")")"
|
||||
root_dir="$(dirname "$script_dir")"
|
||||
|
||||
if [[ ! -d "$root_dir/$workspace_dir" ]]; then
|
||||
echo "Error: $workspace_dir directory not found in $root_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$root_dir/$workspace_dir"
|
||||
|
||||
files=()
|
||||
for file in "$@"; do
|
||||
files+=("${file#$workspace_dir/}")
|
||||
done
|
||||
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No files to format"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
npx oxfmt --write --no-error-on-unmatched-pattern -- "${files[@]}"
|
||||
@@ -23,6 +23,7 @@ from superset_core.tasks.types import TaskContext, TaskScope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.tasks.models import Task
|
||||
from superset_core.tasks.subscription import TaskSubscriptionPolicy
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
@@ -32,6 +33,7 @@ def task(
|
||||
name: str | None = None,
|
||||
scope: TaskScope = TaskScope.PRIVATE,
|
||||
timeout: int | None = None,
|
||||
subscription_policy: "TaskSubscriptionPolicy | None" = None,
|
||||
) -> Callable[[Callable[P, R]], "TaskWrapper[P]"]:
|
||||
"""
|
||||
Decorator to register a task.
|
||||
@@ -46,6 +48,13 @@ def task(
|
||||
:param timeout: Optional timeout in seconds. When the timeout is reached,
|
||||
abort handlers are triggered if registered. Can be overridden
|
||||
at call time via TaskOptions(timeout=...).
|
||||
:param subscription_policy: Optional per-client subscription policy. The
|
||||
framework subscribes tasks at principal grain (one row per
|
||||
user/guest); a policy refines that with a finer per-client
|
||||
grain (e.g. one browser tab) so a cancel from one client does
|
||||
not abort a SHARED task another client of the same principal
|
||||
is still awaiting. See
|
||||
``superset_core.tasks.subscription.TaskSubscriptionPolicy``.
|
||||
:returns: TaskWrapper with .schedule() method
|
||||
|
||||
Note:
|
||||
|
||||
@@ -120,11 +120,29 @@ class Task(CoreModel):
|
||||
"""
|
||||
raise NotImplementedError("Property will be replaced during initialization")
|
||||
|
||||
@property
|
||||
def properties_dict(self) -> "TaskProperties":
|
||||
"""
|
||||
Get the parsed properties as a sparse ``TaskProperties`` dict.
|
||||
|
||||
The canonical read accessor for runtime state and execution config
|
||||
(progress, error info, the internal ``private`` bucket). Always use
|
||||
``.get()`` since only explicitly-set keys are present.
|
||||
|
||||
Host implementations will replace this property during initialization.
|
||||
|
||||
:returns: Parsed ``TaskProperties`` dict
|
||||
"""
|
||||
raise NotImplementedError("Property will be replaced during initialization")
|
||||
|
||||
def update_properties(self, updates: "TaskProperties") -> None:
|
||||
"""
|
||||
Update specific properties fields (merge semantics).
|
||||
|
||||
Only updates fields present in the updates dict.
|
||||
Only updates fields present in the updates dict. The ``private`` subtree
|
||||
is merged recursively (its ``framework``, ``task`` and ``subscription``
|
||||
namespaces merge independently), so a write to one namespace never
|
||||
clobbers the others.
|
||||
|
||||
Host implementations will replace this method during initialization.
|
||||
|
||||
@@ -135,6 +153,23 @@ class Task(CoreModel):
|
||||
"""
|
||||
raise NotImplementedError("Method will be replaced during initialization")
|
||||
|
||||
def update_task_private(self, updates: dict[str, Any]) -> None:
|
||||
"""
|
||||
Merge keys into the task-owned ``private["task"]`` namespace.
|
||||
|
||||
The freeform, task-type-specific internal namespace (isolated from the
|
||||
framework-owned ``private["framework"]`` keys) for handles a task type
|
||||
needs to persist but that are not task output — e.g. an engine query
|
||||
cancel handle. A subscription policy's per-client bookkeeping belongs in
|
||||
the separate ``private["subscription"]`` namespace instead. Never
|
||||
surfaced to user-facing API payloads except in debug mode.
|
||||
|
||||
Host implementations will replace this method during initialization.
|
||||
|
||||
:param updates: Keys to merge into ``private["task"]``
|
||||
"""
|
||||
raise NotImplementedError("Method will be replaced during initialization")
|
||||
|
||||
|
||||
class TaskSubscriber(CoreModel):
|
||||
"""
|
||||
@@ -145,7 +180,9 @@ class TaskSubscriber(CoreModel):
|
||||
|
||||
This model tracks task subscriptions for multi-user shared tasks. When a user
|
||||
schedules a shared task with the same parameters as an existing task,
|
||||
they are subscribed to that task instead of creating a duplicate.
|
||||
they are subscribed to that task instead of creating a duplicate. A subscriber
|
||||
is identified by exactly one of ``user_id`` (authenticated) or ``guest_key``
|
||||
(an embedded guest, which has no ``ab_user`` row).
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
@@ -153,7 +190,8 @@ class TaskSubscriber(CoreModel):
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int
|
||||
user_id: int | None
|
||||
guest_key: str | None
|
||||
subscribed_at: datetime
|
||||
|
||||
# Audit fields from AuditMixinNullable
|
||||
@@ -161,3 +199,30 @@ class TaskSubscriber(CoreModel):
|
||||
changed_on: datetime | None
|
||||
created_by_fk: int | None
|
||||
changed_by_fk: int | None
|
||||
|
||||
|
||||
class TaskDependency(CoreModel):
|
||||
"""
|
||||
Abstract TaskDependency model interface.
|
||||
|
||||
Host implementations will replace this class during initialization
|
||||
with concrete implementation providing actual functionality.
|
||||
|
||||
This model represents a directed edge in the task dependency graph (DAG):
|
||||
the task identified by ``task_id`` depends on the prerequisite task
|
||||
identified by ``depends_on_task_id``. A task only runs once all of its
|
||||
prerequisites have reached a terminal SUCCESS.
|
||||
"""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
# Type hints for expected attributes (no actual field definitions)
|
||||
id: int
|
||||
task_id: int # The dependent task
|
||||
depends_on_task_id: int # The prerequisite task
|
||||
|
||||
# Audit fields from AuditMixinNullable
|
||||
created_on: datetime | None
|
||||
changed_on: datetime | None
|
||||
created_by_fk: int | None
|
||||
changed_by_fk: int | None
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# 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.
|
||||
|
||||
"""Task-type subscription policies for the Global Task Framework (GTF).
|
||||
|
||||
The framework's own subscription model is **principal-oriented**: a task has one
|
||||
subscriber row per principal (an authenticated user, or an embedded guest keyed
|
||||
by a token-derived identity), and cancel/abort decisions are made from that
|
||||
principal-grain subscriber count. That model — and everything built on it
|
||||
(``TaskFilter`` visibility, ``subscriber_count``, ``raise_for_access``) — is
|
||||
intentionally kept free of any finer notion of "who exactly is watching".
|
||||
|
||||
Some task types need a finer grain than the principal. The canonical case is
|
||||
async chart-data: a single ``SHARED`` task is deduplicated across every request
|
||||
for the same ``query_cache_key``, so one user watching it from **two browser
|
||||
tabs** is still a single principal. If either tab's "cancel" (an explicit cancel
|
||||
or a navigate-away teardown) were treated as *the* principal leaving, it would
|
||||
abort the shared task and kill the other tab's still-pending query.
|
||||
|
||||
A **subscription policy** lets a task type refine this without the framework
|
||||
knowing anything about tabs (or any other per-client grain). A task registers a
|
||||
policy on its :func:`superset_core.tasks.decorators.task` decorator; the
|
||||
framework invokes it, under the same lock that serializes submit/cancel, at two
|
||||
points:
|
||||
|
||||
- **on subscribe** — after the framework has ensured the principal's subscriber
|
||||
row (create or dedup-join). The policy records the calling client.
|
||||
- **on unsubscribe** — when a principal cancels. The policy drops the calling
|
||||
client and returns whether the principal has *any client left*. ``False`` means
|
||||
"one client detached, keep the principal subscribed and the task running";
|
||||
``True`` means "the principal's last client is gone" and the framework then
|
||||
applies its normal principal-grain rule (unsubscribe the principal, and abort
|
||||
if it was the last subscriber).
|
||||
|
||||
A task type with no policy behaves exactly as before (principal-grain). The
|
||||
policy owns its own bookkeeping — the chart-data policy, for instance, stores
|
||||
its per-tab set in the task's ``private["subscription"]`` namespace (see
|
||||
:class:`superset_core.tasks.types.PrivateProperties`), which the framework never
|
||||
inspects. ``client_ref`` is an opaque, client-supplied identifier (e.g. a
|
||||
browser-tab id); it is **not** an authorization token — the framework has
|
||||
already authorized the calling principal before the policy runs, and the policy
|
||||
only ever records/removes entries scoped to that principal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.tasks.models import Task
|
||||
|
||||
|
||||
class TaskSubscriptionPolicy(ABC):
|
||||
"""Per-client subscription refinement for a task type (see module docstring).
|
||||
|
||||
Register an instance on the ``@task`` decorator
|
||||
(``@task(..., subscription_policy=MyPolicy())``). Both hooks run in the web
|
||||
request process, inside the distributed lock that serializes concurrent
|
||||
submit/cancel for the task, so an implementation may safely read-modify-write
|
||||
task state (e.g. a list in ``private["subscription"]``) without additional locking.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def on_subscribe(
|
||||
self,
|
||||
task: "Task",
|
||||
*,
|
||||
principal: str,
|
||||
client_ref: str | None,
|
||||
) -> None:
|
||||
"""Record that ``client_ref`` (a client of ``principal``) joined ``task``.
|
||||
|
||||
Called after the framework has ensured ``principal``'s subscriber row.
|
||||
Should be idempotent: the same ``(principal, client_ref)`` may be
|
||||
submitted more than once (e.g. a resubmit from the same tab).
|
||||
|
||||
:param task: the task being subscribed to
|
||||
:param principal: the calling principal's stable routing id
|
||||
(``user:<id>`` for a user, the guest key for an embedded guest)
|
||||
:param client_ref: the opaque per-client id (e.g. a browser-tab id), or
|
||||
``None`` when the caller supplied none (the policy should then no-op,
|
||||
preserving principal-grain behavior)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def on_unsubscribe(
|
||||
self,
|
||||
task: "Task",
|
||||
*,
|
||||
principal: str,
|
||||
client_ref: str | None,
|
||||
) -> bool:
|
||||
"""Drop ``client_ref`` and report whether ``principal`` has any client left.
|
||||
|
||||
Called when ``principal`` cancels the task.
|
||||
|
||||
:param task: the task being cancelled
|
||||
:param principal: the calling principal's stable routing id
|
||||
:param client_ref: the opaque per-client id being removed, or ``None``
|
||||
:returns: ``True`` if the framework should proceed to unsubscribe
|
||||
``principal`` (its last client is gone, or the caller supplied no
|
||||
``client_ref``); ``False`` to keep ``principal`` subscribed because it
|
||||
still has other clients on this task (a single client detached).
|
||||
"""
|
||||
|
||||
def routing_channels(self, task: "Task") -> list[str] | None:
|
||||
"""Realtime websocket routing keys for this task's status fanout.
|
||||
|
||||
Lets a task type deliver ``task-status`` at a finer grain than the
|
||||
principal — e.g. only to the specific browser tab watching the task,
|
||||
rather than every tab the principal has open. Returns the list of opaque
|
||||
routing keys the realtime transport should target (it prefixes each with
|
||||
``realtime:`` and never parses them); the caller delivers to exactly those
|
||||
keys.
|
||||
|
||||
Return ``None`` (the default) to keep principal-grain fanout — the
|
||||
framework then derives one key per subscriber principal. A concrete policy
|
||||
that manages per-client keys should also return ``None`` (not an empty
|
||||
list) when it currently has no keys, so fanout falls back to
|
||||
principal-grain rather than silently delivering to no one.
|
||||
|
||||
:param task: the task whose status is being published
|
||||
:returns: the routing keys to target, or ``None`` for principal-grain
|
||||
"""
|
||||
return None
|
||||
@@ -20,7 +20,11 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Literal, TypedDict
|
||||
from typing import Any, Callable, Literal, TYPE_CHECKING, TypedDict, Union
|
||||
from uuid import UUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.tasks.models import Task
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
@@ -77,13 +81,60 @@ class TaskProperties(TypedDict, total=False):
|
||||
progress_percent: float
|
||||
progress_current: int
|
||||
progress_total: int
|
||||
dedupe_count: int
|
||||
|
||||
# Error info - set when task fails
|
||||
# Error info - set when task fails. ``error_message`` is the consumer-facing
|
||||
# failure reason (public); the exception class and traceback are internal
|
||||
# debug detail and live under ``private["framework"]`` instead.
|
||||
error_message: str
|
||||
|
||||
# Internal runtime state, surfaced to user-facing API payloads only in debug
|
||||
# mode (the Task REST API strips this key otherwise). Holds framework/task
|
||||
# plumbing rather than task output. See ``PrivateProperties``.
|
||||
private: "PrivateProperties"
|
||||
|
||||
|
||||
class FrameworkPrivateProperties(TypedDict, total=False):
|
||||
"""Framework-owned internal task state, under ``private["framework"]``.
|
||||
|
||||
Named keys written *only* by the framework, common to every task type: the
|
||||
Celery job id the orphan reaper revokes, and error-debug detail (exception
|
||||
class + traceback). Isolated from task-owned keys so a task type can never
|
||||
clobber them. Task-execution handles specific to one kind of task (e.g. a
|
||||
warehouse-query cancel handle) belong in the freeform ``task`` namespace, not
|
||||
here.
|
||||
"""
|
||||
|
||||
celery_task_id: str
|
||||
exception_type: str
|
||||
stack_trace: str
|
||||
|
||||
|
||||
class PrivateProperties(TypedDict, total=False):
|
||||
"""Internal task runtime state, stored under ``TaskProperties["private"]``.
|
||||
|
||||
Never surfaced to user-facing API payloads except in debug mode; distinct
|
||||
from task output, which belongs in the task's ``payload``. Split into three
|
||||
structurally isolated namespaces so a task type's freeform key can never
|
||||
collide with a framework orchestration key or a subscription policy's
|
||||
bookkeeping:
|
||||
|
||||
- ``framework``: named framework-owned keys, common to all tasks (see
|
||||
``FrameworkPrivateProperties``).
|
||||
- ``task``: freeform, task-type-specific internal handles, written only by
|
||||
task/execution code. E.g. the chart-data query task stores its engine
|
||||
cancel handle here (``cancel_query_id`` / ``cancel_database_id``).
|
||||
- ``subscription``: freeform bookkeeping owned by the task type's
|
||||
``SubscriptionPolicy`` (see ``superset_core.tasks.subscription``), written
|
||||
only through the policy hooks. E.g. the chart-data policy stores its
|
||||
per-client consumer list here. The framework never inspects it.
|
||||
"""
|
||||
|
||||
framework: "FrameworkPrivateProperties"
|
||||
task: dict[str, Any]
|
||||
subscription: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskOptions:
|
||||
"""
|
||||
@@ -122,11 +173,24 @@ class TaskOptions:
|
||||
task = long_task.schedule(
|
||||
options=TaskOptions(timeout=600) # 10 minute timeout
|
||||
)
|
||||
|
||||
# Task that waits for prerequisite tasks to succeed before running.
|
||||
# Pass the scheduled Task objects (canonical); UUIDs are also accepted.
|
||||
parent = parent_task.schedule()
|
||||
task = dependent_task.schedule(
|
||||
options=TaskOptions(depends_on=[parent])
|
||||
)
|
||||
"""
|
||||
|
||||
task_key: str | None = None
|
||||
task_name: str | None = None
|
||||
timeout: int | None = None # Timeout in seconds
|
||||
# Prerequisite tasks this task depends on. Each entry may be a scheduled
|
||||
# Task, its UUID, or a UUID string. The task only runs once every
|
||||
# prerequisite has reached a terminal SUCCESS; if any prerequisite ends in a
|
||||
# non-SUCCESS terminal state the task fails without running (all_success
|
||||
# semantics).
|
||||
depends_on: list[Union["Task", UUID, str]] | None = None
|
||||
|
||||
|
||||
class TaskContext(ABC):
|
||||
@@ -146,6 +210,8 @@ class TaskContext(ABC):
|
||||
self,
|
||||
progress: float | int | tuple[int, int] | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
immediate: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Update task progress and/or payload atomically.
|
||||
@@ -153,6 +219,11 @@ class TaskContext(ABC):
|
||||
All parameters are optional. Payload is merged with existing data,
|
||||
not replaced. All updates occur in a single database transaction.
|
||||
|
||||
Writes are throttled by default to protect the database from eager
|
||||
tasks. Pass ``immediate=True`` to force a synchronous write, bypassing
|
||||
throttling, when a downstream consumer must observe this update as soon
|
||||
as the task completes (e.g. a dependent task reading a published value).
|
||||
|
||||
Progress can be specified in three ways:
|
||||
- float (0.0-1.0): Percentage only, e.g., 0.5 means 50%
|
||||
- int: Count only (total unknown), e.g., 42 means "42 items processed"
|
||||
@@ -161,6 +232,7 @@ class TaskContext(ABC):
|
||||
|
||||
:param progress: Progress value, or None to leave unchanged
|
||||
:param payload: Payload data to merge (dict), or None to leave unchanged
|
||||
:param immediate: When True, write synchronously and bypass throttling
|
||||
|
||||
Examples:
|
||||
# Percentage only - displays as "In progress: 50 %"
|
||||
@@ -183,6 +255,17 @@ class TaskContext(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_dependency_payloads(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return payloads published by prerequisite tasks.
|
||||
|
||||
The payloads are returned in dependency edge order. They let dependent
|
||||
task code consume small pieces of output metadata from tasks that have
|
||||
already satisfied the DAG all-success gate.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def on_cleanup(self, handler: Callable[[], None]) -> Callable[[], None]:
|
||||
"""
|
||||
|
||||
@@ -90,7 +90,11 @@ module.exports = {
|
||||
// @ant-design/colors and @ant-design/fast-color are allowed through because
|
||||
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
|
||||
// from its CJS output, so babel-jest must transform those files.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
|
||||
//
|
||||
// react-markdown and the remark/rehype/vfile packages it pulls in are
|
||||
// ESM-only, so they are allowed through for the suites that opt out of the
|
||||
// react-markdown stub in spec/helpers/shim.tsx to render real Markdown.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|react-markdown|vfile|web-namespaces|html-void-elements|html-url-attributes|estree-util-is-identifier-name|trim-lines|is-plain-obj|trough|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
Generated
+3845
-604
File diff suppressed because it is too large
Load Diff
@@ -320,7 +320,7 @@
|
||||
"history": "^5.3.0",
|
||||
"html-webpack-plugin": "^5.6.8",
|
||||
"imports-loader": "^5.0.0",
|
||||
"jest": "^30.4.2",
|
||||
"jest": "^30.5.0",
|
||||
"jest-environment-jsdom": "^30.5.0",
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"fs-extra": "^11.4.0",
|
||||
"jest": "^30.4.2",
|
||||
"jest": "^30.5.0",
|
||||
"yeoman-test": "^11.6.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+184
-16
@@ -19,6 +19,7 @@
|
||||
|
||||
import { render, waitFor, configure, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import type { QueryData } from '../..';
|
||||
import StatefulChart from './StatefulChart';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
|
||||
@@ -714,10 +715,7 @@ test('should refetch when mixing renderTrigger string control with non-renderTri
|
||||
|
||||
test('resolves async (202) responses via the injected handleAsyncChartData hook', async () => {
|
||||
const asyncJob = {
|
||||
channel_id: 'c1',
|
||||
job_id: 'j1',
|
||||
status: 'running',
|
||||
result_url: '/api/v1/chart/data/abc',
|
||||
task_ids: ['task-1', 'task-2'],
|
||||
};
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
@@ -738,10 +736,12 @@ test('resolves async (202) responses via the injected handleAsyncChartData hook'
|
||||
await waitFor(() => {
|
||||
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Delegates the raw response + job metadata (and abort signal)
|
||||
// Delegates the raw response + async job (task_ids), a refetch thunk, and the
|
||||
// abort signal.
|
||||
expect(handleAsyncChartData).toHaveBeenCalledWith(
|
||||
{ status: 202 },
|
||||
asyncJob,
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
// Chart renders once the async data resolves
|
||||
@@ -750,10 +750,178 @@ test('resolves async (202) responses via the injected handleAsyncChartData hook'
|
||||
});
|
||||
});
|
||||
|
||||
test('forced async read-back re-sends force with per-query task ids as nonces', async () => {
|
||||
mockChartClient.client.post
|
||||
.mockResolvedValueOnce({
|
||||
response: { status: 202 } as Response,
|
||||
json: { task_ids: ['task-1', 'task-2'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: { status: 200 } as Response,
|
||||
json: [{ data: 'cached' }],
|
||||
});
|
||||
// The handler resolves by re-issuing the request with the task ids (the
|
||||
// force nonces), mirroring how the app-level async middleware calls refetch.
|
||||
const handleAsyncChartData = jest.fn(
|
||||
async (
|
||||
_response: Response,
|
||||
_json: unknown,
|
||||
refetch: (nonces?: string[]) => Promise<QueryData[]>,
|
||||
) => refetch(['task-1', 'task-2']),
|
||||
);
|
||||
|
||||
render(
|
||||
<StatefulChart
|
||||
formData={mockFormData}
|
||||
chartType="test_chart"
|
||||
force
|
||||
hooks={{ handleAsyncChartData }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const { jsonPayload } = mockChartClient.client.post.mock.calls[1][0];
|
||||
// Read-back re-forces so the marker can suppress recompute, stamps each query
|
||||
// with its task id, and resolves inline (no async_mode).
|
||||
expect(jsonPayload.force).toBe(true);
|
||||
expect(jsonPayload.queries[0].force_nonce).toBe('task-1');
|
||||
expect(jsonPayload.async_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
test('non-forced async read-back carries neither force nor a nonce', async () => {
|
||||
mockChartClient.client.post
|
||||
.mockResolvedValueOnce({
|
||||
response: { status: 202 } as Response,
|
||||
json: { task_ids: ['task-1'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: { status: 200 } as Response,
|
||||
json: [{ data: 'cached' }],
|
||||
});
|
||||
const handleAsyncChartData = jest.fn(
|
||||
async (
|
||||
_response: Response,
|
||||
_json: unknown,
|
||||
refetch: (nonces?: string[]) => Promise<QueryData[]>,
|
||||
) => refetch(['task-1']),
|
||||
);
|
||||
|
||||
render(
|
||||
<StatefulChart
|
||||
formData={mockFormData}
|
||||
chartType="test_chart"
|
||||
hooks={{ handleAsyncChartData }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const { jsonPayload } = mockChartClient.client.post.mock.calls[1][0];
|
||||
expect(jsonPayload.force).not.toBe(true);
|
||||
expect(jsonPayload.queries[0].force_nonce).toBeUndefined();
|
||||
});
|
||||
|
||||
test('requests async_mode and the tab id when opting in and 202 is handled', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 200 } as Response,
|
||||
json: [{ data: 'sync-from-cache' }],
|
||||
});
|
||||
const handleAsyncChartData = jest.fn().mockResolvedValue([{ data: 'x' }]);
|
||||
|
||||
render(
|
||||
<StatefulChart
|
||||
formData={mockFormData}
|
||||
chartType="test_chart"
|
||||
hooks={{
|
||||
handleAsyncChartData,
|
||||
resolveAsyncMode: () => true,
|
||||
getTabId: () => 'tab-7',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const { jsonPayload } = mockChartClient.client.post.mock.calls[0][0];
|
||||
expect(jsonPayload.async_mode).toBe(true);
|
||||
// The tab id lets the backend ref-count this tab (per-tab cancel/detach).
|
||||
expect(jsonPayload.tab_id).toBe('tab-7');
|
||||
});
|
||||
|
||||
test('omits the tab id when no getTabId hook is wired', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 200 } as Response,
|
||||
json: [{ data: 'sync-from-cache' }],
|
||||
});
|
||||
const handleAsyncChartData = jest.fn().mockResolvedValue([{ data: 'x' }]);
|
||||
|
||||
render(
|
||||
<StatefulChart
|
||||
formData={mockFormData}
|
||||
chartType="test_chart"
|
||||
hooks={{ handleAsyncChartData, resolveAsyncMode: () => true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const { jsonPayload } = mockChartClient.client.post.mock.calls[0][0];
|
||||
expect(jsonPayload.async_mode).toBe(true);
|
||||
expect(jsonPayload.tab_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test('omits async_mode when resolveAsyncMode opts out', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 200 } as Response,
|
||||
json: [{ data: 'sync' }],
|
||||
});
|
||||
const handleAsyncChartData = jest.fn();
|
||||
|
||||
render(
|
||||
<StatefulChart
|
||||
formData={mockFormData}
|
||||
chartType="test_chart"
|
||||
hooks={{ handleAsyncChartData, resolveAsyncMode: () => false }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const { jsonPayload } = mockChartClient.client.post.mock.calls[0][0];
|
||||
expect(jsonPayload.async_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
test('omits async_mode when no async handler is wired even if resolveAsyncMode opts in', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 200 } as Response,
|
||||
json: [{ data: 'sync' }],
|
||||
});
|
||||
|
||||
render(
|
||||
<StatefulChart
|
||||
formData={mockFormData}
|
||||
chartType="test_chart"
|
||||
hooks={{ resolveAsyncMode: () => true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const { jsonPayload } = mockChartClient.client.post.mock.calls[0][0];
|
||||
expect(jsonPayload.async_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
test('errors on async (202) response when no async handler is provided', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j1', channel_id: 'c1', status: 'running' },
|
||||
json: { task_ids: ['task-1'] },
|
||||
});
|
||||
const onError = jest.fn();
|
||||
|
||||
@@ -792,7 +960,7 @@ test('renders synchronous (200) responses that include a response object', async
|
||||
test('does not apply a superseded async response over a newer one', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j', channel_id: 'c' },
|
||||
json: { task_ids: ['task-1'] },
|
||||
});
|
||||
let resolveFirst: (data: unknown) => void = () => {};
|
||||
let resolveSecond: (data: unknown) => void = () => {};
|
||||
@@ -859,7 +1027,7 @@ test('does not apply a superseded async response over a newer one', async () =>
|
||||
test('preserves the detailed message from an async (array) rejection', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j', channel_id: 'c' },
|
||||
json: { task_ids: ['task-1'] },
|
||||
});
|
||||
const handleAsyncChartData = jest
|
||||
.fn()
|
||||
@@ -919,7 +1087,7 @@ test('refetches with the latest formData rather than the initial props', async (
|
||||
test('does not revert a render-only change when a slow async request resolves', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j', channel_id: 'c' },
|
||||
json: { task_ids: ['task-1'] },
|
||||
});
|
||||
// color_scheme is a renderTrigger control -> its change does not refetch
|
||||
jest.mocked(getChartControlPanelRegistry).mockReturnValue({
|
||||
@@ -933,10 +1101,10 @@ test('does not revert a render-only change when a slow async request resolves',
|
||||
],
|
||||
}),
|
||||
} as unknown as ReturnType<typeof getChartControlPanelRegistry>);
|
||||
let resolveAsync: (data: unknown) => void = () => {};
|
||||
let resolveAsync: (data: QueryData[]) => void = () => {};
|
||||
const handleAsyncChartData = jest.fn(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
new Promise<QueryData[]>(resolve => {
|
||||
resolveAsync = resolve;
|
||||
}),
|
||||
);
|
||||
@@ -976,9 +1144,9 @@ test('does not revert a render-only change when a slow async request resolves',
|
||||
test('passes an abort signal to the async handler and aborts it on unmount', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j', channel_id: 'c' },
|
||||
json: { task_ids: ['task-1'] },
|
||||
});
|
||||
// Typed with a rest param so mock.calls is indexable (the 3rd arg is the signal)
|
||||
// Typed with a rest param so mock.calls is indexable (the 4th arg is the signal)
|
||||
const handleAsyncChartData = jest.fn(
|
||||
(..._args: unknown[]) => new Promise<never>(() => {}), // never resolves
|
||||
);
|
||||
@@ -994,7 +1162,7 @@ test('passes an abort signal to the async handler and aborts it on unmount', asy
|
||||
await waitFor(() => {
|
||||
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const signal = handleAsyncChartData.mock.calls[0][2] as AbortSignal;
|
||||
const signal = handleAsyncChartData.mock.calls[0][3] as AbortSignal;
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
@@ -1006,7 +1174,7 @@ test('passes an abort signal to the async handler and aborts it on unmount', asy
|
||||
test('suppresses stale error state from a superseded request', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j', channel_id: 'c' },
|
||||
json: { task_ids: ['task-1'] },
|
||||
});
|
||||
let rejectFirst: (err: unknown) => void = () => {};
|
||||
const handleAsyncChartData = jest
|
||||
@@ -1056,7 +1224,7 @@ test('does not publish stale data when switching from chartId to formData mode',
|
||||
mockChartClient.loadFormData.mockResolvedValue({ ...mockFormData });
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j', channel_id: 'c' },
|
||||
json: { task_ids: ['task-1'] },
|
||||
});
|
||||
let resolveFirst: (data: unknown) => void = () => {};
|
||||
const handleAsyncChartData = jest
|
||||
|
||||
+60
-18
@@ -33,6 +33,7 @@ import {
|
||||
} from '../..';
|
||||
import { Loading } from '../../components/Loading';
|
||||
import ChartClient from '../clients/ChartClient';
|
||||
import type { Hooks } from '../models/ChartProps';
|
||||
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import SuperChart from './SuperChart';
|
||||
@@ -75,7 +76,6 @@ function shouldRefetchData(
|
||||
return true;
|
||||
}
|
||||
|
||||
// If viz_type changed, always refetch
|
||||
if (prevFormData.viz_type !== nextFormData.viz_type) {
|
||||
return true;
|
||||
}
|
||||
@@ -178,7 +178,16 @@ export interface StatefulChartProps {
|
||||
className?: string;
|
||||
|
||||
// Hooks for chart interactions (drill, cross-filter, etc.)
|
||||
hooks?: any;
|
||||
hooks?: Hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a chart-data body into result rows: the API nests them under `result`,
|
||||
* but a caller may hand back the rows themselves.
|
||||
*/
|
||||
function extractRows(json: JsonObject | JsonObject[]): QueryData[] {
|
||||
const rows = ensureIsArray(json) as JsonObject[];
|
||||
return (rows[0]?.result ? rows[0].result : rows) as QueryData[];
|
||||
}
|
||||
|
||||
export default function StatefulChart(props: StatefulChartProps) {
|
||||
@@ -256,13 +265,11 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
let finalFormData: QueryFormData;
|
||||
|
||||
if (chartId && !propsFormData) {
|
||||
// Load formData from chartId
|
||||
finalFormData = await chartClientRef.current!.loadFormData(
|
||||
{ sliceId: chartId },
|
||||
{ signal: controller.signal } as RequestConfig,
|
||||
);
|
||||
} else if (propsFormData) {
|
||||
// Use provided formData
|
||||
finalFormData = propsFormData;
|
||||
} else {
|
||||
throw new Error('Either chartId or formData must be provided');
|
||||
@@ -303,6 +310,17 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
jsonPayload: {
|
||||
...queryContext,
|
||||
...(force && { force: true }),
|
||||
// Opt into async execution per the injected policy (feature flag +
|
||||
// deployment default + dashboard override). We handle the 202 below via
|
||||
// handleAsyncChartData; without the hook we stay synchronous. Send the
|
||||
// tab id (when the app injected getTabId) so the backend ref-counts
|
||||
// this tab as a consumer of the shared task, matching the Redux path.
|
||||
...(hooks?.handleAsyncChartData && hooks?.resolveAsyncMode?.()
|
||||
? {
|
||||
async_mode: true,
|
||||
...(hooks?.getTabId ? { tab_id: hooks.getTabId() } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -319,12 +337,12 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
|
||||
let responseData: QueryData[];
|
||||
if (rawResponse?.status === 202) {
|
||||
// With GLOBAL_ASYNC_QUERIES the query is dispatched to a Celery worker
|
||||
// and the 202 body is job metadata (channel_id, job_id, result_url),
|
||||
// not chart data. Delegate to the injected handler, which polls the
|
||||
// async event channel and resolves the cached results. Without a
|
||||
// handler we fail loudly rather than rendering the job metadata as if
|
||||
// it were an (empty) result set.
|
||||
// With GLOBAL_ASYNC_QUERIES the query runs as one GTF task per
|
||||
// QueryObject and the 202 body is the async job ({task_ids}), not chart
|
||||
// data. Delegate to the injected handler, which polls task statuses and,
|
||||
// once they succeed, calls `refetch` to re-issue this request and read
|
||||
// the now-cached results. Without a handler we fail loudly rather than
|
||||
// rendering the job metadata as if it were an (empty) result set.
|
||||
if (!hooks?.handleAsyncChartData) {
|
||||
throw new Error(
|
||||
'Received an async chart data response (HTTP 202) but no async ' +
|
||||
@@ -332,10 +350,41 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
'the async handler or disable GLOBAL_ASYNC_QUERIES for this chart.',
|
||||
);
|
||||
}
|
||||
// Re-issue synchronously from the warm per-query cache and extract rows.
|
||||
// A forced request re-sends `force: true` and stamps each query's task id
|
||||
// (passed by the async handler) as its `force_nonce`, so the backend serves
|
||||
// the result that task cached rather than recomputing — and re-forces
|
||||
// (instead of serving stale) if that result was not persisted. Non-forced
|
||||
// reads carry neither. `async_mode` is intentionally omitted so the read-back
|
||||
// resolves inline instead of returning another 202.
|
||||
const refetch = async (
|
||||
queryForceNonces?: string[],
|
||||
): Promise<QueryData[]> => {
|
||||
const nonces = force ? queryForceNonces : undefined;
|
||||
const readBackContext = nonces?.length
|
||||
? {
|
||||
...queryContext,
|
||||
queries: queryContext.queries.map((query, index) =>
|
||||
nonces[index]
|
||||
? { ...query, force_nonce: nonces[index] }
|
||||
: query,
|
||||
),
|
||||
}
|
||||
: queryContext;
|
||||
const cached = await chartClientRef.current!.client.post({
|
||||
...requestConfig,
|
||||
jsonPayload: {
|
||||
...readBackContext,
|
||||
...(force && { force: true }),
|
||||
},
|
||||
});
|
||||
return extractRows(cached.json);
|
||||
};
|
||||
responseData = ensureIsArray(
|
||||
await hooks.handleAsyncChartData(
|
||||
rawResponse,
|
||||
clientResponse.json as JsonObject,
|
||||
refetch,
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
@@ -345,14 +394,7 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const rows = (
|
||||
Array.isArray(clientResponse.json)
|
||||
? clientResponse.json
|
||||
: [clientResponse.json]
|
||||
) as JsonObject[];
|
||||
|
||||
// Handle the nested result structure from the API
|
||||
responseData = (rows[0]?.result ? rows[0].result : rows) as QueryData[];
|
||||
responseData = extractRows(clientResponse.json);
|
||||
}
|
||||
|
||||
// Don't pair this request's data with newer props or fire a stale onLoad
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import ChartProps, { ChartPropsConfig } from './models/ChartProps';
|
||||
import ChartProps, { ChartPropsConfig, Hooks } from './models/ChartProps';
|
||||
|
||||
export { default as ChartClient } from './clients/ChartClient';
|
||||
export { default as ChartMetadata } from './models/ChartMetadata';
|
||||
export { default as ChartPlugin } from './models/ChartPlugin';
|
||||
export { ChartProps };
|
||||
export type { ChartPropsConfig };
|
||||
export type { ChartPropsConfig, Hooks };
|
||||
|
||||
export { default as createLoadableRenderer } from './components/createLoadableRenderer';
|
||||
export { default as reactify } from './components/reactify';
|
||||
|
||||
@@ -46,7 +46,7 @@ type RawFormData = CamelCaseFormData | SnakeCaseFormData;
|
||||
type ChartPropsSelector = (c: ChartPropsConfig) => ChartProps;
|
||||
|
||||
/** Optional field for event handlers, renderers */
|
||||
type Hooks = {
|
||||
export type Hooks = {
|
||||
/**
|
||||
* sync active filters between chart and dashboard, "add" actually
|
||||
* also handles "change" and "remove".
|
||||
@@ -70,13 +70,31 @@ type Hooks = {
|
||||
* Resolve an async chart-data response (HTTP 202 from GLOBAL_ASYNC_QUERIES).
|
||||
* Injected by the app so components in this package (e.g. Matrixify's
|
||||
* StatefulChart) can await async results without importing app-level
|
||||
* async-event middleware. Returns the resolved query results.
|
||||
* async-event middleware. `refetch` re-issues the request synchronously once
|
||||
* the query tasks have succeeded; it receives the per-query task ids, which
|
||||
* double as forced-refresh idempotency nonces (see `requestChartDataResolved`)
|
||||
* so a forced read-back reads the result its task cached instead of recomputing
|
||||
* — and does not serve stale data if that result was not persisted. Returns the
|
||||
* resolved query results.
|
||||
*/
|
||||
handleAsyncChartData?: (
|
||||
response: Response,
|
||||
json: JsonObject,
|
||||
refetch: (queryForceNonces?: string[]) => Promise<QueryData[]>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<QueryData[]> | QueryData[];
|
||||
/**
|
||||
* Whether those self-contained components should request asynchronous
|
||||
* execution, per the app's resolved async policy.
|
||||
*/
|
||||
resolveAsyncMode?: () => boolean;
|
||||
/**
|
||||
* The app's stable per-tab id, sent with an async chart-data request so the
|
||||
* backend ref-counts this tab as a consumer of the (shared) task — a later
|
||||
* cancel/navigate-away then detaches only this tab. Injected from the app (the
|
||||
* package cannot import the app-level tab-id hook).
|
||||
*/
|
||||
getTabId?: () => string;
|
||||
} & PlainObject;
|
||||
|
||||
/**
|
||||
|
||||
@@ -105,6 +105,9 @@ export function DeleteModal({
|
||||
name={name}
|
||||
title={title}
|
||||
wrapProps={{ 'aria-busy': loading }}
|
||||
// Remove the modal from the DOM on close so a confirmed delete tears it
|
||||
// down deterministically even inside memoized list-view table cells.
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
{description}
|
||||
|
||||
+14
@@ -74,3 +74,17 @@ test('passes button type to underlying Dropdown.Button', () => {
|
||||
);
|
||||
expect(container.querySelector('.ant-btn-primary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens the popupRender content without crashing on click trigger', async () => {
|
||||
const { getAllByRole, findByText } = render(
|
||||
<DropdownButton
|
||||
popupRender={() => <div>Custom Menu</div>}
|
||||
trigger={['click']}
|
||||
>
|
||||
Click
|
||||
</DropdownButton>,
|
||||
);
|
||||
const buttons = getAllByRole('button');
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
expect(await findByText('Custom Menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
+26
@@ -332,3 +332,29 @@ test('should not apply highlight when records have no id field and highlightRowI
|
||||
const highlightedRows = container.querySelectorAll('.table-row-highlighted');
|
||||
expect(highlightedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should highlight every row for which isRowHighlighted returns true', () => {
|
||||
const dataWithIds = [
|
||||
{ col1: 'a', col2: 'a2', id: 1, parent: { child: 'n1' } },
|
||||
{ col1: 'b', col2: 'b2', id: 2, parent: { child: 'n2' } },
|
||||
{ col1: 'c', col2: 'c2', id: 3, parent: { child: 'n3' } },
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useTable({ columns: tableHook.columns, data: dataWithIds }),
|
||||
);
|
||||
const newTableHook = result.current;
|
||||
|
||||
const { container } = render(
|
||||
<TableCollection
|
||||
{...defaultProps}
|
||||
rows={newTableHook.rows}
|
||||
prepareRow={newTableHook.prepareRow}
|
||||
// Predicate matches on an arbitrary field (here: id in a set), highlighting
|
||||
// multiple rows — this is what the Task List uses to highlight dependencies.
|
||||
isRowHighlighted={record => [1, 3].includes(record.id as number)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const highlightedRows = container.querySelectorAll('.table-row-highlighted');
|
||||
expect(highlightedRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
+42
-26
@@ -44,6 +44,10 @@ export interface TableCollectionProps<T extends object> {
|
||||
columns: ColumnInstance<T>[];
|
||||
loading: boolean;
|
||||
highlightRowId?: number;
|
||||
// Optional predicate to highlight arbitrary rows (in addition to
|
||||
// highlightRowId). Receives the mapped record (which spreads row.original), so
|
||||
// callers can match on any field, e.g. by uuid.
|
||||
isRowHighlighted?: (record: Record<string, unknown>) => boolean;
|
||||
columnsForWrapText?: string[];
|
||||
setSortBy?: (updater: SortingRule<T>[]) => void;
|
||||
bulkSelectEnabled?: boolean;
|
||||
@@ -161,6 +165,7 @@ function TableCollection<T extends object>({
|
||||
rows,
|
||||
loading,
|
||||
highlightRowId,
|
||||
isRowHighlighted,
|
||||
setSortBy,
|
||||
headerGroups,
|
||||
columnsForWrapText,
|
||||
@@ -292,10 +297,44 @@ function TableCollection<T extends object>({
|
||||
|
||||
const getRowClassName = useCallback(
|
||||
(record: Record<string, unknown>) =>
|
||||
highlightRowId !== undefined && record?.id === highlightRowId
|
||||
(highlightRowId !== undefined && record?.id === highlightRowId) ||
|
||||
isRowHighlighted?.(record)
|
||||
? 'table-row-highlighted'
|
||||
: '',
|
||||
[highlightRowId],
|
||||
[highlightRowId, isRowHighlighted],
|
||||
);
|
||||
|
||||
// Memoize the custom cell/row components. A fresh `components` object (with
|
||||
// new inner function identities) makes antd treat them as new component types
|
||||
// and remount every row and cell on each render — which would, for example,
|
||||
// tear down an open hover popover inside a cell whenever the table re-renders
|
||||
// (e.g. when rowClassName changes for row highlighting).
|
||||
const tableComponents = useMemo(
|
||||
() => ({
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => {
|
||||
const isSelectionColumn =
|
||||
props.className?.includes('ant-table-selection-column') ?? false;
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
data-test={
|
||||
isSelectionColumn ? 'header-toggle-all' : 'sort-header'
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
body: {
|
||||
row: (props: HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr {...props} data-test="table-row" />
|
||||
),
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td {...props} data-test="table-row-cell" />
|
||||
),
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -322,30 +361,7 @@ function TableCollection<T extends object>({
|
||||
getRowClassName as unknown as TableProps<object>['rowClassName']
|
||||
}
|
||||
expandable={expandable}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => {
|
||||
const isSelectionColumn =
|
||||
props.className?.includes('ant-table-selection-column') ?? false;
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
data-test={
|
||||
isSelectionColumn ? 'header-toggle-all' : 'sort-header'
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
body: {
|
||||
row: (props: HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr {...props} data-test="table-row" />
|
||||
),
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td {...props} data-test="table-row-cell" />
|
||||
),
|
||||
},
|
||||
}}
|
||||
components={tableComponents}
|
||||
onChange={handleTableChange as unknown as TableProps<object>['onChange']}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -63,6 +63,10 @@ export default function buildQueryContext(
|
||||
return {
|
||||
datasource: new DatasourceKey(formData.datasource).toObject(),
|
||||
force: formData.force || false,
|
||||
// Idempotency token for a forced refresh; only present when the caller sets
|
||||
// it (see requestChartDataResolved). Omitted otherwise so the payload is
|
||||
// unchanged for non-forced requests.
|
||||
...(formData.force_nonce ? { force_nonce: formData.force_nonce } : {}),
|
||||
queries,
|
||||
form_data: formData,
|
||||
result_format: formData.result_format || 'json',
|
||||
|
||||
@@ -164,6 +164,8 @@ export interface QueryContext {
|
||||
};
|
||||
/** Force refresh of all queries */
|
||||
force: boolean;
|
||||
/** Idempotency token for a forced refresh (present only when forcing) */
|
||||
force_nonce?: string;
|
||||
/** Type of result to return for queries */
|
||||
result_type: string;
|
||||
/** Response format */
|
||||
|
||||
@@ -181,6 +181,8 @@ export interface BaseFormData extends TimeRange, FormDataResidual {
|
||||
timeseries_limit_metric?: QueryFormMetric;
|
||||
/** Force refresh */
|
||||
force?: boolean;
|
||||
/** Idempotency token for a forced refresh (see requestChartDataResolved) */
|
||||
force_nonce?: string;
|
||||
result_format?: string;
|
||||
result_type?: string;
|
||||
annotation_layers?: AnnotationLayer[];
|
||||
|
||||
+13
-2
@@ -25,11 +25,22 @@ export default function stringifyTimeInput(
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
let time: Date;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
const isIntegerString = /^-?\d+$/.test(trimmed);
|
||||
return fn(new Date(isIntegerString ? Number(trimmed) : value));
|
||||
time = new Date(isIntegerString ? Number(trimmed) : value);
|
||||
} else {
|
||||
time = value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
return fn(value instanceof Date ? value : new Date(value));
|
||||
// An input that does not resolve to a valid date - a duration such as
|
||||
// "00:01:54", for instance - would otherwise be formatted from an Invalid
|
||||
// Date and render as "NaN:NaN:NaN". Fall back to its own representation,
|
||||
// as is already done for null and undefined above.
|
||||
if (Number.isNaN(time.getTime())) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
return fn(time);
|
||||
}
|
||||
|
||||
@@ -29,9 +29,22 @@ describe('buildQueryContext', () => {
|
||||
expect(queryContext.datasource.id).toBe(5);
|
||||
expect(queryContext.datasource.type).toBe('table');
|
||||
expect(queryContext.force).toBe(false);
|
||||
// A non-forced request carries no idempotency nonce.
|
||||
expect(queryContext.force_nonce).toBeUndefined();
|
||||
expect(queryContext.result_format).toBe('json');
|
||||
expect(queryContext.result_type).toBe('full');
|
||||
});
|
||||
test('should carry force_nonce when set on the form data', () => {
|
||||
const queryContext = buildQueryContext({
|
||||
datasource: '5__table',
|
||||
granularity_sqla: 'ds',
|
||||
viz_type: VizType.Table,
|
||||
force: true,
|
||||
force_nonce: 'nonce-123',
|
||||
});
|
||||
expect(queryContext.force).toBe(true);
|
||||
expect(queryContext.force_nonce).toBe('nonce-123');
|
||||
});
|
||||
test('should build datasource for table sources with columns', () => {
|
||||
const queryContext = buildQueryContext(
|
||||
{
|
||||
|
||||
+58
@@ -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.
|
||||
*/
|
||||
import stringifyTimeInput from '../../../src/time-format/utils/stringifyTimeInput';
|
||||
|
||||
const format = (time: Date) => time.toISOString();
|
||||
|
||||
test('returns the stringified value for null and undefined', () => {
|
||||
expect(stringifyTimeInput(null, format)).toBe('null');
|
||||
expect(stringifyTimeInput(undefined, format)).toBe('undefined');
|
||||
});
|
||||
|
||||
test('formats Date and numeric inputs', () => {
|
||||
const date = new Date(Date.UTC(2017, 1, 14, 11, 22, 33));
|
||||
expect(stringifyTimeInput(date, format)).toBe('2017-02-14T11:22:33.000Z');
|
||||
expect(stringifyTimeInput(date.getTime(), format)).toBe(
|
||||
'2017-02-14T11:22:33.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
test('treats an integer string as a timestamp in milliseconds', () => {
|
||||
expect(stringifyTimeInput('1487071353000', format)).toBe(
|
||||
'2017-02-14T11:22:33.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
test('formats a parseable timestamp string', () => {
|
||||
expect(stringifyTimeInput('2017-02-14T11:22:33Z', format)).toBe(
|
||||
'2017-02-14T11:22:33.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns unparseable strings unchanged instead of formatting an Invalid Date', () => {
|
||||
// Duration values such as these are not timestamps. Formatting them used to
|
||||
// render as "NaN:NaN:NaN" in the Table chart.
|
||||
expect(stringifyTimeInput('00:01:54', format)).toBe('00:01:54');
|
||||
expect(stringifyTimeInput('0 days 00:01:54', format)).toBe('0 days 00:01:54');
|
||||
expect(stringifyTimeInput('not a date', format)).toBe('not a date');
|
||||
});
|
||||
|
||||
test('returns the representation of a Date that could not be resolved', () => {
|
||||
expect(stringifyTimeInput(new Date('00:01:54'), format)).toBe('Invalid Date');
|
||||
});
|
||||
@@ -573,12 +573,6 @@ images:
|
||||
alt: Apache Spark SQL
|
||||
source_file: docs/intro.md
|
||||
selector: null
|
||||
- type: database-logo
|
||||
page_url: "https://superset.apache.org/user-docs"
|
||||
image_url: docs/static/img/databases/ascend.webp
|
||||
alt: Ascend
|
||||
source_file: docs/intro.md
|
||||
selector: null
|
||||
- type: database-logo
|
||||
page_url: "https://superset.apache.org/user-docs"
|
||||
image_url: docs/static/img/databases/aws.png
|
||||
@@ -947,13 +941,6 @@ images:
|
||||
alt: Apache Spark SQL
|
||||
source_file: docs/index.mdx
|
||||
selector: null
|
||||
- type: database-logo
|
||||
page_url: "https://superset.apache.org/user-docs"
|
||||
image_url: "https://superset.apache.org/img/databases/ascend.webp"
|
||||
output_path: docs/static/img/databases/ascend.webp
|
||||
alt: Ascend
|
||||
source_file: docs/index.mdx
|
||||
selector: null
|
||||
- type: database-logo
|
||||
page_url: "https://superset.apache.org/user-docs"
|
||||
image_url: "https://superset.apache.org/img/databases/aws.png"
|
||||
|
||||
@@ -47,6 +47,7 @@ type LayoutElementLabel =
|
||||
export class DashboardPage {
|
||||
private readonly page: Page;
|
||||
private readonly filterBar: DashboardFilterBar;
|
||||
private readonly dashboardTabs: Tabs;
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
DASHBOARD_HEADER: '[data-test="dashboard-header-container"]',
|
||||
@@ -72,11 +73,19 @@ export class DashboardPage {
|
||||
ACE_CONTENT: '.ace_content',
|
||||
ACE_TEXT_INPUT: '.ace_text-input',
|
||||
RESIZE_HANDLE_BOTTOM: '.resizable-container-handle--bottom',
|
||||
DASHBOARD_TABS: '[data-test="dashboard-component-tabs"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.filterBar = new DashboardFilterBar(page);
|
||||
this.dashboardTabs = new Tabs(
|
||||
page,
|
||||
page
|
||||
.locator(DashboardPage.SELECTORS.DASHBOARD_TABS)
|
||||
.first()
|
||||
.locator(':scope > [data-test="nav-list"]'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,6 +225,16 @@ export class DashboardPage {
|
||||
return this.filterBar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a top-level dashboard tab and waits for it to become active.
|
||||
*/
|
||||
async switchDashboardTab(tabName: string): Promise<void> {
|
||||
await this.dashboardTabs.clickTab(tabName);
|
||||
await expect
|
||||
.poll(() => this.dashboardTabs.getActiveTabName())
|
||||
.toBe(tabName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dashboard header actions menu (three-dot menu)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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 getEmptyLayout from '../../../src/dashboard/util/getEmptyLayout';
|
||||
import {
|
||||
BACKGROUND_TRANSPARENT,
|
||||
DASHBOARD_GRID_ID,
|
||||
DASHBOARD_ROOT_ID,
|
||||
} from '../../../src/dashboard/util/constants';
|
||||
import {
|
||||
CHART_TYPE,
|
||||
ROW_TYPE,
|
||||
TABS_TYPE,
|
||||
TAB_TYPE,
|
||||
} from '../../../src/dashboard/util/componentTypes';
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import type {
|
||||
DashboardLayoutChart,
|
||||
DashboardPositionJson,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { createDashboardWithCharts } from './dashboard-test-helpers';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const WIDE_VIEWPORT = { width: 1400, height: 900 };
|
||||
const NARROW_VIEWPORT = { width: 700, height: 900 };
|
||||
const TABS_ID = 'TABS-TOP';
|
||||
const FIRST_TAB_ID = 'TAB-A';
|
||||
const SECOND_TAB_ID = 'TAB-B';
|
||||
const ROW_ID = 'ROW-A';
|
||||
|
||||
function buildTabbedDashboardLayout(
|
||||
charts: readonly DashboardLayoutChart[],
|
||||
): DashboardPositionJson {
|
||||
const [treemap] = charts;
|
||||
if (!treemap) {
|
||||
throw new Error('Tabbed dashboard layout requires a chart');
|
||||
}
|
||||
|
||||
const emptyLayout = getEmptyLayout();
|
||||
const chartKey = `CHART-${treemap.id}`;
|
||||
|
||||
return {
|
||||
...emptyLayout,
|
||||
[DASHBOARD_GRID_ID]: {
|
||||
...emptyLayout[DASHBOARD_GRID_ID],
|
||||
children: [TABS_ID],
|
||||
},
|
||||
[TABS_ID]: {
|
||||
type: TABS_TYPE,
|
||||
id: TABS_ID,
|
||||
children: [FIRST_TAB_ID, SECOND_TAB_ID],
|
||||
parents: [DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID],
|
||||
meta: {},
|
||||
},
|
||||
[FIRST_TAB_ID]: {
|
||||
type: TAB_TYPE,
|
||||
id: FIRST_TAB_ID,
|
||||
children: [ROW_ID],
|
||||
parents: [DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, TABS_ID],
|
||||
meta: {
|
||||
text: 'Tab A',
|
||||
defaultText: 'Tab title',
|
||||
placeholder: 'Tab title',
|
||||
},
|
||||
},
|
||||
[SECOND_TAB_ID]: {
|
||||
type: TAB_TYPE,
|
||||
id: SECOND_TAB_ID,
|
||||
children: [],
|
||||
parents: [DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, TABS_ID],
|
||||
meta: {
|
||||
text: 'Tab B',
|
||||
defaultText: 'Tab title',
|
||||
placeholder: 'Tab title',
|
||||
},
|
||||
},
|
||||
[ROW_ID]: {
|
||||
type: ROW_TYPE,
|
||||
id: ROW_ID,
|
||||
children: [chartKey],
|
||||
parents: [DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, TABS_ID, FIRST_TAB_ID],
|
||||
meta: { background: BACKGROUND_TRANSPARENT },
|
||||
},
|
||||
[chartKey]: {
|
||||
type: CHART_TYPE,
|
||||
id: chartKey,
|
||||
children: [],
|
||||
parents: [
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_GRID_ID,
|
||||
TABS_ID,
|
||||
FIRST_TAB_ID,
|
||||
ROW_ID,
|
||||
],
|
||||
meta: {
|
||||
chartId: treemap.id,
|
||||
width: 12,
|
||||
height: 50,
|
||||
sliceName: treemap.sliceName,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'chart in a hidden tab refits its container after the tab is revealed at a new width',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const { dashboardId, charts } = await createDashboardWithCharts(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
{
|
||||
datasetName: DATASET_NAME,
|
||||
chartNamePrefix: 'tabs',
|
||||
dashboardTitlePrefix: 'tabs_resize',
|
||||
chartSpecs: [
|
||||
{
|
||||
viz_type: 'treemap_v2',
|
||||
params: {
|
||||
metric: 'count',
|
||||
groupby: ['gender'],
|
||||
row_limit: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
buildLayout: buildTabbedDashboardLayout,
|
||||
},
|
||||
);
|
||||
const [treemap] = charts;
|
||||
if (!treemap) {
|
||||
throw new Error('Dashboard setup did not create the treemap');
|
||||
}
|
||||
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
|
||||
const treemapContainer = dashboard
|
||||
.getChart(treemap.id)
|
||||
.locator('[data-test="chart-container"]');
|
||||
await treemapContainer.waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await dashboard.waitForChartsToLoad();
|
||||
|
||||
const echartsHost = treemapContainer.locator('.echarts-host');
|
||||
const widthAtWide = await echartsHost.evaluate(
|
||||
(element: HTMLElement) => element.offsetWidth,
|
||||
);
|
||||
|
||||
await dashboard.switchDashboardTab('Tab B');
|
||||
await page.setViewportSize(NARROW_VIEWPORT);
|
||||
await dashboard.switchDashboardTab('Tab A');
|
||||
|
||||
await treemapContainer.waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await dashboard.waitForChartsToLoad();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
echartsHost.evaluate((element: HTMLElement) => element.offsetWidth),
|
||||
{
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
message: 'treemap should resize after the hidden tab is revealed',
|
||||
},
|
||||
)
|
||||
.toBeLessThan(widthAtWide);
|
||||
|
||||
// Guards against a container that shrinks via CSS while the chart's
|
||||
// rendered content stays at its old (wider) size: offsetWidth alone
|
||||
// can't tell the two apart, since it reflects the container's CSS box,
|
||||
// not what ECharts actually painted. `.echarts-host` renders its
|
||||
// content at exact pixel sizes, so any gap beyond sub-pixel rounding
|
||||
// means the content is overflowing rather than having resized with it.
|
||||
// ECharts' resize is debounced relative to the CSS reflow the poll
|
||||
// above waits on, so this needs its own poll rather than a one-shot
|
||||
// read right after.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { offsetWidth, scrollWidth } = await echartsHost.evaluate(
|
||||
(element: HTMLElement) => ({
|
||||
offsetWidth: element.offsetWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
}),
|
||||
);
|
||||
return scrollWidth - offsetWidth;
|
||||
},
|
||||
{
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
message: "treemap content should not overflow its container's width",
|
||||
},
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
apiPostDashboard,
|
||||
buildSingleRowDashboardLayout,
|
||||
type DashboardLayoutChart,
|
||||
type DashboardPositionJson,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
@@ -236,14 +237,17 @@ interface CreateDashboardWithChartsOptions {
|
||||
/** Dashboard title prefix: `${dashboardTitlePrefix}_${suffix}`. */
|
||||
dashboardTitlePrefix: string;
|
||||
chartSpecs: DashboardChartSpec[];
|
||||
/** Custom dashboard layout; defaults to placing every chart in one row. */
|
||||
buildLayout?: (
|
||||
charts: readonly DashboardLayoutChart[],
|
||||
) => DashboardPositionJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a published dashboard via the API: creates each chart, lays them out in
|
||||
* a single row, and associates them so they render. Every created chart and the
|
||||
* dashboard are registered for fixture cleanup. Charts are returned in the same
|
||||
* order as `chartSpecs`, so callers can pair them back to per-spec metadata by
|
||||
* index.
|
||||
* Builds a published dashboard via the API: creates each chart, lays them out,
|
||||
* and associates them so they render. Every created chart and the dashboard are
|
||||
* registered for fixture cleanup. Charts are returned in the same order as
|
||||
* `chartSpecs`, so callers can pair them back to per-spec metadata by index.
|
||||
*/
|
||||
export async function createDashboardWithCharts(
|
||||
page: Page,
|
||||
@@ -282,8 +286,9 @@ export async function createDashboardWithCharts(
|
||||
charts.push({ id: chartId, sliceName });
|
||||
}
|
||||
|
||||
// Lay all charts out in a single row.
|
||||
const positionJson = buildSingleRowDashboardLayout(charts);
|
||||
const positionJson = options.buildLayout
|
||||
? options.buildLayout(charts)
|
||||
: buildSingleRowDashboardLayout(charts);
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `${options.dashboardTitlePrefix}_${uniqueSuffix}`,
|
||||
published: true,
|
||||
|
||||
@@ -49,6 +49,13 @@ export default class DateWithFormatter extends Date {
|
||||
if (this.formatter === String) {
|
||||
return String(this.input);
|
||||
}
|
||||
// Values that are not parseable timestamps - durations such as
|
||||
// "00:01:54" or "0 days 00:01:54", for instance - produce an Invalid
|
||||
// Date, and formatting one renders as "NaN:NaN:NaN". Fall back to the
|
||||
// original value instead.
|
||||
if (Number.isNaN(this.getTime())) {
|
||||
return String(this.input);
|
||||
}
|
||||
return this.formatter ? this.formatter(this) : Date.toString.call(this);
|
||||
};
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 { getTimeFormatter } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import DateWithFormatter from '../../src/utils/DateWithFormatter';
|
||||
import { formatColumnValue } from '../../src/utils/formatValue';
|
||||
import { DataColumnMeta } from '../../src/types';
|
||||
|
||||
const formatter = getTimeFormatter('%H:%M:%S');
|
||||
|
||||
test('formats a parseable timestamp with the configured formatter', () => {
|
||||
const value = new DateWithFormatter('2017-02-14T11:22:33Z', { formatter });
|
||||
expect(String(value)).toBe('11:22:33');
|
||||
});
|
||||
|
||||
test('renders the original value when it is not a parseable timestamp', () => {
|
||||
// Duration columns hold values like these. They produce an Invalid Date,
|
||||
// which used to be formatted and rendered as "NaN:NaN:NaN".
|
||||
['00:01:54', '0 days 00:01:54'].forEach(input => {
|
||||
const value = new DateWithFormatter(input, { formatter });
|
||||
expect(Number.isNaN(value.getTime())).toBe(true);
|
||||
expect(String(value)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
test('retains the original input when the formatter is String', () => {
|
||||
const value = new DateWithFormatter('00:01:54');
|
||||
expect(String(value)).toBe('00:01:54');
|
||||
});
|
||||
|
||||
test('renders a duration cell through the column formatter without producing NaN', () => {
|
||||
// The cell text is produced by formatColumnValue, which hands the wrapped
|
||||
// value straight to the formatter rather than going through toString().
|
||||
const column: DataColumnMeta = {
|
||||
key: 'call_period',
|
||||
label: 'call_period',
|
||||
dataType: GenericDataType.Temporal,
|
||||
formatter,
|
||||
isNumeric: false,
|
||||
};
|
||||
const value = new DateWithFormatter('00:01:54', { formatter });
|
||||
|
||||
expect(formatColumnValue(column, value)).toEqual([false, '00:01:54']);
|
||||
});
|
||||
@@ -115,4 +115,48 @@ describe('Treemap transformProps', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not draw borders around labels', () => {
|
||||
// A label border is drawn by ECharts as a box that spans the full node
|
||||
// height, which shows up as a vertical line right after the label text
|
||||
// (see #37808 and #43862). Every label style must stay border-free,
|
||||
// including emphasis, upper labels, and the filtered-node label.
|
||||
const filteredChartProps = new ChartProps({
|
||||
...chartProps,
|
||||
filterState: { selectedValues: ['Sylvester,bar1'] },
|
||||
});
|
||||
const { echartOptions } = transformProps(
|
||||
filteredChartProps as EchartsTreemapChartProps,
|
||||
);
|
||||
|
||||
const labelStyles: Record<string, unknown>[] = [];
|
||||
const collectLabelStyles = (node: unknown) => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(collectLabelStyles);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== 'object') {
|
||||
return;
|
||||
}
|
||||
Object.entries(node as Record<string, unknown>).forEach(
|
||||
([key, value]) => {
|
||||
if (
|
||||
(key === 'label' || key === 'upperLabel') &&
|
||||
value &&
|
||||
typeof value === 'object'
|
||||
) {
|
||||
labelStyles.push(value as Record<string, unknown>);
|
||||
}
|
||||
collectLabelStyles(value);
|
||||
},
|
||||
);
|
||||
};
|
||||
collectLabelStyles(echartOptions.series);
|
||||
|
||||
expect(labelStyles.length).toBeGreaterThan(0);
|
||||
labelStyles.forEach(style => {
|
||||
expect(style).not.toHaveProperty('borderWidth');
|
||||
expect(style).not.toHaveProperty('borderColor');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"jest": "^30.4.2"
|
||||
"jest": "^30.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -31,19 +31,22 @@ const Styles = styled.div<HandlebarsStylesProps>`
|
||||
|
||||
export default function Handlebars(props: HandlebarsProps) {
|
||||
const { data, height, width, formData } = props;
|
||||
const styleTemplateSource = formData.styleTemplate
|
||||
const styleSource = formData.styleTemplate
|
||||
? `<style>${formData.styleTemplate}</style>`
|
||||
: '';
|
||||
const handlebarTemplateSource = formData.handlebarsTemplate
|
||||
: undefined;
|
||||
const templateSource = formData.handlebarsTemplate
|
||||
? formData.handlebarsTemplate
|
||||
: '{{data}}';
|
||||
const templateSource = `${handlebarTemplateSource}\n${styleTemplateSource} `;
|
||||
|
||||
const rootElem = createRef<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<Styles ref={rootElem} height={height} width={width}>
|
||||
<HandlebarsViewer data={{ data }} templateSource={templateSource} />
|
||||
<HandlebarsViewer
|
||||
data={{ data }}
|
||||
templateSource={templateSource}
|
||||
styleSource={styleSource}
|
||||
/>
|
||||
</Styles>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-12
@@ -28,14 +28,20 @@ import HandlebarsGroupBy from 'handlebars-group-by';
|
||||
|
||||
export interface HandlebarsViewerProps {
|
||||
templateSource: string;
|
||||
/** CSS from the chart's CSS Styles control, already wrapped in a `<style>` tag. */
|
||||
styleSource?: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export const HandlebarsViewer = ({
|
||||
templateSource,
|
||||
styleSource,
|
||||
data,
|
||||
}: HandlebarsViewerProps) => {
|
||||
const [renderedTemplate, setRenderedTemplate] = useState('');
|
||||
const [rendered, setRendered] = useState<{
|
||||
template: string;
|
||||
style: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const appContainer = document.getElementById('app');
|
||||
const { common } = JSON.parse(
|
||||
@@ -47,15 +53,18 @@ export const HandlebarsViewer = ({
|
||||
|
||||
useMemo(() => {
|
||||
try {
|
||||
const template = Handlebars.compile(templateSource);
|
||||
const result = template(data);
|
||||
setRenderedTemplate(result);
|
||||
// The two sources are compiled separately so that Handlebars whitespace
|
||||
// control (`~`) in one cannot strip text from the other, while
|
||||
// expressions in the CSS field still expand.
|
||||
const template = Handlebars.compile(templateSource)(data);
|
||||
const style = styleSource ? Handlebars.compile(styleSource)(data) : '';
|
||||
setRendered({ template, style });
|
||||
setError('');
|
||||
} catch (error) {
|
||||
setRenderedTemplate('');
|
||||
setRendered(null);
|
||||
setError(error.message);
|
||||
}
|
||||
}, [templateSource, data]);
|
||||
}, [templateSource, styleSource, data]);
|
||||
|
||||
const Error = styled.pre`
|
||||
white-space: pre-wrap;
|
||||
@@ -65,13 +74,31 @@ export const HandlebarsViewer = ({
|
||||
return <Error>{error}</Error>;
|
||||
}
|
||||
|
||||
if (renderedTemplate) {
|
||||
if (rendered) {
|
||||
// The template and the CSS render as two separate Markdown documents so
|
||||
// that neither source can affect how the other is parsed: joined into one
|
||||
// document, a template could pull the `<style>` tag into its own block or
|
||||
// leave a construct open that swallows it, and blank lines in the CSS then
|
||||
// re-opened Markdown parsing mid-stylesheet. react-markdown emits no
|
||||
// wrapper element, so the chart's DOM is the same flat sequence a single
|
||||
// document produced: the style block renders second to keep the template
|
||||
// first, where positional selectors expect it, and to let the CSS
|
||||
// control's rules win the cascade over any `<style>` in the template.
|
||||
return (
|
||||
<SafeMarkdown
|
||||
source={renderedTemplate}
|
||||
htmlSanitization={htmlSanitization}
|
||||
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||
/>
|
||||
<>
|
||||
<SafeMarkdown
|
||||
source={rendered.template}
|
||||
htmlSanitization={htmlSanitization}
|
||||
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||
/>
|
||||
{rendered.style ? (
|
||||
<SafeMarkdown
|
||||
source={rendered.style}
|
||||
htmlSanitization={htmlSanitization}
|
||||
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <p>{t('Loading...')}</p>;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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 { render, screen } from '@superset-ui/core/spec';
|
||||
import Handlebars from '../src/Handlebars';
|
||||
import { HandlebarsProps } from '../src/types';
|
||||
|
||||
// `spec/helpers/shim.tsx` swaps react-markdown and the rehype plugins for stubs
|
||||
// that echo their input, so suites that don't care about Markdown skip parsing
|
||||
// it. What these tests assert is how Markdown parses the chart source, so they
|
||||
// need the real pipeline: against the stub nothing is ever parsed and the
|
||||
// regression below cannot show up.
|
||||
jest.mock('react-markdown', () => jest.requireActual('react-markdown'));
|
||||
jest.mock('rehype-raw', () => jest.requireActual('rehype-raw'));
|
||||
jest.mock('rehype-sanitize', () => jest.requireActual('rehype-sanitize'));
|
||||
|
||||
// The blank line between the two rules is what Markdown reacts to, and the
|
||||
// universal selector on the line after it is what makes the damage legible:
|
||||
// parsed as Markdown, `* ` opens a list item and the selector is swallowed.
|
||||
const STYLE_TEMPLATE =
|
||||
'td {\n color: red;\n}\n\n* {\n font-family: monospace;\n}';
|
||||
const TABLE_TEMPLATE =
|
||||
'<table>\n <tr><th>Header</th></tr>\n <tr><td>Cell</td></tr>\n</table>';
|
||||
|
||||
// `style` is not in the sanitizer's default allowlist, so the chart's CSS
|
||||
// reaches the DOM only where an operator allows the tag through
|
||||
// HTML_SANITIZATION_SCHEMA_EXTENSIONS. HandlebarsViewer reads that config from
|
||||
// the bootstrap data on `#app`, which these tests have to provide to observe
|
||||
// the style block at all.
|
||||
const appRoot = () => document.getElementById('app');
|
||||
|
||||
beforeEach(() => {
|
||||
appRoot()?.setAttribute(
|
||||
'data-bootstrap',
|
||||
JSON.stringify({
|
||||
common: {
|
||||
conf: {
|
||||
HTML_SANITIZATION_SCHEMA_EXTENSIONS: { tagNames: ['style'] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
appRoot()?.setAttribute('data-bootstrap', '');
|
||||
});
|
||||
|
||||
const renderChart = (
|
||||
formData: Partial<HandlebarsProps['formData']>,
|
||||
data: Record<string, unknown>[] = [],
|
||||
) =>
|
||||
render(
|
||||
<Handlebars
|
||||
{...({
|
||||
data,
|
||||
height: 100,
|
||||
width: 100,
|
||||
formData,
|
||||
} as unknown as HandlebarsProps)}
|
||||
/>,
|
||||
);
|
||||
|
||||
/** What a user sees in the chart, i.e. everything but the style block. */
|
||||
const chartText = (container: HTMLElement) => {
|
||||
const clone = container.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('style').forEach(node => node.remove());
|
||||
return clone.textContent?.trim();
|
||||
};
|
||||
|
||||
test('renders the CSS as a style block rather than as chart content', async () => {
|
||||
const { container } = renderChart({
|
||||
handlebarsTemplate: TABLE_TEMPLATE,
|
||||
styleTemplate: STYLE_TEMPLATE,
|
||||
});
|
||||
expect(await screen.findByText('Cell')).toBeInTheDocument();
|
||||
|
||||
// Markdown only treats `<style>` as a raw-text block that may contain blank
|
||||
// lines when the tag starts a block of its own. Appended to a template that
|
||||
// opens with an HTML tag it was absorbed into that block instead, so the
|
||||
// first blank line in the CSS closed the block and everything after it was
|
||||
// parsed as Markdown: rules reached the style block rewritten or, as with
|
||||
// the universal selector here, silently dropped, and could end up displayed
|
||||
// in the chart.
|
||||
expect(container.querySelector('style')).toHaveProperty(
|
||||
'textContent',
|
||||
STYLE_TEMPLATE,
|
||||
);
|
||||
expect(chartText(container)).toBe('HeaderCell');
|
||||
});
|
||||
|
||||
test('keeps the CSS intact when the template ends with whitespace control', async () => {
|
||||
const { container } = renderChart({
|
||||
handlebarsTemplate: `${TABLE_TEMPLATE}{{!-- comment --~}}`,
|
||||
styleTemplate: STYLE_TEMPLATE,
|
||||
});
|
||||
expect(await screen.findByText('Cell')).toBeInTheDocument();
|
||||
|
||||
// `~}}` strips every whitespace character that follows it in the compiled
|
||||
// source. When the CSS was joined to the template before compilation, that
|
||||
// erased the separator between them and glued `<style>` back onto the
|
||||
// template's HTML block, so a valid template re-opened the bug above. The
|
||||
// CSS parses as its own document, out of the template's reach.
|
||||
expect(container.querySelector('style')).toHaveProperty(
|
||||
'textContent',
|
||||
STYLE_TEMPLATE,
|
||||
);
|
||||
expect(chartText(container)).toBe('HeaderCell');
|
||||
});
|
||||
|
||||
test('leaves the template markup untouched when CSS is configured', async () => {
|
||||
const { container } = renderChart({
|
||||
handlebarsTemplate: '- one\n- two',
|
||||
styleTemplate: STYLE_TEMPLATE,
|
||||
});
|
||||
expect(await screen.findByText('one')).toBeInTheDocument();
|
||||
|
||||
// The template renders as its own Markdown document with nothing appended,
|
||||
// so the list stays tight (no paragraph wrapping its items).
|
||||
expect(container.querySelectorAll('li p')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('renders the style block after the template', async () => {
|
||||
const { container } = renderChart({
|
||||
handlebarsTemplate: TABLE_TEMPLATE,
|
||||
styleTemplate: STYLE_TEMPLATE,
|
||||
});
|
||||
expect(await screen.findByText('Cell')).toBeInTheDocument();
|
||||
|
||||
// Positional selectors are written against the template, so the template has
|
||||
// to keep its place in the DOM: a rule like `table:first-child` still has to
|
||||
// match the table the chart author wrote.
|
||||
const chart = container.querySelector('table')?.parentElement;
|
||||
expect(chart?.firstElementChild?.tagName).toBe('TABLE');
|
||||
expect(chart?.lastElementChild?.tagName).toBe('STYLE');
|
||||
expect(chart?.querySelector('table:first-child')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('lets the CSS control override a style block in the template', async () => {
|
||||
const { container } = renderChart({
|
||||
handlebarsTemplate: `<style>td { color: blue; }</style>\n${TABLE_TEMPLATE}`,
|
||||
styleTemplate: STYLE_TEMPLATE,
|
||||
});
|
||||
expect(await screen.findByText('Cell')).toBeInTheDocument();
|
||||
|
||||
// Two competing rules of equal specificity are resolved by document order,
|
||||
// so the CSS control has to stay last to keep winning over a `<style>` block
|
||||
// written into the template itself.
|
||||
const styles = Array.from(container.querySelectorAll('style'));
|
||||
expect(styles.map(node => node.textContent)).toEqual([
|
||||
'td { color: blue; }',
|
||||
STYLE_TEMPLATE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('expands Handlebars expressions in the CSS against the chart data', async () => {
|
||||
const { container } = renderChart(
|
||||
{
|
||||
handlebarsTemplate: '{{#each data}}<p>{{name}}</p>{{/each}}',
|
||||
styleTemplate: 'p {\n color: {{data.[0].color}};\n}',
|
||||
},
|
||||
[{ name: 'Alpha', color: 'rebeccapurple' }],
|
||||
);
|
||||
expect(await screen.findByText('Alpha')).toBeInTheDocument();
|
||||
|
||||
// The CSS is compiled on its own, but against the same context as the
|
||||
// template, so expressions in it keep resolving to the chart's data.
|
||||
expect(container.querySelector('style')).toHaveProperty(
|
||||
'textContent',
|
||||
'p {\n color: rebeccapurple;\n}',
|
||||
);
|
||||
});
|
||||
|
||||
test('renders no style block when no CSS is configured', async () => {
|
||||
const { container } = renderChart({ handlebarsTemplate: TABLE_TEMPLATE });
|
||||
expect(await screen.findByText('Cell')).toBeInTheDocument();
|
||||
|
||||
expect(container.querySelector('style')).toBeNull();
|
||||
});
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
|
||||
interface PartitionDataNode {
|
||||
name: string;
|
||||
// A plain string for the metric row and the first grouping level;
|
||||
// an array of the full ancestor path (e.g. ["a", "a.1", "a.1.1"])
|
||||
// for any node below that, per PartitionViz.nest_values /
|
||||
// transformData. Consumers should read PartitionNode.name instead,
|
||||
// which is normalized to a plain leaf string in `init`.
|
||||
name: string | string[];
|
||||
val: number;
|
||||
children?: PartitionDataNode[];
|
||||
}
|
||||
@@ -99,11 +104,21 @@ const lazyFunction = (f: () => Record<string, unknown>) =>
|
||||
return f().apply(this, args);
|
||||
};
|
||||
const leafType = PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
// A plain string at the first grouping level, or an array of the
|
||||
// full ancestor path at any level below that.
|
||||
name: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
]),
|
||||
val: PropTypes.number.isRequired,
|
||||
});
|
||||
const parentShape = {
|
||||
name: PropTypes.string,
|
||||
// A plain string at the first grouping level, or an array of the
|
||||
// full ancestor path at any level below that.
|
||||
name: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
]),
|
||||
val: PropTypes.number.isRequired,
|
||||
children: PropTypes.arrayOf(
|
||||
PropTypes.oneOfType([
|
||||
@@ -228,7 +243,13 @@ function Icicle(element: HTMLElement, props: IcicleProps): void {
|
||||
n.disp = n.data.val;
|
||||
n.value = n.disp < 0 ? -n.disp : n.disp;
|
||||
n.weight = n.value;
|
||||
n.name = n.data.name;
|
||||
// n.data.name is an array of the full ancestor path for any node
|
||||
// below the first grouping level; normalize to this node's own
|
||||
// leaf value so sorting, tooltips, on-chart labels, and the
|
||||
// categorical color key all key off a plain string.
|
||||
n.name = Array.isArray(n.data.name)
|
||||
? n.data.name[n.data.name.length - 1]
|
||||
: n.data.name;
|
||||
// If the parent is a metric and we still have
|
||||
// the time column, perform a date-time format
|
||||
if (n.parent && hasDateNode(n.parent)) {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@testing-library/react": "*",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.4.2"
|
||||
"jest": "^30.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
+31
-2
@@ -20,6 +20,10 @@ import {
|
||||
memo,
|
||||
ComponentType,
|
||||
ChangeEventHandler,
|
||||
CompositionEvent,
|
||||
CompositionEventHandler,
|
||||
FocusEvent,
|
||||
FocusEventHandler,
|
||||
useRef,
|
||||
useEffect,
|
||||
Ref,
|
||||
@@ -33,7 +37,9 @@ export interface SearchInputProps {
|
||||
count: number;
|
||||
value: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onBlur?: () => void;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onCompositionStart?: CompositionEventHandler<HTMLInputElement>;
|
||||
onCompositionEnd?: CompositionEventHandler<HTMLInputElement>;
|
||||
inputRef?: Ref<InputRef>;
|
||||
}
|
||||
|
||||
@@ -56,6 +62,8 @@ function DefaultSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
inputRef,
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
@@ -68,6 +76,8 @@ function DefaultSearchInput({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
className="form-control input-sm"
|
||||
/>
|
||||
</Space>
|
||||
@@ -87,10 +97,14 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
|
||||
}: GlobalFilterProps<D>) {
|
||||
const count = serverPagination ? rowCount : preGlobalFilteredRows.length;
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
const [value, setValue] = useAsyncState(
|
||||
filterValue,
|
||||
(newValue: string) => {
|
||||
if (isComposingRef.current) {
|
||||
return;
|
||||
}
|
||||
setGlobalFilter(newValue || undefined);
|
||||
},
|
||||
200,
|
||||
@@ -114,8 +128,21 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
|
||||
setValue(target.value);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
|
||||
isSearchFocused.set(id, false);
|
||||
if (isComposingRef.current) {
|
||||
isComposingRef.current = false;
|
||||
setValue(e.currentTarget.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
isComposingRef.current = true;
|
||||
};
|
||||
|
||||
const handleCompositionEnd = (e: CompositionEvent<HTMLInputElement>) => {
|
||||
isComposingRef.current = false;
|
||||
setValue(e.currentTarget.value);
|
||||
};
|
||||
|
||||
const SearchInput = searchInput || DefaultSearchInput;
|
||||
@@ -127,6 +154,8 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
|
||||
inputRef={inputRef}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -264,7 +264,14 @@ const VisuallyHidden = styled.label`
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
function SearchInput({ value, onChange, onBlur, inputRef }: SearchInputProps) {
|
||||
function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
inputRef,
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<Space direction="vertical" size={4} className="dt-global-filter">
|
||||
<span aria-hidden="true">{t('Search')}</span>
|
||||
@@ -275,6 +282,8 @@ function SearchInput({ value, onChange, onBlur, inputRef }: SearchInputProps) {
|
||||
size="small"
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
@@ -49,6 +49,13 @@ export default class DateWithFormatter extends Date {
|
||||
if (this.formatter === String) {
|
||||
return String(this.input);
|
||||
}
|
||||
// Values that are not parseable timestamps - durations such as
|
||||
// "00:01:54" or "0 days 00:01:54", for instance - produce an Invalid
|
||||
// Date, and formatting one renders as "NaN:NaN:NaN". Fall back to the
|
||||
// original value instead.
|
||||
if (Number.isNaN(this.getTime())) {
|
||||
return String(this.input);
|
||||
}
|
||||
return this.formatter ? this.formatter(this) : Date.toString.call(this);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
@@ -2687,6 +2688,253 @@ describe('plugin-chart-table', () => {
|
||||
expect(screen.queryByText('Search by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
eventOrder: 'change before compositionend',
|
||||
commitComposition: (searchInput: HTMLElement) => {
|
||||
fireEvent.change(searchInput, { target: { value: '你好' } });
|
||||
fireEvent.compositionEnd(searchInput);
|
||||
},
|
||||
},
|
||||
{
|
||||
eventOrder: 'compositionend carrying the committed value',
|
||||
commitComposition: (searchInput: HTMLElement) => {
|
||||
fireEvent.compositionEnd(searchInput, {
|
||||
target: { value: '你好' },
|
||||
});
|
||||
},
|
||||
},
|
||||
])(
|
||||
'defers server-side search until IME composition ends ($eventOrder)',
|
||||
async ({ commitComposition }) => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const setDataMask = jest.fn();
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
},
|
||||
hooks: { setDataMask },
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
colnames: ['name'],
|
||||
coltypes: [GenericDataType.String],
|
||||
data: [{ name: 'Michael' }, { name: 'John' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const searchInput = screen.getByRole('textbox');
|
||||
const searchCalls = () =>
|
||||
setDataMask.mock.calls.filter(([mask]) =>
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
mask?.ownState ?? {},
|
||||
'searchText',
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.compositionStart(searchInput);
|
||||
fireEvent.change(searchInput, { target: { value: 'nihao' } });
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
expect(searchInput).toHaveValue('nihao');
|
||||
expect(searchCalls()).toHaveLength(0);
|
||||
|
||||
commitComposition(searchInput);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
|
||||
const calls = searchCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0].ownState.searchText).toBe('你好');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('restores server-side search after composition is interrupted by blur', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const setDataMask = jest.fn();
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
},
|
||||
hooks: { setDataMask },
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
colnames: ['name'],
|
||||
coltypes: [GenericDataType.String],
|
||||
data: [{ name: 'Michael' }, { name: 'John' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const searchInput = screen.getByRole('textbox');
|
||||
const searchCalls = () =>
|
||||
setDataMask.mock.calls.filter(([mask]) =>
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
mask?.ownState ?? {},
|
||||
'searchText',
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.compositionStart(searchInput);
|
||||
fireEvent.change(searchInput, { target: { value: 'nihao' } });
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
expect(searchInput).toHaveValue('nihao');
|
||||
expect(searchCalls()).toHaveLength(0);
|
||||
|
||||
fireEvent.blur(searchInput);
|
||||
expect(searchCalls()).toHaveLength(0);
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'hello' } });
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
|
||||
const calls = searchCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0].ownState.searchText).toBe('hello');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
eventOrder: 'compositionend before blur',
|
||||
pauseBeforeLeaving: 50,
|
||||
leaveInput: (searchInput: HTMLElement) => {
|
||||
fireEvent.compositionEnd(searchInput, {
|
||||
target: { value: 'nihao' },
|
||||
});
|
||||
fireEvent.blur(searchInput);
|
||||
},
|
||||
},
|
||||
{
|
||||
eventOrder: 'blur without compositionend',
|
||||
pauseBeforeLeaving: 50,
|
||||
leaveInput: (searchInput: HTMLElement) => {
|
||||
fireEvent.blur(searchInput);
|
||||
},
|
||||
},
|
||||
{
|
||||
eventOrder: 'blur without compositionend after the debounce fired',
|
||||
pauseBeforeLeaving: 300,
|
||||
leaveInput: (searchInput: HTMLElement) => {
|
||||
fireEvent.blur(searchInput);
|
||||
},
|
||||
},
|
||||
])(
|
||||
'searches the input value after blur mid-composition ($eventOrder)',
|
||||
async ({ pauseBeforeLeaving, leaveInput }) => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const setDataMask = jest.fn();
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
},
|
||||
hooks: { setDataMask },
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
colnames: ['name'],
|
||||
coltypes: [GenericDataType.String],
|
||||
data: [{ name: 'Michael' }, { name: 'John' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const searchInput = screen.getByRole('textbox');
|
||||
const searchCalls = () =>
|
||||
setDataMask.mock.calls.filter(([mask]) =>
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
mask?.ownState ?? {},
|
||||
'searchText',
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.compositionStart(searchInput);
|
||||
fireEvent.change(searchInput, { target: { value: 'nihao' } });
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(pauseBeforeLeaving);
|
||||
});
|
||||
leaveInput(searchInput);
|
||||
expect(searchCalls()).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
|
||||
expect(searchInput).toHaveValue('nihao');
|
||||
const calls = searchCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0].ownState.searchText).toBe('nihao');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should read the totals row from the correct query when percent metrics ' +
|
||||
'use the "all records" calculation mode',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 { getTimeFormatter } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import DateWithFormatter from '../../src/utils/DateWithFormatter';
|
||||
import { formatColumnValue } from '../../src/utils/formatValue';
|
||||
import { DataColumnMeta } from '../../src/types';
|
||||
|
||||
const formatter = getTimeFormatter('%H:%M:%S');
|
||||
|
||||
test('formats a parseable timestamp with the configured formatter', () => {
|
||||
const value = new DateWithFormatter('2017-02-14T11:22:33Z', { formatter });
|
||||
expect(String(value)).toBe('11:22:33');
|
||||
});
|
||||
|
||||
test('renders the original value when it is not a parseable timestamp', () => {
|
||||
// Duration columns hold values like these. They produce an Invalid Date,
|
||||
// which used to be formatted and rendered as "NaN:NaN:NaN".
|
||||
['00:01:54', '0 days 00:01:54'].forEach(input => {
|
||||
const value = new DateWithFormatter(input, { formatter });
|
||||
expect(Number.isNaN(value.getTime())).toBe(true);
|
||||
expect(String(value)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
test('retains the original input when the formatter is String', () => {
|
||||
const value = new DateWithFormatter('00:01:54');
|
||||
expect(String(value)).toBe('00:01:54');
|
||||
});
|
||||
|
||||
test('renders a duration cell through the column formatter without producing NaN', () => {
|
||||
// The cell text is produced by formatColumnValue, which hands the wrapped
|
||||
// value straight to the formatter rather than going through toString().
|
||||
const column: DataColumnMeta = {
|
||||
key: 'call_period',
|
||||
label: 'call_period',
|
||||
dataType: GenericDataType.Temporal,
|
||||
formatter,
|
||||
isNumeric: false,
|
||||
};
|
||||
const value = new DateWithFormatter('00:01:54', { formatter });
|
||||
|
||||
expect(formatColumnValue(column, value)).toEqual([false, '00:01:54']);
|
||||
});
|
||||
@@ -87,6 +87,8 @@ setupSupersetClient();
|
||||
// and https://github.com/facebook/jest/issues/6814 for more information.
|
||||
jest.mock('src/hooks/useTabId', () => ({
|
||||
useTabId: () => 1,
|
||||
getTabId: () => 'test-tab-id',
|
||||
subscribeTabIdChange: () => () => {},
|
||||
}));
|
||||
|
||||
// Check https://github.com/remarkjs/react-markdown/issues/635
|
||||
|
||||
+24
@@ -19,6 +19,8 @@
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { Store } from 'redux';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
|
||||
import { render, fireEvent, waitFor } from 'spec/helpers/testing-library';
|
||||
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
|
||||
@@ -153,3 +155,25 @@ test('dispatch stopQuery on click while running state', async () => {
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(stopQuery).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
const ctasMenu = <Menu items={[{ key: 'table', label: 'CREATE TABLE AS' }]} />;
|
||||
|
||||
test('opening the CTAS/CVAS dropdown menu does not crash and shows the menu', async () => {
|
||||
const { getAllByRole, findByText } = setup(
|
||||
{ overlayCreateAsMenu: ctasMenu },
|
||||
mockStore(initialState),
|
||||
);
|
||||
const buttons = getAllByRole('button');
|
||||
const caretButton = buttons[buttons.length - 1];
|
||||
fireEvent.click(caretButton);
|
||||
expect(await findByText('CREATE TABLE AS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the caret icon with a color that contrasts with the primary button background', () => {
|
||||
const { container } = setup(
|
||||
{ overlayCreateAsMenu: ctasMenu },
|
||||
mockStore(initialState),
|
||||
);
|
||||
const caretIcon = container.querySelector('[data-test="down"]');
|
||||
expect(caretIcon).toHaveStyle({ color: supersetTheme.colorTextLightSolid });
|
||||
});
|
||||
|
||||
@@ -149,11 +149,13 @@ const RunQueryActionButton = ({
|
||||
cta
|
||||
{...(overlayCreateAsMenu
|
||||
? {
|
||||
overlay: overlayCreateAsMenu,
|
||||
popupRender: () => overlayCreateAsMenu,
|
||||
icon: (
|
||||
<Icons.DownOutlined
|
||||
iconColor={
|
||||
isDisabled ? theme.colorTextDisabled : theme.colorIcon
|
||||
isDisabled
|
||||
? theme.colorTextDisabled
|
||||
: theme.colorTextLightSolid
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -40,6 +40,7 @@ import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { isCurrentUserBot } from 'src/utils/isBot';
|
||||
import type { AsyncModeOverride } from 'src/utils/asyncMode';
|
||||
import { ChartSource } from 'src/types/ChartSource';
|
||||
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||
import { Dispatch } from 'redux';
|
||||
@@ -92,6 +93,8 @@ export interface ChartProps {
|
||||
/** Whether to suppress the loading spinner (during auto-refresh) */
|
||||
suppressLoadingSpinner?: boolean;
|
||||
filterState?: FilterState;
|
||||
/** Per-dashboard `async_mode` override, threaded to self-contained charts. */
|
||||
asyncModeOverride?: AsyncModeOverride;
|
||||
}
|
||||
|
||||
export type Actions = {
|
||||
@@ -211,6 +214,7 @@ function Chart({
|
||||
onChartStateChange,
|
||||
suppressLoadingSpinner,
|
||||
filterState,
|
||||
asyncModeOverride,
|
||||
} = restProps;
|
||||
|
||||
const renderStartTimeRef = useRef<number>(Logger.getTimestamp());
|
||||
@@ -383,6 +387,7 @@ function Chart({
|
||||
filterState={filterState}
|
||||
suppressLoadingSpinner={suppressLoadingSpinner}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
asyncModeOverride={asyncModeOverride}
|
||||
/>
|
||||
) : (
|
||||
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
|
||||
@@ -393,6 +398,7 @@ function Chart({
|
||||
actions,
|
||||
addFilter,
|
||||
annotationData,
|
||||
asyncModeOverride,
|
||||
chartAlert,
|
||||
chartId,
|
||||
chartIsStale,
|
||||
|
||||
@@ -19,11 +19,22 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators, Dispatch, AnyAction } from 'redux';
|
||||
|
||||
import { selectAsyncModeOverride } from 'src/utils/asyncMode';
|
||||
import type { StateWithAsyncModeOverride } from 'src/utils/asyncMode';
|
||||
import * as actions from './chartAction';
|
||||
import { logEvent } from '../../logger/actions';
|
||||
import Chart from './Chart';
|
||||
import { updateDataMask } from '../../dataMask/actions';
|
||||
|
||||
// Read the per-dashboard `async_mode` override here (the connected boundary) so
|
||||
// the presentational Chart/ChartRenderer stay store-agnostic. Undefined outside a
|
||||
// dashboard (e.g. Explore), which resolves to the deployment default. This lets
|
||||
// self-contained charts (StatefulChart / the Matrixify path) honor the override
|
||||
// the Redux chart path already applies.
|
||||
function mapStateToProps(state: StateWithAsyncModeOverride) {
|
||||
return { asyncModeOverride: selectAsyncModeOverride(state) };
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
|
||||
return {
|
||||
actions: bindActionCreators(
|
||||
@@ -37,4 +48,4 @@ function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(null, mapDispatchToProps)(Chart);
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Chart);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getChartMetadataRegistry,
|
||||
VizType,
|
||||
JsonObject,
|
||||
FeatureFlag,
|
||||
FeatureFlagMap,
|
||||
} from '@superset-ui/core';
|
||||
import ChartRenderer, {
|
||||
@@ -47,6 +48,11 @@ jest.mock('@superset-ui/core', () => ({
|
||||
data-test="mock-super-chart"
|
||||
data-is-refreshing={isRefreshing ? 'true' : 'false'}
|
||||
data-enable-no-results={props.enableNoResults ? 'true' : 'false'}
|
||||
data-async-mode={String(
|
||||
(props.hooks as { resolveAsyncMode?: () => boolean } | undefined)?.[
|
||||
'resolveAsyncMode'
|
||||
]?.(),
|
||||
)}
|
||||
>
|
||||
{JSON.stringify(postTransformProps(props).formData)}
|
||||
</div>
|
||||
@@ -437,6 +443,34 @@ test('does not mark chart as refreshing when spinner suppression is disabled', (
|
||||
);
|
||||
});
|
||||
|
||||
test('threads the per-dashboard async_mode override into resolveAsyncMode for self-contained charts', () => {
|
||||
// Self-contained charts (e.g. StatefulChart / the Matrixify path) resolve
|
||||
// async mode through the injected `resolveAsyncMode` hook; it must receive the
|
||||
// dashboard override so `force_on`/`force_off` win over the deployment default,
|
||||
// matching the Redux chart path. GAQ must be on for the override to matter.
|
||||
const previousFlags = window.featureFlags;
|
||||
window.featureFlags = {
|
||||
...previousFlags,
|
||||
[FeatureFlag.GlobalAsyncQueries]: true,
|
||||
} as FeatureFlagMap;
|
||||
try {
|
||||
const { getByTestId, rerender } = render(
|
||||
<ChartRenderer {...requiredProps} asyncModeOverride="force_off" />,
|
||||
);
|
||||
expect(getByTestId('mock-super-chart')).toHaveAttribute(
|
||||
'data-async-mode',
|
||||
'false',
|
||||
);
|
||||
rerender(<ChartRenderer {...requiredProps} asyncModeOverride="force_on" />);
|
||||
expect(getByTestId('mock-super-chart')).toHaveAttribute(
|
||||
'data-async-mode',
|
||||
'true',
|
||||
);
|
||||
} finally {
|
||||
window.featureFlags = previousFlags;
|
||||
}
|
||||
});
|
||||
|
||||
test('does not render chart during loading when last data has errors', () => {
|
||||
const props = {
|
||||
...requiredProps,
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
ContextMenuFilters,
|
||||
DataRecordFilters,
|
||||
} from '@superset-ui/core';
|
||||
import type { Hooks } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
@@ -57,6 +58,8 @@ import ChartContextMenu, {
|
||||
ChartContextMenuRef,
|
||||
} from './ChartContextMenu/ChartContextMenu';
|
||||
import { handleChartDataResponse } from './chartAction';
|
||||
import { AsyncModeOverride, resolveAsyncMode } from 'src/utils/asyncMode';
|
||||
import { getTabId } from 'src/hooks/useTabId';
|
||||
|
||||
// Types for filter values
|
||||
type FilterValue = string | number | boolean | null | undefined;
|
||||
@@ -140,10 +143,19 @@ export interface ChartRendererProps {
|
||||
cacheBusterProp?: string;
|
||||
onChartStateChange?: (chartState: AgGridChartState) => void;
|
||||
suppressLoadingSpinner?: boolean;
|
||||
asyncModeOverride?: AsyncModeOverride;
|
||||
}
|
||||
|
||||
// Async resolution is injected for self-contained chart components in
|
||||
// superset-ui-core (e.g. StatefulChart), which read these off `Hooks` and cannot
|
||||
// import app-level async-event middleware themselves.
|
||||
type AsyncChartHooks = Pick<
|
||||
Hooks,
|
||||
'handleAsyncChartData' | 'resolveAsyncMode' | 'getTabId'
|
||||
>;
|
||||
|
||||
// Hooks interface
|
||||
interface ChartHooks {
|
||||
interface ChartHooks extends AsyncChartHooks {
|
||||
onAddFilter: (
|
||||
col: string,
|
||||
vals: FilterValue[],
|
||||
@@ -163,14 +175,6 @@ interface ChartHooks {
|
||||
setDataMask: (dataMask: DataMask) => void;
|
||||
onLegendScroll: (legendIndex: number) => void;
|
||||
onChartStateChange?: (chartState: AgGridChartState) => void;
|
||||
// Resolve async (HTTP 202 / GLOBAL_ASYNC_QUERIES) chart-data responses for
|
||||
// self-contained chart components in superset-ui-core (e.g. StatefulChart),
|
||||
// which cannot import app-level async-event middleware.
|
||||
handleAsyncChartData?: (
|
||||
response: Response,
|
||||
json: JsonObject,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<QueryData[]> | QueryData[];
|
||||
}
|
||||
|
||||
const BLANK = {};
|
||||
@@ -214,6 +218,7 @@ function ChartRendererComponent({
|
||||
source,
|
||||
emitCrossFilters,
|
||||
onChartStateChange,
|
||||
asyncModeOverride,
|
||||
} = restProps;
|
||||
|
||||
const theme = useTheme();
|
||||
@@ -398,6 +403,15 @@ function ChartRendererComponent({
|
||||
// StatefulChart) resolve async (202) chart-data responses without
|
||||
// depending on app-level async-event middleware.
|
||||
handleAsyncChartData: handleChartDataResponse,
|
||||
// Shares the async opt-in policy (feature flag + deployment default, plus
|
||||
// the per-dashboard `async_mode` override) with those self-contained
|
||||
// producers so they don't always run synchronously and honor the
|
||||
// dashboard override like the Redux chart path.
|
||||
resolveAsyncMode: () => resolveAsyncMode(asyncModeOverride),
|
||||
// Lets those producers send this tab's id on an async request so the
|
||||
// backend ref-counts the tab (per-tab cancel/detach), matching the Redux
|
||||
// chart path (see chartAction.ts).
|
||||
getTabId: () => getTabId(),
|
||||
}),
|
||||
[
|
||||
handleAddFilter,
|
||||
@@ -411,6 +425,7 @@ function ChartRendererComponent({
|
||||
onFilterMenuOpen,
|
||||
setDataMaskCallback,
|
||||
showContextMenu,
|
||||
asyncModeOverride,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -56,11 +56,12 @@ import {
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { exportChart } from 'src/explore/exploreUtils';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { useAsyncModeOverride } from 'src/utils/asyncMode';
|
||||
import { Dataset, DrillByType } from '../types';
|
||||
import DrillByChart from './DrillByChart';
|
||||
import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu';
|
||||
import { useContextMenu } from '../ChartContextMenu/useContextMenu';
|
||||
import { getChartDataRequest, handleChartDataResponse } from '../chartAction';
|
||||
import { requestChartDataResolved } from '../chartAction';
|
||||
import { useDisplayModeToggle } from './useDisplayModeToggle';
|
||||
import { useResultsTableView } from './useResultsTableView';
|
||||
|
||||
@@ -178,6 +179,8 @@ export default function DrillByModal({
|
||||
const theme = useTheme();
|
||||
const { addDangerToast } = useToasts();
|
||||
const [isChartDataLoading, setIsChartDataLoading] = useState(true);
|
||||
// Drill-by queries honor the same async policy as the dashboard's charts.
|
||||
const asyncModeOverride = useAsyncModeOverride();
|
||||
|
||||
const [drillByConfigs, setDrillByConfigs] = useState<DrillByConfigs>([
|
||||
{ ...drillByConfig, column },
|
||||
@@ -402,10 +405,10 @@ export default function DrillByModal({
|
||||
setChartDataResult(undefined);
|
||||
setIsChartDataLoading(true);
|
||||
|
||||
getChartDataRequest({
|
||||
requestChartDataResolved({
|
||||
formData: drilledFormData,
|
||||
requestParams: { async_mode_override: asyncModeOverride },
|
||||
})
|
||||
.then(({ response, json }) => handleChartDataResponse(response, json))
|
||||
.then(queriesResponse => {
|
||||
setChartDataResult(queriesResponse);
|
||||
})
|
||||
@@ -415,7 +418,7 @@ export default function DrillByModal({
|
||||
.finally(() => {
|
||||
setIsChartDataLoading(false);
|
||||
});
|
||||
}, [addDangerToast, drilledFormData]);
|
||||
}, [addDangerToast, asyncModeOverride, drilledFormData]);
|
||||
|
||||
const resultsTable = useResultsTableView(
|
||||
chartDataResult,
|
||||
@@ -491,10 +494,10 @@ export default function DrillByModal({
|
||||
if (drilledFormData) {
|
||||
setIsChartDataLoading(true);
|
||||
setChartDataResult(undefined);
|
||||
getChartDataRequest({
|
||||
requestChartDataResolved({
|
||||
formData: drilledFormData,
|
||||
requestParams: { async_mode_override: asyncModeOverride },
|
||||
})
|
||||
.then(({ response, json }) => handleChartDataResponse(response, json))
|
||||
.then(queriesResponse => {
|
||||
setChartDataResult(queriesResponse);
|
||||
})
|
||||
@@ -505,7 +508,7 @@ export default function DrillByModal({
|
||||
setIsChartDataLoading(false);
|
||||
});
|
||||
}
|
||||
}, [addDangerToast, drilledFormData]);
|
||||
}, [addDangerToast, asyncModeOverride, drilledFormData]);
|
||||
const { metadataBar } = useDatasetMetadataBar({ dataset });
|
||||
|
||||
return (
|
||||
|
||||
@@ -214,15 +214,22 @@ test('should render the metadata bar', async () => {
|
||||
test('should render the error', async () => {
|
||||
jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('Something went wrong'));
|
||||
.mockRejectedValue(new Error('Something went wrong\nPlease retry'));
|
||||
await waitForRender();
|
||||
// The error is wrapped in an Alert component with a stable headline and the
|
||||
// raw error text in the description — no more bare ``<pre>`` elements.
|
||||
// The error is wrapped in an Alert component with a stable headline; the
|
||||
// raw error text renders as the description with its line breaks
|
||||
// preserved (via the shared PreformattedErrorDescription).
|
||||
expect(await screen.findByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText('Failed to load drill-to-detail rows'),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
|
||||
const errorDescription = screen.getByText(
|
||||
'Error: Something went wrong Please retry',
|
||||
);
|
||||
expect(errorDescription.textContent).toBe(
|
||||
'Error: Something went wrong\nPlease retry',
|
||||
);
|
||||
expect(errorDescription).toHaveStyle({ whiteSpace: 'pre-wrap' });
|
||||
});
|
||||
|
||||
describe('download actions', () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import { PreformattedErrorDescription } from 'src/components/ErrorMessage/PreformattedErrorDescription';
|
||||
import Table, {
|
||||
ColumnsType,
|
||||
TableSize,
|
||||
@@ -372,7 +373,11 @@ export default function DrillDetailPane({
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load drill-to-detail rows')}
|
||||
description={responseError}
|
||||
description={
|
||||
<PreformattedErrorDescription>
|
||||
{responseError}
|
||||
</PreformattedErrorDescription>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,13 @@ import { logEvent } from 'src/logger/actions';
|
||||
import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
|
||||
import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig';
|
||||
import { updateDataMask } from 'src/dataMask/actions';
|
||||
import { waitForAsyncData } from 'src/middleware/asyncEvent';
|
||||
import { AsyncJob, waitForAsyncData } from 'src/middleware/asyncEvent';
|
||||
import { getTabId } from 'src/hooks/useTabId';
|
||||
import {
|
||||
resolveAsyncMode,
|
||||
selectAsyncModeOverride,
|
||||
AsyncModeOverride,
|
||||
} from 'src/utils/asyncMode';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import { extendedDayjs } from '@superset-ui/core/utils/dates';
|
||||
@@ -71,6 +77,9 @@ export interface CommonState {
|
||||
|
||||
export interface DashboardInfoState {
|
||||
common: CommonState;
|
||||
// Parsed dashboard json_metadata (when rendering within a dashboard); its
|
||||
// `async_mode` is the per-dashboard async override.
|
||||
metadata?: { async_mode?: AsyncModeOverride } & JsonObject;
|
||||
}
|
||||
|
||||
export interface DataMaskState {
|
||||
@@ -247,6 +256,10 @@ export interface RequestParams {
|
||||
dashboard_id?: number;
|
||||
mode?: string;
|
||||
credentials?: RequestCredentials;
|
||||
// Per-dashboard async-mode override (from json_metadata.async_mode). Resolves
|
||||
// whether this render requests async execution; never sent to the server as a
|
||||
// request option.
|
||||
async_mode_override?: AsyncModeOverride;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -259,12 +272,13 @@ export interface QuerySettings extends RequestParams {
|
||||
body?: string;
|
||||
}
|
||||
|
||||
// API response type for chart data request
|
||||
// API response type for chart data request. A 200 carries the query results in
|
||||
// `result`; a 202 carries the async job whose tasks must be awaited instead (its
|
||||
// body has no `result`, which only `requestChartDataResolved` has to reason
|
||||
// about — every other consumer reads a synchronous response).
|
||||
export interface ChartDataRequestResponse {
|
||||
response: Response;
|
||||
json: {
|
||||
result: QueryData[];
|
||||
};
|
||||
json: { result: QueryData[] } & Partial<AsyncJob>;
|
||||
}
|
||||
|
||||
// getChartDataRequest params interface
|
||||
@@ -274,8 +288,18 @@ export interface GetChartDataRequestParams {
|
||||
resultFormat?: string;
|
||||
resultType?: string;
|
||||
force?: boolean;
|
||||
// Forced-refresh idempotency tokens for the synchronous read-back: each is the
|
||||
// async task's UUID for the query at the same index (from the 202 `task_ids`),
|
||||
// so a forced refresh reads the result its task warmed instead of recomputing
|
||||
// (see requestChartDataResolved). Only meaningful with `force`.
|
||||
queryForceNonces?: string[];
|
||||
requestParams?: RequestParams;
|
||||
ownState?: JsonObject;
|
||||
// Opt into asynchronous execution. Only set by callers that handle an HTTP 202
|
||||
// task response (via requestChartDataResolved / handleChartDataResponse);
|
||||
// direct consumers that read `response.json.result` must leave this false so
|
||||
// they keep the synchronous flow.
|
||||
enableAsyncMode?: boolean;
|
||||
}
|
||||
|
||||
// runAnnotationQuery params interface
|
||||
@@ -411,13 +435,16 @@ const v1ChartDataRequest = async (
|
||||
requestParams: RequestParams,
|
||||
setDataMask: (dataMask: DataMask) => void,
|
||||
ownState: JsonObject,
|
||||
parseMethod?: string,
|
||||
parseMethod: string | undefined,
|
||||
asyncMode: boolean,
|
||||
queryForceNonces?: string[],
|
||||
): Promise<ChartDataRequestResponse> => {
|
||||
const payload = await buildV1ChartDataPayload({
|
||||
formData: formData as QueryFormData,
|
||||
resultType,
|
||||
resultFormat,
|
||||
force,
|
||||
queryForceNonces,
|
||||
setDataMask,
|
||||
ownState,
|
||||
});
|
||||
@@ -441,11 +468,19 @@ const v1ChartDataRequest = async (
|
||||
allowDomainSharding,
|
||||
}).toString();
|
||||
|
||||
// In async mode, send the tab id so the backend can ref-count this tab as a
|
||||
// consumer of the (shared) chart-data task — a later cancel/navigate-away from
|
||||
// this tab then detaches only this tab rather than aborting a task another tab
|
||||
// of the same user is still awaiting.
|
||||
const body = JSON.stringify(
|
||||
asyncMode ? { ...payload, async_mode: true, tab_id: getTabId() } : payload,
|
||||
);
|
||||
|
||||
const querySettings: QuerySettings = {
|
||||
...requestParams,
|
||||
url,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body,
|
||||
parseMethod,
|
||||
};
|
||||
|
||||
@@ -460,12 +495,27 @@ export async function getChartDataRequest({
|
||||
resultFormat = 'json',
|
||||
resultType = 'full',
|
||||
force = false,
|
||||
queryForceNonces,
|
||||
requestParams = {},
|
||||
ownState = {},
|
||||
enableAsyncMode = false,
|
||||
}: GetChartDataRequestParams): Promise<ChartDataRequestResponse> {
|
||||
let querySettings: RequestParams = {
|
||||
...requestParams,
|
||||
};
|
||||
// Keep the async-mode inputs out of the request options: they resolve the
|
||||
// `async_mode` payload flag, they are not `SupersetClient.post` settings.
|
||||
const { async_mode_override: asyncModeOverride, ...postParams } =
|
||||
requestParams;
|
||||
|
||||
// Opt full JSON chart-data renders into async execution per the resolved policy
|
||||
// (feature flag + deployment default + optional per-dashboard override). Only
|
||||
// callers that handle a 202 task response set enableAsyncMode; the server treats
|
||||
// an absent async_mode as synchronous, so this is additive.
|
||||
const asyncMode =
|
||||
enableAsyncMode &&
|
||||
resultFormat === 'json' &&
|
||||
resultType === 'full' &&
|
||||
resolveAsyncMode(asyncModeOverride);
|
||||
|
||||
let querySettings: RequestParams = { ...postParams };
|
||||
|
||||
if (domainShardingEnabled) {
|
||||
querySettings = {
|
||||
@@ -484,6 +534,8 @@ export async function getChartDataRequest({
|
||||
setDataMask,
|
||||
ownState,
|
||||
parseMethod,
|
||||
asyncMode,
|
||||
queryForceNonces,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -636,28 +688,30 @@ export function addChart(
|
||||
return { type: ADD_CHART, chart, key };
|
||||
}
|
||||
|
||||
// An async-flow chart-data body is `{result: [...]}`, or the results themselves
|
||||
// when a caller (e.g. a chart component in superset-ui-core) has already
|
||||
// unwrapped them.
|
||||
const extractResult = (json: ChartDataRequestResponse['json']): QueryData[] =>
|
||||
('result' in json ? json.result : json) as QueryData[];
|
||||
|
||||
export function handleChartDataResponse(
|
||||
response: Response,
|
||||
json: { result: QueryData[] },
|
||||
json: ChartDataRequestResponse['json'],
|
||||
refetch: (queryForceNonces?: string[]) => Promise<QueryData[]>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<QueryData[]> | QueryData[] {
|
||||
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
|
||||
// deal with getChartDataRequest transforming the response data
|
||||
const result = 'result' in json ? json.result : json;
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
// Query results returned synchronously, meaning query was already cached.
|
||||
return Promise.resolve(result);
|
||||
return Promise.resolve(extractResult(json));
|
||||
case 202:
|
||||
// Query is running asynchronously and we must await the results.
|
||||
// When status is 202, result contains async event data (job_id, channel_id, etc.)
|
||||
// which differs from QueryData. We cast through unknown to handle this safely.
|
||||
// The optional signal lets a caller abort the wait (Stop pressed, chart
|
||||
// superseded or unmounted), cancelling the job and avoiding leaked listeners.
|
||||
return waitForAsyncData(
|
||||
result as unknown as Parameters<typeof waitForAsyncData>[0],
|
||||
signal,
|
||||
) as Promise<QueryData[]>;
|
||||
// Query is running asynchronously as one GTF task per QueryObject. The
|
||||
// 202 body is the async job ({task_ids}); await every task, then
|
||||
// `refetch` to read the now-cached results. The optional signal lets a
|
||||
// caller abort the wait (Stop pressed, chart superseded or unmounted),
|
||||
// cancelling the outstanding tasks.
|
||||
return waitForAsyncData(json as AsyncJob, refetch, signal);
|
||||
default:
|
||||
throw new Error(
|
||||
`Received unexpected response status (${response.status}) while fetching chart data`,
|
||||
@@ -667,6 +721,56 @@ export function handleChartDataResponse(
|
||||
return json.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a chart-data request and resolve it to query results, transparently
|
||||
* awaiting asynchronous execution.
|
||||
*
|
||||
* On a 202 the body is the async job rather than data: every query task is
|
||||
* awaited, then the same request is re-issued *synchronously* so the server
|
||||
* serves it inline. Double execution (the async task computing, then the
|
||||
* re-issue recomputing the identical query) is prevented by a forced-refresh
|
||||
* idempotency nonce that IS the async task's UUID: the async submit carries no
|
||||
* nonce (the worker stamps each query with its own task UUID and records a marker
|
||||
* keyed by (task_uuid, cache_key) once cached), and the synchronous read-back
|
||||
* carries each query's task id — returned in the 202 `task_ids`, in query order —
|
||||
* as that query's `force_nonce`, so it reads the freshly-warmed cache instead of
|
||||
* recomputing. Because the token is the task's identity, a concurrent force
|
||||
* refresh that joins the same shared task (deduped by query cache key) reads back
|
||||
* under the same id — so it does NOT double-execute either. Non-forced requests
|
||||
* carry no nonce and keep the plain flow.
|
||||
*
|
||||
* `signal` aborts the wait (Stop pressed, chart superseded or unmounted) and
|
||||
* cancels the outstanding tasks.
|
||||
*/
|
||||
export async function requestChartDataResolved(
|
||||
params: Omit<GetChartDataRequestParams, 'enableAsyncMode'>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<QueryData[]> {
|
||||
// The synchronous read-back of a forced refresh stamps each query with its
|
||||
// task id (from the 202, index-aligned to queries) as the forced-refresh nonce.
|
||||
const reissueSynchronously = async (
|
||||
queryForceNonces?: string[],
|
||||
): Promise<QueryData[]> => {
|
||||
const { response, json } = await getChartDataRequest({
|
||||
...params,
|
||||
queryForceNonces: params.force ? queryForceNonces : undefined,
|
||||
enableAsyncMode: false,
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`Received unexpected response status (${response.status}) while fetching chart data`,
|
||||
);
|
||||
}
|
||||
return extractResult(json);
|
||||
};
|
||||
|
||||
const { response, json } = await getChartDataRequest({
|
||||
...params,
|
||||
enableAsyncMode: true,
|
||||
});
|
||||
return handleChartDataResponse(response, json, reissueSynchronously, signal);
|
||||
}
|
||||
|
||||
export function exploreJSON(
|
||||
formData: QueryFormData | LatestQueryFormData,
|
||||
force = false,
|
||||
@@ -691,6 +795,10 @@ export function exploreJSON(
|
||||
timeout: queryTimeout * 1000,
|
||||
};
|
||||
if (dashboardId) requestParams.dashboard_id = dashboardId;
|
||||
// Honor the per-dashboard async override when rendering within a dashboard.
|
||||
const asyncModeOverride = selectAsyncModeOverride(state);
|
||||
if (asyncModeOverride)
|
||||
requestParams.async_mode_override = asyncModeOverride;
|
||||
|
||||
const setDataMask = (dataMask: DataMask): void => {
|
||||
dispatch(updateDataMask(formData.slice_id, dataMask));
|
||||
@@ -705,20 +813,18 @@ export function exploreJSON(
|
||||
setTimeout(() => prevController.abort(), 0);
|
||||
}
|
||||
|
||||
const chartDataRequest = getChartDataRequest({
|
||||
setDataMask,
|
||||
formData,
|
||||
resultFormat: 'json',
|
||||
resultType: 'full',
|
||||
force,
|
||||
requestParams,
|
||||
ownState,
|
||||
});
|
||||
|
||||
const chartDataRequestCaught = chartDataRequest
|
||||
.then(({ response, json }) =>
|
||||
handleChartDataResponse(response, json, controller.signal),
|
||||
)
|
||||
const chartDataRequestCaught = requestChartDataResolved(
|
||||
{
|
||||
setDataMask,
|
||||
formData,
|
||||
resultFormat: 'json',
|
||||
resultType: 'full',
|
||||
force,
|
||||
requestParams,
|
||||
ownState,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.then(queriesResponse => {
|
||||
// Drop stale responses: if this request was aborted (Stop, or a newer
|
||||
// query that aborted ours), or a newer query has since replaced our
|
||||
@@ -734,7 +840,7 @@ export function exploreJSON(
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
(queriesResponse as QueryData[]).forEach(
|
||||
queriesResponse.forEach(
|
||||
(resultItem: QueryData & { applied_filters?: JsonObject[] }) =>
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_LOAD_CHART, {
|
||||
@@ -758,15 +864,13 @@ export function exploreJSON(
|
||||
}),
|
||||
),
|
||||
);
|
||||
(queriesResponse as QueryData[]).forEach(response => {
|
||||
queriesResponse.forEach(response => {
|
||||
const { warning } = response as { warning?: string | null };
|
||||
if (warning) {
|
||||
dispatch(addWarningToast(warning, { noDuplicate: true }));
|
||||
}
|
||||
});
|
||||
return dispatch(
|
||||
chartUpdateSucceeded(queriesResponse as QueryData[], key as number),
|
||||
);
|
||||
return dispatch(chartUpdateSucceeded(queriesResponse, key as number));
|
||||
})
|
||||
.catch(
|
||||
(
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getChartBuildQueryRegistry,
|
||||
QueryFormData,
|
||||
JsonObject,
|
||||
QueryData,
|
||||
AnnotationLayer,
|
||||
AnnotationType,
|
||||
AnnotationSourceType,
|
||||
@@ -42,6 +43,10 @@ import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { initialState } from 'src/SqlLab/fixtures';
|
||||
|
||||
/** A 200 response never re-issues, so the refetch must not be called. */
|
||||
const neverRefetch = () =>
|
||||
Promise.reject(new Error('refetch should not be called'));
|
||||
|
||||
interface MockState {
|
||||
charts: {
|
||||
[key: string]: {
|
||||
@@ -153,7 +158,12 @@ describe('chart actions', () => {
|
||||
);
|
||||
waitForAsyncDataStub = jest
|
||||
.spyOn(asyncEvent, 'waitForAsyncData')
|
||||
.mockImplementation((data: unknown) => Promise.resolve(data));
|
||||
// New contract: resolve by invoking the caller-provided refetch thunk,
|
||||
// forwarding the job's task_ids as the per-query forced-refresh nonces.
|
||||
.mockImplementation(
|
||||
(job: unknown, refetch: (nonces?: string[]) => Promise<unknown>) =>
|
||||
refetch((job as asyncEvent.AsyncJob)?.task_ids),
|
||||
);
|
||||
});
|
||||
|
||||
test('should drop stale success dispatches when a newer controller has replaced ours in state', async () => {
|
||||
@@ -375,6 +385,7 @@ describe('chart actions', () => {
|
||||
1, 2, 3,
|
||||
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||
},
|
||||
neverRefetch,
|
||||
);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
@@ -392,6 +403,7 @@ describe('chart actions', () => {
|
||||
1, 2, 3,
|
||||
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||
},
|
||||
neverRefetch,
|
||||
);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
@@ -402,14 +414,20 @@ describe('chart actions', () => {
|
||||
).featureFlags = {
|
||||
[FeatureFlag.GlobalAsyncQueries]: true,
|
||||
};
|
||||
// On 202 the body is the async job ({task_ids}); once the tasks resolve
|
||||
// (stubbed waitForAsyncData invokes the refetch), the re-request returns
|
||||
// the cached data.
|
||||
const refetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue([1, 2, 3] as unknown as QueryData[]);
|
||||
const result = await handleChartDataResponse(
|
||||
{ status: 202 } as Response,
|
||||
{
|
||||
result: [
|
||||
1, 2, 3,
|
||||
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||
},
|
||||
task_ids: ['task-1'],
|
||||
} as unknown as actions.ChartDataRequestResponse['json'],
|
||||
refetch,
|
||||
);
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
@@ -479,7 +497,7 @@ describe('chart actions', () => {
|
||||
fetchMock.removeRoute(MOCK_URL);
|
||||
fetchMock.post(
|
||||
`glob:*${MOCK_URL}*`,
|
||||
{ status: 202, body: { result: [{ job_id: 'job-1' }] } },
|
||||
{ status: 202, body: { task_ids: ['task-1'] } },
|
||||
{ name: MOCK_URL },
|
||||
);
|
||||
});
|
||||
@@ -568,6 +586,93 @@ describe('chart actions', () => {
|
||||
'validation failed',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects rather than treating a repeat 202 body as chart data', async () => {
|
||||
// Regression: the post-completion re-issue is always synchronous, so a
|
||||
// 202 body ({task_ids}) can never reach a consumer as if it were rows.
|
||||
// The route mocked above answers 202 for every POST.
|
||||
await expect(
|
||||
actions.requestChartDataResolved({
|
||||
formData: { viz_type: 'my_viz' } as QueryFormData,
|
||||
}),
|
||||
).rejects.toThrow('unexpected response status (202)');
|
||||
|
||||
// The initial async submit plus exactly one synchronous re-issue.
|
||||
const history = fetchMock.callHistory.calls(`glob:*${MOCK_URL}*`);
|
||||
expect(history).toHaveLength(2);
|
||||
expect(String(history[1].options.body)).not.toContain('async_mode');
|
||||
|
||||
// The initial async submit carries this tab's id, so the backend can
|
||||
// ref-count the tab as a consumer of the (shared) chart-data task.
|
||||
const initialBody = JSON.parse(String(history[0].options.body));
|
||||
expect(initialBody.async_mode).toBe(true);
|
||||
expect(typeof initialBody.tab_id).toBe('string');
|
||||
expect(initialBody.tab_id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('re-issues synchronously after async completion, reading the warm cache', async () => {
|
||||
// 1: initial async request → 202; 2: the post-completion re-issue is
|
||||
// synchronous and returns the payload inline (200). No third call and no
|
||||
// second background task.
|
||||
fetchMock.removeRoute(MOCK_URL);
|
||||
let calls = 0;
|
||||
fetchMock.post(
|
||||
`glob:*${MOCK_URL}*`,
|
||||
() => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? { status: 202, body: { task_ids: ['task-1'] } }
|
||||
: { status: 200, body: { result: [{ data: [1, 2, 3] }] } };
|
||||
},
|
||||
{ name: MOCK_URL },
|
||||
);
|
||||
|
||||
const actionThunk = actions.postChartFormData(
|
||||
{ viz_type: 'my_viz' } as QueryFormData,
|
||||
true, // force: the async task computes fresh, the re-issue reads its result
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
await actionThunk(
|
||||
dispatch as unknown as actions.ChartThunkDispatch,
|
||||
mockGetState as unknown as () => actions.RootState,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Exactly two requests: the async submit, then one synchronous re-issue —
|
||||
// no repeat-202 loop and no duplicate background task.
|
||||
expect(calls).toBe(2);
|
||||
const history = fetchMock.callHistory.calls(`glob:*${MOCK_URL}*`);
|
||||
expect(history).toHaveLength(2);
|
||||
// Both requests keep force=true — server-side dedup (the force nonce), not
|
||||
// a client-flipped force, is what prevents the re-issue from recomputing.
|
||||
expect(history[0].url).toContain('force=true');
|
||||
expect(history[1].url).toContain('force=true');
|
||||
// The submit opts into async; the re-issue is synchronous (no new GTF task).
|
||||
expect(String(history[0].options.body)).toContain('async_mode');
|
||||
expect(String(history[1].options.body)).not.toContain('async_mode');
|
||||
// The forced-refresh nonce IS the async task's UUID: the submit carries
|
||||
// none (the worker stamps each query with its own task id and records the
|
||||
// marker), and the synchronous re-issue carries the 202's task_ids as the
|
||||
// per-query force nonces, so the server reads back instead of recomputing.
|
||||
const forceCalls = buildV1ChartDataPayloadStub.mock.calls.filter(
|
||||
([arg]) => (arg as { force?: boolean }).force === true,
|
||||
);
|
||||
expect(forceCalls).toHaveLength(2);
|
||||
const submitNonces = (
|
||||
forceCalls[0][0] as { queryForceNonces?: string[] }
|
||||
).queryForceNonces;
|
||||
const reissueNonces = (
|
||||
forceCalls[1][0] as { queryForceNonces?: string[] }
|
||||
).queryForceNonces;
|
||||
expect(submitNonces).toBeUndefined();
|
||||
expect(reissueNonces).toEqual(['task-1']);
|
||||
|
||||
const succeeded = dispatch.mock.calls.find(
|
||||
([action]) => action?.type === actions.CHART_UPDATE_SUCCEEDED,
|
||||
)?.[0];
|
||||
expect(succeeded).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 { styled } from '@apache-superset/core/theme';
|
||||
|
||||
/**
|
||||
* Preformatted error detail for an Alert description: preserves the server
|
||||
* error's line breaks, wraps long unbroken tokens (SQL, URIs, payloads)
|
||||
* instead of overflowing the Alert, and uses the theme's code font so it
|
||||
* matches other preformatted errors in the app.
|
||||
*/
|
||||
export const PreformattedErrorDescription = styled.pre`
|
||||
margin: 0;
|
||||
font-family: ${({ theme }) => theme.fontFamilyCode};
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
@@ -341,6 +341,8 @@ export interface ListViewProps<T extends object = any> {
|
||||
defaultViewMode?: ViewModeType;
|
||||
forceViewMode?: ViewModeType;
|
||||
highlightRowId?: number;
|
||||
/** Highlight arbitrary rows by predicate on the mapped record (e.g. by uuid). */
|
||||
isRowHighlighted?: (record: Record<string, unknown>) => boolean;
|
||||
showThumbnails?: boolean;
|
||||
emptyState?: EmptyStateProps;
|
||||
columnsForWrapText?: string[];
|
||||
@@ -384,6 +386,7 @@ export function ListView<T extends object = any>({
|
||||
defaultViewMode = 'card',
|
||||
forceViewMode,
|
||||
highlightRowId,
|
||||
isRowHighlighted,
|
||||
emptyState,
|
||||
columnsForWrapText,
|
||||
enableBulkTag = false,
|
||||
@@ -658,6 +661,7 @@ export function ListView<T extends object = any>({
|
||||
columns={columns}
|
||||
loading={loading && rows.length > 0}
|
||||
highlightRowId={highlightRowId}
|
||||
isRowHighlighted={isRowHighlighted}
|
||||
columnsForWrapText={columnsForWrapText}
|
||||
expandable={expandable}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
|
||||
@@ -165,19 +165,48 @@ const StyledContent = styled.div<{
|
||||
}>`
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
/* @z-index-above-dashboard-header (100) + 1 = 101 */
|
||||
${({ fullSizeChartId }) => fullSizeChartId && `z-index: 101;`}
|
||||
/* @z-index-above-dashboard-header (100) + 2 = 102: a maximized chart
|
||||
must also cover the version-history overlay (101) so the two stack the
|
||||
same way on both sides of the overlay breakpoint. */
|
||||
${({ fullSizeChartId }) => fullSizeChartId && `z-index: 102;`}
|
||||
`;
|
||||
|
||||
// Sticks alongside the page scroll so the panel stays fully visible.
|
||||
// Below the XXL breakpoint the dashboard grid's min-content width plus the
|
||||
// panel exceed the viewport (the content column cannot shrink), which would
|
||||
// push the panel past the page's right edge and clip its own controls
|
||||
// (sc-119737). Mirror the Explore panel host in spirit — Explore anchors
|
||||
// absolutely inside its relatively-positioned container, but the dashboard
|
||||
// page owns the scroll, so this pins to the viewport instead. While open at
|
||||
// these widths the overlay covers the page's right edge (including the top
|
||||
// navbar while scrolled to the top) — accepted: it is a closable surface.
|
||||
const VersionHistoryColumn = styled.div`
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
height: 100vh;
|
||||
z-index: 99;
|
||||
${({ theme }) => css`
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
height: 100vh;
|
||||
z-index: 99;
|
||||
@media (max-width: ${theme.screenXLMax}px) {
|
||||
/* @z-index-above-dashboard-header (100) + 1 = 101 */
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
z-index: 101;
|
||||
box-shadow: ${theme.boxShadow};
|
||||
/* Load-bearing contract with DashboardVersionHistory's closed state:
|
||||
it must render nothing in place (its restore modal portals out of
|
||||
the column), so the column stays :empty and this zero-width fixed
|
||||
box paints no stray shadow at the viewport edge. Pinned by the
|
||||
closed-state test in DashboardVersionHistory.test.tsx. */
|
||||
&:empty {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const DashboardContentWrapper = styled.div`
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
} from 'src/dashboard/actions/dashboardState';
|
||||
import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
|
||||
import { areObjectsEqual } from 'src/reduxUtils';
|
||||
import { AsyncModeOverride } from 'src/utils/asyncMode';
|
||||
import { StandardModal, useModalValidation } from 'src/components/Modal';
|
||||
import { validateRefreshFrequency } from '../RefreshFrequency';
|
||||
import {
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
AccessSection,
|
||||
StylingSection,
|
||||
RefreshSection,
|
||||
AsyncModeSection,
|
||||
CertificationSection,
|
||||
AdvancedSection,
|
||||
} from './sections';
|
||||
@@ -148,6 +150,7 @@ const PropertiesModal = ({
|
||||
const [tags, setTags] = useState<TagType[]>([]);
|
||||
const [customCss, setCustomCss] = useState('');
|
||||
const [refreshFrequency, setRefreshFrequency] = useState(0);
|
||||
const [asyncMode, setAsyncMode] = useState<AsyncModeOverride>('default');
|
||||
const [selectedThemeId, setSelectedThemeId] = useState<number | null>(null);
|
||||
const [showChartTimestamps, setShowChartTimestamps] = useState(false);
|
||||
const [themes, setThemes] = useState<
|
||||
@@ -227,10 +230,16 @@ const PropertiesModal = ({
|
||||
'map_label_colors',
|
||||
'color_scheme_domain',
|
||||
'show_chart_timestamps',
|
||||
// Edited via the async-mode dropdown, not the raw JSON editor, so the
|
||||
// dropdown is the single source of truth (mirrors show_chart_timestamps).
|
||||
'async_mode',
|
||||
]);
|
||||
|
||||
setJsonMetadata(metaDataCopy ? jsonStringify(metaDataCopy) : '');
|
||||
setRefreshFrequency(metadata?.refresh_frequency || 0);
|
||||
setAsyncMode(
|
||||
(metadata?.async_mode as AsyncModeOverride | undefined) || 'default',
|
||||
);
|
||||
setShowChartTimestamps(metadata?.show_chart_timestamps ?? false);
|
||||
originalDashboardMetadata.current = metadata;
|
||||
},
|
||||
@@ -372,6 +381,14 @@ const PropertiesModal = ({
|
||||
// refresh") rather than falling through to the dropdown value (#42116).
|
||||
jsonMetadataObj.refresh_frequency =
|
||||
jsonMetadataObj.refresh_frequency ?? refreshFrequency;
|
||||
// Persist the per-dashboard async override from the dropdown (the sole source
|
||||
// of truth — async_mode is omitted from the Advanced JSON editor). 'default'
|
||||
// clears it so the deployment default applies.
|
||||
if (asyncMode === 'default') {
|
||||
delete jsonMetadataObj.async_mode;
|
||||
} else {
|
||||
jsonMetadataObj.async_mode = asyncMode;
|
||||
}
|
||||
jsonMetadataObj.show_chart_timestamps = Boolean(showChartTimestamps);
|
||||
const customLabelColors = jsonMetadataObj.label_colors || {};
|
||||
const updatedDashboardMetadata = {
|
||||
@@ -845,6 +862,29 @@ const PropertiesModal = ({
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)
|
||||
? [
|
||||
{
|
||||
key: 'async',
|
||||
label: (
|
||||
<CollapseLabelInModal
|
||||
title={t('Asynchronous query execution')}
|
||||
subtitle={t(
|
||||
'Control whether this dashboard loads chart data ' +
|
||||
'asynchronously',
|
||||
)}
|
||||
testId="async-mode-section"
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<AsyncModeSection
|
||||
value={asyncMode}
|
||||
onChange={setAsyncMode}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'certification',
|
||||
label: (
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 { render, screen, selectOption } from 'spec/helpers/testing-library';
|
||||
import AsyncModeSection from './AsyncModeSection';
|
||||
|
||||
const defaultProps = {
|
||||
value: 'default' as const,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders the async execution field', () => {
|
||||
render(<AsyncModeSection {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Asynchronous query execution')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deployment default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('reflects the current override value', () => {
|
||||
render(<AsyncModeSection {...defaultProps} value="force_on" />);
|
||||
|
||||
expect(screen.getByText('Force enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onChange with the selected override', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<AsyncModeSection {...defaultProps} onChange={onChange} />);
|
||||
|
||||
await selectOption('Force disabled', 'Asynchronous query execution');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('force_off');
|
||||
});
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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';
|
||||
import { Select } from '@superset-ui/core/components';
|
||||
import { ModalFormField } from 'src/components/Modal';
|
||||
import { AsyncModeOverride } from 'src/utils/asyncMode';
|
||||
|
||||
interface AsyncModeSectionProps {
|
||||
value: AsyncModeOverride;
|
||||
onChange: (value: AsyncModeOverride) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-dashboard override for asynchronous chart-data loading, persisted to
|
||||
* json_metadata.async_mode. Rendered by the properties modal only while the
|
||||
* GLOBAL_ASYNC_QUERIES feature is enabled.
|
||||
*/
|
||||
const AsyncModeSection = ({ value, onChange }: AsyncModeSectionProps) => {
|
||||
const label = t('Asynchronous query execution');
|
||||
const options: { value: AsyncModeOverride; label: string }[] = [
|
||||
{ value: 'default', label: t('Deployment default') },
|
||||
{ value: 'force_on', label: t('Force enabled') },
|
||||
{ value: 'force_off', label: t('Force disabled') },
|
||||
];
|
||||
return (
|
||||
<ModalFormField
|
||||
label={label}
|
||||
helperText={t(
|
||||
'Leave on the deployment default unless you need to force ' +
|
||||
'asynchronous loading on or off for this dashboard.',
|
||||
)}
|
||||
bottomSpacing={false}
|
||||
>
|
||||
<Select
|
||||
ariaLabel={label}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={val => onChange(val as AsyncModeOverride)}
|
||||
/>
|
||||
</ModalFormField>
|
||||
);
|
||||
};
|
||||
|
||||
export default AsyncModeSection;
|
||||
@@ -21,5 +21,6 @@ export { default as BasicInfoSection } from './BasicInfoSection';
|
||||
export { default as AccessSection } from './AccessSection';
|
||||
export { default as StylingSection } from './StylingSection';
|
||||
export { default as RefreshSection } from './RefreshSection';
|
||||
export { default as AsyncModeSection } from './AsyncModeSection';
|
||||
export { default as CertificationSection } from './CertificationSection';
|
||||
export { default as AdvancedSection } from './AdvancedSection';
|
||||
|
||||
+34
-26
@@ -24,13 +24,10 @@ import { NativeFilterType } from '@superset-ui/core';
|
||||
import type { Filter } from '@superset-ui/core';
|
||||
import FilterValue from './FilterValue';
|
||||
|
||||
const mockGetChartDataRequest = jest.fn();
|
||||
const mockRequestChartData = jest.fn();
|
||||
jest.mock('src/components/Chart/chartAction', () => ({
|
||||
getChartDataRequest: (...args: unknown[]) => mockGetChartDataRequest(...args),
|
||||
}));
|
||||
|
||||
jest.mock('src/middleware/asyncEvent', () => ({
|
||||
waitForAsyncData: jest.fn(),
|
||||
requestChartDataResolved: (...args: unknown[]) =>
|
||||
mockRequestChartData(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => {
|
||||
@@ -133,7 +130,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
test('renders loading spinner when filter has a data source', () => {
|
||||
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
|
||||
mockRequestChartData.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
renderFilterValue();
|
||||
|
||||
@@ -142,10 +139,7 @@ test('renders loading spinner when filter has a data source', () => {
|
||||
});
|
||||
|
||||
test('renders SuperChart after data loads successfully', async () => {
|
||||
mockGetChartDataRequest.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
json: { result: [{ data: [{ country: 'US' }] }] },
|
||||
});
|
||||
mockRequestChartData.mockResolvedValue([{ data: [{ country: 'US' }] }]);
|
||||
|
||||
renderFilterValue();
|
||||
|
||||
@@ -156,8 +150,28 @@ test('renders SuperChart after data loads successfully', async () => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('forwards the dashboard async override to the request', async () => {
|
||||
mockRequestChartData.mockResolvedValue([{ data: [{ country: 'US' }] }]);
|
||||
|
||||
renderFilterValue(
|
||||
{},
|
||||
{ dashboardInfo: { id: 1, metadata: { async_mode: 'force_off' } } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRequestChartData).toHaveBeenCalled();
|
||||
});
|
||||
// Filter requests carry the dashboard's override so they follow the same async
|
||||
// policy as the dashboard's charts.
|
||||
expect(mockRequestChartData).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
requestParams: { async_mode_override: 'force_off' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('renders error state when API call fails', async () => {
|
||||
mockGetChartDataRequest.mockRejectedValue(
|
||||
mockRequestChartData.mockRejectedValue(
|
||||
new Response(JSON.stringify({ message: 'Server Error' }), { status: 500 }),
|
||||
);
|
||||
|
||||
@@ -175,14 +189,14 @@ test('renders error state when API call fails', async () => {
|
||||
test('does not fetch data when filter has not been in view', () => {
|
||||
renderFilterValue({ inView: false });
|
||||
|
||||
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||
expect(mockRequestChartData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not render loading spinner when filter has no data source', () => {
|
||||
const filterWithoutDataSource = createMockFilter({
|
||||
targets: [{ column: { name: 'country' } }],
|
||||
});
|
||||
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
|
||||
mockRequestChartData.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
renderFilterValue({ filter: filterWithoutDataSource });
|
||||
|
||||
@@ -224,14 +238,11 @@ test('guard: does not fetch while a defaultToFirstItem parent has not yet auto-s
|
||||
stateWithDefaultFirstItemParent,
|
||||
);
|
||||
|
||||
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||
expect(mockRequestChartData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('guard: fetches once a defaultToFirstItem parent has set its first value', async () => {
|
||||
mockGetChartDataRequest.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
json: { result: [{ data: [{ model: 'Corolla' }] }] },
|
||||
});
|
||||
mockRequestChartData.mockResolvedValue([{ data: [{ model: 'Corolla' }] }]);
|
||||
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
|
||||
mockUseFilterDependencies.mockReturnValue({
|
||||
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
|
||||
@@ -254,16 +265,13 @@ test('guard: fetches once a defaultToFirstItem parent has set its first value',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
expect(mockRequestChartData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('guard: does not block fetch for a parent without defaultToFirstItem', async () => {
|
||||
// Non-defaultToFirstItem parents with values should pass the guard as before.
|
||||
mockGetChartDataRequest.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
json: { result: [{ data: [] }] },
|
||||
});
|
||||
mockRequestChartData.mockResolvedValue([{ data: [] }]);
|
||||
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
|
||||
mockUseFilterDependencies.mockReturnValue({
|
||||
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
|
||||
@@ -295,7 +303,7 @@ test('guard: does not block fetch for a parent without defaultToFirstItem', asyn
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
expect(mockRequestChartData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,5 +341,5 @@ test('skips data fetch when cascade parent filters have no values selected', ()
|
||||
stateWithParent,
|
||||
);
|
||||
|
||||
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||
expect(mockRequestChartData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+12
-36
@@ -32,8 +32,6 @@ import {
|
||||
Behavior,
|
||||
DataMask,
|
||||
DatasourceType,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
@@ -46,10 +44,10 @@ import { styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
|
||||
import { isEqual, isEqualWith } from 'lodash-es';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { requestChartDataResolved } from 'src/components/Chart/chartAction';
|
||||
import { ErrorAlert, ErrorMessageWithStackTrace } from 'src/components';
|
||||
import { Loading, Constants, Flex } from '@superset-ui/core/components';
|
||||
import { waitForAsyncData } from 'src/middleware/asyncEvent';
|
||||
import { useAsyncModeOverride } from 'src/utils/asyncMode';
|
||||
import { FilterBarOrientation, RootState } from 'src/dashboard/types';
|
||||
import {
|
||||
onFiltersRefreshSuccess,
|
||||
@@ -150,6 +148,9 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
const dashboardId = useSelector<RootState, number>(
|
||||
state => state.dashboardInfo.id,
|
||||
);
|
||||
// Per-dashboard async override so filter requests honor the same policy
|
||||
// (force on/off) as the dashboard's charts.
|
||||
const asyncModeOverride = useAsyncModeOverride();
|
||||
|
||||
const [error, setError] = useState<ClientErrorObject>();
|
||||
const [formData, setFormData] = useState<Partial<QueryFormData>>({
|
||||
@@ -278,42 +279,16 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
return;
|
||||
}
|
||||
setIsRefreshing(true);
|
||||
getChartDataRequest({
|
||||
requestChartDataResolved({
|
||||
formData: newFormData,
|
||||
force: shouldRefresh,
|
||||
ownState: filterOwnState,
|
||||
requestParams: { async_mode_override: asyncModeOverride },
|
||||
})
|
||||
.then(({ response, json }) => {
|
||||
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
|
||||
// deal with getChartDataRequest transforming the response data
|
||||
const result = 'result' in json ? json.result[0] : json;
|
||||
if (response.status === 200) {
|
||||
setState([result as ChartDataResponseResult]);
|
||||
setError(undefined);
|
||||
handleFilterLoadFinish();
|
||||
} else if (response.status === 202) {
|
||||
waitForAsyncData(result as Parameters<typeof waitForAsyncData>[0])
|
||||
.then((asyncResult: ChartDataResponseResult[]) => {
|
||||
setState(asyncResult);
|
||||
setError(undefined);
|
||||
handleFilterLoadFinish();
|
||||
})
|
||||
.catch((error: Response) => {
|
||||
getClientErrorObject(error).then(clientErrorObject => {
|
||||
setError(clientErrorObject);
|
||||
handleFilterLoadFinish();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
`Received unexpected response status (${response.status}) while fetching chart data`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setState(json.result as ChartDataResponseResult[]);
|
||||
setError(undefined);
|
||||
handleFilterLoadFinish();
|
||||
}
|
||||
.then(queriesResponse => {
|
||||
setState(queriesResponse as ChartDataResponseResult[]);
|
||||
setError(undefined);
|
||||
handleFilterLoadFinish();
|
||||
})
|
||||
.catch((error: Response) => {
|
||||
getClientErrorObject(error).then(clientErrorObject => {
|
||||
@@ -336,6 +311,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
setHasDepsFilterValue,
|
||||
transitiveParentIds,
|
||||
parentDefaultToFirstItem,
|
||||
asyncModeOverride,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user