This is an automated email from the ASF dual-hosted git repository. michaelsmolina pushed a commit to branch 5.0-pulse in repository https://gitbox.apache.org/repos/asf/superset.git
commit d29ddd1defd54efdbedb4fed2e6fafd4fe7cf79d Author: Luiz Otavio <[email protected]> AuthorDate: Fri Jan 9 17:02:18 2026 -0300 feat: Chart query last run timestamp (#36934) (cherry picked from commit 1e8d648f478f85633d1e1093e6e9c65d7d371972) --- .../src/query/types/QueryResponse.ts | 6 +- .../plugins/plugin-chart-table/test/testData.ts | 1 + .../src/components/LastQueriedLabel/index.tsx | 56 ++++++ .../PropertiesModal/PropertiesModal.test.tsx | 4 +- .../dashboard/components/PropertiesModal/index.tsx | 219 +++++++++++++-------- .../src/dashboard/components/SliceHeader/index.tsx | 3 + .../components/SliceHeaderControls/index.tsx | 21 +- .../components/SliceHeaderControls/types.ts | 1 + .../dashboard/components/gridComponents/Chart.jsx | 46 +++-- .../explore/components/ExploreChartPanel/index.jsx | 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 + 14 files changed, 289 insertions(+), 104 deletions(-) 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 2e8943cff1..362cb96e75 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 { TimeseriesDataRecord } from '../../chart'; import { AnnotationData } from './AnnotationLayer'; @@ -51,6 +50,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 aa36bb2bc7..d647880933 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 0000000000..dc53fbdf8d --- /dev/null +++ b/superset-frontend/src/components/LastQueriedLabel/index.tsx @@ -0,0 +1,56 @@ +/** + * 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, css, useTheme } from '@superset-ui/core'; +import { extendedDayjs } from 'src/utils/dates'; + +interface LastQueriedLabelProps { + queriedDttm: string | null; +} + +const LastQueriedLabel: FC<LastQueriedLabelProps> = ({ 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 ( + <div + css={css` + font-size: ${theme.typography.sizes.s}px; + color: ${theme.colors.text.label}; + padding: ${theme.gridUnit / 2}px ${theme.gridUnit}px; + text-align: right; + `} + data-test="last-queried-label" + > + {t('Last queried at')}: {formattedTime} + </div> + ); +}; + +export default LastQueriedLabel; diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx index f65f8bb30e..853383d7ee 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx @@ -177,7 +177,7 @@ test('should render - FeatureFlag disabled', async () => { screen.getByRole('heading', { name: 'Basic information' }), ).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Access' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Colors' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Style' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Advanced' })).toBeInTheDocument(); expect( screen.getByRole('heading', { name: 'Certification' }), @@ -223,7 +223,7 @@ test('should render - FeatureFlag enabled', async () => { ).toBeInTheDocument(); // Tags will be included since isFeatureFlag always returns true in this test expect(screen.getByRole('heading', { name: 'Tags' })).toBeInTheDocument(); - expect(screen.getAllByRole('heading')).toHaveLength(5); + expect(screen.getAllByRole('heading')).toHaveLength(6); expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Advanced' })).toBeInTheDocument(); diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx index 4831ece8a5..3d70692de2 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx @@ -22,6 +22,7 @@ import { Input } from 'src/components/Input'; import { FormItem } from 'src/components/Form'; import jsonStringify from 'json-stringify-pretty-compact'; import Button from 'src/components/Button'; +import { Switch } from 'src/components/Switch'; import { AntdForm, AsyncSelect, Col, Row } from 'src/components'; import rison from 'rison'; import { @@ -67,6 +68,32 @@ const StyledJsonEditor = styled(JsonEditor)` border: 1px solid ${({ theme }) => theme.colors.secondary.light2}; `; +const StyledSwitchContainer = styled.div` + ${({ theme }) => ` + display: flex; + flex-direction: column; + padding-left: ${theme.gridUnit * 2}px; + + .switch-row { + display: flex; + align-items: center; + gap: ${theme.gridUnit * 2}px; + } + + .switch-label { + color: ${theme.colors.text.label}; + font-size: ${theme.typography.sizes.m}px; + } + + .switch-helper { + display: block; + color: ${theme.colors.text.help}; + font-size: ${theme.typography.sizes.m}px; + margin-top: ${theme.gridUnit}px; + } + `} +`; + type PropertiesModalProps = { dashboardId: number; dashboardTitle?: string; @@ -120,6 +147,7 @@ const PropertiesModal = ({ const [roles, setRoles] = useState<Roles>([]); const saveLabel = onlyApply ? t('Apply') : t('Save'); const [tags, setTags] = useState<TagType[]>([]); + const [showChartTimestamps, setShowChartTimestamps] = useState(false); const categoricalSchemeRegistry = getCategoricalSchemeRegistry(); const originalDashboardMetadata = useRef<Record<string, any>>({}); @@ -134,7 +162,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; @@ -146,7 +178,7 @@ const PropertiesModal = ({ Modal.error({ title: t('Error'), - content: errorText, + content: String(errorText), okButtonProps: { danger: true, className: 'btn-danger' }, }); }; @@ -209,9 +241,11 @@ const PropertiesModal = ({ 'shared_label_colors', 'map_label_colors', 'color_scheme_domain', + 'show_chart_timestamps', ]); setJsonMetadata(metaDataCopy ? jsonStringify(metaDataCopy) : ''); + setShowChartTimestamps(metadata?.show_chart_timestamps ?? false); originalDashboardMetadata.current = metadata; }, [form], @@ -358,11 +392,13 @@ const PropertiesModal = ({ ? resettableCustomLabels : false; const jsonMetadataObj = getJsonMetadata(); + 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; @@ -378,6 +414,8 @@ const PropertiesModal = ({ updateMetadata: false, }); + // Add show_chart_timestamps back to metadata since it was omitted from jsonMetadata state + metadata.show_chart_timestamps = showChartTimestamps; currentJsonMetadata = jsonStringify(metadata); const moreOnSubmitProps: { roles?: Roles } = {}; @@ -428,19 +466,45 @@ const PropertiesModal = ({ } }; - const getRowsWithoutRoles = () => { - const jsonMetadataObj = getJsonMetadata(); - const hasCustomLabelsColor = !!Object.keys( - jsonMetadataObj?.label_colors || {}, - ).length; + const getRowsWithoutRoles = () => ( + <Row gutter={16}> + <Col xs={24} md={12}> + <h3 style={{ marginTop: '1em' }}>{t('Access')}</h3> + <StyledFormItem label={t('Owners')}> + <AsyncSelect + allowClear + ariaLabel={t('Owners')} + disabled={isLoading} + mode="multiple" + onChange={handleOnChangeOwners} + options={(input, page, pageSize) => + loadAccessOptions('owners', input, page, pageSize) + } + value={handleOwnersSelectValue()} + /> + </StyledFormItem> + <p className="help-block"> + {t( + 'Owners is a list of users who can alter the dashboard. Searchable by name or username.', + )} + </p> + </Col> + </Row> + ); - return ( + const getRowsWithRoles = () => ( + <> + <Row> + <Col xs={24} md={24}> + <h3 style={{ marginTop: '1em' }}>{t('Access')}</h3> + </Col> + </Row> <Row gutter={16}> <Col xs={24} md={12}> - <h3 style={{ marginTop: '1em' }}>{t('Access')}</h3> <StyledFormItem label={t('Owners')}> <AsyncSelect allowClear + allowNewOptions ariaLabel={t('Owners')} disabled={isLoading} mode="multiple" @@ -458,85 +522,28 @@ const PropertiesModal = ({ </p> </Col> <Col xs={24} md={12}> - <h3 style={{ marginTop: '1em' }}>{t('Colors')}</h3> - <ColorSchemeControlWrapper - hasCustomLabelsColor={hasCustomLabelsColor} - onChange={onColorSchemeChange} - colorScheme={colorScheme} - /> + <StyledFormItem label={t('Roles')}> + <AsyncSelect + allowClear + ariaLabel={t('Roles')} + disabled={isLoading} + mode="multiple" + onChange={handleOnChangeRoles} + options={(input, page, pageSize) => + loadAccessOptions('roles', input, page, pageSize) + } + value={handleRolesSelectValue()} + /> + </StyledFormItem> + <p className="help-block"> + {t( + 'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles are defined, regular access permissions apply.', + )} + </p> </Col> </Row> - ); - }; - - const getRowsWithRoles = () => { - const jsonMetadataObj = getJsonMetadata(); - const hasCustomLabelsColor = !!Object.keys( - jsonMetadataObj?.label_colors || {}, - ).length; - - return ( - <> - <Row> - <Col xs={24} md={24}> - <h3 style={{ marginTop: '1em' }}>{t('Access')}</h3> - </Col> - </Row> - <Row gutter={16}> - <Col xs={24} md={12}> - <StyledFormItem label={t('Owners')}> - <AsyncSelect - allowClear - allowNewOptions - ariaLabel={t('Owners')} - disabled={isLoading} - mode="multiple" - onChange={handleOnChangeOwners} - options={(input, page, pageSize) => - loadAccessOptions('owners', input, page, pageSize) - } - value={handleOwnersSelectValue()} - /> - </StyledFormItem> - <p className="help-block"> - {t( - 'Owners is a list of users who can alter the dashboard. Searchable by name or username.', - )} - </p> - </Col> - <Col xs={24} md={12}> - <StyledFormItem label={t('Roles')}> - <AsyncSelect - allowClear - ariaLabel={t('Roles')} - disabled={isLoading} - mode="multiple" - onChange={handleOnChangeRoles} - options={(input, page, pageSize) => - loadAccessOptions('roles', input, page, pageSize) - } - value={handleRolesSelectValue()} - /> - </StyledFormItem> - <p className="help-block"> - {t( - 'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles are defined, regular access permissions apply.', - )} - </p> - </Col> - </Row> - <Row> - <Col xs={24} md={12}> - <ColorSchemeControlWrapper - hasCustomLabelsColor={hasCustomLabelsColor} - onChange={onColorSchemeChange} - colorScheme={colorScheme} - /> - </Col> - </Row> - </> - ); - }; + </> + ); useEffect(() => { if (show) { @@ -591,6 +598,11 @@ const PropertiesModal = ({ setTags(parsedTags); }; + const jsonMetadataObj = getJsonMetadata(); + const hasCustomLabelsColor = !!Object.keys( + jsonMetadataObj?.label_colors || {}, + ).length; + return ( <Modal show={show} @@ -663,6 +675,41 @@ const PropertiesModal = ({ {isFeatureEnabled(FeatureFlag.DashboardRbac) ? getRowsWithRoles() : getRowsWithoutRoles()} + <Row> + <Col xs={24} md={24}> + <h3>{t('Style')}</h3> + </Col> + </Row> + <Row> + <Col xs={24} md={12}> + <div css={{ paddingRight: '8px' }}> + <ColorSchemeControlWrapper + hasCustomLabelsColor={hasCustomLabelsColor} + onChange={onColorSchemeChange} + colorScheme={colorScheme} + /> + </div> + </Col> + <Col xs={24} md={12}> + <StyledSwitchContainer data-test="dashboard-show-timestamps-field"> + <div className="switch-row"> + <Switch + data-test="dashboard-show-timestamps-switch" + checked={showChartTimestamps} + onChange={setShowChartTimestamps} + /> + <span className="switch-label"> + {t('Show chart query timestamps')} + </span> + </div> + <span className="switch-helper"> + {t( + 'Display the last queried timestamp on charts in the dashboard view', + )} + </span> + </StyledSwitchContainer> + </Col> + </Row> <Row> <Col xs={24} md={24}> <h3>{t('Certification')}</h3> diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 8df6200220..50fc8ed79d 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -50,6 +50,7 @@ type SliceHeaderProps = SliceHeaderControlsProps & { formData: object; width: number; height: number; + queriedDttm?: string | null; }; const annotationsLoading = t('Annotation layers are still loading.'); @@ -141,6 +142,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>( annotationQuery = {}, annotationError = {}, cachedDttm = null, + queriedDttm = null, updatedDttm = null, isCached = [], isExpanded = false, @@ -271,6 +273,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>( 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 12479d02f6..c7c52dccf1 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -117,6 +117,7 @@ export interface SliceHeaderControlsProps { chartStatus: string; isCached: boolean[]; cachedDttm: string[] | null; + queriedDttm?: string | null; isExpanded?: boolean; updatedDttm: number | null; isFullSize?: boolean; @@ -292,6 +293,7 @@ const SliceHeaderControls = ( slice, isFullSize, cachedDttm = [], + queriedDttm = null, updatedDttm = null, addSuccessToast = () => {}, addDangerToast = () => {}, @@ -324,6 +326,10 @@ const SliceHeaderControls = ( : item} </div> )); + + const queriedLabel = queriedDttm + ? extendedDayjs.utc(queriedDttm).local().format('L LTS') + : null; const fullscreenLabel = isFullSize ? t('Exit fullscreen') : t('Enter fullscreen'); @@ -359,10 +365,17 @@ const SliceHeaderControls = ( style={{ height: 'auto', lineHeight: 'initial' }} data-test="refresh-chart-menu-item" > - {t('Force refresh')} - <RefreshTooltip data-test="dashboard-slice-refresh-tooltip"> - {refreshTooltip} - </RefreshTooltip> + <Tooltip + title={queriedLabel ? `${t('Last queried at')}: ${queriedLabel}` : ''} + overlayStyle={{ maxWidth: 'none' }} + > + <div> + {t('Force refresh')} + <RefreshTooltip data-test="dashboard-slice-refresh-tooltip"> + {refreshTooltip} + </RefreshTooltip> + </div> + </Tooltip> </Menu.Item> <Menu.Item key={MenuKeys.Fullscreen}>{fullscreenLabel}</Menu.Item> diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts index f13929b82c..62d4e94e6b 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.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx index 299f329b67..5f4a68b1a6 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx @@ -27,6 +27,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 { LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, LOG_ACTIONS_EXPLORE_DASHBOARD_CHART, @@ -79,6 +80,7 @@ const propTypes = { // resizing across all slices on a dashboard on every update const RESIZE_TIMEOUT = 500; const DEFAULT_HEADER_HEIGHT = 22; +const QUERIED_LABEL_HEIGHT = 24; const ChartWrapper = styled.div` overflow: hidden; @@ -161,6 +163,21 @@ const Chart = props => { PLACEHOLDER_DATASOURCE, ); const dashboardInfo = useSelector(state => state.dashboardInfo); + const showChartTimestamps = useSelector( + state => state.dashboardInfo?.metadata?.show_chart_timestamps ?? false, + ); + + const { queriesResponse, chartUpdateEndTime, chartStatus, annotationQuery } = + chart; + const isLoading = chartStatus === 'loading'; + // eslint-disable-next-line camelcase + const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || []; + const cachedDttm = + // eslint-disable-next-line camelcase + queriesResponse?.map(({ cached_dttm }) => cached_dttm) || []; + const queriedDttm = Array.isArray(queriesResponse) + ? (queriesResponse[queriesResponse.length - 1]?.queried_dttm ?? null) + : (queriesResponse?.queried_dttm ?? null); const [descriptionHeight, setDescriptionHeight] = useState(0); const [height, setHeight] = useState(props.height); @@ -224,8 +241,19 @@ const Chart = props => { 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) => { @@ -419,15 +447,6 @@ const Chart = props => { return <MissingChart height={getChartHeight()} />; } - const { queriesResponse, chartUpdateEndTime, chartStatus, annotationQuery } = - chart; - const isLoading = chartStatus === 'loading'; - // eslint-disable-next-line camelcase - const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || []; - const cachedDttm = - // eslint-disable-next-line camelcase - queriesResponse?.map(({ cached_dttm }) => cached_dttm) || []; - return ( <SliceContainer className="chart-slice" @@ -442,6 +461,7 @@ const Chart = props => { isExpanded={isExpanded} isCached={isCached} cachedDttm={cachedDttm} + queriedDttm={queriedDttm} updatedDttm={chartUpdateEndTime} toggleExpandSlice={boundActionCreators.toggleExpandSlice} forceRefresh={forceRefresh} @@ -531,6 +551,10 @@ const Chart = props => { emitCrossFilters={emitCrossFilters} /> </ChartWrapper> + + {!isLoading && showChartTimestamps && queriedDttm != null && ( + <LastQueriedLabel queriedDttm={queriedDttm} /> + )} </SliceContainer> ); }; diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx index 189177019c..93a87699ce 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx @@ -43,6 +43,7 @@ import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal'; import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils'; import { buildV1ChartDataPayload } from 'src/explore/exploreUtils'; import { getChartRequiredFieldsMissingMessage } from 'src/utils/getChartRequiredFieldsMissingMessage'; +import LastQueriedLabel from 'src/components/LastQueriedLabel'; import { DataTablesPane } from '../DataTablesPane'; import { ChartPills } from '../ChartPills'; import { ExploreAlert } from '../ExploreAlert'; @@ -372,6 +373,19 @@ const ExploreChartPanel = ({ rowLimit={formData?.row_limit} /> {renderChart()} + {!chart.chartStatus || chart.chartStatus !== 'loading' ? ( + <div + css={css` + display: flex; + justify-content: flex-end; + padding-top: ${theme.sizeUnit * 2}px; + `} + > + <LastQueriedLabel + queriedDttm={chart.queriesResponse?.[0]?.queried_dttm ?? null} + /> + </div> + ) : null} </div> ), [ @@ -386,6 +400,7 @@ const ExploreChartPanel = ({ refreshCachedQuery, formData?.row_limit, renderChart, + theme.sizeUnit, ], ); diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 7faa42aebc..5bfb0f60bb 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -1433,6 +1433,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 5d99547a84..3d61cbe100 100644 --- a/superset/common/query_context_processor.py +++ b/superset/common/query_context_processor.py @@ -224,6 +224,7 @@ class QueryContextProcessor: 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 8a48837111..5b8af13f07 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_caching import Cache @@ -65,6 +66,7 @@ class QueryCacheManager: 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 @@ -81,6 +83,7 @@ class QueryCacheManager: 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( @@ -106,6 +109,9 @@ class QueryCacheManager: 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: stats_logger.incr("loaded_from_source") @@ -121,6 +127,8 @@ class QueryCacheManager: "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( @@ -175,6 +183,9 @@ class QueryCacheManager: 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 stats_logger.incr("loaded_from_cache") except KeyError as ex: diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index d0b6230dce..6150309adb 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()
