123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422 |
- import {useState} from 'react';
- import styled from '@emotion/styled';
- import type {LegendComponentOption} from 'echarts';
- import type {Location} from 'history';
- import type {Client} from 'sentry/api';
- import type {BadgeProps} from 'sentry/components/badge/badge';
- import ErrorBoundary from 'sentry/components/errorBoundary';
- import {isWidgetViewerPath} from 'sentry/components/modals/widgetViewerModal/utils';
- import Panel from 'sentry/components/panels/panel';
- import PanelAlert from 'sentry/components/panels/panelAlert';
- import Placeholder from 'sentry/components/placeholder';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {PageFilters} from 'sentry/types/core';
- import type {Series} from 'sentry/types/echarts';
- import type {WithRouterProps} from 'sentry/types/legacyReactRouter';
- import type {Organization} from 'sentry/types/organization';
- import {defined} from 'sentry/utils';
- import {getFormattedDate} from 'sentry/utils/dates';
- import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
- import type {AggregationOutputType} from 'sentry/utils/discover/fields';
- import {hasOnDemandMetricWidgetFeature} from 'sentry/utils/onDemandMetrics/features';
- import {useExtractionStatus} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext';
- import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
- import useOrganization from 'sentry/utils/useOrganization';
- import withApi from 'sentry/utils/withApi';
- import withOrganization from 'sentry/utils/withOrganization';
- import withPageFilters from 'sentry/utils/withPageFilters';
- // eslint-disable-next-line no-restricted-imports
- import withSentryRouter from 'sentry/utils/withSentryRouter';
- import {DASHBOARD_CHART_GROUP} from 'sentry/views/dashboards/dashboard';
- import {useDiscoverSplitAlert} from 'sentry/views/dashboards/discoverSplitAlert';
- import {MetricWidgetCard} from 'sentry/views/dashboards/metrics/widgetCard';
- import type {DashboardFilters, Widget} from '../types';
- import {DisplayType, OnDemandExtractionState, WidgetType} from '../types';
- import {DEFAULT_RESULTS_LIMIT} from '../widgetBuilder/utils';
- import type WidgetLegendSelectionState from '../widgetLegendSelectionState';
- import {BigNumberWidget} from '../widgets/bigNumberWidget/bigNumberWidget';
- import type {Meta} from '../widgets/common/types';
- import {WidgetFrame} from '../widgets/common/widgetFrame';
- import {useDashboardsMEPContext} from './dashboardsMEPContext';
- import WidgetCardChartContainer from './widgetCardChartContainer';
- import {getMenuOptions, useIndexedEventsWarning} from './widgetCardContextMenu';
- import {WidgetCardDataLoader} from './widgetCardDataLoader';
- const SESSION_DURATION_INGESTION_STOP_DATE = new Date('2023-01-12');
- export const SESSION_DURATION_ALERT_TEXT = t(
- 'session.duration is no longer being recorded as of %s. Data in this widget may be incomplete.',
- getFormattedDate(SESSION_DURATION_INGESTION_STOP_DATE, 'MMM D, YYYY')
- );
- export const SESSION_DURATION_ALERT = (
- <PanelAlert type="warning">{SESSION_DURATION_ALERT_TEXT}</PanelAlert>
- );
- type Props = WithRouterProps & {
- api: Client;
- isEditingDashboard: boolean;
- location: Location;
- organization: Organization;
- selection: PageFilters;
- widget: Widget;
- widgetLegendState: WidgetLegendSelectionState;
- widgetLimitReached: boolean;
- dashboardFilters?: DashboardFilters;
- index?: string;
- isEditingWidget?: boolean;
- isMobile?: boolean;
- isPreview?: boolean;
- isWidgetInvalid?: boolean;
- legendOptions?: LegendComponentOption;
- onDataFetched?: (results: TableDataWithTitle[]) => void;
- onDelete?: () => void;
- onDuplicate?: () => void;
- onEdit?: () => void;
- onLegendSelectChanged?: () => void;
- onSetTransactionsDataset?: () => void;
- onUpdate?: (widget: Widget | null) => void;
- onWidgetSplitDecision?: (splitDecision: WidgetType) => void;
- renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
- shouldResize?: boolean;
- showContextMenu?: boolean;
- showStoredAlert?: boolean;
- tableItemLimit?: number;
- windowWidth?: number;
- };
- type Data = {
- pageLinks?: string;
- tableResults?: TableDataWithTitle[];
- timeseriesResults?: Series[];
- timeseriesResultsTypes?: Record<string, AggregationOutputType>;
- totalIssuesCount?: string;
- };
- function WidgetCard(props: Props) {
- const [data, setData] = useState<Data>();
- const onDataFetched = (newData: Data) => {
- if (props.onDataFetched && newData.tableResults) {
- props.onDataFetched(newData.tableResults);
- }
- setData(newData);
- };
- const {
- api,
- organization,
- selection,
- widget,
- isMobile,
- renderErrorMessage,
- tableItemLimit,
- windowWidth,
- dashboardFilters,
- isWidgetInvalid,
- location,
- onWidgetSplitDecision,
- shouldResize,
- onLegendSelectChanged,
- onSetTransactionsDataset,
- legendOptions,
- widgetLegendState,
- } = props;
- if (widget.displayType === DisplayType.TOP_N) {
- const queries = widget.queries.map(query => ({
- ...query,
- // Use the last aggregate because that's where the y-axis is stored
- aggregates: query.aggregates.length
- ? [query.aggregates[query.aggregates.length - 1]]
- : [],
- }));
- widget.queries = queries;
- widget.limit = DEFAULT_RESULTS_LIMIT;
- }
- const hasSessionDuration = widget.queries.some(query =>
- query.aggregates.some(aggregate => aggregate.includes('session.duration'))
- );
- const {isMetricsData} = useDashboardsMEPContext();
- const extractionStatus = useExtractionStatus({queryKey: widget});
- const indexedEventsWarning = useIndexedEventsWarning();
- const onDemandWarning = useOnDemandWarning({widget});
- const discoverSplitAlert = useDiscoverSplitAlert({widget, onSetTransactionsDataset});
- const sessionDurationWarning = hasSessionDuration ? SESSION_DURATION_ALERT_TEXT : null;
- if (widget.widgetType === WidgetType.METRICS) {
- return (
- <MetricWidgetCard
- index={props.index}
- isEditingDashboard={props.isEditingDashboard}
- onEdit={props.onEdit}
- onDelete={props.onDelete}
- onDuplicate={props.onDuplicate}
- router={props.router}
- location={props.location}
- organization={organization}
- selection={selection}
- widget={widget}
- dashboardFilters={dashboardFilters}
- renderErrorMessage={renderErrorMessage}
- showContextMenu={props.showContextMenu}
- />
- );
- }
- const onFullScreenViewClick = () => {
- if (!isWidgetViewerPath(location.pathname)) {
- props.router.push({
- pathname: `${location.pathname}${
- location.pathname.endsWith('/') ? '' : '/'
- }widget/${props.index}/`,
- query: location.query,
- });
- }
- };
- const onDemandExtractionBadge: BadgeProps | undefined =
- extractionStatus === 'extracted'
- ? {
- text: t('Extracted'),
- }
- : extractionStatus === 'not-extracted'
- ? {
- text: t('Not Extracted'),
- }
- : undefined;
- const indexedDataBadge: BadgeProps | undefined = indexedEventsWarning
- ? {
- text: t('Indexed'),
- }
- : undefined;
- const badges = [indexedDataBadge, onDemandExtractionBadge].filter(
- Boolean
- ) as BadgeProps[];
- const warnings = [onDemandWarning, discoverSplitAlert, sessionDurationWarning].filter(
- Boolean
- ) as string[];
- const actionsDisabled = Boolean(props.isPreview);
- const actionsMessage = actionsDisabled
- ? t('This is a preview only. To edit, you must add this dashboard.')
- : undefined;
- const actions = props.showContextMenu
- ? getMenuOptions(
- organization,
- selection,
- widget,
- Boolean(isMetricsData),
- props.widgetLimitReached,
- props.onDelete,
- props.onDuplicate,
- props.onEdit
- )
- : [];
- const widgetQueryError = isWidgetInvalid
- ? t('Widget query condition is invalid.')
- : undefined;
- return (
- <ErrorBoundary
- customComponent={<ErrorCard>{t('Error loading widget data')}</ErrorCard>}
- >
- <VisuallyCompleteWithData
- id="DashboardList-FirstWidgetCard"
- hasData={
- ((data?.tableResults?.length || data?.timeseriesResults?.length) ?? 0) > 0
- }
- disabled={Number(props.index) !== 0}
- >
- {widget.displayType === DisplayType.BIG_NUMBER ? (
- <WidgetCardDataLoader
- widget={widget}
- selection={selection}
- dashboardFilters={dashboardFilters}
- onDataFetched={onDataFetched}
- onWidgetSplitDecision={onWidgetSplitDecision}
- tableItemLimit={tableItemLimit}
- >
- {({loading, errorMessage, tableResults}) => {
- // Big Number widgets only support one query, so we take the first query's results and meta
- const tableData = tableResults?.[0]?.data;
- const tableMeta = tableResults?.[0]?.meta as Meta | undefined;
- const fields = Object.keys(tableMeta?.fields ?? {});
- let field = fields[0];
- let selectedField = field;
- if (defined(widget.queries[0].selectedAggregate)) {
- const index = widget.queries[0].selectedAggregate;
- selectedField = widget.queries[0].aggregates[index];
- if (fields.includes(selectedField)) {
- field = selectedField;
- }
- }
- const value = tableData?.[0]?.[selectedField];
- return (
- <BigNumberWidget
- title={widget.title}
- description={widget.description}
- badgeProps={badges}
- warnings={warnings}
- actionsDisabled={actionsDisabled}
- actionsMessage={actionsMessage}
- actions={actions}
- onFullScreenViewClick={onFullScreenViewClick}
- isLoading={loading}
- thresholds={widget.thresholds ?? undefined}
- value={value}
- field={field}
- meta={tableMeta}
- error={widgetQueryError || errorMessage || undefined}
- preferredPolarity="-"
- />
- );
- }}
- </WidgetCardDataLoader>
- ) : (
- <WidgetFrame
- title={widget.title}
- description={widget.description}
- badgeProps={badges}
- warnings={warnings}
- actionsDisabled={actionsDisabled}
- error={widgetQueryError}
- actionsMessage={actionsMessage}
- actions={actions}
- onFullScreenViewClick={onFullScreenViewClick}
- >
- <WidgetCardChartContainer
- location={location}
- api={api}
- organization={organization}
- selection={selection}
- widget={widget}
- isMobile={isMobile}
- renderErrorMessage={renderErrorMessage}
- tableItemLimit={tableItemLimit}
- windowWidth={windowWidth}
- onDataFetched={onDataFetched}
- dashboardFilters={dashboardFilters}
- chartGroup={DASHBOARD_CHART_GROUP}
- onWidgetSplitDecision={onWidgetSplitDecision}
- shouldResize={shouldResize}
- onLegendSelectChanged={onLegendSelectChanged}
- legendOptions={legendOptions}
- widgetLegendState={widgetLegendState}
- />
- </WidgetFrame>
- )}
- </VisuallyCompleteWithData>
- </ErrorBoundary>
- );
- }
- export default withApi(withOrganization(withPageFilters(withSentryRouter(WidgetCard))));
- function useOnDemandWarning(props: {widget: Widget}): string | null {
- const organization = useOrganization();
- if (!hasOnDemandMetricWidgetFeature(organization)) {
- return null;
- }
- // prettier-ignore
- const widgetContainsHighCardinality = props.widget.queries.some(
- wq =>
- wq.onDemand?.some(
- d => d.extractionState === OnDemandExtractionState.DISABLED_HIGH_CARDINALITY
- )
- );
- // prettier-ignore
- const widgetReachedSpecLimit = props.widget.queries.some(
- wq =>
- wq.onDemand?.some(
- d => d.extractionState === OnDemandExtractionState.DISABLED_SPEC_LIMIT
- )
- );
- if (widgetContainsHighCardinality) {
- return t(
- 'This widget is using indexed data because it has a column with too many unique values.'
- );
- }
- if (widgetReachedSpecLimit) {
- return t(
- "This widget is using indexed data because you've reached your organization limit for dynamically extracted metrics."
- );
- }
- return null;
- }
- const ErrorCard = styled(Placeholder)`
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: ${p => p.theme.alert.error.backgroundLight};
- border: 1px solid ${p => p.theme.alert.error.border};
- color: ${p => p.theme.alert.error.textLight};
- border-radius: ${p => p.theme.borderRadius};
- margin-bottom: ${space(2)};
- `;
- export const WidgetCardContextMenuContainer = styled('div')`
- opacity: 1;
- transition: opacity 0.1s;
- `;
- export const WidgetCardPanel = styled(Panel, {
- shouldForwardProp: prop => prop !== 'isDragging',
- })<{
- isDragging: boolean;
- }>`
- margin: 0;
- visibility: ${p => (p.isDragging ? 'hidden' : 'visible')};
- /* If a panel overflows due to a long title stretch its grid sibling */
- height: 100%;
- min-height: 96px;
- display: flex;
- flex-direction: column;
- &:not(:hover):not(:focus-within) {
- ${WidgetCardContextMenuContainer} {
- opacity: 0;
- ${p => p.theme.visuallyHidden}
- }
- }
- :hover {
- background-color: ${p => p.theme.surface200};
- transition:
- background-color 100ms linear,
- box-shadow 100ms linear;
- box-shadow: ${p => p.theme.dropShadowLight};
- }
- `;
- export const WidgetTitleRow = styled('span')`
- display: flex;
- align-items: center;
- gap: ${space(0.75)};
- `;
- export const WidgetDescription = styled('small')`
- ${p => p.theme.overflowEllipsis}
- color: ${p => p.theme.gray300};
- `;
|