Compare commits

...

13 Commits

Author SHA1 Message Date
Beto Dealmeida
992226527f feat: run pre-query cost estimation 2025-07-29 12:49:55 -04:00
dependabot[bot]
a9cd58508b chore(deps): bump cookie and @types/cookie in /superset-websocket (#34335)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2025-07-29 20:19:31 +07:00
Beto Dealmeida
122bb68e5a fix: subquery alias in RLS (#34374) 2025-07-28 22:58:15 -04:00
Beto Dealmeida
914ce9aa4f feat: read column metadata (#34359) 2025-07-28 22:57:57 -04:00
Gabriel Torres Ruiz
bb572983cd feat(theming): Align embedded sdk with theme configs (#34273) 2025-07-28 19:26:17 -07:00
Đỗ Trọng Hải
ff76ab647f build(deps): update ag-grid to non-breaking major v34 (#34326) 2025-07-29 07:46:55 +07:00
Mehmet Salih Yavuz
f554848c9f fix(PivotTable): Render html in cells if allowRenderHtml is true (#34351) 2025-07-29 01:12:37 +03:00
Hari Kiran
dc0c389488 docs(development): fix 2 typos in the dockerfile (#34341) 2025-07-28 15:06:21 -07:00
Beto Dealmeida
22b3cc0480 chore: bump BigQuery dialect to 1.15.0 (#34371) 2025-07-28 16:39:18 -04:00
Maxime Beauchemin
604d72cc98 feat: introducing a docker-compose-light.yml for lighter development (#34324) 2025-07-28 09:27:07 -07:00
Enzo Martellucci
913e068113 style(FastVizSwitcher): Adjust padding for FastVizSwitcher selector (#34317) 2025-07-28 14:39:10 +03:00
Geido
1a4e2173f5 fix(NavBar): Add brand text back (#34318) 2025-07-28 12:19:14 +03:00
Ian McEwen
c49789167b style(chart): restyle table pagination (#34311) 2025-07-27 19:39:10 -07:00
58 changed files with 2507 additions and 283 deletions

View File

@@ -59,7 +59,7 @@ RUN mkdir -p /app/superset/static/assets \
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
# as the full content of these folders don't change, yielding a decent cache reuse rate.
# Note that's it's not possible selectively COPY of mount using blobs.
# Note that it's not possible to selectively COPY or mount using blobs.
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
--mount=type=cache,target=/root/.cache \

View File

@@ -20,6 +20,9 @@
# If you choose to use this type of deployment make sure to
# create you own docker environment file (docker/.env) with your own
# unique random secure passwords and SECRET_KEY.
#
# For verbose logging during development:
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
# -----------------------------------------------------------------------
x-superset-image: &superset-image apachesuperset.docker.scarf.sh/apache/superset:${TAG:-latest-dev}
x-superset-volumes:

157
docker-compose-light.yml Normal file
View File

@@ -0,0 +1,157 @@
#
# 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.
#
# -----------------------------------------------------------------------
# Lightweight docker-compose for running multiple Superset instances
# This includes only essential services: database, Redis, and Superset app
#
# IMPORTANT: To run multiple instances in parallel:
# - Use different project names: docker-compose -p project1 -f docker-compose-light.yml up
# - Use different NODE_PORT values: NODE_PORT=9002 docker-compose -p project2 -f docker-compose-light.yml up
# - Volumes are isolated by project name (e.g., project1_db_home_light, project2_db_home_light)
# - Database name is intentionally different (superset_light) to prevent accidental cross-connections
#
# For verbose logging during development:
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
# -----------------------------------------------------------------------
x-superset-user: &superset-user root
x-superset-volumes: &superset-volumes
# /app/pythonpath_docker will be appended to the PYTHONPATH in the final container
- ./docker:/app/docker
- ./superset:/app/superset
- ./superset-frontend:/app/superset-frontend
- superset_home_light:/app/superset_home
- ./tests:/app/tests
x-common-build: &common-build
context: .
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`
cache_from:
- apache/superset-cache:3.10-slim-bookworm
args:
DEV_MODE: "true"
INCLUDE_CHROMIUM: ${INCLUDE_CHROMIUM:-false}
INCLUDE_FIREFOX: ${INCLUDE_FIREFOX:-false}
BUILD_TRANSLATIONS: ${BUILD_TRANSLATIONS:-false}
services:
db-light:
env_file:
- path: docker/.env # default
required: true
- path: docker/.env-local # optional override
required: false
image: postgres:16
restart: unless-stopped
# No host port mapping - only accessible within Docker network
volumes:
- db_home_light:/var/lib/postgresql/data
- ./docker/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
environment:
# Override database name to avoid conflicts
POSTGRES_DB: superset_light
superset-light:
env_file:
- path: docker/.env # default
required: true
- path: docker/.env-local # optional override
required: false
build:
<<: *common-build
command: ["/app/docker/docker-bootstrap.sh", "app"]
restart: unless-stopped
# No host port mapping - accessed via webpack dev server proxy
extra_hosts:
- "host.docker.internal:host-gateway"
user: *superset-user
depends_on:
superset-init-light:
condition: service_completed_successfully
volumes: *superset-volumes
environment:
# Override DB connection for light service
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
# Use light-specific config that disables Redis
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
superset-init-light:
build:
<<: *common-build
command: ["/app/docker/docker-init.sh"]
env_file:
- path: docker/.env # default
required: true
- path: docker/.env-local # optional override
required: false
depends_on:
db-light:
condition: service_started
user: *superset-user
volumes: *superset-volumes
environment:
# Override DB connection for light service
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
# Use light-specific config that disables Redis
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
healthcheck:
disable: true
superset-node-light:
build:
context: .
target: superset-node
args:
# This prevents building the frontend bundle since we'll mount local folder
# and build it on startup while firing docker-frontend.sh in dev mode, where
# it'll mount and watch local files and rebuild as you update them
DEV_MODE: "true"
BUILD_TRANSLATIONS: ${BUILD_TRANSLATIONS:-false}
environment:
# set this to false if you have perf issues running the npm i; npm run dev in-docker
# if you do so, you have to run this manually on the host, which should perform better!
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
NPM_RUN_PRUNE: false
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
ports:
- "127.0.0.1:${NODE_PORT:-9001}:9000" # Parameterized port
command: ["/app/docker/docker-frontend.sh"]
env_file:
- path: docker/.env # default
required: true
- path: docker/.env-local # optional override
required: false
volumes: *superset-volumes
volumes:
superset_home_light:
external: false
db_home_light:
external: false

View File

@@ -20,6 +20,9 @@
# If you choose to use this type of deployment make sure to
# create you own docker environment file (docker/.env) with your own
# unique random secure passwords and SECRET_KEY.
#
# For verbose logging during development:
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
# -----------------------------------------------------------------------
x-superset-volumes:
&superset-volumes # /app/pythonpath_docker will be appended to the PYTHONPATH in the final container

View File

@@ -20,6 +20,9 @@
# If you choose to use this type of deployment make sure to
# create you own docker environment file (docker/.env) with your own
# unique random secure passwords and SECRET_KEY.
#
# For verbose logging during development:
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
# -----------------------------------------------------------------------
x-superset-user: &superset-user root
x-superset-volumes: &superset-volumes

View File

@@ -53,7 +53,12 @@ PYTHONPATH=/app/pythonpath:/app/docker/pythonpath_dev
REDIS_HOST=redis
REDIS_PORT=6379
# Development and logging configuration
# FLASK_DEBUG: Enables Flask dev features (auto-reload, better error pages) - keep 'true' for development
FLASK_DEBUG=true
# SUPERSET_LOG_LEVEL: Controls Superset application logging verbosity (debug, info, warning, error, critical)
SUPERSET_LOG_LEVEL=info
SUPERSET_APP_ROOT="/"
SUPERSET_ENV=development
SUPERSET_LOAD_EXAMPLES=yes
@@ -66,4 +71,3 @@ SUPERSET_SECRET_KEY=TEST_NON_DEV_SECRET
ENABLE_PLAYWRIGHT=false
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
BUILD_SUPERSET_FRONTEND_IN_DOCKER=true
SUPERSET_LOG_LEVEL=info

View File

@@ -20,4 +20,5 @@
# DON'T ignore the .gitignore
!.gitignore
!superset_config.py
!superset_config_docker_light.py
!superset_config_local.example

View File

@@ -129,7 +129,7 @@ if os.getenv("CYPRESS_CONFIG") == "true":
#
try:
import superset_config_docker
from superset_config_docker import * # noqa
from superset_config_docker import * # noqa: F403
logger.info(
f"Loaded your Docker configuration at [{superset_config_docker.__file__}]"

View File

@@ -0,0 +1,37 @@
# 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.
#
# Configuration for docker-compose-light.yml - disables Redis and uses minimal services
# Import all settings from the main config first
from flask_caching.backends.filesystemcache import FileSystemCache
from superset_config import * # noqa: F403
# Override caching to use simple in-memory cache instead of Redis
RESULTS_BACKEND = FileSystemCache("/app/superset_home/sqllab")
CACHE_CONFIG = {
"CACHE_TYPE": "SimpleCache",
"CACHE_DEFAULT_TIMEOUT": 300,
"CACHE_KEY_PREFIX": "superset_light_",
}
DATA_CACHE_CONFIG = CACHE_CONFIG
THUMBNAIL_CACHE_CONFIG = CACHE_CONFIG
# Disable Celery entirely for lightweight mode
CELERY_CONFIG = None # type: ignore[assignment,misc]

View File

@@ -26,11 +26,14 @@ Superset locally is using Docker Compose on a Linux or Mac OSX
computer. Superset does not have official support for Windows. It's also the easiest
way to launch a fully functioning **development environment** quickly.
Note that there are 3 major ways we support to run `docker compose`:
Note that there are 4 major ways we support to run `docker compose`:
1. **docker-compose.yml:** for interactive development, where we mount your local folder with the
frontend/backend files that you can edit and experience the changes you
make in the app in real time
1. **docker-compose-light.yml:** a lightweight configuration with minimal services (database,
Superset app, and frontend dev server) for development. Uses in-memory caching instead of Redis
and is designed for running multiple instances simultaneously
1. **docker-compose-non-dev.yml** where we just build a more immutable image based on the
local branch and get all the required images running. Changes in the local branch
at the time you fire this up will be reflected, but changes to the code
@@ -44,7 +47,7 @@ Note that there are 3 major ways we support to run `docker compose`:
The `dev` builds include the `psycopg2-binary` required to connect
to the Postgres database launched as part of the `docker compose` builds.
More on these two approaches after setting up the requirements for either.
More on these approaches after setting up the requirements for either.
## Requirements
@@ -103,13 +106,36 @@ and help you start fresh. In the context of `docker compose` setting
from within docker. This will slow down the startup, but will fix various npm-related issues.
:::
### Option #2 - build a set of immutable images from the local branch
### Option #2 - lightweight development with multiple instances
For a lighter development setup that uses fewer resources and supports running multiple instances:
```bash
# Single lightweight instance (default port 9001)
docker compose -f docker-compose-light.yml up
# Multiple instances with different ports
NODE_PORT=9001 docker compose -p superset-1 -f docker-compose-light.yml up
NODE_PORT=9002 docker compose -p superset-2 -f docker-compose-light.yml up
NODE_PORT=9003 docker compose -p superset-3 -f docker-compose-light.yml up
```
This configuration includes:
- PostgreSQL database (internal network only)
- Superset application server
- Frontend development server with webpack hot reloading
- In-memory caching (no Redis)
- Isolated volumes and networks per instance
Access each instance at `http://localhost:{NODE_PORT}` (e.g., `http://localhost:9001`).
### Option #3 - build a set of immutable images from the local branch
```bash
docker compose -f docker-compose-non-dev.yml up
```
### Option #3 - boot up an official release
### Option #4 - boot up an official release
```bash
# Set the version you want to run

View File

@@ -111,7 +111,7 @@ athena = ["pyathena[pandas]>=2, <3"]
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.19.1",
"sqlalchemy-bigquery>=1.6.1",
"sqlalchemy-bigquery>=1.15.0",
"google-cloud-bigquery>=3.10.0",
]
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]

View File

@@ -795,7 +795,7 @@ sqlalchemy==1.4.54
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-utils
sqlalchemy-bigquery==1.12.0
sqlalchemy-bigquery==1.15.0
# via apache-superset
sqlalchemy-utils==0.38.3
# via

View File

@@ -53,8 +53,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
@@ -18747,27 +18747,27 @@
}
},
"node_modules/ag-charts-types": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-11.1.1.tgz",
"integrity": "sha512-bRmUcf5VVhEEekhX8Vk0NSwa8Te8YM/zchjyYKR2CX4vDYiwoohM1Jg9RFvbIhVbLC1S6QrPEbx5v2C6RDfpSA==",
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "33.1.1",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-33.1.1.tgz",
"integrity": "sha512-CNubIro0ipj4nfQ5WJPG9Isp7UI6MMDvNzrPdHNf3W+IoM8Uv3RUhjEn7xQqpQHuu6o/tMjrqpacipMUkhzqnw==",
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "11.1.1"
"ag-charts-types": "12.0.2"
}
},
"node_modules/ag-grid-react": {
"version": "33.1.1",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-33.1.1.tgz",
"integrity": "sha512-xJ+t2gpqUUwpFqAeDvKz/GLVR4unkOghfQBr8iIY9RAdGFarYFClJavsOa8XPVVUqEB9OIuPVFnOdtocbX0jeA==",
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "33.1.1",
"ag-grid-community": "34.0.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@@ -61168,8 +61168,8 @@
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20",
"ag-grid-community": "^33.1.1",
"ag-grid-react": "^33.1.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"classnames": "^2.5.1",
"d3-array": "^2.4.0",
"lodash": "^4.17.21",
@@ -61205,8 +61205,10 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@reduxjs/toolkit": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@types/react-redux": "*",
"geostyler": "^14.1.3",
"geostyler-data": "^1.0.0",
"geostyler-openlayers-parser": "^4.0.0",

View File

@@ -121,8 +121,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { render } from '@superset-ui/core/spec';
import TelemetryPixel from '.';
import { TelemetryPixel } from '.';
const OLD_ENV = process.env;

View File

@@ -39,7 +39,7 @@ interface TelemetryPixelProps {
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
const TelemetryPixel = ({
export const TelemetryPixel = ({
version = 'unknownVersion',
sha = 'unknownSHA',
build = 'unknownBuild',
@@ -56,4 +56,3 @@ const TelemetryPixel = ({
/>
);
};
export default TelemetryPixel;

View File

@@ -1,116 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Dropdown, Icons } from '@superset-ui/core/components';
import type { MenuItem } from '@superset-ui/core/components/Menu';
import { t, useTheme } from '@superset-ui/core';
import { ThemeAlgorithm, ThemeMode } from '../../theme/types';
export interface ThemeSelectProps {
setThemeMode: (newMode: ThemeMode) => void;
tooltipTitle?: string;
themeMode: ThemeMode;
hasLocalOverride?: boolean;
onClearLocalSettings?: () => void;
allowOSPreference?: boolean;
}
const ThemeSelect: React.FC<ThemeSelectProps> = ({
setThemeMode,
tooltipTitle = 'Select theme',
themeMode,
hasLocalOverride = false,
onClearLocalSettings,
allowOSPreference = true,
}) => {
const theme = useTheme();
const handleSelect = (mode: ThemeMode) => {
setThemeMode(mode);
};
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> = {
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
};
// Use different icon when local theme is active
const triggerIcon = hasLocalOverride ? (
<Icons.FormatPainterOutlined style={{ color: theme.colorErrorText }} />
) : (
themeIconMap[themeMode] || <Icons.FormatPainterOutlined />
);
const menuItems: MenuItem[] = [
{
type: 'group',
label: t('Theme'),
},
{
key: ThemeMode.DEFAULT,
label: t('Light'),
icon: <Icons.SunOutlined />,
onClick: () => handleSelect(ThemeMode.DEFAULT),
},
{
key: ThemeMode.DARK,
label: t('Dark'),
icon: <Icons.MoonOutlined />,
onClick: () => handleSelect(ThemeMode.DARK),
},
...(allowOSPreference
? [
{
key: ThemeMode.SYSTEM,
label: t('Match system'),
icon: <Icons.FormatPainterOutlined />,
onClick: () => handleSelect(ThemeMode.SYSTEM),
},
]
: []),
];
// Add clear settings option only when there's a local theme active
if (onClearLocalSettings && hasLocalOverride) {
menuItems.push(
{ type: 'divider' } as MenuItem,
{
key: 'clear-local',
label: t('Clear local theme'),
icon: <Icons.ClearOutlined />,
onClick: onClearLocalSettings,
} as MenuItem,
);
}
return (
<Dropdown
menu={{
items: menuItems,
selectedKeys: [themeMode],
}}
trigger={['hover']}
>
{triggerIcon}
</Dropdown>
);
};
export default ThemeSelect;

View File

@@ -0,0 +1,273 @@
/**
* 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,
userEvent,
waitFor,
within,
} from '@superset-ui/core/spec';
import { ThemeMode } from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components';
import { ThemeSubMenu } from '.';
// Mock the translation function
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
t: (key: string) => key,
}));
describe('ThemeSubMenu', () => {
const defaultProps = {
allowOSPreference: true,
setThemeMode: jest.fn(),
themeMode: ThemeMode.DEFAULT,
hasLocalOverride: false,
onClearLocalSettings: jest.fn(),
};
const renderThemeSubMenu = (props = defaultProps) =>
render(
<Menu>
<ThemeSubMenu {...props} />
</Menu>,
);
const findMenuWithText = async (text: string) => {
await waitFor(() => {
const found = screen
.getAllByRole('menu')
.some(m => within(m).queryByText(text));
if (!found) throw new Error(`Menu with text "${text}" not yet rendered`);
});
return screen.getAllByRole('menu').find(m => within(m).queryByText(text))!;
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders Light and Dark theme options by default', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
expect(within(menu!).getByText('Light')).toBeInTheDocument();
expect(within(menu!).getByText('Dark')).toBeInTheDocument();
});
it('does not render Match system option when allowOSPreference is false', async () => {
renderThemeSubMenu({ ...defaultProps, allowOSPreference: false });
userEvent.hover(await screen.findByRole('menuitem'));
await waitFor(() => {
expect(screen.queryByText('Match system')).not.toBeInTheDocument();
});
});
it('renders with allowOSPreference as true by default', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
expect(within(menu).getByText('Match system')).toBeInTheDocument();
});
it('renders clear option when both hasLocalOverride and onClearLocalSettings are provided', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
expect(within(menu).getByText('Clear local theme')).toBeInTheDocument();
});
it('does not render clear option when hasLocalOverride is false', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: false,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
await waitFor(() => {
expect(screen.queryByText('Clear local theme')).not.toBeInTheDocument();
});
});
it('calls setThemeMode with DEFAULT when Light is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
userEvent.click(within(menu).getByText('Light'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DEFAULT);
});
it('calls setThemeMode with DARK when Dark is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Dark');
userEvent.click(within(menu).getByText('Dark'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DARK);
});
it('calls setThemeMode with SYSTEM when Match system is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
userEvent.click(within(menu).getByText('Match system'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.SYSTEM);
});
it('calls onClearLocalSettings when Clear local theme is clicked', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
userEvent.click(within(menu).getByText('Clear local theme'));
expect(mockClear).toHaveBeenCalledTimes(1);
});
it('displays sun icon for DEFAULT theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DEFAULT });
expect(screen.getByTestId('sun')).toBeInTheDocument();
});
it('displays moon icon for DARK theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DARK });
expect(screen.getByTestId('moon')).toBeInTheDocument();
});
it('displays format-painter icon for SYSTEM theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.SYSTEM });
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
});
it('displays override icon when hasLocalOverride is true', () => {
renderThemeSubMenu({ ...defaultProps, hasLocalOverride: true });
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
});
it('renders Theme group header', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Theme');
expect(within(menu).getByText('Theme')).toBeInTheDocument();
});
it('renders sun icon for Light theme option', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
const lightOption = within(menu).getByText('Light').closest('li');
expect(within(lightOption!).getByTestId('sun')).toBeInTheDocument();
});
it('renders moon icon for Dark theme option', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Dark');
const darkOption = within(menu).getByText('Dark').closest('li');
expect(within(darkOption!).getByTestId('moon')).toBeInTheDocument();
});
it('renders format-painter icon for Match system option', async () => {
renderThemeSubMenu({ ...defaultProps, allowOSPreference: true });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
const matchOption = within(menu).getByText('Match system').closest('li');
expect(
within(matchOption!).getByTestId('format-painter'),
).toBeInTheDocument();
});
it('renders clear icon for Clear local theme option', async () => {
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: jest.fn(),
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
const clearOption = within(menu)
.getByText('Clear local theme')
.closest('li');
expect(within(clearOption!).getByTestId('clear')).toBeInTheDocument();
});
it('renders divider before clear option when clear option is present', async () => {
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: jest.fn(),
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
const divider = within(menu).queryByRole('separator');
expect(divider).toBeInTheDocument();
});
it('does not render divider when clear option is not present', async () => {
renderThemeSubMenu({ ...defaultProps });
userEvent.hover(await screen.findByRole('menuitem'));
const divider = document.querySelector('.ant-menu-item-divider');
expect(divider).toBeNull();
});
});

View File

@@ -0,0 +1,170 @@
/**
* 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 { useMemo } from 'react';
import { Icons, Menu } from '@superset-ui/core/components';
import {
css,
styled,
t,
ThemeMode,
useTheme,
ThemeAlgorithm,
} from '@superset-ui/core';
const StyledThemeSubMenu = styled(Menu.SubMenu)`
${({ theme }) => css`
[data-icon='caret-down'] {
color: ${theme.colorIcon};
font-size: ${theme.fontSizeXS}px;
margin-left: ${theme.sizeUnit}px;
}
&.ant-menu-submenu-active {
.ant-menu-title-content {
color: ${theme.colorPrimary};
}
}
`}
`;
const StyledThemeSubMenuItem = styled(Menu.Item)<{ selected: boolean }>`
${({ theme, selected }) => css`
&:hover {
color: ${theme.colorPrimary} !important;
cursor: pointer !important;
}
${selected &&
css`
background-color: ${theme.colors.primary.light4} !important;
color: ${theme.colors.primary.dark1} !important;
`}
`}
`;
export interface ThemeSubMenuOption {
key: ThemeMode;
label: string;
icon: React.ReactNode;
onClick: () => void;
}
export interface ThemeSubMenuProps {
setThemeMode: (newMode: ThemeMode) => void;
themeMode: ThemeMode;
hasLocalOverride?: boolean;
onClearLocalSettings?: () => void;
allowOSPreference?: boolean;
}
export const ThemeSubMenu: React.FC<ThemeSubMenuProps> = ({
setThemeMode,
themeMode,
hasLocalOverride = false,
onClearLocalSettings,
allowOSPreference = true,
}: ThemeSubMenuProps) => {
const theme = useTheme();
const handleSelect = (mode: ThemeMode) => {
setThemeMode(mode);
};
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> =
useMemo(
() => ({
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
}),
[],
);
const selectedThemeModeIcon = useMemo(
() =>
hasLocalOverride ? (
<Icons.FormatPainterOutlined
style={{ color: theme.colors.error.base }}
/>
) : (
themeIconMap[themeMode]
),
[hasLocalOverride, theme.colors.error.base, themeIconMap, themeMode],
);
const themeOptions: ThemeSubMenuOption[] = [
{
key: ThemeMode.DEFAULT,
label: t('Light'),
icon: <Icons.SunOutlined />,
onClick: () => handleSelect(ThemeMode.DEFAULT),
},
{
key: ThemeMode.DARK,
label: t('Dark'),
icon: <Icons.MoonOutlined />,
onClick: () => handleSelect(ThemeMode.DARK),
},
...(allowOSPreference
? [
{
key: ThemeMode.SYSTEM,
label: t('Match system'),
icon: <Icons.FormatPainterOutlined />,
onClick: () => handleSelect(ThemeMode.SYSTEM),
},
]
: []),
];
// Add clear settings option only when there's a local theme active
const clearOption =
onClearLocalSettings && hasLocalOverride
? {
key: 'clear-local',
label: t('Clear local theme'),
icon: <Icons.ClearOutlined />,
onClick: onClearLocalSettings,
}
: null;
return (
<StyledThemeSubMenu
key="theme-sub-menu"
title={selectedThemeModeIcon}
icon={<Icons.CaretDownOutlined iconSize="xs" />}
>
<Menu.ItemGroup title={t('Theme')} />
{themeOptions.map(option => (
<StyledThemeSubMenuItem
key={option.key}
onClick={option.onClick}
selected={option.key === themeMode}
>
{option.icon} {option.label}
</StyledThemeSubMenuItem>
))}
{clearOption && [
<Menu.Divider key="theme-divider" />,
<Menu.Item key={clearOption.key} onClick={clearOption.onClick}>
{clearOption.icon} {clearOption.label}
</Menu.Item>,
]}
</StyledThemeSubMenu>
);
};

View File

@@ -164,6 +164,8 @@ export * from './Steps';
export * from './Table';
export * from './TableView';
export * from './Tag';
export * from './TelemetryPixel';
export * from './ThemeSubMenu';
export * from './UnsavedChangesModal';
export * from './constants';
export * from './Result';

View File

@@ -26,7 +26,9 @@ import {
type ThemeStorage,
type ThemeControllerOptions,
type ThemeContextType,
type SupersetThemeConfig,
ThemeAlgorithm,
ThemeMode,
} from './types';
export {
@@ -66,7 +68,16 @@ const themeObject: Theme = Theme.fromConfig({
const { theme } = themeObject;
const supersetTheme = theme;
export { Theme, themeObject, styled, theme, supersetTheme };
export {
Theme,
ThemeAlgorithm,
ThemeMode,
themeObject,
styled,
theme,
supersetTheme,
};
export type {
SupersetTheme,
SerializableThemeConfig,
@@ -74,6 +85,7 @@ export type {
ThemeStorage,
ThemeControllerOptions,
ThemeContextType,
SupersetThemeConfig,
};
// Export theme utility functions

View File

@@ -429,3 +429,16 @@ export interface ThemeContextType {
canDetectOSPreference: () => boolean;
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
}
/**
* Configuration object for complete theme setup including default, dark themes and settings
*/
export interface SupersetThemeConfig {
theme_default: AnyThemeConfig;
theme_dark?: AnyThemeConfig;
theme_settings?: {
enforced?: boolean;
allowSwitching?: boolean;
allowOSPreference?: boolean;
};
}

View File

@@ -27,8 +27,8 @@
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20",
"ag-grid-community": "^33.1.1",
"ag-grid-react": "^33.1.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"classnames": "^2.5.1",
"d3-array": "^2.4.0",
"lodash": "^4.17.21",

View File

@@ -34,6 +34,12 @@ const parseLabel = value => {
return String(value);
};
function displayCell(value, allowRenderHtml) {
if (allowRenderHtml && typeof value === 'string') {
return safeHtmlSpan(value);
}
return parseLabel(value);
}
function displayHeaderCell(
needToggle,
ArrowIcon,
@@ -742,7 +748,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
style={style}
>
{agg.format(aggValue)}
{displayCell(agg.format(aggValue), allowRenderHtml)}
</td>
);
});
@@ -759,7 +765,7 @@ export class TableRenderer extends Component {
onClick={rowTotalCallbacks[flatRowKey]}
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
>
{agg.format(aggValue)}
{displayCell(agg.format(aggValue), allowRenderHtml)}
</td>
);
}
@@ -823,7 +829,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
style={{ padding: '5px' }}
>
{agg.format(aggValue)}
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
</td>
);
});
@@ -840,7 +846,7 @@ export class TableRenderer extends Component {
onClick={grandTotalCallback}
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
>
{agg.format(aggValue)}
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
</td>
);
}

View File

@@ -149,7 +149,12 @@ export default styled.div`
.dt-pagination {
text-align: right;
/* use padding instead of margin so clientHeight can capture it */
padding-top: 0.5em;
padding: ${theme.paddingXXS}px 0px;
}
.dt-pagination .pagination > li {
display: inline;
margin: 0 ${theme.marginXXS}px;
}
.dt-pagination .pagination > li > a,
@@ -157,6 +162,8 @@ export default styled.div`
background-color: ${theme.colorBgBase};
color: ${theme.colorText};
border-color: ${theme.colorBorderSecondary};
padding: ${theme.paddingXXS}px ${theme.paddingXS}px;
border-radius: ${theme.borderRadius}px;
}
.dt-pagination .pagination > li.active > a,

View File

@@ -97,6 +97,10 @@ export const COST_ESTIMATE_STARTED = 'COST_ESTIMATE_STARTED';
export const COST_ESTIMATE_RETURNED = 'COST_ESTIMATE_RETURNED';
export const COST_ESTIMATE_FAILED = 'COST_ESTIMATE_FAILED';
export const COST_THRESHOLD_CHECK_STARTED = 'COST_THRESHOLD_CHECK_STARTED';
export const COST_THRESHOLD_CHECK_RETURNED = 'COST_THRESHOLD_CHECK_RETURNED';
export const COST_THRESHOLD_CHECK_FAILED = 'COST_THRESHOLD_CHECK_FAILED';
export const CREATE_DATASOURCE_STARTED = 'CREATE_DATASOURCE_STARTED';
export const CREATE_DATASOURCE_SUCCESS = 'CREATE_DATASOURCE_SUCCESS';
export const CREATE_DATASOURCE_FAILED = 'CREATE_DATASOURCE_FAILED';
@@ -233,6 +237,45 @@ export function estimateQueryCost(queryEditor) {
};
}
export function checkCostThreshold(queryEditor) {
return (dispatch, getState) => {
const { dbId, catalog, schema, sql, selectedText, templateParams } =
getUpToDateQuery(getState(), queryEditor);
const requestSql = selectedText || sql;
const postPayload = {
database_id: dbId,
catalog,
schema,
sql: requestSql,
template_params: JSON.parse(templateParams || '{}'),
};
return Promise.all([
dispatch({ type: COST_THRESHOLD_CHECK_STARTED, query: queryEditor }),
SupersetClient.post({
endpoint: '/api/v1/sqllab/check_cost_threshold/',
body: JSON.stringify(postPayload),
headers: { 'Content-Type': 'application/json' },
})
.then(({ json }) =>
dispatch({ type: COST_THRESHOLD_CHECK_RETURNED, query: queryEditor, json }),
)
.catch(response =>
getClientErrorObject(response).then(error => {
const message =
error.error ||
error.statusText ||
t('Failed at checking cost threshold');
return dispatch({
type: COST_THRESHOLD_CHECK_FAILED,
query: queryEditor,
error: message,
});
}),
),
]);
};
}
export function clearInactiveQueries(interval) {
return { type: CLEAR_INACTIVE_QUERIES, interval };
}

View File

@@ -0,0 +1,131 @@
/**
* 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, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@superset-ui/core';
import { theme } from 'src/preamble';
import CostWarningModal from './index';
const mockProps = {
visible: true,
onHide: jest.fn(),
onProceed: jest.fn(),
warningMessage: 'This query will scan 10 GB of data, which exceeds the threshold of 5 GB.',
thresholdInfo: {
bytes_threshold: 5 * 1024 ** 3, // 5 GB
estimated_bytes: 10 * 1024 ** 3, // 10 GB
},
};
const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
describe('CostWarningModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders with warning message', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
expect(screen.getByText('Query Cost Warning')).toBeInTheDocument();
expect(screen.getByText(mockProps.warningMessage)).toBeInTheDocument();
});
it('shows threshold details when provided', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
expect(screen.getByText('Threshold Details:')).toBeInTheDocument();
expect(screen.getByText('Data to scan:')).toBeInTheDocument();
expect(screen.getByText('10.0 GB')).toBeInTheDocument();
expect(screen.getByText('5.0 GB')).toBeInTheDocument();
});
it('disables proceed button until checkbox is checked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const proceedButton = screen.getByText('Run Query Anyway');
const checkbox = screen.getByRole('checkbox');
expect(proceedButton).toBeDisabled();
fireEvent.click(checkbox);
expect(proceedButton).not.toBeDisabled();
});
it('calls onProceed when proceed button is clicked with checkbox checked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const checkbox = screen.getByRole('checkbox');
const proceedButton = screen.getByText('Run Query Anyway');
fireEvent.click(checkbox);
fireEvent.click(proceedButton);
expect(mockProps.onProceed).toHaveBeenCalledTimes(1);
});
it('calls onHide when cancel button is clicked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockProps.onHide).toHaveBeenCalledTimes(1);
});
it('renders without threshold details when not provided', () => {
const propsWithoutThreshold = {
...mockProps,
thresholdInfo: undefined,
};
renderWithTheme(<CostWarningModal {...propsWithoutThreshold} />);
expect(screen.queryByText('Threshold Details:')).not.toBeInTheDocument();
});
it('shows default message when warningMessage is null', () => {
const propsWithNoMessage = {
...mockProps,
warningMessage: null,
};
renderWithTheme(<CostWarningModal {...propsWithNoMessage} />);
expect(screen.getByText('This query may be expensive to run.')).toBeInTheDocument();
});
it('handles cost threshold details', () => {
const propsWithCostThreshold = {
...mockProps,
thresholdInfo: {
cost_threshold: 100,
estimated_cost: 250,
},
};
renderWithTheme(<CostWarningModal {...propsWithCostThreshold} />);
expect(screen.getByText('Estimated cost:')).toBeInTheDocument();
expect(screen.getByText('250')).toBeInTheDocument();
expect(screen.getByText('Cost threshold:')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,166 @@
/**
* 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 { useState } from 'react';
import { styled, t } from '@superset-ui/core';
import { Button, Modal, Checkbox } from '@superset-ui/core/components';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
const StyledModal = styled(Modal)`
.ant-modal-body {
padding: 24px;
}
`;
const WarningContent = styled.div`
margin: 16px 0;
font-size: 14px;
line-height: 1.5;
`;
const DetailsSection = styled.div`
margin: 16px 0;
padding: 12px;
background-color: ${({ theme }) => theme.colors.grayscale.light4};
border-radius: 4px;
font-size: 12px;
`;
const CheckboxWrapper = styled.div`
margin: 16px 0;
`;
interface CostWarningModalProps {
visible: boolean;
onHide: () => void;
onProceed: () => void;
warningMessage: string | null;
thresholdInfo?: {
bytes_threshold?: number;
estimated_bytes?: number;
cost_threshold?: number;
estimated_cost?: number;
};
}
export default function CostWarningModal({
visible,
onHide,
onProceed,
warningMessage,
thresholdInfo,
}: CostWarningModalProps) {
const [proceedAnyway, setProceedAnyway] = useState(false);
const handleProceed = () => {
if (proceedAnyway) {
onProceed();
}
};
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes < 1024 ** 5) return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
return `${(bytes / 1024 ** 5).toFixed(1)} PB`;
};
const renderThresholdDetails = () => {
if (!thresholdInfo) return null;
const details = [];
if (thresholdInfo.bytes_threshold && thresholdInfo.estimated_bytes) {
details.push(
<div key="bytes">
<strong>{t('Data to scan:')}</strong> {formatBytes(thresholdInfo.estimated_bytes)}
<br />
<strong>{t('Threshold:')}</strong> {formatBytes(thresholdInfo.bytes_threshold)}
</div>
);
}
if (thresholdInfo.cost_threshold && thresholdInfo.estimated_cost) {
details.push(
<div key="cost">
<strong>{t('Estimated cost:')}</strong> {thresholdInfo.estimated_cost}
<br />
<strong>{t('Cost threshold:')}</strong> {thresholdInfo.cost_threshold}
</div>
);
}
return details.length > 0 ? (
<DetailsSection>
<div style={{ marginBottom: '8px' }}>
<strong>{t('Threshold Details:')}</strong>
</div>
{details.map((detail, index) => (
<div key={index} style={{ marginBottom: index < details.length - 1 ? '8px' : '0' }}>
{detail}
</div>
))}
</DetailsSection>
) : null;
};
return (
<StyledModal
show={visible}
onHide={onHide}
title={
<ModalTitleWithIcon
icon="exclamation-triangle"
title={t('Query Cost Warning')}
/>
}
footer={
<>
<Button onClick={onHide}>
{t('Cancel')}
</Button>
<Button
buttonStyle="primary"
onClick={handleProceed}
disabled={!proceedAnyway}
>
{t('Run Query Anyway')}
</Button>
</>
}
>
<WarningContent>
{warningMessage || t('This query may be expensive to run.')}
</WarningContent>
{renderThresholdDetails()}
<CheckboxWrapper>
<Checkbox
checked={proceedAnyway}
onChange={(e) => setProceedAnyway(e.target.checked)}
>
{t('I understand the cost implications and want to proceed anyway')}
</Checkbox>
</CheckboxWrapper>
</StyledModal>
);
}

View File

@@ -71,6 +71,7 @@ import {
addNewQueryEditor,
CtasEnum,
estimateQueryCost,
checkCostThreshold,
persistEditorHeight,
postStopQuery,
queryEditorSetAutorun,
@@ -123,6 +124,7 @@ import SouthPane from '../SouthPane';
import SaveQuery, { QueryPayload } from '../SaveQuery';
import ScheduleQueryButton from '../ScheduleQueryButton';
import EstimateQueryCostButton from '../EstimateQueryCostButton';
import CostWarningModal from '../CostWarningModal';
import ShareSqlLabQuery from '../ShareSqlLabQuery';
import SqlEditorLeftBar from '../SqlEditorLeftBar';
import AceEditorWrapper from '../AceEditorWrapper';
@@ -270,6 +272,7 @@ const SqlEditor: FC<Props> = ({
hideLeftBar,
currentQueryEditorId,
hasSqlStatement,
costThresholdData,
} = useSelector<
SqlLabRootState,
{
@@ -278,8 +281,9 @@ const SqlEditor: FC<Props> = ({
hideLeftBar?: boolean;
currentQueryEditorId: QueryEditor['id'];
hasSqlStatement: boolean;
costThresholdData?: any;
}
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory } }) => {
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory, queryCostThresholds } }) => {
let { dbId, latestQueryId, hideLeftBar } = queryEditor;
if (unsavedQueryEditor?.id === queryEditor.id) {
dbId = unsavedQueryEditor.dbId || dbId;
@@ -295,6 +299,7 @@ const SqlEditor: FC<Props> = ({
latestQuery: queries[latestQueryId || ''],
hideLeftBar,
currentQueryEditorId: tabHistory.slice(-1)[0],
costThresholdData: queryCostThresholds[queryEditor.id],
};
}, shallowEqual);
@@ -317,6 +322,11 @@ const SqlEditor: FC<Props> = ({
);
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState('');
const [showCostWarningModal, setShowCostWarningModal] = useState(false);
const [costWarningData, setCostWarningData] = useState<{
warningMessage: string | null;
thresholdInfo?: any;
} | null>(null);
const currentSQL = useRef<string>(queryEditor.sql);
const showEmptyState = useMemo(
() => !database || isEmpty(database),
@@ -330,7 +340,69 @@ const SqlEditor: FC<Props> = ({
const isTempId = (value: unknown): boolean => Number.isNaN(Number(value));
const checkCostThresholdAndRun = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
if (!database) {
return;
}
// Check if cost threshold checking is enabled via feature flag or configuration
// For now, we'll implement the logic directly
dispatch(checkCostThreshold(queryEditor)).then(([_, response]) => {
if (response && response.json) {
const { exceeds_threshold, formatted_warning, threshold_info } = response.json;
if (exceeds_threshold && formatted_warning) {
// Show warning modal
setCostWarningData({
warningMessage: formatted_warning,
thresholdInfo: threshold_info,
});
setShowCostWarningModal(true);
return;
}
}
// If no threshold exceeded or checking failed, proceed with query
dispatch(
runQueryFromSqlEditor(
database,
queryEditor,
defaultQueryLimit,
ctasArg ? ctas : '',
ctasArg,
ctas_method,
),
);
dispatch(setActiveSouthPaneTab('Results'));
}).catch(() => {
// If cost checking fails, proceed with query anyway
dispatch(
runQueryFromSqlEditor(
database,
queryEditor,
defaultQueryLimit,
ctasArg ? ctas : '',
ctasArg,
ctas_method,
),
);
dispatch(setActiveSouthPaneTab('Results'));
});
},
[ctas, database, defaultQueryLimit, dispatch, queryEditor],
);
const startQuery = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
// Use cost threshold checking for regular queries
checkCostThresholdAndRun(ctasArg, ctas_method);
},
[checkCostThresholdAndRun],
);
// Direct query execution without cost checking (for modal "proceed anyway")
const executeQueryDirectly = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
if (!database) {
return;
@@ -1121,6 +1193,20 @@ const SqlEditor: FC<Props> = ({
<span>{t('Name')}</span>
<Input placeholder={createModalPlaceHolder} onChange={ctasChanged} />
</Modal>
<CostWarningModal
visible={showCostWarningModal}
onHide={() => {
setShowCostWarningModal(false);
setCostWarningData(null);
}}
onProceed={() => {
setShowCostWarningModal(false);
setCostWarningData(null);
executeQueryDirectly();
}}
warningMessage={costWarningData?.warningMessage || null}
thresholdInfo={costWarningData?.thresholdInfo}
/>
</StyledSqlEditor>
);
};

View File

@@ -264,6 +264,7 @@ export default function getInitialState({
queriesLastUpdate: Date.now(),
editorTabLastUpdatedAt,
queryCostEstimates: {},
queryCostThresholds: {},
unsavedQueryEditor,
lastUpdatedActiveTab,
destroyedQueryEditors,

View File

@@ -315,6 +315,51 @@ export default function sqlLabReducer(state = {}, action) {
},
};
},
[actions.COST_THRESHOLD_CHECK_STARTED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: false,
exceedsThreshold: false,
thresholdInfo: null,
formattedWarning: null,
error: null,
},
},
};
},
[actions.COST_THRESHOLD_CHECK_RETURNED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: true,
exceedsThreshold: action.json.exceeds_threshold,
thresholdInfo: action.json.threshold_info,
formattedWarning: action.json.formatted_warning,
error: null,
},
},
};
},
[actions.COST_THRESHOLD_CHECK_FAILED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: false,
exceedsThreshold: false,
thresholdInfo: null,
formattedWarning: null,
error: action.error,
},
},
};
},
[actions.START_QUERY]() {
let newState = { ...state };
if (action.query.sqlEditorId) {

View File

@@ -96,7 +96,6 @@ const StyledTabsContainer = styled.div`
.ant-tabs-content-holder {
overflow: visible;
padding-top: ${theme.sizeUnit * 4}px;
}
}

View File

@@ -0,0 +1,93 @@
/**
* 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 { Route } from 'react-router-dom';
import { getExtensionsRegistry } from '@superset-ui/core';
import { Provider as ReduxProvider } from 'react-redux';
import { QueryParamProvider } from 'use-query-params';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { FlashProvider, DynamicPluginProvider } from 'src/components';
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
import { ThemeController } from 'src/theme/ThemeController';
import type { ThemeStorage } from '@superset-ui/core';
import { store } from 'src/views/store';
import getBootstrapData from 'src/utils/getBootstrapData';
/**
* In-memory implementation of ThemeStorage interface for embedded contexts.
* Persistent storage is not required for embedded dashboards.
*/
class ThemeMemoryStorageAdapter implements ThemeStorage {
private storage = new Map<string, string>();
getItem(key: string): string | null {
return this.storage.get(key) || null;
}
setItem(key: string, value: string): void {
this.storage.set(key, value);
}
removeItem(key: string): void {
this.storage.delete(key);
}
}
const themeController = new ThemeController({
storage: new ThemeMemoryStorageAdapter(),
});
export const getThemeController = (): ThemeController => themeController;
const { common } = getBootstrapData();
const extensionsRegistry = getExtensionsRegistry();
export const EmbeddedContextProviders: React.FC = ({ children }) => {
const RootContextProviderExtension = extensionsRegistry.get(
'root.context.provider',
);
return (
<SupersetThemeProvider themeController={themeController}>
<ReduxProvider store={store}>
<DndProvider backend={HTML5Backend}>
<FlashProvider messages={common.flash_messages}>
<EmbeddedUiConfigProvider>
<DynamicPluginProvider>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
{RootContextProviderExtension ? (
<RootContextProviderExtension>
{children}
</RootContextProviderExtension>
) : (
children
)}
</QueryParamProvider>
</DynamicPluginProvider>
</EmbeddedUiConfigProvider>
</FlashProvider>
</DndProvider>
</ReduxProvider>
</SupersetThemeProvider>
);
};

View File

@@ -21,20 +21,27 @@ import 'src/public-path';
import { lazy, Suspense } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { makeApi, t, logging, themeObject } from '@superset-ui/core';
import {
type SupersetThemeConfig,
makeApi,
t,
logging,
} from '@superset-ui/core';
import Switchboard from '@superset-ui/switchboard';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import setupClient from 'src/setup/setupClient';
import setupPlugins from 'src/setup/setupPlugins';
import { useUiConfig } from 'src/components/UiConfigContext';
import { RootContextProviders } from 'src/views/RootContextProviders';
import { store, USER_LOADED } from 'src/views/store';
import { Loading } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { AnyThemeConfig } from 'packages/superset-ui-core/src/theme/types';
import {
EmbeddedContextProviders,
getThemeController,
} from './EmbeddedContextProviders';
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
@@ -44,9 +51,7 @@ const debugMode = process.env.WEBPACK_MODE === 'development';
const bootstrapData = getBootstrapData();
function log(...info: unknown[]) {
if (debugMode) {
logging.debug(`[superset]`, ...info);
}
if (debugMode) logging.debug(`[superset]`, ...info);
}
const LazyDashboardPage = lazy(
@@ -85,12 +90,12 @@ const EmbededLazyDashboardPage = () => {
const EmbeddedRoute = () => (
<Suspense fallback={<Loading />}>
<RootContextProviders>
<EmbeddedContextProviders>
<ErrorBoundary>
<EmbededLazyDashboardPage />
</ErrorBoundary>
<ToastContainer position="top" />
</RootContextProviders>
</EmbeddedContextProviders>
</Suspense>
);
@@ -245,12 +250,13 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
Switchboard.defineMethod(
'setThemeConfig',
(payload: { themeConfig: AnyThemeConfig }) => {
(payload: { themeConfig: SupersetThemeConfig }) => {
const { themeConfig } = payload;
log('Received setThemeConfig request:', themeConfig);
try {
themeObject.setConfig(themeConfig);
const themeController = getThemeController();
themeController.setThemeConfig(themeConfig);
return { success: true, message: 'Theme applied' };
} catch (error) {
logging.error('Failed to apply theme config:', error);
@@ -258,8 +264,22 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
}
},
);
Switchboard.start();
}
});
// Clean up theme controller on page unload
window.addEventListener('beforeunload', () => {
try {
const controller = getThemeController();
if (controller) {
log('Destroying theme controller');
controller.destroy();
}
} catch (error) {
logging.warn('Failed to destroy theme controller:', error);
}
});
log('embed page is ready to receive messages');

View File

@@ -19,7 +19,7 @@
import { memo, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { css, SupersetTheme } from '@superset-ui/core';
import { Icons } from '@superset-ui/core/components/Icons';
import { Flex, Icons } from '@superset-ui/core/components';
import { getChartKey } from 'src/explore/exploreUtils';
import { ExplorePageState } from 'src/explore/types';
import { FastVizSwitcherProps } from './types';
@@ -79,14 +79,7 @@ export const FastVizSwitcher = memo(
}, [currentSelection, currentViz]);
return (
<div
css={(theme: SupersetTheme) => css`
display: flex;
justify-content: space-between;
column-gap: ${theme.sizeUnit}px;
`}
data-test="fast-viz-switcher"
>
<Flex justify="space-between" gap={4} data-test="fast-viz-switcher">
{vizTiles.map(vizMeta => (
<VizTile
vizMeta={vizMeta}
@@ -96,7 +89,7 @@ export const FastVizSwitcher = memo(
key={vizMeta.name}
/>
))}
</div>
</Flex>
);
},
);

View File

@@ -124,7 +124,7 @@ export const VizTile = ({
>
<span
css={css`
padding: 0px ${theme.sizeUnit}px;
padding: 0px ${theme.sizeUnit * 1.25}px;
`}
>
{vizMeta.icon}
@@ -136,6 +136,7 @@ export const VizTile = ({
font-size: ${theme.fontSizeSM}px;
min-width: 0;
padding-right: ${theme.sizeUnit}px;
line-height: 1;
`}
ref={chartNameRef}
>

View File

@@ -612,3 +612,42 @@ test('should render an extension component if one is supplied', async () => {
expect(extension[0]).toBeInTheDocument();
});
test('should render the brand text if available', async () => {
useSelectorMock.mockReturnValue({ roles: [] });
const modifiedProps = {
...mockedProps,
data: {
...mockedProps.data,
brand: {
...mockedProps.data.brand,
text: 'Welcome to Superset',
},
},
};
render(<Menu {...modifiedProps} />, {
useRouter: true,
useQueryParams: true,
useRedux: true,
useTheme: true,
});
const brandText = await screen.findByText('Welcome to Superset');
expect(brandText).toBeInTheDocument();
});
test('should not render the brand text if not available', async () => {
useSelectorMock.mockReturnValue({ roles: [] });
const text = 'Welcome to Superset';
render(<Menu {...mockedProps} />, {
useRouter: true,
useQueryParams: true,
useRedux: true,
useTheme: true,
});
const brandText = screen.queryByText(text);
expect(brandText).not.toBeInTheDocument();
});

View File

@@ -52,6 +52,8 @@ const StyledHeader = styled.header`
display: none;
}
& .ant-image{
display: contents;
height: 100%;
padding: ${theme.sizeUnit}px
${theme.sizeUnit * 2}px
${theme.sizeUnit}px
@@ -87,7 +89,7 @@ const StyledHeader = styled.header`
padding-left: ${theme.sizeUnit * 4}px;
padding-right: ${theme.sizeUnit * 4}px;
margin-right: ${theme.sizeUnit * 6}px;
font-size: ${theme.sizeUnit * 4}px;
font-size: ${theme.fontSizeLG}px;
float: left;
display: flex;
flex-direction: column;
@@ -322,6 +324,11 @@ export function Menu({
>
{renderBrand()}
</Tooltip>
{brand.text && (
<div className="navbar-brand-text">
<span>{brand.text}</span>
</div>
)}
<MainNav
mode={showMenu}
data-test="navbar-top"

View File

@@ -17,13 +17,11 @@
* under the License.
*/
import { Fragment, useState, useEffect, FC, PureComponent } from 'react';
import rison from 'rison';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { useQueryParams, BooleanParam } from 'use-query-params';
import { get, isEmpty } from 'lodash';
import {
t,
styled,
@@ -33,10 +31,15 @@ import {
getExtensionsRegistry,
useTheme,
} from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components/Menu';
import { Label, Tooltip } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { Typography } from '@superset-ui/core/components/Typography';
import {
Label,
Tooltip,
ThemeSubMenu,
Menu,
Icons,
Typography,
TelemetryPixel,
} from '@superset-ui/core/components';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { findPermission } from 'src/utils/findPermission';
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
@@ -49,9 +52,7 @@ import { RootState } from 'src/dashboard/types';
import DatabaseModal from 'src/features/databases/DatabaseModal';
import UploadDataModal from 'src/features/databases/UploadDataModel';
import { uploadUserPerms } from 'src/views/CRUD/utils';
import TelemetryPixel from '@superset-ui/core/components/TelemetryPixel';
import { useThemeContext } from 'src/theme/ThemeProvider';
import ThemeSelect from '@superset-ui/core/components/ThemeSelect';
import LanguagePicker from './LanguagePicker';
import {
ExtensionConfigs,
@@ -138,6 +139,7 @@ const RightMenu = ({
datasetAdded?: boolean;
}) => void;
}) => {
const theme = useTheme();
const user = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -371,7 +373,6 @@ const RightMenu = ({
localStorage.removeItem('redux');
};
const theme = useTheme();
return (
<StyledDiv align={align}>
{canDatabase && (
@@ -493,16 +494,15 @@ const RightMenu = ({
})}
</StyledSubMenu>
)}
{canSetMode() && (
<span>
<ThemeSelect
setThemeMode={setThemeMode}
themeMode={themeMode}
hasLocalOverride={hasDevOverride()}
onClearLocalSettings={clearLocalOverrides}
allowOSPreference={canDetectOSPreference()}
/>
</span>
<ThemeSubMenu
setThemeMode={setThemeMode}
themeMode={themeMode}
hasLocalOverride={hasDevOverride()}
onClearLocalSettings={clearLocalOverrides}
allowOSPreference={canDetectOSPreference()}
/>
)}
<StyledSubMenu

View File

@@ -17,13 +17,15 @@
* under the License.
*/
import {
type AnyThemeConfig,
type SupersetTheme,
type SupersetThemeConfig,
type ThemeControllerOptions,
type ThemeStorage,
Theme,
AnyThemeConfig,
ThemeStorage,
ThemeControllerOptions,
ThemeMode,
themeObject as supersetThemeObject,
} from '@superset-ui/core';
import { SupersetTheme, ThemeMode } from '@superset-ui/core/theme/types';
import {
getAntdConfig,
normalizeThemeConfig,
@@ -94,7 +96,7 @@ export class ThemeController {
private currentMode: ThemeMode;
private readonly hasBootstrapThemes: boolean;
private hasCustomThemes: boolean;
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
@@ -109,15 +111,13 @@ export class ThemeController {
private dashboardCrudTheme: AnyThemeConfig | null = null;
constructor(options: ThemeControllerOptions = {}) {
const {
storage = new LocalStorageAdapter(),
modeStorageKey = STORAGE_KEYS.THEME_MODE,
themeObject = supersetThemeObject,
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
onChange = null,
} = options;
constructor({
storage = new LocalStorageAdapter(),
modeStorageKey = STORAGE_KEYS.THEME_MODE,
themeObject = supersetThemeObject,
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
onChange = undefined,
}: ThemeControllerOptions = {}) {
this.storage = storage;
this.modeStorageKey = modeStorageKey;
@@ -129,14 +129,14 @@ export class ThemeController {
bootstrapDefaultTheme,
bootstrapDarkTheme,
bootstrapThemeSettings,
hasBootstrapThemes,
hasCustomThemes,
}: BootstrapThemeData = this.loadBootstrapData();
this.hasBootstrapThemes = hasBootstrapThemes;
this.hasCustomThemes = hasCustomThemes;
this.themeSettings = bootstrapThemeSettings || {};
// Set themes based on bootstrap data availability
if (this.hasBootstrapThemes) {
if (this.hasCustomThemes) {
this.darkTheme = bootstrapDarkTheme || bootstrapDefaultTheme || null;
this.defaultTheme =
bootstrapDefaultTheme || bootstrapDarkTheme || defaultTheme;
@@ -424,6 +424,42 @@ export class ThemeController {
return allowOSPreference === true;
}
/**
* Sets an entire new theme configuration, replacing all existing theme data and settings.
* This method is designed for use cases like embedded dashboards where themes are provided
* dynamically from external sources.
* @param config - The complete theme configuration object
*/
public setThemeConfig(config: SupersetThemeConfig): void {
this.defaultTheme = config.theme_default;
this.darkTheme = config.theme_dark || null;
this.hasCustomThemes = true;
this.themeSettings = {
enforced: config.theme_settings?.enforced ?? false,
allowSwitching: config.theme_settings?.allowSwitching ?? true,
allowOSPreference: config.theme_settings?.allowOSPreference ?? true,
};
let newMode: ThemeMode;
try {
this.validateModeUpdatePermission(this.currentMode);
const hasRequiredTheme = this.isValidThemeMode(this.currentMode);
newMode = hasRequiredTheme
? this.currentMode
: this.determineInitialMode();
} catch {
newMode = this.determineInitialMode();
}
this.currentMode = newMode;
const themeToApply =
this.getThemeForMode(this.currentMode) || this.defaultTheme;
this.updateTheme(themeToApply);
}
/**
* Handles system theme changes with error recovery.
*/
@@ -547,7 +583,7 @@ export class ThemeController {
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
bootstrapDarkTheme: hasValidDark ? darkTheme : null,
bootstrapThemeSettings: hasValidSettings ? themeSettings : null,
hasBootstrapThemes: hasValidDefault || hasValidDark,
hasCustomThemes: hasValidDefault || hasValidDark,
};
}
@@ -607,7 +643,7 @@ export class ThemeController {
resolvedMode = ThemeController.getSystemPreferredMode();
}
if (!this.hasBootstrapThemes) {
if (!this.hasCustomThemes) {
const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
}

View File

@@ -24,8 +24,12 @@ import {
useMemo,
useState,
} from 'react';
import { Theme, AnyThemeConfig, ThemeContextType } from '@superset-ui/core';
import { ThemeMode } from '@superset-ui/core/theme/types';
import {
type AnyThemeConfig,
type ThemeContextType,
Theme,
ThemeMode,
} from '@superset-ui/core';
import { ThemeController } from './ThemeController';
const ThemeContext = createContext<ThemeContextType | null>(null);

View File

@@ -17,12 +17,17 @@
* under the License.
*/
import { theme as antdThemeImport } from 'antd';
import { Theme } from '@superset-ui/core';
import {
type AnyThemeConfig,
type SupersetThemeConfig,
Theme,
ThemeAlgorithm,
ThemeMode,
} from '@superset-ui/core';
import type {
BootstrapThemeDataConfig,
CommonBootstrapData,
} from 'src/types/bootstrapTypes';
import { ThemeAlgorithm, ThemeMode } from '@superset-ui/core/theme/types';
import getBootstrapData from 'src/utils/getBootstrapData';
import { LocalStorageAdapter, ThemeController } from '../ThemeController';
@@ -43,7 +48,7 @@ const mockThemeFromConfig = jest.fn();
const mockSetConfig = jest.fn();
// Mock data constants
const DEFAULT_THEME = {
const DEFAULT_THEME: AnyThemeConfig = {
token: {
colorBgBase: '#ededed',
colorTextBase: '#120f0f',
@@ -55,7 +60,7 @@ const DEFAULT_THEME = {
},
};
const DARK_THEME = {
const DARK_THEME: AnyThemeConfig = {
token: {
colorBgBase: '#141118',
colorTextBase: '#fdc7c7',
@@ -65,7 +70,7 @@ const DARK_THEME = {
colorSuccess: '#3c7c1b',
colorWarning: '#dc9811',
},
algorithm: ThemeMode.DARK,
algorithm: ThemeAlgorithm.DARK,
};
const THEME_SETTINGS = {
@@ -1049,4 +1054,298 @@ describe('ThemeController', () => {
);
});
});
describe('setThemeConfig', () => {
beforeEach(() => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
settings: {},
}),
);
controller = new ThemeController({
themeObject: mockThemeObject,
defaultTheme: { token: {} },
});
jest.clearAllMocks();
});
it('should set complete theme configuration', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
enforced: false,
allowSwitching: true,
allowOSPreference: true,
},
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
expect(controller.canSetTheme()).toBe(true);
expect(controller.canSetMode()).toBe(true);
});
it('should handle theme_default only', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
expect(controller.canSetTheme()).toBe(true);
expect(controller.canSetMode()).toBe(true);
});
it('should handle theme_default and theme_dark without settings', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
}),
);
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DARK_THEME.token),
algorithm: antdThemeImport.darkAlgorithm,
}),
);
});
it('should handle enforced theme settings', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
enforced: true,
allowSwitching: false,
allowOSPreference: false,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.canSetTheme()).toBe(false);
expect(controller.canSetMode()).toBe(false);
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(() => {
controller.setThemeMode(ThemeMode.DARK);
}).toThrow('User does not have permission to update the theme mode');
});
it('should handle allowOSPreference: false setting', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
enforced: false,
allowSwitching: true,
allowOSPreference: false,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(controller.canSetMode()).toBe(true);
expect(() => {
controller.setThemeMode(ThemeMode.SYSTEM);
}).toThrow('System theme mode is not allowed');
});
it('should re-determine initial mode based on new settings', () => {
mockMatchMedia.mockReturnValue({
matches: true,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
enforced: false,
allowSwitching: false,
allowOSPreference: true,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
expect(controller.canSetMode()).toBe(false);
});
it('should apply appropriate theme after configuration', () => {
controller.setThemeMode(ThemeMode.DARK);
jest.clearAllMocks();
const themeConfig = {
theme_default: {
token: {
colorPrimary: '#00ff00',
},
},
theme_dark: {
token: {
colorPrimary: '#ff0000',
colorBgBase: '#000000',
},
algorithm: 'dark',
},
};
controller.setThemeConfig(themeConfig as SupersetThemeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorPrimary: '#ff0000',
colorBgBase: '#000000',
}),
algorithm: antdThemeImport.darkAlgorithm,
}),
);
});
it('should handle missing theme_dark gracefully', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_settings: {
allowSwitching: true,
},
};
controller.setThemeConfig(themeConfig);
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
});
it('should preserve existing theme mode when possible', () => {
controller.setThemeMode(ThemeMode.DARK);
const initialMode = controller.getCurrentMode();
jest.clearAllMocks();
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
allowSwitching: true,
allowOSPreference: false,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.getCurrentMode()).toBe(initialMode);
});
it('should trigger onChange callbacks', () => {
const changeCallback = jest.fn();
controller.onChange(changeCallback);
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback).toHaveBeenCalledWith(mockThemeObject);
});
it('should handle partial theme_settings', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_settings: {
enforced: true,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.canSetTheme()).toBe(false);
expect(controller.canSetMode()).toBe(false);
});
it('should handle error in theme application', () => {
mockSetConfig.mockImplementationOnce(() => {
throw new Error('Theme application error');
});
const themeConfig = {
theme_default: DEFAULT_THEME,
};
expect(() => {
controller.setThemeConfig(themeConfig);
}).not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to apply theme:',
expect.any(Error),
);
});
it('should update stored theme mode', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'superset-theme-mode',
expect.any(String),
);
});
});
});

View File

@@ -17,8 +17,7 @@
* under the License.
*/
import { ReactNode } from 'react';
import { Theme } from '@superset-ui/core';
import { ThemeContextType, ThemeMode } from '@superset-ui/core/theme/types';
import { type ThemeContextType, Theme, ThemeMode } from '@superset-ui/core';
import { act, render, screen } from '@superset-ui/core/spec';
import { renderHook } from '@testing-library/react-hooks';
import { SupersetThemeProvider, useThemeContext } from '../ThemeProvider';

View File

@@ -16,14 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
ColorSchemeConfig,
FeatureFlagMap,
JsonObject,
LanguagePack,
Locale,
SequentialSchemeConfig,
} from '@superset-ui/core';
import { FormatLocaleDefinition } from 'd3-format';
import { TimeLocaleDefinition } from 'd3-time-format';
import { isPlainObject } from 'lodash';
@@ -31,8 +23,14 @@ import { Languages } from 'src/features/home/LanguagePicker';
import type { FlashMessage } from 'src/components';
import type {
AnyThemeConfig,
ColorSchemeConfig,
FeatureFlagMap,
JsonObject,
LanguagePack,
Locale,
SequentialSchemeConfig,
SerializableThemeConfig,
} from '@superset-ui/core/theme/types';
} from '@superset-ui/core';
export type User = {
createdOn?: string;
@@ -189,7 +187,7 @@ export interface BootstrapThemeData {
bootstrapDefaultTheme: AnyThemeConfig | null;
bootstrapDarkTheme: AnyThemeConfig | null;
bootstrapThemeSettings: SerializableThemeSettings | null;
hasBootstrapThemes: boolean;
hasCustomThemes: boolean;
}
export function isUser(user: any): user is User {

View File

@@ -53,6 +53,7 @@ const {
measure = false,
nameChunks = false,
} = parsedArgs;
const isDevMode = mode !== 'production';
const isDevServer = process.argv[1].includes('webpack-dev-server');
@@ -535,6 +536,11 @@ if (isDevMode) {
runtimeErrors: error => !/ResizeObserver/.test(error.message),
},
logging: 'error',
webSocketURL: {
hostname: '0.0.0.0',
pathname: '/ws',
port: 0,
},
},
static: {
directory: path.join(process.cwd(), '../static/assets'),

View File

@@ -9,7 +9,7 @@
"version": "0.0.1",
"license": "Apache-2.0",
"dependencies": {
"cookie": "^0.7.0",
"cookie": "^1.0.2",
"hot-shots": "^11.1.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
@@ -20,7 +20,6 @@
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/cookie": "^0.6.0",
"@types/eslint__js": "^8.42.3",
"@types/ioredis": "^4.27.8",
"@types/jest": "^29.5.14",
@@ -1721,12 +1720,6 @@
"@babel/types": "^7.3.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -3045,11 +3038,11 @@
"dev": true
},
"node_modules/cookie": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"engines": {
"node": ">= 0.6"
"node": ">=18"
}
},
"node_modules/create-jest": {
@@ -8402,12 +8395,6 @@
"@babel/types": "^7.3.0"
}
},
"@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true
},
"@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -9317,9 +9304,9 @@
"dev": true
},
"cookie": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ=="
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="
},
"create-jest": {
"version": "29.7.0",

View File

@@ -17,7 +17,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"cookie": "^0.7.0",
"cookie": "^1.0.2",
"hot-shots": "^11.1.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
@@ -28,7 +28,6 @@
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/cookie": "^0.6.0",
"@types/eslint__js": "^8.42.3",
"@types/ioredis": "^4.27.8",
"@types/jest": "^29.5.14",
@@ -52,7 +51,7 @@
"typescript-eslint": "^8.19.0"
},
"engines": {
"node": "^16.9.1",
"npm": "^7.5.4 || ^8.1.2"
"node": "^20.19.4",
"npm": "^10.8.2"
}
}

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { buildConfig } from '../src/config';
import { expect, test } from '@jest/globals';
test('buildConfig() builds configuration and applies env var overrides', () => {
let config = buildConfig();

View File

@@ -19,15 +19,26 @@
const jwt = require('jsonwebtoken');
const config = require('../config.test.json');
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
import {
describe,
expect,
test,
beforeEach,
afterEach,
jest,
} from '@jest/globals';
import * as http from 'http';
import * as net from 'net';
import { WebSocket } from 'ws';
interface MockedRedisXrange {
(): Promise<server.StreamResult[]>;
}
// NOTE: these mock variables needs to start with "mock" due to
// calls to `jest.mock` being hoisted to the top of the file.
// https://jestjs.io/docs/es6-class-mocks#calling-jestmock-with-the-module-factory-parameter
const mockRedisXrange = jest.fn();
const mockRedisXrange = jest.fn() as jest.MockedFunction<MockedRedisXrange>;
jest.mock('ws');
jest.mock('ioredis', () => {
@@ -59,7 +70,7 @@ import * as server from '../src/index';
import { statsd } from '../src/index';
describe('server', () => {
let statsdIncrementMock: jest.SpyInstance;
let statsdIncrementMock: jest.SpiedFunction<typeof statsd.increment>;
beforeEach(() => {
mockRedisXrange.mockClear();
@@ -319,10 +330,12 @@ describe('server', () => {
describe('wsConnection', () => {
let ws: WebSocket;
let wsEventMock: jest.SpyInstance;
let trackClientSpy: jest.SpyInstance;
let fetchRangeFromStreamSpy: jest.SpyInstance;
let dateNowSpy: jest.SpyInstance;
let wsEventMock: jest.SpiedFunction<typeof ws.on>;
let trackClientSpy: jest.SpiedFunction<typeof server.trackClient>;
let fetchRangeFromStreamSpy: jest.SpiedFunction<
typeof server.fetchRangeFromStream
>;
let dateNowSpy: jest.SpiedFunction<typeof Date.now>;
let socketInstanceExpected: server.SocketInstance;
const getRequest = (token: string, url: string): http.IncomingMessage => {
@@ -431,8 +444,8 @@ describe('server', () => {
describe('httpUpgrade', () => {
let socket: net.Socket;
let socketDestroySpy: jest.SpyInstance;
let wssUpgradeSpy: jest.SpyInstance;
let socketDestroySpy: jest.SpiedFunction<typeof socket.destroy>;
let wssUpgradeSpy: jest.SpiedFunction<typeof server.wss.handleUpgrade>;
const getRequest = (token: string, url: string): http.IncomingMessage => {
const request = new http.IncomingMessage(new net.Socket());
@@ -496,8 +509,8 @@ describe('server', () => {
describe('checkSockets', () => {
let ws: WebSocket;
let pingSpy: jest.SpyInstance;
let terminateSpy: jest.SpyInstance;
let pingSpy: jest.SpiedFunction<typeof ws.ping>;
let terminateSpy: jest.SpiedFunction<typeof ws.terminate>;
let socketInstance: server.SocketInstance;
beforeEach(() => {

View File

@@ -21,7 +21,7 @@ import * as net from 'net';
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
import jwt, { Algorithm } from 'jsonwebtoken';
import cookie from 'cookie';
import { parse } from 'cookie';
import Redis, { RedisOptions } from 'ioredis';
import StatsD from 'hot-shots';
@@ -285,7 +285,7 @@ export const processStreamResults = (results: StreamResult[]): void => {
* configured via 'jwtCookieName' in the config.
*/
const readChannelId = (request: http.IncomingMessage): string => {
const cookies = cookie.parse(request.headers.cookie || '');
const cookies = parse(request.headers.cookie || '');
const token = cookies[opts.jwtCookieName];
if (!token) throw new Error('JWT not present');

View File

@@ -0,0 +1,230 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import logging
from typing import Any, TypedDict
from superset import app
from superset.commands.base import BaseCommand
from superset.commands.sql_lab.estimate import QueryEstimationCommand, EstimateQueryCostType
config = app.config
logger = logging.getLogger(__name__)
class CostThresholdResult(TypedDict):
exceeds_threshold: bool
estimated_cost: list[dict[str, Any]]
threshold_info: dict[str, Any]
formatted_warning: str | None
class QueryCostThresholdCheckCommand(BaseCommand):
"""
Command to check if a query's estimated cost exceeds configured thresholds.
"""
_estimation_command: QueryEstimationCommand
def __init__(self, estimation_params: EstimateQueryCostType) -> None:
self._estimation_command = QueryEstimationCommand(estimation_params)
def validate(self) -> None:
# Use the estimation command's validation
self._estimation_command.validate()
def run(self) -> CostThresholdResult:
"""
Check if query cost exceeds thresholds.
Returns a result indicating whether the query exceeds cost thresholds
and provides information for user warnings.
"""
self.validate()
# Check if cost checking is enabled
if not config.get("SQLLAB_QUERY_COST_CHECKING_ENABLED", False):
return self._create_empty_result()
estimated_cost = self._get_estimated_cost()
if not estimated_cost:
return self._create_empty_result()
thresholds = self._get_engine_thresholds()
if not thresholds:
return CostThresholdResult(
exceeds_threshold=False,
estimated_cost=estimated_cost,
threshold_info={},
formatted_warning=None,
)
return self._check_thresholds(estimated_cost, thresholds)
def _create_empty_result(self) -> CostThresholdResult:
"""Create an empty result when cost checking is disabled or fails."""
return CostThresholdResult(
exceeds_threshold=False,
estimated_cost=[],
threshold_info={},
formatted_warning=None,
)
def _get_estimated_cost(self) -> list[dict[str, Any]] | None:
"""Get cost estimation, returning None if it fails."""
try:
return self._estimation_command.run()
except Exception as ex:
logger.warning("Cost estimation failed: %s", str(ex))
return None
def _get_engine_thresholds(self) -> dict[str, Any]:
"""Get thresholds for the current database engine."""
database = self._estimation_command._database
engine_name = database.db_engine_spec.engine_name
if engine_name is None:
return {}
engine_name = engine_name.lower()
return config.get("SQLLAB_QUERY_COST_THRESHOLDS", {}).get(engine_name, {})
def _check_thresholds(
self, estimated_cost: list[dict[str, Any]], thresholds: dict[str, Any]
) -> CostThresholdResult:
"""Check if estimated cost exceeds configured thresholds."""
exceeds_threshold = False
warning_messages = []
threshold_info = {}
for cost_item in estimated_cost:
if self._check_bytes_threshold(cost_item, thresholds, threshold_info, warning_messages):
exceeds_threshold = True
if self._check_cost_threshold(cost_item, thresholds, threshold_info, warning_messages):
exceeds_threshold = True
formatted_warning = None
if warning_messages:
formatted_warning = (
" ".join(warning_messages) + " Are you sure you want to continue?"
)
return CostThresholdResult(
exceeds_threshold=exceeds_threshold,
estimated_cost=estimated_cost,
threshold_info=threshold_info,
formatted_warning=formatted_warning,
)
def _check_bytes_threshold(
self,
cost_item: dict[str, Any],
thresholds: dict[str, Any],
threshold_info: dict[str, Any],
warning_messages: list[str]
) -> bool:
"""Check bytes scanned threshold. Returns True if threshold exceeded."""
if "bytes_scanned" not in thresholds or "Bytes Scanned" not in cost_item:
return False
try:
bytes_scanned = self._parse_bytes_from_cost_item(cost_item["Bytes Scanned"])
threshold_bytes = thresholds["bytes_scanned"]
threshold_info["bytes_threshold"] = threshold_bytes
threshold_info["estimated_bytes"] = bytes_scanned
if bytes_scanned > threshold_bytes:
warning_messages.append(
f"This query will scan approximately {self._format_bytes(bytes_scanned)} "
f"of data, which exceeds the threshold of {self._format_bytes(threshold_bytes)}."
)
return True
except (ValueError, KeyError) as ex:
logger.warning("Failed to parse bytes from cost estimation: %s", str(ex))
return False
def _check_cost_threshold(
self,
cost_item: dict[str, Any],
thresholds: dict[str, Any],
threshold_info: dict[str, Any],
warning_messages: list[str]
) -> bool:
"""Check cost threshold. Returns True if threshold exceeded."""
if "cost_threshold" not in thresholds or "Cost" not in cost_item:
return False
try:
cost_value = float(cost_item["Cost"])
threshold_cost = thresholds["cost_threshold"]
threshold_info["cost_threshold"] = threshold_cost
threshold_info["estimated_cost"] = cost_value
if cost_value > threshold_cost:
warning_messages.append(
f"This query has an estimated cost of {cost_value}, "
f"which exceeds the threshold of {threshold_cost}."
)
return True
except (ValueError, KeyError) as ex:
logger.warning("Failed to parse cost from cost estimation: %s", str(ex))
return False
def _parse_bytes_from_cost_item(self, bytes_str: str) -> int:
"""Parse bytes from formatted string like '5.2 GB' or '1024 MB'."""
if not isinstance(bytes_str, str):
return int(bytes_str)
# Remove commas and split
parts = bytes_str.replace(",", "").strip().split()
if len(parts) != 2:
raise ValueError(f"Cannot parse bytes from: {bytes_str}")
value_str, unit = parts
value = float(value_str)
unit = unit.upper()
multipliers = {
"B": 1,
"KB": 1024,
"MB": 1024**2,
"GB": 1024**3,
"TB": 1024**4,
"PB": 1024**5,
}
if unit not in multipliers:
raise ValueError(f"Unknown unit: {unit}")
return int(value * multipliers[unit])
def _format_bytes(self, bytes_count: int) -> str:
"""Format bytes into human-readable string."""
if bytes_count < 1024:
return f"{bytes_count} B"
elif bytes_count < 1024**2:
return f"{bytes_count / 1024:.1f} KB"
elif bytes_count < 1024**3:
return f"{bytes_count / (1024**2):.1f} MB"
elif bytes_count < 1024**4:
return f"{bytes_count / (1024**3):.1f} GB"
elif bytes_count < 1024**5:
return f"{bytes_count / (1024**4):.1f} TB"
else:
return f"{bytes_count / (1024**5):.1f} PB"

View File

@@ -1155,7 +1155,7 @@ class CeleryConfig: # pylint: disable=too-few-public-methods
}
CELERY_CONFIG: type[CeleryConfig] = CeleryConfig
CELERY_CONFIG: type[CeleryConfig] | None = CeleryConfig
# Set celery config to None to disable all the above configuration
# CELERY_CONFIG = None
@@ -1191,6 +1191,18 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = int(timedelta(hours=6).total_seconds())
# timeout.
SQLLAB_QUERY_COST_ESTIMATE_TIMEOUT = int(timedelta(seconds=10).total_seconds())
# Query cost governance configuration
# Enable automatic cost checking before query execution
SQLLAB_QUERY_COST_CHECKING_ENABLED = False
# Cost thresholds that trigger warnings before query execution
# This is a dictionary where keys are database engine names and values are threshold configs
# Each threshold config can contain:
# - 'bytes_scanned': maximum bytes that can be scanned without warning
# - 'cost_threshold': monetary cost threshold (engine-specific units)
# Example: {'bigquery': {'bytes_scanned': 5 * 1024**4}, 'presto': {'cost_threshold': 1000}}
SQLLAB_QUERY_COST_THRESHOLDS = {}
# Timeout duration for SQL Lab fetching query results by the resultsKey.
# 0 means no timeout.
SQLLAB_QUERY_RESULT_TIMEOUT = 0

View File

@@ -1680,6 +1680,9 @@ class SqlaTable(
table=self,
)
new_column.is_dttm = new_column.is_temporal
# Set description from comment field if available
if col.get("comment"):
new_column.description = col["comment"]
db_engine_spec.alter_new_orm_column(new_column)
else:
new_column = old_column
@@ -1687,6 +1690,9 @@ class SqlaTable(
results.modified.append(col["column_name"])
new_column.type = col["type"]
new_column.expression = ""
# Set description from comment field if available
if col.get("comment"):
new_column.description = col["comment"]
new_column.groupby = True
new_column.filterable = True
columns.append(new_column)

View File

@@ -262,8 +262,16 @@ class RLSAsSubqueryTransformer(RLSTransformer):
return node
if predicate := self.get_predicate(node):
# use alias or name
alias = node.alias or node.sql()
if node.alias:
alias = node.alias
else:
name = ".".join(
part
for part in (node.catalog or "", node.db or "", node.name)
if part
)
alias = exp.TableAlias(this=exp.Identifier(this=name, quoted=True))
node.set("alias", None)
node = exp.Subquery(
this=exp.Select(
@@ -683,7 +691,10 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
"""
return {
eq.this.sql(comments=False): eq.expression.sql(comments=False)
eq.this.sql(
dialect=self._dialect,
comments=False,
): eq.expression.sql(comments=False)
for set_item in self._parsed.find_all(exp.SetItem)
for eq in set_item.find_all(exp.EQ)
}

View File

@@ -25,6 +25,9 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
from marshmallow import ValidationError
from superset import app, is_feature_enabled
from superset.commands.sql_lab.check_cost_threshold import (
QueryCostThresholdCheckCommand,
)
from superset.commands.sql_lab.estimate import QueryEstimationCommand
from superset.commands.sql_lab.execute import CommandResult, ExecuteSqlCommand
from superset.commands.sql_lab.export import SqlResultExportCommand
@@ -188,6 +191,66 @@ class SqlLabRestApi(BaseSupersetApi):
result = command.run()
return self.response(200, result=result)
@expose("/check_cost_threshold/", methods=("POST",))
@protect()
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".check_cost_threshold",
log_to_statsd=False,
)
def check_cost_threshold(self) -> Response:
"""Check if query cost exceeds configured thresholds.
---
post:
summary: Check if query cost exceeds thresholds
requestBody:
description: SQL query and params
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/EstimateQueryCostSchema'
responses:
200:
description: Cost threshold check result
content:
application/json:
schema:
type: object
properties:
exceeds_threshold:
type: boolean
description: Whether query exceeds cost thresholds
estimated_cost:
type: array
description: Detailed cost estimation
threshold_info:
type: object
description: Information about thresholds and estimates
formatted_warning:
type: string
nullable: true
description: Human-readable warning message
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
500:
$ref: '#/components/responses/500'
"""
try:
model = self.estimate_model_schema.load(request.json)
except ValidationError as error:
return self.response_400(message=error.messages)
command = QueryCostThresholdCheckCommand(model)
result = command.run()
return self.response(200, **result)
@expose("/format_sql/", methods=("POST",))
@statsd_metrics
@protect()

View File

@@ -287,3 +287,321 @@ def test_normalize_prequery_result_type_custom_sql() -> None:
sqla_table._normalize_prequery_result_type(row, dimension, columns_by_name)
== "Car"
)
def test_fetch_metadata_with_comment_field_new_columns(mocker: MockerFixture) -> None:
"""Test that fetch_metadata correctly assigns comment field to description
for new columns
"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table",
database=database,
)
# Mock external_metadata to return columns with comment fields
mock_columns = [
{
"column_name": "id",
"type": "INTEGER",
"comment": "Primary key identifier",
},
{
"column_name": "name",
"type": "VARCHAR",
"comment": "Full name of the user",
},
{
"column_name": "status",
"type": "VARCHAR",
# No comment field for this column
},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Verify results
assert len(result.added) == 3
assert set(result.added) == {"id", "name", "status"}
# Check that descriptions were set correctly from comments
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["id"].description == "Primary key identifier"
assert columns_by_name["name"].description == "Full name of the user"
# Column without comment should have None description
assert columns_by_name["status"].description is None
def test_fetch_metadata_with_comment_field_existing_columns(
mocker: MockerFixture,
) -> None:
"""Test that fetch_metadata correctly updates description for existing columns"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table with existing columns
table = SqlaTable(
table_name="test_table_existing",
database=database,
)
table.id = 1 # Set ID so it's treated as existing table
# Create existing columns
existing_col1 = TableColumn(
column_name="id",
type="INTEGER",
table=table,
description="Old description",
)
existing_col2 = TableColumn(
column_name="name",
type="VARCHAR",
table=table,
)
table.columns = [existing_col1, existing_col2]
# Mock external_metadata to return updated columns with comments
mock_columns = [
{
"column_name": "id",
"type": "INTEGER",
"comment": "Updated primary key description",
},
{
"column_name": "name",
"type": "VARCHAR",
"comment": "Updated name description",
},
]
# Mock dependencies
mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
mock_session.query.return_value.filter.return_value.all.return_value = [
existing_col1,
existing_col2,
]
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Verify no new columns were added
assert len(result.added) == 0
# Check that descriptions were updated from comments
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["id"].description == "Updated primary key description"
assert columns_by_name["name"].description == "Updated name description"
def test_fetch_metadata_mixed_comment_scenarios(mocker: MockerFixture) -> None:
"""Test fetch_metadata with mix of new/existing columns and with/without
comments
"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table with one existing column
table = SqlaTable(
table_name="test_table_mixed",
database=database,
)
table.id = 1
existing_col = TableColumn(
column_name="existing_col",
type="INTEGER",
table=table,
description="Existing description",
)
table.columns = [existing_col]
# Mock external_metadata with mixed scenarios
mock_columns = [
{
"column_name": "existing_col",
"type": "INTEGER",
"comment": "Updated existing column comment",
},
{
"column_name": "new_with_comment",
"type": "VARCHAR",
"comment": "New column with comment",
},
{
"column_name": "new_without_comment",
"type": "VARCHAR",
# No comment field
},
]
# Mock dependencies
mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
mock_session.query.return_value.filter.return_value.all.return_value = [
existing_col
]
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Check added columns
assert len(result.added) == 2
assert set(result.added) == {"new_with_comment", "new_without_comment"}
# Check all column descriptions
columns_by_name = {col.column_name: col for col in table.columns}
# Existing column should have updated description
assert (
columns_by_name["existing_col"].description == "Updated existing column comment"
)
# New column with comment should have description set
assert columns_by_name["new_with_comment"].description == "New column with comment"
# New column without comment should have None description
assert columns_by_name["new_without_comment"].description is None
def test_fetch_metadata_no_comment_field_safe_handling(
mocker: MockerFixture,
) -> None:
"""Test that fetch_metadata safely handles columns with no comment field"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table_no_comments",
database=database,
)
# Mock external_metadata with columns that have no comment fields
mock_columns = [
{"column_name": "col1", "type": "INTEGER"},
{"column_name": "col2", "type": "VARCHAR"},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata - should not raise any exceptions
result = table.fetch_metadata()
# Check that columns were added successfully
assert len(result.added) == 2
assert set(result.added) == {"col1", "col2"}
# Check that descriptions are None (not set)
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["col1"].description is None
assert columns_by_name["col2"].description is None
def test_fetch_metadata_empty_comment_field_handling(mocker: MockerFixture) -> None:
"""Test that fetch_metadata handles empty comment fields correctly"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table_empty_comments",
database=database,
)
# Mock external_metadata with empty comment fields
mock_columns = [
{
"column_name": "col_with_empty_comment",
"type": "INTEGER",
"comment": "", # Empty string comment
},
{
"column_name": "col_with_none_comment",
"type": "VARCHAR",
"comment": None, # None comment
},
{
"column_name": "col_with_valid_comment",
"type": "VARCHAR",
"comment": "Valid comment",
},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Check that all columns were added
assert len(result.added) == 3
columns_by_name = {col.column_name: col for col in table.columns}
# Empty string comment should not be set (falsy)
assert columns_by_name["col_with_empty_comment"].description is None
# None comment should not be set
assert columns_by_name["col_with_none_comment"].description is None
# Valid comment should be set
assert columns_by_name["col_with_valid_comment"].description == "Valid comment"

View File

@@ -1851,7 +1851,7 @@ FROM (
FROM some_table
WHERE
id = 42
) AS some_table
) AS "some_table"
WHERE
1 = 1
""".strip(),
@@ -1868,7 +1868,7 @@ FROM (
FROM table
WHERE
id = 42
) AS table
) AS "table"
WHERE
1 = 1
""".strip(),
@@ -1925,7 +1925,7 @@ JOIN (
FROM other_table
WHERE
id = 42
) AS other_table
) AS "other_table"
ON table.id = other_table.id
""".strip(),
),
@@ -1961,7 +1961,7 @@ FROM (
FROM some_table
WHERE
id = 42
) AS some_table
) AS "some_table"
)
""".strip(),
),
@@ -1977,7 +1977,7 @@ FROM (
FROM table
WHERE
id = 42
) AS table
) AS "table"
UNION ALL
SELECT
*
@@ -2000,7 +2000,7 @@ FROM (
FROM other_table
WHERE
id = 42
) AS other_table
) AS "other_table"
""".strip(),
),
(
@@ -2039,6 +2039,22 @@ INNER JOIN tbl_b AS b
ON a.col = b.col
""".strip(),
),
(
"SELECT * FROM public.flights LIMIT 100",
{Table("flights", "public", "catalog1"): "\"AIRLINE\" like 'A%'"},
"""
SELECT
*
FROM (
SELECT
*
FROM public.flights
WHERE
"AIRLINE" LIKE 'A%'
) AS "public.flights"
LIMIT 100
""".strip(),
),
],
)
def test_rls_subquery_transformer(

View File

@@ -259,13 +259,13 @@ FROM (
FROM t1
WHERE
c1 = 1
) AS t1, (
) AS "t1", (
SELECT
*
FROM t2
WHERE
c2 = 2
) AS t2
) AS "t2"
""".strip()
)