feat: Chart query last run timestamp (#36934)

(cherry picked from commit 1e8d648f47)
This commit is contained in:
Luiz Otavio
2026-01-09 17:02:18 -03:00
committed by Michael S. Molina
parent edbeadd249
commit d29ddd1def
14 changed files with 289 additions and 104 deletions

View File

@@ -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
*/

View File

@@ -75,6 +75,7 @@ const basicQueryResult: ChartDataResponseResult = {
cache_key: null,
cached_dttm: null,
cache_timeout: null,
queried_dttm: null,
data: [],
colnames: [],
coltypes: [],

View File

@@ -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;

View File

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

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -33,6 +33,7 @@ export interface SliceHeaderControlsProps {
chartStatus: string;
isCached: boolean[];
cachedDttm: string[] | null;
queriedDttm?: string | null;
isExpanded?: boolean;
updatedDttm: number | null;
isFullSize?: boolean;

View File

@@ -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>
);
};

View File

@@ -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,
],
);

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

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