mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
1242 lines
43 KiB
TypeScript
1242 lines
43 KiB
TypeScript
import {Fragment, memo, useEffect, useMemo, useRef, useState} from 'react';
|
||
import {components} from 'react-select';
|
||
import {css} from '@emotion/react';
|
||
import styled from '@emotion/styled';
|
||
import * as Sentry from '@sentry/react';
|
||
import {truncate} from '@sentry/utils';
|
||
import type {DataZoomComponentOption} from 'echarts';
|
||
import type {Location} from 'history';
|
||
import cloneDeep from 'lodash/cloneDeep';
|
||
import isEqual from 'lodash/isEqual';
|
||
import trimStart from 'lodash/trimStart';
|
||
import moment from 'moment';
|
||
|
||
import {fetchTotalCount} from 'sentry/actionCreators/events';
|
||
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
|
||
import type {Client} from 'sentry/api';
|
||
import {Alert} from 'sentry/components/alert';
|
||
import {Button} from 'sentry/components/button';
|
||
import ButtonBar from 'sentry/components/buttonBar';
|
||
import SelectControl from 'sentry/components/forms/controls/selectControl';
|
||
import Option from 'sentry/components/forms/controls/selectOption';
|
||
import type {GridColumnOrder} from 'sentry/components/gridEditable';
|
||
import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
|
||
import Pagination from 'sentry/components/pagination';
|
||
import QuestionTooltip from 'sentry/components/questionTooltip';
|
||
import {parseSearch} from 'sentry/components/searchSyntax/parser';
|
||
import HighlightQuery from 'sentry/components/searchSyntax/renderer';
|
||
import {t, tct} from 'sentry/locale';
|
||
import {space} from 'sentry/styles/space';
|
||
import type {Organization, PageFilters, SelectValue} from 'sentry/types';
|
||
import type {Series} from 'sentry/types/echarts';
|
||
import {defined} from 'sentry/utils';
|
||
import {trackAnalytics} from 'sentry/utils/analytics';
|
||
import {getUtcDateString} from 'sentry/utils/dates';
|
||
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
|
||
import type EventView from 'sentry/utils/discover/eventView';
|
||
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
|
||
import {
|
||
isAggregateField,
|
||
isEquation,
|
||
isEquationAlias,
|
||
} from 'sentry/utils/discover/fields';
|
||
import {hasDDMFeature} from 'sentry/utils/metrics/features';
|
||
import {createOnDemandFilterWarning} from 'sentry/utils/onDemandMetrics';
|
||
import {hasOnDemandMetricWidgetFeature} from 'sentry/utils/onDemandMetrics/features';
|
||
import parseLinkHeader from 'sentry/utils/parseLinkHeader';
|
||
import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
|
||
import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
|
||
import {decodeInteger, decodeList, decodeScalar} from 'sentry/utils/queryString';
|
||
import useApi from 'sentry/utils/useApi';
|
||
import {useLocation} from 'sentry/utils/useLocation';
|
||
import useProjects from 'sentry/utils/useProjects';
|
||
import useRouter from 'sentry/utils/useRouter';
|
||
import withPageFilters from 'sentry/utils/withPageFilters';
|
||
import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types';
|
||
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
|
||
import {
|
||
dashboardFiltersToString,
|
||
eventViewFromWidget,
|
||
getColoredWidgetIndicator,
|
||
getFieldsFromEquations,
|
||
getNumEquations,
|
||
getWidgetDDMUrl,
|
||
getWidgetDiscoverUrl,
|
||
getWidgetIssueUrl,
|
||
getWidgetReleasesUrl,
|
||
} from 'sentry/views/dashboards/utils';
|
||
import {
|
||
SESSION_DURATION_ALERT,
|
||
WidgetDescription,
|
||
} from 'sentry/views/dashboards/widgetCard';
|
||
import type {AugmentedEChartDataZoomHandler} from 'sentry/views/dashboards/widgetCard/chart';
|
||
import WidgetCardChart, {SLIDER_HEIGHT} from 'sentry/views/dashboards/widgetCard/chart';
|
||
import {
|
||
DashboardsMEPConsumer,
|
||
DashboardsMEPProvider,
|
||
useDashboardsMEPContext,
|
||
} from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext';
|
||
import type {GenericWidgetQueriesChildrenProps} from 'sentry/views/dashboards/widgetCard/genericWidgetQueries';
|
||
import IssueWidgetQueries from 'sentry/views/dashboards/widgetCard/issueWidgetQueries';
|
||
import {MetricWidgetChartContainer} from 'sentry/views/dashboards/widgetCard/metricWidgetCard';
|
||
import ReleaseWidgetQueries from 'sentry/views/dashboards/widgetCard/releaseWidgetQueries';
|
||
import {WidgetCardChartContainer} from 'sentry/views/dashboards/widgetCard/widgetCardChartContainer';
|
||
import WidgetQueries from 'sentry/views/dashboards/widgetCard/widgetQueries';
|
||
import {decodeColumnOrder} from 'sentry/views/discover/utils';
|
||
import {OrganizationContext} from 'sentry/views/organizationContext';
|
||
import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
|
||
|
||
import {Tooltip} from '../tooltip';
|
||
|
||
import {WidgetViewerQueryField} from './widgetViewerModal/utils';
|
||
import {
|
||
renderDiscoverGridHeaderCell,
|
||
renderGridBodyCell,
|
||
renderIssueGridHeaderCell,
|
||
renderReleaseGridHeaderCell,
|
||
} from './widgetViewerModal/widgetViewerTableCell';
|
||
|
||
export interface WidgetViewerModalOptions {
|
||
organization: Organization;
|
||
widget: Widget;
|
||
dashboardFilters?: DashboardFilters;
|
||
onEdit?: () => void;
|
||
onMetricWidgetEdit?: (widget: Widget) => void;
|
||
pageLinks?: string;
|
||
seriesData?: Series[];
|
||
seriesResultsType?: Record<string, AggregationOutputType>;
|
||
tableData?: TableDataWithTitle[];
|
||
totalIssuesCount?: string;
|
||
}
|
||
|
||
interface Props extends ModalRenderProps, WidgetViewerModalOptions {
|
||
organization: Organization;
|
||
selection: PageFilters;
|
||
}
|
||
|
||
const FULL_TABLE_ITEM_LIMIT = 20;
|
||
const HALF_TABLE_ITEM_LIMIT = 10;
|
||
const HALF_CONTAINER_HEIGHT = 300;
|
||
const EMPTY_QUERY_NAME = '(Empty Query Condition)';
|
||
|
||
const shouldWidgetCardChartMemo = (prevProps, props) => {
|
||
const selectionMatches = props.selection === prevProps.selection;
|
||
const sortMatches =
|
||
props.location.query[WidgetViewerQueryField.SORT] ===
|
||
prevProps.location.query[WidgetViewerQueryField.SORT];
|
||
const chartZoomOptionsMatches = isEqual(
|
||
props.chartZoomOptions,
|
||
prevProps.chartZoomOptions
|
||
);
|
||
const isNotTopNWidget =
|
||
props.widget.displayType !== DisplayType.TOP_N && !defined(props.widget.limit);
|
||
return selectionMatches && chartZoomOptionsMatches && (sortMatches || isNotTopNWidget);
|
||
};
|
||
|
||
// WidgetCardChartContainer and WidgetCardChart rerenders if selection was changed.
|
||
// This is required because we want to prevent ECharts interactions from causing
|
||
// unnecessary rerenders which can break legends and zoom functionality.
|
||
const MemoizedWidgetCardChartContainer = memo(
|
||
WidgetCardChartContainer,
|
||
shouldWidgetCardChartMemo
|
||
);
|
||
const MemoizedWidgetCardChart = memo(WidgetCardChart, shouldWidgetCardChartMemo);
|
||
|
||
async function fetchDiscoverTotal(
|
||
api: Client,
|
||
organization: Organization,
|
||
location: Location,
|
||
eventView: EventView
|
||
): Promise<string | undefined> {
|
||
if (!eventView.isValid()) {
|
||
return undefined;
|
||
}
|
||
|
||
try {
|
||
const total = await fetchTotalCount(
|
||
api,
|
||
organization.slug,
|
||
eventView.getEventsAPIPayload(location)
|
||
);
|
||
return total.toLocaleString();
|
||
} catch (err) {
|
||
Sentry.captureException(err);
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
function WidgetViewerModal(props: Props) {
|
||
const {
|
||
organization,
|
||
widget,
|
||
selection,
|
||
Footer,
|
||
Body,
|
||
Header,
|
||
closeModal,
|
||
onEdit,
|
||
seriesData,
|
||
tableData,
|
||
totalIssuesCount,
|
||
pageLinks: defaultPageLinks,
|
||
seriesResultsType,
|
||
dashboardFilters,
|
||
} = props;
|
||
const location = useLocation();
|
||
const {projects} = useProjects();
|
||
const router = useRouter();
|
||
const shouldShowSlider = organization.features.includes('widget-viewer-modal-minimap');
|
||
// TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
|
||
// let widgetContentLoadingStatus: boolean | undefined = undefined;
|
||
// Get widget zoom from location
|
||
// We use the start and end query params for just the initial state
|
||
const start = decodeScalar(location.query[WidgetViewerQueryField.START]);
|
||
const end = decodeScalar(location.query[WidgetViewerQueryField.END]);
|
||
const isTableWidget = widget.displayType === DisplayType.TABLE;
|
||
const hasSessionDuration = widget.queries.some(query =>
|
||
query.aggregates.some(aggregate => aggregate.includes('session.duration'))
|
||
);
|
||
const locationPageFilter = useMemo(
|
||
() =>
|
||
start && end
|
||
? {
|
||
...selection,
|
||
datetime: {start, end, period: null, utc: null},
|
||
}
|
||
: selection,
|
||
[start, end, selection]
|
||
);
|
||
|
||
const [chartUnmodified, setChartUnmodified] = useState<boolean>(true);
|
||
|
||
const [chartZoomOptions, setChartZoomOptions] = useState<DataZoomComponentOption>({
|
||
start: 0,
|
||
end: 100,
|
||
});
|
||
|
||
// We wrap the modalChartSelection in a useRef because we do not want to recalculate this value
|
||
// (which would cause an unnecessary rerender on calculation) except for the initial load.
|
||
// We use this for when a user visit a widget viewer url directly.
|
||
const [modalTableSelection, setModalTableSelection] =
|
||
useState<PageFilters>(locationPageFilter);
|
||
const modalChartSelection = useRef(modalTableSelection);
|
||
|
||
// Detect when a user clicks back and set the PageFilter state to match the location
|
||
// We need to use useEffect to prevent infinite looping rerenders due to the setModalTableSelection call
|
||
useEffect(() => {
|
||
if (location.action === 'POP') {
|
||
setModalTableSelection(locationPageFilter);
|
||
if (start && end) {
|
||
setChartZoomOptions({
|
||
startValue: moment.utc(start).unix() * 1000,
|
||
endValue: moment.utc(end).unix() * 1000,
|
||
});
|
||
} else {
|
||
setChartZoomOptions({start: 0, end: 100});
|
||
}
|
||
}
|
||
}, [end, location, locationPageFilter, start]);
|
||
|
||
// Get legends toggle settings from location
|
||
// We use the legend query params for just the initial state
|
||
const [disabledLegends, setDisabledLegends] = useState<{[key: string]: boolean}>(
|
||
decodeList(location.query[WidgetViewerQueryField.LEGEND]).reduce((acc, legend) => {
|
||
acc[legend] = false;
|
||
return acc;
|
||
}, {})
|
||
);
|
||
const [totalResults, setTotalResults] = useState<string | undefined>();
|
||
|
||
// Get query selection settings from location
|
||
const selectedQueryIndex =
|
||
decodeInteger(location.query[WidgetViewerQueryField.QUERY]) ?? 0;
|
||
|
||
// Get pagination settings from location
|
||
const page = decodeInteger(location.query[WidgetViewerQueryField.PAGE]) ?? 0;
|
||
const cursor = decodeScalar(location.query[WidgetViewerQueryField.CURSOR]);
|
||
|
||
// Get table column widths from location
|
||
const widths = decodeList(location.query[WidgetViewerQueryField.WIDTH]);
|
||
|
||
// Get table sort settings from location
|
||
const sort = decodeScalar(location.query[WidgetViewerQueryField.SORT]);
|
||
|
||
const sortedQueries = cloneDeep(
|
||
sort ? widget.queries.map(query => ({...query, orderby: sort})) : widget.queries
|
||
);
|
||
|
||
// Top N widget charts (including widgets with limits) results rely on the sorting of the query
|
||
// Set the orderby of the widget chart to match the location query params
|
||
const primaryWidget =
|
||
widget.displayType === DisplayType.TOP_N || widget.limit !== undefined
|
||
? {...widget, queries: sortedQueries}
|
||
: widget;
|
||
const api = useApi();
|
||
|
||
// Create Table widget
|
||
const tableWidget = {
|
||
...cloneDeep({...widget, queries: [sortedQueries[selectedQueryIndex]]}),
|
||
displayType: DisplayType.TABLE,
|
||
};
|
||
const {aggregates, columns} = tableWidget.queries[0];
|
||
const {orderby} = widget.queries[0];
|
||
const order = orderby.startsWith('-');
|
||
const rawOrderby = trimStart(orderby, '-');
|
||
|
||
const fields = defined(tableWidget.queries[0].fields)
|
||
? tableWidget.queries[0].fields
|
||
: [...columns, ...aggregates];
|
||
|
||
// Some Discover Widgets (Line, Area, Bar) allow the user to specify an orderby
|
||
// that is not explicitly selected as an aggregate or column. We need to explicitly
|
||
// include the orderby in the table widget aggregates and columns otherwise
|
||
// eventsv2 will complain about sorting on an unselected field.
|
||
if (
|
||
widget.widgetType === WidgetType.DISCOVER &&
|
||
orderby &&
|
||
!isEquationAlias(rawOrderby) &&
|
||
!fields.includes(rawOrderby)
|
||
) {
|
||
fields.push(rawOrderby);
|
||
[tableWidget, primaryWidget].forEach(aggregatesAndColumns => {
|
||
if (isAggregateField(rawOrderby) || isEquation(rawOrderby)) {
|
||
aggregatesAndColumns.queries.forEach(query => {
|
||
if (!query.aggregates.includes(rawOrderby)) {
|
||
query.aggregates.push(rawOrderby);
|
||
}
|
||
});
|
||
} else {
|
||
aggregatesAndColumns.queries.forEach(query => {
|
||
if (!query.columns.includes(rawOrderby)) {
|
||
query.columns.push(rawOrderby);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Need to set the orderby of the eventsv2 query to equation[index] format
|
||
// since eventsv2 does not accept the raw equation as a valid sort payload
|
||
if (isEquation(rawOrderby) && tableWidget.queries[0].orderby === orderby) {
|
||
tableWidget.queries[0].orderby = `${order ? '-' : ''}equation[${
|
||
getNumEquations(fields) - 1
|
||
}]`;
|
||
}
|
||
|
||
// Default table columns for visualizations that don't have a column setting
|
||
const shouldReplaceTableColumns =
|
||
[
|
||
DisplayType.AREA,
|
||
DisplayType.LINE,
|
||
DisplayType.BIG_NUMBER,
|
||
DisplayType.BAR,
|
||
].includes(widget.displayType) &&
|
||
widget.widgetType &&
|
||
[WidgetType.DISCOVER, WidgetType.RELEASE].includes(widget.widgetType) &&
|
||
!defined(widget.limit);
|
||
|
||
// Updates fields by adding any individual terms from equation fields as a column
|
||
if (!isTableWidget) {
|
||
const equationFields = getFieldsFromEquations(fields);
|
||
equationFields.forEach(term => {
|
||
if (isAggregateField(term) && !aggregates.includes(term)) {
|
||
aggregates.unshift(term);
|
||
}
|
||
if (!isAggregateField(term) && !columns.includes(term)) {
|
||
columns.unshift(term);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Add any group by columns into table fields if missing
|
||
columns.forEach(column => {
|
||
if (!fields.includes(column)) {
|
||
fields.unshift(column);
|
||
}
|
||
});
|
||
|
||
if (shouldReplaceTableColumns) {
|
||
switch (widget.widgetType) {
|
||
case WidgetType.DISCOVER:
|
||
if (fields.length === 1) {
|
||
tableWidget.queries[0].orderby =
|
||
tableWidget.queries[0].orderby || `-${fields[0]}`;
|
||
}
|
||
fields.unshift('title');
|
||
columns.unshift('title');
|
||
break;
|
||
case WidgetType.RELEASE:
|
||
fields.unshift('release');
|
||
columns.unshift('release');
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
const eventView = eventViewFromWidget(
|
||
tableWidget.title,
|
||
tableWidget.queries[0],
|
||
modalTableSelection
|
||
);
|
||
|
||
let columnOrder = decodeColumnOrder(
|
||
fields.map(field => ({
|
||
field,
|
||
}))
|
||
);
|
||
const columnSortBy = eventView.getSorts();
|
||
columnOrder = columnOrder.map((column, index) => ({
|
||
...column,
|
||
width: parseInt(widths[index], 10) || -1,
|
||
}));
|
||
|
||
const getOnDemandFilterWarning = createOnDemandFilterWarning(
|
||
t(
|
||
'We don’t routinely collect metrics from this property. As such, historical data may be limited.'
|
||
)
|
||
);
|
||
|
||
const queryOptions = sortedQueries.map(({name, conditions}, index) => {
|
||
// Creates the highlighted query elements to be used in the Query Select
|
||
const dashboardFiltersString = dashboardFiltersToString(dashboardFilters);
|
||
const parsedQuery =
|
||
!name && !!conditions
|
||
? parseSearch(
|
||
conditions +
|
||
(dashboardFiltersString === '' ? '' : ` ${dashboardFiltersString}`),
|
||
{
|
||
getFilterTokenWarning: hasOnDemandMetricWidgetFeature(organization)
|
||
? getOnDemandFilterWarning
|
||
: undefined,
|
||
}
|
||
)
|
||
: null;
|
||
const getHighlightedQuery = (
|
||
highlightedContainerProps: React.ComponentProps<typeof HighlightContainer>
|
||
) => {
|
||
return parsedQuery !== null ? (
|
||
<HighlightContainer {...highlightedContainerProps}>
|
||
<HighlightQuery parsedQuery={parsedQuery} />
|
||
</HighlightContainer>
|
||
) : undefined;
|
||
};
|
||
|
||
return {
|
||
label: truncate(name || conditions, 120),
|
||
value: index,
|
||
getHighlightedQuery,
|
||
};
|
||
});
|
||
|
||
const onResizeColumn = (columnIndex: number, nextColumn: GridColumnOrder) => {
|
||
const newWidth = nextColumn.width ? Number(nextColumn.width) : COL_WIDTH_UNDEFINED;
|
||
const newWidths: number[] = new Array(Math.max(columnIndex, widths.length)).fill(
|
||
COL_WIDTH_UNDEFINED
|
||
);
|
||
widths.forEach((width, index) => (newWidths[index] = parseInt(width, 10)));
|
||
newWidths[columnIndex] = newWidth;
|
||
router.replace({
|
||
pathname: location.pathname,
|
||
query: {
|
||
...location.query,
|
||
[WidgetViewerQueryField.WIDTH]: newWidths,
|
||
},
|
||
});
|
||
};
|
||
|
||
// Get discover result totals
|
||
useEffect(() => {
|
||
const getDiscoverTotals = async () => {
|
||
if (widget.widgetType === WidgetType.DISCOVER) {
|
||
setTotalResults(await fetchDiscoverTotal(api, organization, location, eventView));
|
||
}
|
||
};
|
||
getDiscoverTotals();
|
||
// Disabling this for now since this effect should only run on initial load and query index changes
|
||
// Including all exhaustive deps would cause fetchDiscoverTotal on nearly every update
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [selectedQueryIndex]);
|
||
|
||
function onLegendSelectChanged({selected}: {selected: Record<string, boolean>}) {
|
||
setDisabledLegends(selected);
|
||
router.replace({
|
||
pathname: location.pathname,
|
||
query: {
|
||
...location.query,
|
||
[WidgetViewerQueryField.LEGEND]: Object.keys(selected).filter(
|
||
key => !selected[key]
|
||
),
|
||
},
|
||
});
|
||
trackAnalytics('dashboards_views.widget_viewer.toggle_legend', {
|
||
organization,
|
||
widget_type: widget.widgetType ?? WidgetType.DISCOVER,
|
||
display_type: widget.displayType,
|
||
});
|
||
}
|
||
|
||
function DiscoverTable({
|
||
tableResults,
|
||
loading,
|
||
pageLinks,
|
||
}: GenericWidgetQueriesChildrenProps) {
|
||
const {isMetricsData} = useDashboardsMEPContext();
|
||
const links = parseLinkHeader(pageLinks ?? null);
|
||
const isFirstPage = links.previous?.results === false;
|
||
return (
|
||
<Fragment>
|
||
<GridEditable
|
||
isLoading={loading}
|
||
data={tableResults?.[0]?.data ?? []}
|
||
columnOrder={columnOrder}
|
||
columnSortBy={columnSortBy}
|
||
grid={{
|
||
renderHeadCell: renderDiscoverGridHeaderCell({
|
||
...props,
|
||
location,
|
||
widget: tableWidget,
|
||
tableData: tableResults?.[0],
|
||
onHeaderClick: () => {
|
||
if (
|
||
[DisplayType.TOP_N, DisplayType.TABLE].includes(widget.displayType) ||
|
||
defined(widget.limit)
|
||
) {
|
||
setChartUnmodified(false);
|
||
}
|
||
},
|
||
isMetricsData,
|
||
}) as (column: GridColumnOrder, columnIndex: number) => React.ReactNode,
|
||
renderBodyCell: renderGridBodyCell({
|
||
...props,
|
||
location,
|
||
tableData: tableResults?.[0],
|
||
isFirstPage,
|
||
projects,
|
||
eventView,
|
||
}),
|
||
onResizeColumn,
|
||
}}
|
||
location={location}
|
||
/>
|
||
{(links?.previous?.results || links?.next?.results) && (
|
||
<Pagination
|
||
pageLinks={pageLinks}
|
||
onCursor={newCursor => {
|
||
router.replace({
|
||
pathname: location.pathname,
|
||
query: {
|
||
...location.query,
|
||
[WidgetViewerQueryField.CURSOR]: newCursor,
|
||
},
|
||
});
|
||
|
||
if (widget.displayType === DisplayType.TABLE) {
|
||
setChartUnmodified(false);
|
||
}
|
||
|
||
trackAnalytics('dashboards_views.widget_viewer.paginate', {
|
||
organization,
|
||
widget_type: WidgetType.DISCOVER,
|
||
display_type: widget.displayType,
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
</Fragment>
|
||
);
|
||
}
|
||
|
||
const renderIssuesTable = ({
|
||
tableResults,
|
||
loading,
|
||
pageLinks,
|
||
totalCount,
|
||
}: GenericWidgetQueriesChildrenProps) => {
|
||
if (totalResults === undefined && totalCount) {
|
||
setTotalResults(totalCount);
|
||
}
|
||
const links = parseLinkHeader(pageLinks ?? null);
|
||
return (
|
||
<Fragment>
|
||
<GridEditable
|
||
isLoading={loading}
|
||
data={tableResults?.[0]?.data ?? []}
|
||
columnOrder={columnOrder}
|
||
columnSortBy={columnSortBy}
|
||
grid={{
|
||
renderHeadCell: renderIssueGridHeaderCell({
|
||
location,
|
||
organization,
|
||
selection,
|
||
widget: tableWidget,
|
||
onHeaderClick: () => {
|
||
setChartUnmodified(false);
|
||
},
|
||
}) as (column: GridColumnOrder, columnIndex: number) => React.ReactNode,
|
||
renderBodyCell: renderGridBodyCell({
|
||
location,
|
||
organization,
|
||
selection,
|
||
widget: tableWidget,
|
||
}),
|
||
onResizeColumn,
|
||
}}
|
||
location={location}
|
||
/>
|
||
{(links?.previous?.results || links?.next?.results) && (
|
||
<Pagination
|
||
pageLinks={pageLinks}
|
||
onCursor={(nextCursor, _path, _query, delta) => {
|
||
let nextPage = isNaN(page) ? delta : page + delta;
|
||
let newCursor = nextCursor;
|
||
// unset cursor and page when we navigate back to the first page
|
||
// also reset cursor if somehow the previous button is enabled on
|
||
// first page and user attempts to go backwards
|
||
if (nextPage <= 0) {
|
||
newCursor = undefined;
|
||
nextPage = 0;
|
||
}
|
||
router.replace({
|
||
pathname: location.pathname,
|
||
query: {
|
||
...location.query,
|
||
[WidgetViewerQueryField.CURSOR]: newCursor,
|
||
[WidgetViewerQueryField.PAGE]: nextPage,
|
||
},
|
||
});
|
||
|
||
if (widget.displayType === DisplayType.TABLE) {
|
||
setChartUnmodified(false);
|
||
}
|
||
|
||
trackAnalytics('dashboards_views.widget_viewer.paginate', {
|
||
organization,
|
||
widget_type: WidgetType.ISSUE,
|
||
display_type: widget.displayType,
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
</Fragment>
|
||
);
|
||
};
|
||
|
||
const renderReleaseTable: ReleaseWidgetQueries['props']['children'] = ({
|
||
tableResults,
|
||
loading,
|
||
pageLinks,
|
||
}) => {
|
||
const links = parseLinkHeader(pageLinks ?? null);
|
||
const isFirstPage = links.previous?.results === false;
|
||
return (
|
||
<Fragment>
|
||
<GridEditable
|
||
isLoading={loading}
|
||
data={tableResults?.[0]?.data ?? []}
|
||
columnOrder={columnOrder}
|
||
columnSortBy={columnSortBy}
|
||
grid={{
|
||
renderHeadCell: renderReleaseGridHeaderCell({
|
||
...props,
|
||
location,
|
||
widget: tableWidget,
|
||
tableData: tableResults?.[0],
|
||
onHeaderClick: () => {
|
||
if (
|
||
[DisplayType.TOP_N, DisplayType.TABLE].includes(widget.displayType) ||
|
||
defined(widget.limit)
|
||
) {
|
||
setChartUnmodified(false);
|
||
}
|
||
},
|
||
}) as (column: GridColumnOrder, columnIndex: number) => React.ReactNode,
|
||
renderBodyCell: renderGridBodyCell({
|
||
...props,
|
||
location,
|
||
tableData: tableResults?.[0],
|
||
isFirstPage,
|
||
}),
|
||
onResizeColumn,
|
||
}}
|
||
location={location}
|
||
/>
|
||
{!tableWidget.queries[0].orderby.match(/^-?release$/) &&
|
||
(links?.previous?.results || links?.next?.results) && (
|
||
<Pagination
|
||
pageLinks={pageLinks}
|
||
onCursor={newCursor => {
|
||
router.replace({
|
||
pathname: location.pathname,
|
||
query: {
|
||
...location.query,
|
||
[WidgetViewerQueryField.CURSOR]: newCursor,
|
||
},
|
||
});
|
||
trackAnalytics('dashboards_views.widget_viewer.paginate', {
|
||
organization,
|
||
widget_type: WidgetType.RELEASE,
|
||
display_type: widget.displayType,
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
</Fragment>
|
||
);
|
||
};
|
||
|
||
const onZoom: AugmentedEChartDataZoomHandler = (evt, chart) => {
|
||
// @ts-expect-error getModel() is private but we need this to retrieve datetime values of zoomed in region
|
||
const model = chart.getModel();
|
||
const {seriesStart, seriesEnd} = evt;
|
||
let startValue, endValue;
|
||
startValue = model._payload.batch?.[0].startValue;
|
||
endValue = model._payload.batch?.[0].endValue;
|
||
const seriesStartTime = seriesStart ? new Date(seriesStart).getTime() : undefined;
|
||
const seriesEndTime = seriesEnd ? new Date(seriesEnd).getTime() : undefined;
|
||
// Slider zoom events don't contain the raw date time value, only the percentage
|
||
// We use the percentage with the start and end of the series to calculate the adjusted zoom
|
||
if (startValue === undefined || endValue === undefined) {
|
||
if (seriesStartTime && seriesEndTime) {
|
||
const diff = seriesEndTime - seriesStartTime;
|
||
startValue = diff * model._payload.start * 0.01 + seriesStartTime;
|
||
endValue = diff * model._payload.end * 0.01 + seriesStartTime;
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
setChartZoomOptions({startValue, endValue});
|
||
const newStart = getUtcDateString(moment.utc(startValue));
|
||
const newEnd = getUtcDateString(moment.utc(endValue));
|
||
setModalTableSelection({
|
||
...modalTableSelection,
|
||
datetime: {
|
||
...modalTableSelection.datetime,
|
||
start: newStart,
|
||
end: newEnd,
|
||
period: null,
|
||
},
|
||
});
|
||
router.push({
|
||
pathname: location.pathname,
|
||
query: {
|
||
...location.query,
|
||
[WidgetViewerQueryField.START]: newStart,
|
||
[WidgetViewerQueryField.END]: newEnd,
|
||
},
|
||
});
|
||
trackAnalytics('dashboards_views.widget_viewer.zoom', {
|
||
organization,
|
||
widget_type: widget.widgetType ?? WidgetType.DISCOVER,
|
||
display_type: widget.displayType,
|
||
});
|
||
};
|
||
|
||
function renderWidgetViewerTable() {
|
||
switch (widget.widgetType) {
|
||
case WidgetType.ISSUE:
|
||
if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
|
||
return renderIssuesTable({
|
||
tableResults: tableData,
|
||
loading: false,
|
||
errorMessage: undefined,
|
||
pageLinks: defaultPageLinks,
|
||
totalCount: totalIssuesCount,
|
||
});
|
||
}
|
||
return (
|
||
<IssueWidgetQueries
|
||
api={api}
|
||
organization={organization}
|
||
widget={tableWidget}
|
||
selection={modalTableSelection}
|
||
limit={
|
||
widget.displayType === DisplayType.TABLE
|
||
? FULL_TABLE_ITEM_LIMIT
|
||
: HALF_TABLE_ITEM_LIMIT
|
||
}
|
||
cursor={cursor}
|
||
dashboardFilters={dashboardFilters}
|
||
>
|
||
{renderIssuesTable}
|
||
</IssueWidgetQueries>
|
||
);
|
||
case WidgetType.RELEASE:
|
||
if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
|
||
return renderReleaseTable({
|
||
tableResults: tableData,
|
||
loading: false,
|
||
pageLinks: defaultPageLinks,
|
||
});
|
||
}
|
||
return (
|
||
<ReleaseWidgetQueries
|
||
api={api}
|
||
organization={organization}
|
||
widget={tableWidget}
|
||
selection={modalTableSelection}
|
||
limit={
|
||
widget.displayType === DisplayType.TABLE
|
||
? FULL_TABLE_ITEM_LIMIT
|
||
: HALF_TABLE_ITEM_LIMIT
|
||
}
|
||
cursor={cursor}
|
||
dashboardFilters={dashboardFilters}
|
||
>
|
||
{renderReleaseTable}
|
||
</ReleaseWidgetQueries>
|
||
);
|
||
case WidgetType.DISCOVER:
|
||
default:
|
||
if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
|
||
return (
|
||
<DiscoverTable
|
||
tableResults={tableData}
|
||
loading={false}
|
||
pageLinks={defaultPageLinks}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<WidgetQueries
|
||
api={api}
|
||
organization={organization}
|
||
widget={tableWidget}
|
||
selection={modalTableSelection}
|
||
limit={
|
||
widget.displayType === DisplayType.TABLE
|
||
? FULL_TABLE_ITEM_LIMIT
|
||
: HALF_TABLE_ITEM_LIMIT
|
||
}
|
||
cursor={cursor}
|
||
dashboardFilters={dashboardFilters}
|
||
>
|
||
{({tableResults, loading, pageLinks}) => {
|
||
// TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
|
||
// small hack that improves the concurrency render of the warning triangle
|
||
// widgetContentLoadingStatus = loading;
|
||
return (
|
||
<DiscoverTable
|
||
tableResults={tableResults}
|
||
loading={loading}
|
||
pageLinks={pageLinks}
|
||
/>
|
||
);
|
||
}}
|
||
</WidgetQueries>
|
||
);
|
||
}
|
||
}
|
||
|
||
function renderWidgetViewer() {
|
||
return (
|
||
<Fragment>
|
||
{hasSessionDuration && SESSION_DURATION_ALERT}
|
||
{widget.displayType !== DisplayType.TABLE && (
|
||
<Container
|
||
height={
|
||
widget.displayType !== DisplayType.BIG_NUMBER
|
||
? HALF_CONTAINER_HEIGHT +
|
||
(shouldShowSlider &&
|
||
[
|
||
DisplayType.AREA,
|
||
DisplayType.LINE,
|
||
DisplayType.BAR,
|
||
DisplayType.TOP_N,
|
||
].includes(widget.displayType)
|
||
? SLIDER_HEIGHT
|
||
: 0)
|
||
: null
|
||
}
|
||
>
|
||
{(!!seriesData || !!tableData) && chartUnmodified ? (
|
||
<MemoizedWidgetCardChart
|
||
timeseriesResults={seriesData}
|
||
timeseriesResultsTypes={seriesResultsType}
|
||
tableResults={tableData}
|
||
errorMessage={undefined}
|
||
loading={false}
|
||
location={location}
|
||
widget={widget}
|
||
selection={selection}
|
||
router={router}
|
||
organization={organization}
|
||
onZoom={onZoom}
|
||
onLegendSelectChanged={onLegendSelectChanged}
|
||
legendOptions={{selected: disabledLegends}}
|
||
expandNumbers
|
||
showSlider={shouldShowSlider}
|
||
noPadding
|
||
chartZoomOptions={chartZoomOptions}
|
||
/>
|
||
) : widget.widgetType === WidgetType.METRICS &&
|
||
hasDDMFeature(organization) ? (
|
||
<MetricWidgetChartContainer widget={widget} selection={selection} />
|
||
) : (
|
||
<MemoizedWidgetCardChartContainer
|
||
location={location}
|
||
api={api}
|
||
organization={organization}
|
||
selection={modalChartSelection.current}
|
||
dashboardFilters={dashboardFilters}
|
||
// Top N charts rely on the orderby of the table
|
||
widget={primaryWidget}
|
||
onZoom={onZoom}
|
||
onLegendSelectChanged={onLegendSelectChanged}
|
||
legendOptions={{selected: disabledLegends}}
|
||
expandNumbers
|
||
showSlider={shouldShowSlider}
|
||
noPadding
|
||
chartZoomOptions={chartZoomOptions}
|
||
/>
|
||
)}
|
||
</Container>
|
||
)}
|
||
{widget.queries.length > 1 && (
|
||
<Alert type="info" showIcon>
|
||
{t(
|
||
'This widget was built with multiple queries. Table data can only be displayed for one query at a time. To edit any of the queries, edit the widget.'
|
||
)}
|
||
</Alert>
|
||
)}
|
||
{(widget.queries.length > 1 || widget.queries[0].conditions) && (
|
||
<QueryContainer>
|
||
<SelectControl
|
||
value={selectedQueryIndex}
|
||
options={queryOptions}
|
||
onChange={(option: SelectValue<number>) => {
|
||
router.replace({
|
||
pathname: location.pathname,
|
||
query: {
|
||
...location.query,
|
||
[WidgetViewerQueryField.QUERY]: option.value,
|
||
[WidgetViewerQueryField.PAGE]: undefined,
|
||
[WidgetViewerQueryField.CURSOR]: undefined,
|
||
},
|
||
});
|
||
|
||
trackAnalytics('dashboards_views.widget_viewer.select_query', {
|
||
organization,
|
||
widget_type: widget.widgetType ?? WidgetType.DISCOVER,
|
||
display_type: widget.displayType,
|
||
});
|
||
}}
|
||
components={{
|
||
// Replaces the displayed selected value
|
||
SingleValue: containerProps => {
|
||
return (
|
||
<components.SingleValue
|
||
{...containerProps}
|
||
// Overwrites some of the default styling that interferes with highlighted query text
|
||
getStyles={() => ({
|
||
wordBreak: 'break-word',
|
||
flex: 1,
|
||
display: 'flex',
|
||
padding: `0 ${space(0.5)}`,
|
||
})}
|
||
>
|
||
{queryOptions[selectedQueryIndex].getHighlightedQuery({
|
||
display: 'block',
|
||
}) ??
|
||
(queryOptions[selectedQueryIndex].label || (
|
||
<EmptyQueryContainer>{EMPTY_QUERY_NAME}</EmptyQueryContainer>
|
||
))}
|
||
</components.SingleValue>
|
||
);
|
||
},
|
||
// Replaces the dropdown options
|
||
Option: containerProps => {
|
||
const highlightedQuery = containerProps.data.getHighlightedQuery({
|
||
display: 'flex',
|
||
});
|
||
return (
|
||
<Option
|
||
{...(highlightedQuery
|
||
? {
|
||
...containerProps,
|
||
label: highlightedQuery,
|
||
}
|
||
: containerProps.label
|
||
? containerProps
|
||
: {
|
||
...containerProps,
|
||
label: (
|
||
<EmptyQueryContainer>
|
||
{EMPTY_QUERY_NAME}
|
||
</EmptyQueryContainer>
|
||
),
|
||
})}
|
||
/>
|
||
);
|
||
},
|
||
// Hide the dropdown indicator if there is only one option
|
||
...(widget.queries.length < 2 ? {IndicatorsContainer: _ => null} : {}),
|
||
}}
|
||
isSearchable={false}
|
||
isDisabled={widget.queries.length < 2}
|
||
/>
|
||
{widget.queries.length === 1 && (
|
||
<StyledQuestionTooltip
|
||
title={t('To edit this query, you must edit the widget.')}
|
||
size="sm"
|
||
/>
|
||
)}
|
||
</QueryContainer>
|
||
)}
|
||
{renderWidgetViewerTable()}
|
||
</Fragment>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Fragment>
|
||
<OrganizationContext.Provider value={organization}>
|
||
<DashboardsMEPProvider>
|
||
<MetricsCardinalityProvider organization={organization} location={location}>
|
||
<MetricsDataSwitcher
|
||
organization={organization}
|
||
eventView={eventView}
|
||
location={location}
|
||
hideLoadingIndicator
|
||
>
|
||
{metricsDataSide => (
|
||
<MEPSettingProvider
|
||
location={location}
|
||
forceTransactions={metricsDataSide.forceTransactionsOnly}
|
||
>
|
||
<Header closeButton>
|
||
<WidgetHeader>
|
||
<WidgetTitleRow>
|
||
<h3>{widget.title}</h3>
|
||
{widget.thresholds &&
|
||
tableData &&
|
||
organization.features.includes('dashboard-widget-indicators') &&
|
||
getColoredWidgetIndicator(widget.thresholds, tableData)}
|
||
</WidgetTitleRow>
|
||
{widget.description && (
|
||
<Tooltip
|
||
title={widget.description}
|
||
containerDisplayMode="grid"
|
||
showOnlyOnOverflow
|
||
isHoverable
|
||
position="bottom"
|
||
>
|
||
<WidgetDescription>{widget.description}</WidgetDescription>
|
||
</Tooltip>
|
||
)}
|
||
<DashboardsMEPConsumer>
|
||
{({}) => {
|
||
// TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
|
||
// if (
|
||
// widgetContentLoadingStatus === false &&
|
||
// widget.widgetType === WidgetType.DISCOVER &&
|
||
// isMetricsData === false
|
||
// ) {
|
||
// return (
|
||
// <Tooltip
|
||
// containerDisplayMode="inline-flex"
|
||
// title={t(
|
||
// 'Based on your search criteria, the sampled events available may be limited and may not be representative of all events.'
|
||
// )}
|
||
// >
|
||
// <IconWarning color="warningText" size="md" />
|
||
// </Tooltip>
|
||
// );
|
||
// }
|
||
|
||
return null;
|
||
}}
|
||
</DashboardsMEPConsumer>
|
||
</WidgetHeader>
|
||
</Header>
|
||
<Body>{renderWidgetViewer()}</Body>
|
||
<Footer>
|
||
<ResultsContainer>
|
||
{renderTotalResults(totalResults, widget.widgetType)}
|
||
<ButtonBar gap={1}>
|
||
{onEdit && widget.id && (
|
||
<Button
|
||
onClick={() => {
|
||
closeModal();
|
||
onEdit();
|
||
trackAnalytics('dashboards_views.widget_viewer.edit', {
|
||
organization,
|
||
widget_type: widget.widgetType ?? WidgetType.DISCOVER,
|
||
display_type: widget.displayType,
|
||
});
|
||
}}
|
||
>
|
||
{t('Edit Widget')}
|
||
</Button>
|
||
)}
|
||
{widget.widgetType && (
|
||
<OpenButton
|
||
widget={primaryWidget}
|
||
organization={organization}
|
||
selection={modalTableSelection}
|
||
selectedQueryIndex={selectedQueryIndex}
|
||
/>
|
||
)}
|
||
</ButtonBar>
|
||
</ResultsContainer>
|
||
</Footer>
|
||
</MEPSettingProvider>
|
||
)}
|
||
</MetricsDataSwitcher>
|
||
</MetricsCardinalityProvider>
|
||
</DashboardsMEPProvider>
|
||
</OrganizationContext.Provider>
|
||
</Fragment>
|
||
);
|
||
}
|
||
|
||
interface OpenButtonProps {
|
||
organization: Organization;
|
||
selectedQueryIndex: number;
|
||
selection: PageFilters;
|
||
widget: Widget;
|
||
}
|
||
|
||
function OpenButton({
|
||
widget,
|
||
selection,
|
||
organization,
|
||
selectedQueryIndex,
|
||
}: OpenButtonProps) {
|
||
let openLabel: string;
|
||
let path: string;
|
||
const {isMetricsData} = useDashboardsMEPContext();
|
||
|
||
switch (widget.widgetType) {
|
||
case WidgetType.ISSUE:
|
||
openLabel = t('Open in Issues');
|
||
path = getWidgetIssueUrl(widget, selection, organization);
|
||
break;
|
||
case WidgetType.RELEASE:
|
||
openLabel = t('Open in Releases');
|
||
path = getWidgetReleasesUrl(widget, selection, organization);
|
||
break;
|
||
case WidgetType.METRICS:
|
||
openLabel = t('Open in Metrics');
|
||
path = getWidgetDDMUrl(widget, selection, organization);
|
||
break;
|
||
case WidgetType.DISCOVER:
|
||
default:
|
||
openLabel = t('Open in Discover');
|
||
path = getWidgetDiscoverUrl(
|
||
{...widget, queries: [widget.queries[selectedQueryIndex]]},
|
||
selection,
|
||
organization,
|
||
0,
|
||
isMetricsData
|
||
);
|
||
break;
|
||
}
|
||
|
||
return (
|
||
<Button
|
||
to={path}
|
||
priority="primary"
|
||
onClick={() => {
|
||
trackAnalytics('dashboards_views.widget_viewer.open_source', {
|
||
organization,
|
||
widget_type: widget.widgetType ?? WidgetType.DISCOVER,
|
||
display_type: widget.displayType,
|
||
});
|
||
}}
|
||
>
|
||
{openLabel}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
function renderTotalResults(totalResults?: string, widgetType?: WidgetType) {
|
||
if (totalResults === undefined) {
|
||
return <span />;
|
||
}
|
||
switch (widgetType) {
|
||
case WidgetType.ISSUE:
|
||
return (
|
||
<span>
|
||
{tct('[description:Total Issues:] [total]', {
|
||
description: <strong />,
|
||
total: totalResults === '1000' ? '1000+' : totalResults,
|
||
})}
|
||
</span>
|
||
);
|
||
case WidgetType.DISCOVER:
|
||
return (
|
||
<span>
|
||
{tct('[description:Sampled Events:] [total]', {
|
||
description: <strong />,
|
||
total: totalResults,
|
||
})}
|
||
</span>
|
||
);
|
||
default:
|
||
return <span />;
|
||
}
|
||
}
|
||
|
||
export const modalCss = css`
|
||
width: 100%;
|
||
max-width: 1200px;
|
||
`;
|
||
|
||
const Container = styled('div')<{height?: number | null}>`
|
||
height: ${p => (p.height ? `${p.height}px` : 'auto')};
|
||
position: relative;
|
||
padding-bottom: ${space(3)};
|
||
`;
|
||
|
||
const QueryContainer = styled('div')`
|
||
margin-bottom: ${space(2)};
|
||
position: relative;
|
||
`;
|
||
|
||
const StyledQuestionTooltip = styled(QuestionTooltip)`
|
||
position: absolute;
|
||
top: ${space(1.5)};
|
||
right: ${space(2)};
|
||
`;
|
||
|
||
const HighlightContainer = styled('span')<{display?: 'block' | 'flex'}>`
|
||
display: ${p => p.display};
|
||
gap: ${space(1)};
|
||
font-family: ${p => p.theme.text.familyMono};
|
||
font-size: ${p => p.theme.fontSizeSmall};
|
||
line-height: 2;
|
||
flex: 1;
|
||
`;
|
||
|
||
const ResultsContainer = styled('div')`
|
||
display: flex;
|
||
flex-grow: 1;
|
||
flex-direction: column;
|
||
gap: ${space(1)};
|
||
|
||
@media (min-width: ${p => p.theme.breakpoints.small}) {
|
||
align-items: center;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
}
|
||
`;
|
||
|
||
const EmptyQueryContainer = styled('span')`
|
||
color: ${p => p.theme.disabled};
|
||
`;
|
||
|
||
const WidgetHeader = styled('div')`
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: ${space(1)};
|
||
`;
|
||
|
||
const WidgetTitleRow = styled('div')`
|
||
display: flex;
|
||
align-items: center;
|
||
gap: ${space(0.75)};
|
||
`;
|
||
|
||
export default withPageFilters(WidgetViewerModal);
|