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
The following commit(s) were added to refs/heads/5.0-pulse by this push:
new e674b8219a feat: Chart query last run timestamp (#36934)
e674b8219a is described below
commit e674b8219a9565e1cf9bdcf0f0059b2cf68e9b0c
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 ++++++
.../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 +
13 files changed, 287 insertions(+), 102 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/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()