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

Reply via email to