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()