From 14c0cad0ba22067ffde4750eb41bf79348a4a259 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 9 Jan 2026 14:52:53 -0500 Subject: [PATCH 1/5] chore: remove deprecated function (#37021) --- superset/utils/excel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/utils/excel.py b/superset/utils/excel.py index 46e1a1f071ac..83061b5d2762 100644 --- a/superset/utils/excel.py +++ b/superset/utils/excel.py @@ -76,7 +76,7 @@ def apply_column_types( ) except ValueError: df[column] = df[column].astype(str) - elif pd.api.types.is_datetime64tz_dtype(df[column]): + elif isinstance(df[column].dtype, pd.DatetimeTZDtype): # timezones are not supported df[column] = df[column].astype(str) return df From 1e8d648f478f85633d1e1093e6e9c65d7d371972 Mon Sep 17 00:00:00 2001 From: Luiz Otavio <45200344+luizotavio32@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:02:18 -0300 Subject: [PATCH 2/5] feat: Chart query last run timestamp (#36934) --- .../src/query/types/QueryResponse.ts | 6 +- .../plugin-chart-table/test/testData.ts | 1 + .../src/components/LastQueriedLabel/index.tsx | 57 +++++++++++++++++++ .../components/PropertiesModal/index.tsx | 15 ++++- .../sections/StylingSection.test.tsx | 45 +++++++++++++++ .../sections/StylingSection.tsx | 49 +++++++++++++++- .../components/SliceHeader/index.tsx | 3 + .../components/SliceHeaderControls/index.tsx | 23 ++++++-- .../components/SliceHeaderControls/types.ts | 1 + .../components/gridComponents/Chart/Chart.jsx | 30 +++++++++- .../components/ExploreChartPanel/index.tsx | 15 +++++ superset/charts/schemas.py | 7 +++ superset/common/query_context_processor.py | 1 + superset/common/utils/query_cache_manager.py | 11 ++++ superset/dashboards/schemas.py | 2 + 15 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 superset-frontend/src/components/LastQueriedLabel/index.tsx diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts b/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts index d31f878ff7d5..71aef8a75c50 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/QueryResponse.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - import { GenericDataType } from '@apache-superset/core/api/core'; import { TimeseriesDataRecord } from '../../chart'; import { AnnotationData } from './AnnotationLayer'; @@ -42,6 +41,11 @@ export interface ChartDataResponseResult { cache_key: string | null; cache_timeout: number | null; cached_dttm: string | null; + /** + * UTC timestamp when the query was executed (ISO 8601 format). + * For cached queries, this is when the original query ran. + */ + queried_dttm: string | null; /** * Array of data records as dictionary */ diff --git a/superset-frontend/plugins/plugin-chart-table/test/testData.ts b/superset-frontend/plugins/plugin-chart-table/test/testData.ts index ca3ed52e3342..9b9aa4c85257 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/testData.ts +++ b/superset-frontend/plugins/plugin-chart-table/test/testData.ts @@ -75,6 +75,7 @@ const basicQueryResult: ChartDataResponseResult = { cache_key: null, cached_dttm: null, cache_timeout: null, + queried_dttm: null, data: [], colnames: [], coltypes: [], diff --git a/superset-frontend/src/components/LastQueriedLabel/index.tsx b/superset-frontend/src/components/LastQueriedLabel/index.tsx new file mode 100644 index 000000000000..f22a3e9afee8 --- /dev/null +++ b/superset-frontend/src/components/LastQueriedLabel/index.tsx @@ -0,0 +1,57 @@ +/** + * 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 { FC } from 'react'; +import { t } from '@superset-ui/core'; +import { css, useTheme } from '@apache-superset/core/ui'; +import { extendedDayjs } from '@superset-ui/core/utils/dates'; + +interface LastQueriedLabelProps { + queriedDttm: string | null; +} + +const LastQueriedLabel: FC = ({ queriedDttm }) => { + const theme = useTheme(); + + if (!queriedDttm) { + return null; + } + + const parsedDate = extendedDayjs.utc(queriedDttm); + if (!parsedDate.isValid()) { + return null; + } + + const formattedTime = parsedDate.local().format('L LTS'); + + return ( +
+ {t('Last queried at')}: {formattedTime} +
+ ); +}; + +export default LastQueriedLabel; diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx index 267c347d1cad..e67029e34974 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx @@ -128,6 +128,7 @@ const PropertiesModal = ({ const [customCss, setCustomCss] = useState(''); const [refreshFrequency, setRefreshFrequency] = useState(0); const [selectedThemeId, setSelectedThemeId] = useState(null); + const [showChartTimestamps, setShowChartTimestamps] = useState(false); const [themes, setThemes] = useState< Array<{ id: number; @@ -140,7 +141,11 @@ const PropertiesModal = ({ const handleErrorResponse = async (response: Response) => { const { error, statusText, message } = await getClientErrorObject(response); let errorText = error || statusText || t('An error has occurred'); - if (typeof message === 'object' && 'json_metadata' in message) { + if ( + typeof message === 'object' && + 'json_metadata' in message && + typeof (message as { json_metadata: unknown }).json_metadata === 'string' + ) { errorText = (message as { json_metadata: string }).json_metadata; } else if (typeof message === 'string') { errorText = message; @@ -150,7 +155,7 @@ const PropertiesModal = ({ } } - addDangerToast(errorText); + addDangerToast(String(errorText)); }; const handleDashboardData = useCallback( @@ -192,10 +197,12 @@ const PropertiesModal = ({ 'shared_label_colors', 'map_label_colors', 'color_scheme_domain', + 'show_chart_timestamps', ]); setJsonMetadata(metaDataCopy ? jsonStringify(metaDataCopy) : ''); setRefreshFrequency(metadata?.refresh_frequency || 0); + setShowChartTimestamps(metadata?.show_chart_timestamps ?? false); originalDashboardMetadata.current = metadata; }, [form], @@ -320,11 +327,13 @@ const PropertiesModal = ({ : false; const jsonMetadataObj = getJsonMetadata(); jsonMetadataObj.refresh_frequency = refreshFrequency; + jsonMetadataObj.show_chart_timestamps = Boolean(showChartTimestamps); const customLabelColors = jsonMetadataObj.label_colors || {}; const updatedDashboardMetadata = { ...originalDashboardMetadata.current, label_colors: customLabelColors, color_scheme: updatedColorScheme, + show_chart_timestamps: showChartTimestamps, }; originalDashboardMetadata.current = updatedDashboardMetadata; @@ -711,9 +720,11 @@ const PropertiesModal = ({ colorScheme={colorScheme} customCss={customCss} hasCustomLabelsColor={hasCustomLabelsColor} + showChartTimestamps={showChartTimestamps} onThemeChange={handleThemeChange} onColorSchemeChange={onColorSchemeChange} onCustomCssChange={setCustomCss} + onShowChartTimestampsChange={setShowChartTimestamps} addDangerToast={addDangerToast} /> ), diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.test.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.test.tsx index 8b5536093f1f..fdafd819dc6a 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.test.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.test.tsx @@ -70,9 +70,11 @@ const defaultProps = { colorScheme: 'supersetColors', customCss: '', hasCustomLabelsColor: false, + showChartTimestamps: false, onThemeChange: jest.fn(), onColorSchemeChange: jest.fn(), onCustomCssChange: jest.fn(), + onShowChartTimestampsChange: jest.fn(), addDangerToast: jest.fn(), }; @@ -156,6 +158,49 @@ test('displays current color scheme value', () => { expect(colorSchemeInput).toHaveValue('testColors'); }); +test('renders chart timestamps field', () => { + render(); + + expect( + screen.getByTestId('dashboard-show-timestamps-field'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('dashboard-show-timestamps-switch'), + ).toBeInTheDocument(); +}); + +test('chart timestamps switch reflects showChartTimestamps prop', () => { + const { rerender } = render( + , + ); + + let timestampSwitch = screen.getByTestId('dashboard-show-timestamps-switch'); + expect(timestampSwitch).not.toBeChecked(); + + rerender(); + + timestampSwitch = screen.getByTestId('dashboard-show-timestamps-switch'); + expect(timestampSwitch).toBeChecked(); +}); + +test('calls onShowChartTimestampsChange when switch is toggled', async () => { + const onShowChartTimestampsChange = jest.fn(); + render( + , + ); + + const timestampSwitch = screen.getByTestId( + 'dashboard-show-timestamps-switch', + ); + await userEvent.click(timestampSwitch); + + expect(onShowChartTimestampsChange).toHaveBeenCalled(); + expect(onShowChartTimestampsChange.mock.calls[0][0]).toBe(true); +}); + // CSS Template Tests // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('CSS Template functionality', () => { diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx index 95e16def7689..33a151bb4c4c 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx @@ -24,7 +24,7 @@ import { FeatureFlag, } from '@superset-ui/core'; import { styled, Alert } from '@apache-superset/core/ui'; -import { CssEditor, Select } from '@superset-ui/core/components'; +import { CssEditor, Select, Switch } from '@superset-ui/core/components'; import rison from 'rison'; import ColorSchemeSelect from 'src/dashboard/components/ColorSchemeSelect'; import { ModalFormField } from 'src/components/Modal'; @@ -38,6 +38,32 @@ const StyledAlert = styled(Alert)` margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px; `; +const StyledSwitchContainer = styled.div` + ${({ theme }) => ` + display: flex; + flex-direction: column; + margin-bottom: ${theme.sizeUnit * 4}px; + + .switch-row { + display: flex; + align-items: center; + gap: ${theme.sizeUnit * 2}px; + } + + .switch-label { + color: ${theme.colorText}; + font-size: ${theme.fontSize}px; + } + + .switch-helper { + display: block; + color: ${theme.colorTextTertiary}; + font-size: ${theme.fontSizeSM}px; + margin-top: ${theme.sizeUnit}px; + } + `} +`; + interface Theme { id: number; theme_name: string; @@ -54,12 +80,14 @@ interface StylingSectionProps { colorScheme?: string; customCss: string; hasCustomLabelsColor: boolean; + showChartTimestamps: boolean; onThemeChange: (value: any) => void; onColorSchemeChange: ( colorScheme: string, options?: { updateMetadata?: boolean }, ) => void; onCustomCssChange: (css: string) => void; + onShowChartTimestampsChange: (value: boolean) => void; addDangerToast?: (message: string) => void; } @@ -69,9 +97,11 @@ const StylingSection = ({ colorScheme, customCss, hasCustomLabelsColor, + showChartTimestamps, onThemeChange, onColorSchemeChange, onCustomCssChange, + onShowChartTimestampsChange, addDangerToast, }: StylingSectionProps) => { const [cssTemplates, setCssTemplates] = useState([]); @@ -167,6 +197,23 @@ const StylingSection = ({ showWarning={hasCustomLabelsColor} /> + +
+ + + {t('Show chart query timestamps')} + +
+ + {t( + 'Display the last queried timestamp on charts in the dashboard view', + )} + +
{isFeatureEnabled(FeatureFlag.CssTemplates) && cssTemplates.length > 0 && ( void; }; @@ -141,6 +142,7 @@ const SliceHeader = forwardRef( annotationQuery = {}, annotationError = {}, cachedDttm = null, + queriedDttm = null, updatedDttm = null, isCached = [], isExpanded = false, @@ -322,6 +324,7 @@ const SliceHeader = forwardRef( isCached={isCached} isExpanded={isExpanded} cachedDttm={cachedDttm} + queriedDttm={queriedDttm} updatedDttm={updatedDttm} toggleExpandSlice={toggleExpandSlice} forceRefresh={forceRefresh} diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 564a4fb745a4..0af6e6a2d21f 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -111,6 +111,7 @@ export interface SliceHeaderControlsProps { chartStatus: string; isCached: boolean[]; cachedDttm: string[] | null; + queriedDttm?: string | null; isExpanded?: boolean; updatedDttm: number | null; isFullSize?: boolean; @@ -309,6 +310,7 @@ const SliceHeaderControls = ( slice, isFullSize, cachedDttm = [], + queriedDttm = null, updatedDttm = null, addSuccessToast = () => {}, addDangerToast = () => {}, @@ -341,6 +343,10 @@ const SliceHeaderControls = ( : item} )); + + const queriedLabel = queriedDttm + ? extendedDayjs.utc(queriedDttm).local().format('L LTS') + : null; const fullscreenLabel = isFullSize ? t('Exit fullscreen') : t('Enter fullscreen'); @@ -355,12 +361,17 @@ const SliceHeaderControls = ( { key: MenuKeys.ForceRefresh, label: ( - <> - {t('Force refresh')} - - {refreshTooltip} - - + +
+ {t('Force refresh')} + + {refreshTooltip} + +
+
), disabled: props.chartStatus === 'loading', style: { height: 'auto', lineHeight: 'initial' }, diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts index f13929b82c9a..62d4e94e6bc5 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts @@ -33,6 +33,7 @@ export interface SliceHeaderControlsProps { chartStatus: string; isCached: boolean[]; cachedDttm: string[] | null; + queriedDttm?: string | null; isExpanded?: boolean; updatedDttm: number | null; isFullSize?: boolean; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx index 37e24b99f633..d3e1be992e36 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx @@ -28,6 +28,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { exportChart, mountExploreUrl } from 'src/explore/exploreUtils'; import ChartContainer from 'src/components/Chart/ChartContainer'; +import LastQueriedLabel from 'src/components/LastQueriedLabel'; import { StreamingExportModal, useStreamingExport, @@ -50,6 +51,7 @@ import { import SliceHeader from '../../SliceHeader'; import MissingChart from '../../MissingChart'; + import { addDangerToast, addSuccessToast, @@ -88,6 +90,7 @@ const propTypes = { const RESIZE_TIMEOUT = 500; const DEFAULT_HEADER_HEIGHT = 22; +const QUERIED_LABEL_HEIGHT = 24; const ChartWrapper = styled.div` overflow: hidden; @@ -206,6 +209,9 @@ const Chart = props => { PLACEHOLDER_DATASOURCE, ); const dashboardInfo = useSelector(state => state.dashboardInfo); + const showChartTimestamps = useSelector( + state => state.dashboardInfo?.metadata?.show_chart_timestamps ?? false, + ); const isCached = useMemo( // eslint-disable-next-line camelcase @@ -310,10 +316,25 @@ const Chart = props => { return DEFAULT_HEADER_HEIGHT; }, [headerRef]); + const queriedDttm = Array.isArray(queriesResponse) + ? (queriesResponse[queriesResponse.length - 1]?.queried_dttm ?? null) + : (queriesResponse?.queried_dttm ?? null); + const getChartHeight = useCallback(() => { const headerHeight = getHeaderHeight(); - return Math.max(height - headerHeight - descriptionHeight, 20); - }, [getHeaderHeight, height, descriptionHeight]); + const queriedLabelHeight = + showChartTimestamps && queriedDttm != null ? QUERIED_LABEL_HEIGHT : 0; + return Math.max( + height - headerHeight - descriptionHeight - queriedLabelHeight, + 20, + ); + }, [ + getHeaderHeight, + height, + descriptionHeight, + queriedDttm, + showChartTimestamps, + ]); const handleFilterMenuOpen = useCallback( (chartId, column) => { @@ -615,6 +636,7 @@ const Chart = props => { isExpanded={isExpanded} isCached={isCached} cachedDttm={cachedDttm} + queriedDttm={queriedDttm} updatedDttm={chartUpdateEndTime} toggleExpandSlice={boundActionCreators.toggleExpandSlice} forceRefresh={forceRefresh} @@ -717,6 +739,10 @@ const Chart = props => { /> + {!isLoading && showChartTimestamps && queriedDttm != null && ( + + )} + { diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx index ab11f2a5bf42..4287e19a4073 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx @@ -43,6 +43,7 @@ import { buildV1ChartDataPayload } from 'src/explore/exploreUtils'; import { getChartRequiredFieldsMissingMessage } from 'src/utils/getChartRequiredFieldsMissingMessage'; import type { ChartState, Datasource } from 'src/explore/types'; import type { Slice } from 'src/types/Chart'; +import LastQueriedLabel from 'src/components/LastQueriedLabel'; import { DataTablesPane } from '../DataTablesPane'; import { ChartPills } from '../ChartPills'; import { ExploreAlert } from '../ExploreAlert'; @@ -399,6 +400,19 @@ const ExploreChartPanel = ({ /> {renderChart()} + {!chart.chartStatus || chart.chartStatus !== 'loading' ? ( +
+ +
+ ) : null} ), [ @@ -415,6 +429,7 @@ const ExploreChartPanel = ({ formData?.matrixify_enable_vertical_layout, formData?.matrixify_enable_horizontal_layout, renderChart, + theme.sizeUnit, ], ); diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index a767be42b08e..2ed6446cee6e 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -1464,6 +1464,13 @@ class ChartDataResponseResult(Schema): required=True, allow_none=True, ) + queried_dttm = fields.String( + metadata={ + "description": "UTC timestamp when the query was executed (ISO 8601 format)" + }, + required=True, + allow_none=True, + ) cache_timeout = fields.Integer( metadata={ "description": "Cache timeout in following order: custom timeout, datasource " # noqa: E501 diff --git a/superset/common/query_context_processor.py b/superset/common/query_context_processor.py index be448873fdda..637b2dba1fab 100644 --- a/superset/common/query_context_processor.py +++ b/superset/common/query_context_processor.py @@ -181,6 +181,7 @@ def get_df_payload( return { "cache_key": cache_key, "cached_dttm": cache.cache_dttm, + "queried_dttm": cache.queried_dttm, "cache_timeout": self.get_cache_timeout(), "df": cache.df, "applied_template_filters": cache.applied_template_filters, diff --git a/superset/common/utils/query_cache_manager.py b/superset/common/utils/query_cache_manager.py index a7c6331930e9..da2d668e8c98 100644 --- a/superset/common/utils/query_cache_manager.py +++ b/superset/common/utils/query_cache_manager.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +from datetime import datetime, timezone from typing import Any from flask import current_app @@ -67,6 +68,7 @@ def __init__( cache_dttm: str | None = None, cache_value: dict[str, Any] | None = None, sql_rowcount: int | None = None, + queried_dttm: str | None = None, ) -> None: self.df = df self.query = query @@ -83,6 +85,7 @@ def __init__( self.cache_dttm = cache_dttm self.cache_value = cache_value self.sql_rowcount = sql_rowcount + self.queried_dttm = queried_dttm # pylint: disable=too-many-arguments def set_query_result( @@ -108,6 +111,9 @@ def set_query_result( self.df = query_result.df self.sql_rowcount = query_result.sql_rowcount self.annotation_data = {} if annotation_data is None else annotation_data + self.queried_dttm = ( + datetime.now(tz=timezone.utc).replace(microsecond=0).isoformat() + ) if self.status != QueryStatus.FAILED: current_app.config["STATS_LOGGER"].incr("loaded_from_source") @@ -125,6 +131,8 @@ def set_query_result( "rejected_filter_columns": self.rejected_filter_columns, "annotation_data": self.annotation_data, "sql_rowcount": self.sql_rowcount, + "queried_dttm": self.queried_dttm, + "dttm": self.queried_dttm, # Backwards compatibility } if self.is_loaded and key and self.status != QueryStatus.FAILED: self.set( @@ -181,6 +189,9 @@ def get( query_cache.cache_dttm = ( cache_value["dttm"] if cache_value is not None else None ) + query_cache.queried_dttm = cache_value.get( + "queried_dttm", cache_value.get("dttm") + ) query_cache.cache_value = cache_value current_app.config["STATS_LOGGER"].incr("loaded_from_cache") except KeyError as ex: diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index 253bf3dc3037..7c9236e99bdd 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -163,6 +163,8 @@ class DashboardJSONMetadataSchema(Schema): map_label_colors = fields.Dict() color_scheme_domain = fields.List(fields.Str()) cross_filters_enabled = fields.Boolean(dump_default=True) + # controls visibility of "last queried at" timestamp on charts in dashboard view + show_chart_timestamps = fields.Boolean(dump_default=False) # used for v0 import/export import_time = fields.Integer() remote_id = fields.Integer() From 53dddf4db26d32ec86673a6d42b009cc49fbd09d Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:02:38 -0300 Subject: [PATCH 3/5] feat(embedded-sdk): Add resolvePermalinkUrl callback for custom permalink URLs (#36924) --- superset-embedded-sdk/README.md | 61 ++++++++++++++++- superset-embedded-sdk/src/index.ts | 38 ++++++++++- .../components/URLShortLinkButton/index.tsx | 6 +- .../components/menu/ShareMenuItems/index.tsx | 6 +- .../dashboard/containers/DashboardPage.tsx | 17 ++++- superset-frontend/src/embedded/api.tsx | 4 +- .../explore/components/EmbedCodeContent.jsx | 8 ++- .../useExploreAdditionalActionsMenu/index.jsx | 17 ++++- superset-frontend/src/utils/urlUtils.ts | 67 ++++++++++++++++--- 9 files changed, 201 insertions(+), 23 deletions(-) diff --git a/superset-embedded-sdk/README.md b/superset-embedded-sdk/README.md index f9250fd19204..6184aebffc84 100644 --- a/superset-embedded-sdk/README.md +++ b/superset-embedded-sdk/README.md @@ -59,10 +59,14 @@ embedDashboard({ // ... } }, - // optional additional iframe sandbox attributes + // optional additional iframe sandbox attributes iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'], + // optional Permissions Policy features + iframeAllowExtras: ['clipboard-write', 'fullscreen'], // optional config to enforce a particular referrerPolicy - referrerPolicy: "same-origin" + referrerPolicy: "same-origin", + // optional callback to customize permalink URLs + resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}` }); ``` @@ -159,6 +163,20 @@ To pass additional sandbox attributes you can use `iframeSandboxExtras`: iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'] ``` +### Permissions Policy + +To enable specific browser features within the embedded iframe, use `iframeAllowExtras` to set the iframe's [Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy) (the `allow` attribute): + +```js + // optional Permissions Policy features + iframeAllowExtras: ['clipboard-write', 'fullscreen'] +``` + +Common permissions you might need: +- `clipboard-write` - Required for "Copy permalink to clipboard" functionality +- `fullscreen` - Required for fullscreen chart viewing +- `camera`, `microphone` - If your dashboards include media capture features + ### Enforcing a ReferrerPolicy on the request triggered by the iframe By default, the Embedded SDK creates an `iframe` element without a `referrerPolicy` value enforced. This means that a policy defined for `iframe` elements at the host app level would reflect to it. @@ -166,3 +184,42 @@ By default, the Embedded SDK creates an `iframe` element without a `referrerPoli This can be an issue as during the embedded enablement for a dashboard it's possible to specify which domain(s) are allowed to embed the dashboard, and this validation happens throuth the `Referrer` header. That said, in case the hosting app has a more restrictive policy that would omit this header, this validation would fail. Use the `referrerPolicy` parameter in the `embedDashboard` method to specify [a particular policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy) that works for your implementation. + +### Customizing Permalink URLs + +When users click share buttons inside an embedded dashboard, Superset generates permalinks using Superset's domain. If you want to use your own domain and URL format for these permalinks, you can provide a `resolvePermalinkUrl` callback: + +```js +embedDashboard({ + id: "abc123", + supersetDomain: "https://superset.example.com", + mountPoint: document.getElementById("my-superset-container"), + fetchGuestToken: () => fetchGuestTokenFromBackend(), + + // Customize permalink URLs + resolvePermalinkUrl: ({ key }) => { + // key: the permalink key (e.g., "xyz789") + return `https://my-app.com/analytics/share/${key}`; + } +}); +``` + +To restore the dashboard state from a permalink in your app: + +```js +// In your route handler for /analytics/share/:key +const permalinkKey = routeParams.key; + +embedDashboard({ + id: "abc123", + supersetDomain: "https://superset.example.com", + mountPoint: document.getElementById("my-superset-container"), + fetchGuestToken: () => fetchGuestTokenFromBackend(), + resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`, + dashboardUiConfig: { + urlParams: { + permalink_key: permalinkKey, // Restores filters, tabs, chart states, and scrolls to anchor + } + } +}); +``` diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts index a10d6e2a61e8..419ed2526ec4 100644 --- a/superset-embedded-sdk/src/index.ts +++ b/superset-embedded-sdk/src/index.ts @@ -66,8 +66,13 @@ export type EmbedDashboardParams = { iframeTitle?: string; /** additional iframe sandbox attributes ex (allow-top-navigation, allow-popups-to-escape-sandbox) **/ iframeSandboxExtras?: string[]; + /** iframe allow attribute for Permissions Policy (e.g., ['clipboard-write', 'fullscreen']) **/ + iframeAllowExtras?: string[]; /** force a specific refererPolicy to be used in the iframe request **/ referrerPolicy?: ReferrerPolicy; + /** Callback to resolve permalink URLs. If provided, this will be called when generating permalinks + * to allow the host app to customize the URL. If not provided, Superset's default URL is used. */ + resolvePermalinkUrl?: ResolvePermalinkUrlFn; }; export type Size = { @@ -83,6 +88,15 @@ export type ObserveDataMaskCallbackFn = ( ) => void; export type ThemeMode = 'default' | 'dark' | 'system'; +/** + * Callback to resolve permalink URLs. + * Receives the permalink key and returns the full URL to use for the permalink. + */ +export type ResolvePermalinkUrlFn = (params: { + /** The permalink key (e.g., "xyz789") */ + key: string; +}) => string | Promise; + export type EmbeddedDashboard = { getScrollSize: () => Promise; unmount: () => void; @@ -110,7 +124,9 @@ export async function embedDashboard({ debug = false, iframeTitle = 'Embedded Dashboard', iframeSandboxExtras = [], + iframeAllowExtras = [], referrerPolicy, + resolvePermalinkUrl, }: EmbedDashboardParams): Promise { function log(...info: unknown[]) { if (debug) { @@ -216,6 +232,9 @@ export async function embedDashboard({ }); iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`; iframe.title = iframeTitle; + if (iframeAllowExtras.length > 0) { + iframe.setAttribute('allow', iframeAllowExtras.join('; ')); + } //@ts-ignore mountPoint.replaceChildren(iframe); log('placed the iframe'); @@ -238,6 +257,24 @@ export async function embedDashboard({ setTimeout(refreshGuestToken, getGuestTokenRefreshTiming(guestToken)); + // Register the resolvePermalinkUrl method for the iframe to call + // Returns null if no callback provided or on error, allowing iframe to use default URL + ourPort.start(); + ourPort.defineMethod( + 'resolvePermalinkUrl', + async ({ key }: { key: string }): Promise => { + if (!resolvePermalinkUrl) { + return null; + } + try { + return await resolvePermalinkUrl({ key }); + } catch (error) { + log('Error in resolvePermalinkUrl callback:', error); + return null; + } + }, + ); + function unmount() { log('unmounting'); //@ts-ignore @@ -255,7 +292,6 @@ export async function embedDashboard({ const observeDataMask = ( callbackFn: ObserveDataMaskCallbackFn, ) => { - ourPort.start(); ourPort.defineMethod('observeDataMask', callbackFn); }; // TODO: Add proper types once theming branch is merged diff --git a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx index 152a514a4dfa..7f83e4fd5334 100644 --- a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx +++ b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx @@ -69,7 +69,7 @@ export default function URLShortLinkButton({ chartStates && Object.keys(chartStates).length > 0; - const url = await getDashboardPermalink({ + const result = await getDashboardPermalink({ dashboardId, dataMask, activeTabs, @@ -77,7 +77,9 @@ export default function URLShortLinkButton({ chartStates: includeChartState ? chartStates : undefined, includeChartState, }); - setShortUrl(url); + if (result?.url) { + setShortUrl(result.url); + } } catch (error) { if (error) { addDangerToast( diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index 984635f03db5..d65ad760c30f 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -94,7 +94,7 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => { chartStates && Object.keys(chartStates).length > 0; - return getDashboardPermalink({ + const result = await getDashboardPermalink({ dashboardId, dataMask, activeTabs, @@ -102,6 +102,10 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => { chartStates: includeChartState ? chartStates : undefined, includeChartState, }); + if (!result?.url) { + throw new Error('Failed to generate permalink URL'); + } + return result.url; } async function onCopyLink() { diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 92e4714a2573..8fa6bc66303f 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -177,10 +177,12 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { // the currently stored value when hydrating let activeTabs: string[] | undefined; let chartStates: DashboardChartStates | undefined; + let anchor: string | undefined; if (permalinkKey) { const permalinkValue = await getPermalinkValue(permalinkKey); - if (permalinkValue) { - ({ dataMask, activeTabs, chartStates } = permalinkValue.state); + if (permalinkValue?.state) { + ({ dataMask, activeTabs, chartStates, anchor } = + permalinkValue.state); } } else if (nativeFilterKeyValue) { dataMask = await getFilterValue(id, nativeFilterKeyValue); @@ -203,6 +205,17 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { chartStates, }), ); + + // Scroll to anchor element if specified in permalink state + if (anchor) { + // Use setTimeout to ensure the DOM has been updated after hydration + setTimeout(() => { + const element = document.getElementById(anchor); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + }, 0); + } } return null; } diff --git a/superset-frontend/src/embedded/api.tsx b/superset-frontend/src/embedded/api.tsx index f1674f0af899..8c0fb711ccc5 100644 --- a/superset-frontend/src/embedded/api.tsx +++ b/superset-frontend/src/embedded/api.tsx @@ -67,7 +67,7 @@ const getDashboardPermalink = async ({ chartStates && Object.keys(chartStates).length > 0; - return getDashboardPermalinkUtil({ + const { url } = await getDashboardPermalinkUtil({ dashboardId, dataMask, activeTabs, @@ -75,6 +75,8 @@ const getDashboardPermalink = async ({ chartStates: includeChartState ? chartStates : undefined, includeChartState, }); + + return url; }; const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || []; diff --git a/superset-frontend/src/explore/components/EmbedCodeContent.jsx b/superset-frontend/src/explore/components/EmbedCodeContent.jsx index 1625f3b76ec6..41a840df7d34 100644 --- a/superset-frontend/src/explore/components/EmbedCodeContent.jsx +++ b/superset-frontend/src/explore/components/EmbedCodeContent.jsx @@ -44,9 +44,11 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => { const updateUrl = useCallback(() => { setUrl(''); getChartPermalink(formData) - .then(url => { - setUrl(url); - setErrorMessage(''); + .then(result => { + if (result?.url) { + setUrl(result.url); + setErrorMessage(''); + } }) .catch(() => { setErrorMessage(t('Error')); diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index 48f5b27a727c..c35881f3ea63 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -204,8 +204,13 @@ export const useExploreAdditionalActionsMenu = ( const shareByEmail = useCallback(async () => { try { const subject = t('Superset Chart'); - const url = await getChartPermalink(latestQueryFormData); - const body = encodeURIComponent(t('%s%s', 'Check out this chart: ', url)); + const result = await getChartPermalink(latestQueryFormData); + if (!result?.url) { + throw new Error('Failed to generate permalink'); + } + const body = encodeURIComponent( + t('%s%s', 'Check out this chart: ', result.url), + ); window.location.href = `mailto:?Subject=${subject}%20&Body=${body}`; } catch (error) { addDangerToast(t('Sorry, something went wrong. Try again later.')); @@ -315,7 +320,13 @@ export const useExploreAdditionalActionsMenu = ( if (!latestQueryFormData) { throw new Error(); } - await copyTextToClipboard(() => getChartPermalink(latestQueryFormData)); + await copyTextToClipboard(async () => { + const result = await getChartPermalink(latestQueryFormData); + if (!result?.url) { + throw new Error('Failed to generate permalink'); + } + return result.url; + }); addSuccessToast(t('Copied to clipboard!')); } catch (error) { addDangerToast(t('Sorry, something went wrong. Try again later.')); diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts index 7b094b848403..6c3a51a987aa 100644 --- a/superset-frontend/src/utils/urlUtils.ts +++ b/superset-frontend/src/utils/urlUtils.ts @@ -22,6 +22,7 @@ import { QueryFormData, SupersetClient, } from '@superset-ui/core'; +import Switchboard from '@superset-ui/switchboard'; import rison from 'rison'; import { isEmpty } from 'lodash'; import { @@ -31,6 +32,7 @@ import { } from '../constants'; import { getActiveFilters } from '../dashboard/util/activeDashboardFilters'; import serializeActiveFilterValues from '../dashboard/util/serializeActiveFilterValues'; +import getBootstrapData from './getBootstrapData'; export type UrlParamType = 'string' | 'number' | 'boolean' | 'object' | 'rison'; export type UrlParam = (typeof URL_PARAMS)[keyof typeof URL_PARAMS]; @@ -139,24 +141,69 @@ export function getDashboardUrlParams( return getUrlParamEntries(urlParams); } -function getPermalink(endpoint: string, jsonPayload: JsonObject) { +export type PermalinkResult = { + key: string; + url: string; +}; + +function getPermalink( + endpoint: string, + jsonPayload: JsonObject, +): Promise { return SupersetClient.post({ endpoint, jsonPayload, - }).then(result => result.json.url as string); + }).then(result => ({ + key: result.json.key as string, + url: result.json.url as string, + })); } -export function getChartPermalink( +/** + * Resolves a permalink URL using the host app's custom callback if in embedded mode. + * Falls back to the default URL if not embedded or if no callback is provided. + */ +async function resolvePermalinkUrl( + result: PermalinkResult, +): Promise { + const { key, url } = result; + + // In embedded mode, check if the host app has a custom resolvePermalinkUrl callback + const bootstrapData = getBootstrapData(); + if (bootstrapData.embedded) { + try { + // Ask the SDK to resolve the permalink URL + // Returns null if no callback was provided by the host + const resolvedUrl = await Switchboard.get( + 'resolvePermalinkUrl', + { key }, + ); + + // If callback returned a valid URL string, use it; otherwise use Superset's default URL + if (typeof resolvedUrl === 'string' && resolvedUrl.length > 0) { + return { key, url: resolvedUrl }; + } + } catch (error) { + // Silently fall back to default URL if Switchboard call fails + // (e.g., if not in embedded context or callback throws) + } + } + + return { key, url }; +} + +export async function getChartPermalink( formData: Pick, excludedUrlParams?: string[], -) { - return getPermalink('/api/v1/explore/permalink', { +): Promise { + const result = await getPermalink('/api/v1/explore/permalink', { formData, urlParams: getChartUrlParams(excludedUrlParams), }); + return resolvePermalinkUrl(result); } -export function getDashboardPermalink({ +export async function getDashboardPermalink({ dashboardId, dataMask, activeTabs, @@ -186,7 +233,7 @@ export function getDashboardPermalink({ * Whether to include chart state in the permalink (default: false) */ includeChartState?: boolean; -}) { +}): Promise { const payload: JsonObject = { urlParams: getDashboardUrlParams(), dataMask, @@ -199,7 +246,11 @@ export function getDashboardPermalink({ payload.chartStates = chartStates; } - return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, payload); + const result = await getPermalink( + `/api/v1/dashboard/${dashboardId}/permalink`, + payload, + ); + return resolvePermalinkUrl(result); } const externalUrlRegex = From ecefba5bf72450e54394063c6c6017c62bee3391 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 9 Jan 2026 18:07:30 -0500 Subject: [PATCH 4/5] fix(mcp): resolve startup failures from circular DAO imports (#37023) --- superset/mcp_service/__main__.py | 2 +- superset/mcp_service/app.py | 11 ++++- superset/mcp_service/server.py | 4 +- .../mcp_service/system/tool/get_schema.py | 12 ++++-- .../mcp_service/test_mcp_tool_registration.py | 42 +++++++------------ 5 files changed, 38 insertions(+), 33 deletions(-) diff --git a/superset/mcp_service/__main__.py b/superset/mcp_service/__main__.py index cda19c9eaaf8..0da7df6655e5 100644 --- a/superset/mcp_service/__main__.py +++ b/superset/mcp_service/__main__.py @@ -123,7 +123,7 @@ def main() -> None: if transport == "streamable-http": host = os.environ.get("FASTMCP_HOST", "127.0.0.1") port = int(os.environ.get("FASTMCP_PORT", "5008")) - mcp.run(transport=transport, host=host, port=port) + mcp.run(transport=transport, host=host, port=port, stateless_http=True) else: mcp.run(transport=transport) diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index 972d11574aab..64730a54caab 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -275,7 +275,16 @@ def create_mcp_app( # Create default MCP instance for backward compatibility -mcp = create_mcp_app(stateless_http=True) +mcp = create_mcp_app() + +# Initialize MCP dependency injection BEFORE importing tools/prompts +# This replaces the abstract @tool and @prompt decorators in superset_core.mcp +# with concrete implementations that can register with the mcp instance +from superset.core.mcp.core_mcp_injection import ( # noqa: E402 + initialize_core_mcp_dependencies, +) + +initialize_core_mcp_dependencies() # Import all MCP tools to register them with the mcp instance # NOTE: Always add new tool imports here when creating new MCP tools. diff --git a/superset/mcp_service/server.py b/superset/mcp_service/server.py index 7d83aa6ecb78..8ca289446cb0 100644 --- a/superset/mcp_service/server.py +++ b/superset/mcp_service/server.py @@ -118,7 +118,9 @@ def run_server( os.environ[env_key] = "1" try: logging.info("Starting FastMCP on %s:%s", host, port) - mcp_instance.run(transport="streamable-http", host=host, port=port) + mcp_instance.run( + transport="streamable-http", host=host, port=port, stateless_http=True + ) except Exception as e: logging.error("FastMCP failed: %s", e) os.environ.pop(env_key, None) diff --git a/superset/mcp_service/system/tool/get_schema.py b/superset/mcp_service/system/tool/get_schema.py index 590d4571801d..eac47be3a808 100644 --- a/superset/mcp_service/system/tool/get_schema.py +++ b/superset/mcp_service/system/tool/get_schema.py @@ -29,9 +29,6 @@ from fastmcp import Context from superset_core.mcp import tool -from superset.daos.chart import ChartDAO -from superset.daos.dashboard import DashboardDAO -from superset.daos.dataset import DatasetDAO from superset.mcp_service.common.schema_discovery import ( CHART_DEFAULT_COLUMNS, CHART_SEARCH_COLUMNS, @@ -57,6 +54,9 @@ def _get_chart_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]: """Create chart schema core with dynamically extracted columns.""" + # Lazy import to avoid circular dependency at module load time + from superset.daos.chart import ChartDAO + return ModelGetSchemaCore( model_type="chart", dao_class=ChartDAO, @@ -73,6 +73,9 @@ def _get_chart_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]: def _get_dataset_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]: """Create dataset schema core with dynamically extracted columns.""" + # Lazy import to avoid circular dependency at module load time + from superset.daos.dataset import DatasetDAO + return ModelGetSchemaCore( model_type="dataset", dao_class=DatasetDAO, @@ -89,6 +92,9 @@ def _get_dataset_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]: def _get_dashboard_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]: """Create dashboard schema core with dynamically extracted columns.""" + # Lazy import to avoid circular dependency at module load time + from superset.daos.dashboard import DashboardDAO + return ModelGetSchemaCore( model_type="dashboard", dao_class=DashboardDAO, diff --git a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py index 57eb1f493805..088b3ad09a2e 100644 --- a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py +++ b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py @@ -15,37 +15,25 @@ # specific language governing permissions and limitations # under the License. -"""Test MCP tool registration through superset-core dependency injection.""" +"""Test MCP app imports and tool/prompt registration.""" -import sys -from unittest.mock import MagicMock, patch -from superset.core.mcp.core_mcp_injection import initialize_core_mcp_dependencies +def test_mcp_app_imports_successfully(): + """Test that the MCP app can be imported without errors.""" + from superset.mcp_service.app import mcp + assert mcp is not None + assert hasattr(mcp, "_tool_manager") -def test_initialize_core_mcp_dependencies_replaces_decorator(): - """Test that initialize_core_mcp_dependencies replaces the abstract tool - decorator.""" - # Mock the superset_core.mcp module - mock_mcp_module = MagicMock() + tools = mcp._tool_manager._tools + assert len(tools) > 0 + assert "health_check" in tools + assert "list_charts" in tools - with patch.dict(sys.modules, {"superset_core.mcp": mock_mcp_module}): - initialize_core_mcp_dependencies() - # Verify the abstract decorator was replaced - assert hasattr(mock_mcp_module, "tool") - assert callable(mock_mcp_module.tool) +def test_mcp_prompts_registered(): + """Test that MCP prompts are registered.""" + from superset.mcp_service.app import mcp - -def test_tool_import_works(): - """Test that tool can be imported from superset_core.mcp after - initialization.""" - # This test verifies the basic import works (dependency injection has happened) - from superset_core.mcp import tool - - # Should be callable - assert callable(tool) - - # Should return a decorator function - decorator = tool(name="test", description="test") - assert callable(decorator) + prompts = mcp._prompt_manager._prompts + assert len(prompts) > 0 From 1cface15e65917aec98d0bc82e7289c9d17902de Mon Sep 17 00:00:00 2001 From: stockarea <51982217+stockarea@users.noreply.github.com> Date: Sat, 10 Jan 2026 04:42:25 +0530 Subject: [PATCH 5/5] docs: Update INTHEWILD.yaml (#36888) Co-authored-by: Evan Rusackas --- RESOURCES/INTHEWILD.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RESOURCES/INTHEWILD.yaml b/RESOURCES/INTHEWILD.yaml index b4d4402763a2..9efd3b711204 100644 --- a/RESOURCES/INTHEWILD.yaml +++ b/RESOURCES/INTHEWILD.yaml @@ -613,6 +613,10 @@ categories: url: https://www.skyscanner.net/ contributors: ["@cleslie", "@stanhoucke"] + Logistics: + - name: Stockarea + url: https://stockarea.io + Others: - name: 10Web url: https://10web.io/