From f5630c17a07b21fe7a79d4deaa428822b39b4863 Mon Sep 17 00:00:00 2001 From: Tomas Guntheri Date: Fri, 8 Sep 2023 14:13:49 -0300 Subject: [PATCH] Add host to assets and add spa_bff/superset prefix Ref: https://zf.atlassian.net/browse/ZFE-75240 --- .terra/insights/superset.tf | 11 + Dockerfile | 5 +- bi_superset/superset_config.py | 3 + bi_superset/superset_config_local.py | 198 ------------------ embedded/README.md | 23 ++ embedded/sample.html | 89 ++++++++ .../src/connection/SupersetClientClass.ts | 41 +++- .../superset-ui-core/src/connection/types.ts | 1 + .../src/components/EmptyState/index.tsx | 10 + superset-frontend/src/embedded/index.tsx | 6 + .../src/middleware/loggerMiddleware.js | 3 + superset-frontend/webpack.config.js | 2 +- 12 files changed, 190 insertions(+), 202 deletions(-) delete mode 100644 bi_superset/superset_config_local.py create mode 100644 embedded/README.md create mode 100644 embedded/sample.html diff --git a/.terra/insights/superset.tf b/.terra/insights/superset.tf index 01cc3b464a10..388389e9a9e4 100644 --- a/.terra/insights/superset.tf +++ b/.terra/insights/superset.tf @@ -45,6 +45,16 @@ variable "zf_api_host" { } } +variable "zf_dashboard_host" { + type = "map" + + default = { + qa = "https://cloud-qa.zerofox.com" + stag = "https://cloud-stag.zerofox.com" + prod = "https://cloud.zerofox.com" + } +} + # ---------------------------------------- # Providers # ---------------------------------------- @@ -161,6 +171,7 @@ module "nomad-job" { git_sha = "${var.git_sha}" docker_file = "../../Dockerfile" docker_path = "../.." + docker_build_args = ["ASSET_BASE_URL=${lookup(var.zf_dashboard_host, var.env)}/spa_bff/superset"] rendered_template = "${data.template_file.nomad_job_spec.rendered}" } diff --git a/Dockerfile b/Dockerfile index 9fba9ad4c6a7..f616dfdbf808 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ ARG PY_VER=3.8.16-slim FROM node:16-slim AS superset-node ARG NPM_BUILD_CMD="build" +ARG ASSET_BASE_URL ENV BUILD_CMD=${NPM_BUILD_CMD} ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true @@ -43,7 +44,7 @@ RUN npm ci COPY ./superset-frontend . # This seems to be the most expensive step -RUN npm run ${BUILD_CMD} +RUN ASSET_BASE_URL=${ASSET_BASE_URL} npm run ${BUILD_CMD} ###################################################################### # Final lean image... @@ -108,7 +109,7 @@ WORKDIR /app USER superset # Copy BI Superset -COPY bi_superset/ /app/bi_superset/ +COPY bi_superset/ /app/bi_superset/ COPY bi_superset/superset_config.py /app/superset_config.py # Injects bi_cli into superset cli diff --git a/bi_superset/superset_config.py b/bi_superset/superset_config.py index 08fe03b810fa..b06127d8fd1f 100644 --- a/bi_superset/superset_config.py +++ b/bi_superset/superset_config.py @@ -88,12 +88,15 @@ def get_env_variable(var_name: str, default: Optional[str] = None) -> str: WTF_CSRF_ENABLED = False # Guest token config options + # To run with `embedded/sample.html` you need to have the Gamma roles on the Guest token + # GUEST_ROLE_NAME = "Gamma" GUEST_ROLE_NAME = "Public" GUEST_TOKEN_JWT_SECRET = get_env_variable("GUEST_TOKEN_JWT_SECRET", None) GUEST_TOKEN_JWT_ALGO = "HS256" GUEST_TOKEN_HEADER_NAME = "X-GuestToken" GUEST_TOKEN_JWT_EXP_SECONDS = 60*60 # 1 hour ZF_JWT_PUBLIC_SECRET = get_env_variable("ZF_JWT_PUBLIC_SECRET", None) + STATIC_ASSETS_PREFIX = f'{os.environ.get("ZF_DASHBOARD_HOST")}/spa_bff/superset' BQ_DATASET = os.getenv("BQ_DATASET", None) ZF_API_HOST = os.getenv("ZF_API_HOST", "https://api-qa.zerofox.com") diff --git a/bi_superset/superset_config_local.py b/bi_superset/superset_config_local.py deleted file mode 100644 index 8a165af9131a..000000000000 --- a/bi_superset/superset_config_local.py +++ /dev/null @@ -1,198 +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. -# -# This file is included in the final Docker image and SHOULD be overridden when -# deploying the image to prod. Settings configured here are intended for use in local -# development environments. Also note that superset_config_docker.py is imported -# as a final step as a means to override "defaults" configured here -# -import logging -import os -from typing import Optional -from datetime import timedelta - -from celery.schedules import crontab -from cachelib.redis import RedisCache -from superset.superset_typing import CacheConfig - -# Security Manager implementation has to be provided -from bi_superset.bi_custom_security_manager import BICustomSecurityManager -from bi_superset.bi_security_manager.models.access_method import AccessMethod -from bi_superset.bi_macros.macros import normalize_idna - - -logger = logging.getLogger() - -# pylint: disable=too-many-lines - - -def get_env_variable(var_name: str, default: Optional[str] = None) -> str: - """Get the environment variable or raise exception.""" - try: - return os.environ[var_name] - except KeyError: - if default is not None: - return default - else: - error_msg = "The environment variable {} was missing, abort...".format( - var_name - ) - raise EnvironmentError(error_msg) - - -# Maybe we could add this variable on env file bu le -SUPERSET_ACCESS_METHOD = os.getenv("SUPERSET_ACCESS_METHOD", None) - -# VALIDATES THAT VARIABLE EXISTS -if not any(method.value == SUPERSET_ACCESS_METHOD for method in AccessMethod): - raise Exception("ENV SUPERSET_ACCESS_METHOD is not set") - -DASHBOARD_RBAC = ( - True if SUPERSET_ACCESS_METHOD == AccessMethod.EXTERNAL.value else False -) - -BQ_DATASET = os.getenv("BQ_DATASET", None) -ZF_API_HOST = os.getenv("ZF_API_HOST", "https://api-qa.zerofox.com") - -FEATURE_FLAGS = { - "ENABLE_TEMPLATE_PROCESSING": True, - "ESTIMATE_QUERY_COST": True, - "ALERT_REPORTS": True, - "ALLOW_FULL_CSV_EXPORT": True, - "DASHBOARD_CROSS_FILTERS": True, - "ENABLE_TEMPLATE_REMOVE_FILTERS": True, - "DASHBOARD_RBAC": DASHBOARD_RBAC, -} - -JINJA_CONTEXT_ADDONS = { - "normalize_idna": normalize_idna, -} - -# The SQLAlchemy connection string. -SQLALCHEMY_DATABASE_URI = get_env_variable("DATABASE_URL") - - -PREVIOUS_SECRET_KEY = "CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET" -SECRET_KEY = os.environ.get("SUPERSET_SECRET_KEY") -SUPERSET_WEBSERVER_TIMEOUT = 300 -SUPERSET_WEBSERVER_PROTOCOL = "http" - -# The SQLAlchemy connection string. -REDIS_HOST = get_env_variable("REDIS_HOST") -REDIS_PORT = get_env_variable("REDIS_PORT") -REDIS_CELERY_DB = get_env_variable("REDIS_CELERY_DB", "0") -REDIS_RESULTS_DB = get_env_variable("REDIS_RESULTS_DB", "1") - -CACHE_CONFIG: CacheConfig = { - "CACHE_TYPE": "redis", - "CACHE_DEFAULT_TIMEOUT": int( - timedelta(days=1).total_seconds() - ), # 1 day default (in secs) - "CACHE_KEY_PREFIX": "superset_results", - "CACHE_REDIS_URL": f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_RESULTS_DB}", -} - -FILTER_STATE_CACHE_CONFIG: CacheConfig = { - "CACHE_TYPE": "redis", - "CACHE_DEFAULT_TIMEOUT": int(timedelta(days=1).total_seconds()), - "CACHE_KEY_PREFIX": "superset_filter_cache", - "CACHE_REDIS_URL": f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_RESULTS_DB}", -} -EXPLORE_FORM_DATA_CACHE_CONFIG: CacheConfig = { - "CACHE_TYPE": "redis", - "CACHE_DEFAULT_TIMEOUT": int(timedelta(days=1).total_seconds()), - "CACHE_KEY_PREFIX": "superset_explore_", - "CACHE_REDIS_URL": f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_RESULTS_DB}", -} - -DATA_CACHE_CONFIG: CacheConfig = { - "CACHE_TYPE": "redis", - "CACHE_DEFAULT_TIMEOUT": int(timedelta(days=1).total_seconds()), - "CACHE_KEY_PREFIX": "superset_data_", - "CACHE_REDIS_URL": f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_RESULTS_DB}", -} - -RESULTS_BACKEND = RedisCache( - host=REDIS_HOST, port=REDIS_PORT, key_prefix="superset_results" -) - -FILTER_SELECT_ROW_LIMIT = 100000 -ROW_LIMIT = 5000000 -SQL_MAX_ROW = 5000000 - -ENABLE_TEMPLATE_PROCESSING = True - -# # smtp server configuration -ALERT_REPORTS_NOTIFICATION_DRY_RUN = False -SMTP_HOST = get_env_variable("SENDGRID_HOST") -SMTP_STARTTLS = True -SMTP_SSL = False -SMTP_USER = get_env_variable("SENDGRID_USERNAME") -SMTP_PORT = get_env_variable("SENDGRID_PORT") -SMTP_PASSWORD = get_env_variable("SENDGRID_PASSWORD") -SMTP_MAIL_FROM = "superset@zerofox.com" -CLIENT_ID = get_env_variable("SSO_CLIENT_ID") -CLIENT_SECRET = get_env_variable("SSO_CLIENT_SECRET") -API_BASE_URL = get_env_variable("SSO_API_BASE_URL") - -WEBDRIVER_BASEURL = "http://superset:8088" -WEBDRIVER_BASEURL_USER_FRIENDLY = "http://localhost:8088" -SCREENSHOT_LOCATE_WAIT = 100 -SCREENSHOT_LOAD_WAIT = 600 - - -# Will allow user self registration, allowing to create Flask users from Authorized User -AUTH_USER_REGISTRATION = True - -# # The default user self registration role -# AUTH_USER_REGISTRATION_ROLE = "Public" - -CUSTOM_SECURITY_MANAGER = BICustomSecurityManager - - -class CeleryConfig: # pylint: disable=too-few-public-methods - broker_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_CELERY_DB}" - imports = ("superset.sql_lab", "superset.tasks") - result_backend = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_RESULTS_DB}" - worker_prefetch_multiplier = 10 - task_acks_late = True - task_annotations = { - "sql_lab.get_sql_results": {"rate_limit": "100/s"}, - "email_reports.send": { - "rate_limit": "1/s", - "time_limit": int(timedelta(seconds=120).total_seconds()), - "soft_time_limit": int(timedelta(seconds=150).total_seconds()), - "ignore_result": True, - }, - } - beat_schedule = { - "email_reports.schedule_hourly": { - "task": "email_reports.schedule_hourly", - "schedule": crontab(minute=1, hour="*"), - }, - "reports.scheduler": { - "task": "reports.scheduler", - "schedule": crontab(minute="*", hour="*"), - }, - "reports.prune_log": { - "task": "reports.prune_log", - "schedule": crontab(minute=0, hour=0), - }, - } - - -CELERY_CONFIG = CeleryConfig # pylint: disable=invalid-name diff --git a/embedded/README.md b/embedded/README.md new file mode 100644 index 000000000000..73448d2d1e3e --- /dev/null +++ b/embedded/README.md @@ -0,0 +1,23 @@ +# Run sample local embedded Dashboard + +## ZF-Dashboard + +1. Configure on `.env`: `SUPERSET_URL=http://host.docker.internal:8088` +2. Run zf-dashboard locally on Docker + +## Superset + +1. Run `make build` +2. Run `make run` +3. Log in and make sure you have a Dashboard configured to be embedded. Copy the ID of the Dashboard + +## Sample + +1. Edit the ID of the `embedded/sample.html` to the one you copied +2. You might have some CORS issues locally. Open Chrome running `open -n -a /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --user-data-dir="/tmp/chrome_dev_test" --disable-web-security` to avoid CORS problems at all +3. Open `embedded/sample.html` in your browser + +# Tips + +- Run `cd superset-frontend` and `npm run build-dev` to run assets locally faster +- Run `make build` and `make run` to update Superset if you change Phyton code \ No newline at end of file diff --git a/embedded/sample.html b/embedded/sample.html new file mode 100644 index 000000000000..d2ffe3fe58e8 --- /dev/null +++ b/embedded/sample.html @@ -0,0 +1,89 @@ + + +
+ + + + + + diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts index fd040faed042..d7ba5a2068a7 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts @@ -66,6 +66,8 @@ export default class SupersetClientClass { handleUnauthorized: () => void; + embedded: boolean; + constructor({ baseUrl = DEFAULT_BASE_URL, host, @@ -79,6 +81,7 @@ export default class SupersetClientClass { guestToken = undefined, guestTokenHeaderName = 'X-GuestToken', unauthorizedHandler = defaultUnauthorizedHandler, + embedded = false, }: ClientConfig = {}) { const url = new URL( host || protocol @@ -109,6 +112,7 @@ export default class SupersetClientClass { if (guestToken) { this.headers[guestTokenHeaderName] = guestToken; } + this.embedded = embedded; this.handleUnauthorized = unauthorizedHandler; } @@ -199,11 +203,13 @@ export default class SupersetClientClass { ...rest }: RequestConfig & { parseMethod?: T }) { await this.ensureAuth(); + const { proxiedUrl, proxiedEndpoint } = this.proxyUrls(endpoint, url); + return callApiAndParseWithTimeout({ ...rest, credentials: credentials ?? this.credentials, mode: mode ?? this.mode, - url: this.getUrl({ endpoint, host, url }), + url: this.getUrl({ endpoint: proxiedEndpoint, host, url: proxiedUrl }), headers: { ...this.headers, ...headers }, timeout: timeout ?? this.timeout, fetchRetryOptions: fetchRetryOptions ?? this.fetchRetryOptions, @@ -215,6 +221,39 @@ export default class SupersetClientClass { }); } + proxyUrls( + endpoint?: string, + url?: string, + ): { proxiedUrl?: string; proxiedEndpoint?: string } { + let proxiedEndpoint = endpoint; + let proxiedUrl = url; + if (!this.embedded) { + return { proxiedUrl: url, proxiedEndpoint: endpoint }; + } + + if (proxiedEndpoint) { + if (proxiedEndpoint.substring(0, 1) !== '/') { + proxiedEndpoint = `/${proxiedEndpoint}`; + } + if (!proxiedEndpoint?.includes('/spa_bff/superset')) { + proxiedEndpoint = `/spa_bff/superset${proxiedEndpoint}`; + } + } + + if (proxiedUrl && !proxiedUrl?.includes('/spa_bff/superset')) { + const pos = this.getPosition(proxiedUrl, '/', 3); + proxiedUrl = `${proxiedUrl.slice( + 0, + pos, + )}/spa_bff/superset${proxiedUrl.slice(pos)}`; + } + return { proxiedUrl, proxiedEndpoint }; + } + + getPosition(string: string, subString: string, index: number) { + return string.split(subString, index).join(subString).length; + } + async ensureAuth(): CsrfPromise { return ( this.csrfPromise ?? diff --git a/superset-frontend/packages/superset-ui-core/src/connection/types.ts b/superset-frontend/packages/superset-ui-core/src/connection/types.ts index a63ffd8b68a0..dd4252eab915 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/types.ts @@ -144,6 +144,7 @@ export interface ClientConfig { mode?: Mode; timeout?: ClientTimeout; unauthorizedHandler?: () => void; + embedded?: boolean; } export interface SupersetClientInterface diff --git a/superset-frontend/src/components/EmptyState/index.tsx b/superset-frontend/src/components/EmptyState/index.tsx index b8230c8fcf24..e25c67ec1b80 100644 --- a/superset-frontend/src/components/EmptyState/index.tsx +++ b/superset-frontend/src/components/EmptyState/index.tsx @@ -121,6 +121,11 @@ const ActionButton = styled(Button)` const getImage = (image: string | ReactNode) => typeof image === 'string' ? `/static/assets/images/${image}` : image; +const getImageSpaBff = (image: string | ReactNode) => + typeof image === 'string' + ? `/spa_bff/superset/static/assets/images/${image}` + : image; + const getImageHeight = (size: EmptyStateSize) => { switch (size) { case EmptyStateSize.Small: @@ -139,6 +144,11 @@ const ImageContainer = ({ image, size }: ImageContainerProps) => ( description={false} image={getImage(image)} imageStyle={getImageHeight(size)} + // @ts-ignore + onError={(e: any) => { + e.currentTarget.onerror = null; + e.target.src = getImageSpaBff(image); + }} /> ); diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index 50c026fba8f9..e0a6f6e6ea89 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -64,6 +64,11 @@ const EmbeddedApp = () => ( {/* todo (embedded) remove this line after uuids are deployed */} + + ); @@ -152,6 +157,7 @@ function setupGuestClient(guestToken: string) { guestToken, guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME, unauthorizedHandler: guestUnauthorizedHandler, + embedded: true, }); } diff --git a/superset-frontend/src/middleware/loggerMiddleware.js b/superset-frontend/src/middleware/loggerMiddleware.js index 475c1784cce5..d9f325f0fdc7 100644 --- a/superset-frontend/src/middleware/loggerMiddleware.js +++ b/superset-frontend/src/middleware/loggerMiddleware.js @@ -26,6 +26,9 @@ import { LOG_EVENT } from '../logger/actions'; import { LOG_EVENT_TYPE_TIMING } from '../logger/LogUtils'; import DebouncedMessageQueue from '../utils/DebouncedMessageQueue'; +// It is used for logging. Either it breaks the embedded or regular flow. +// Will just leave this comment as it might be fixed when we move to CDN +// const LOG_ENDPOINT = '/spa_bff/superset/superset/log/?explode=events'; const LOG_ENDPOINT = '/superset/log/?explode=events'; const sendBeacon = events => { if (events.length <= 0) { diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index f8428e4418fb..c807554954b6 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -52,7 +52,7 @@ const { } = parsedArgs; const isDevMode = mode !== 'production'; const isDevServer = process.argv[1].includes('webpack-dev-server'); -const ASSET_BASE_URL = process.env.ASSET_BASE_URL || ''; +const ASSET_BASE_URL = process.env.ASSET_BASE_URL || 'http://localhost:8000/spa_bff/superset'; const output = { path: BUILD_DIR,