Browse Source

feat(dashboard-widget-indicators): Added new ui. (#57116)

This PR adds functionality for setting thresholds in Dashboard Big
number widgets under the feature flag:
`organizations:dashboard-widget-indicators`.

Note: Backend validation for, "maximums must be sorted" and "all or none
maximums must be set" hasn't been pushed yet. Please abide by these
rules if when setting thresholds in yarn dev-ui. Best if tested with
devserver for now!

WidgetBuilder: 
<img width="871" alt="Screenshot 2023-09-27 at 10 44 37 PM"
src="https://github.com/getsentry/sentry/assets/60121741/e4754a31-1f67-4e1c-a0d5-fa1d60da4b50">

Widget in Dashboard level: 
<img width="881" alt="Screenshot 2023-09-27 at 10 46 12 PM"
src="https://github.com/getsentry/sentry/assets/60121741/f1f990b1-c6e8-440d-839b-171603d20645">

WidgetViewerModal:
<img width="1223" alt="Screenshot 2023-09-27 at 10 46 58 PM"
src="https://github.com/getsentry/sentry/assets/60121741/5a660f42-3b15-4e52-84fd-705ad2531190">

---------

Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
Abdkhan14 1 year ago
parent
commit
2b05e4ece0

+ 28 - 4
static/app/components/modals/widgetViewerModal.tsx

@@ -67,6 +67,7 @@ import {
   getFieldsFromEquations,
   getFieldsFromEquations,
   getNumEquations,
   getNumEquations,
   getWidgetDiscoverUrl,
   getWidgetDiscoverUrl,
+  getWidgetIndicatorColor,
   getWidgetIssueUrl,
   getWidgetIssueUrl,
   getWidgetReleasesUrl,
   getWidgetReleasesUrl,
 } from 'sentry/views/dashboards/utils';
 } from 'sentry/views/dashboards/utils';
@@ -92,6 +93,8 @@ import {decodeColumnOrder} from 'sentry/views/discover/utils';
 import {OrganizationContext} from 'sentry/views/organizationContext';
 import {OrganizationContext} from 'sentry/views/organizationContext';
 import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
 import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
 
 
+import CircleIndicator from '../circleIndicator';
+
 import {WidgetViewerQueryField} from './widgetViewerModal/utils';
 import {WidgetViewerQueryField} from './widgetViewerModal/utils';
 import {
 import {
   renderDiscoverGridHeaderCell,
   renderDiscoverGridHeaderCell,
@@ -1005,8 +1008,23 @@ function WidgetViewerModal(props: Props) {
                   forceTransactions={metricsDataSide.forceTransactionsOnly}
                   forceTransactions={metricsDataSide.forceTransactionsOnly}
                 >
                 >
                   <Header closeButton>
                   <Header closeButton>
-                    <WidgetTitle>
-                      <h3>{widget.title}</h3>
+                    <WidgetHeader>
+                      <WidgetTitleRow>
+                        <h3>{widget.title}</h3>
+                        {widget.thresholds &&
+                          tableData &&
+                          organization.features.includes(
+                            'dashboard-widget-indicators'
+                          ) && (
+                            <CircleIndicator
+                              color={getWidgetIndicatorColor(
+                                widget.thresholds,
+                                tableData
+                              )}
+                              size={12}
+                            />
+                          )}
+                      </WidgetTitleRow>
                       {widget.description && (
                       {widget.description && (
                         <WidgetDescription>{widget.description}</WidgetDescription>
                         <WidgetDescription>{widget.description}</WidgetDescription>
                       )}
                       )}
@@ -1033,7 +1051,7 @@ function WidgetViewerModal(props: Props) {
                           return null;
                           return null;
                         }}
                         }}
                       </DashboardsMEPConsumer>
                       </DashboardsMEPConsumer>
-                    </WidgetTitle>
+                    </WidgetHeader>
                   </Header>
                   </Header>
                   <Body>{renderWidgetViewer()}</Body>
                   <Body>{renderWidgetViewer()}</Body>
                   <Footer>
                   <Footer>
@@ -1208,10 +1226,16 @@ const EmptyQueryContainer = styled('span')`
   color: ${p => p.theme.disabled};
   color: ${p => p.theme.disabled};
 `;
 `;
 
 
-const WidgetTitle = styled('div')`
+const WidgetHeader = styled('div')`
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: ${space(1)};
   gap: ${space(1)};
 `;
 `;
 
 
+const WidgetTitleRow = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(0.75)};
+`;
+
 export default withPageFilters(WidgetViewerModal);
 export default withPageFilters(WidgetViewerModal);

+ 3 - 0
static/app/views/dashboards/types.tsx

@@ -2,6 +2,8 @@ import {Layout} from 'react-grid-layout';
 
 
 import {User} from 'sentry/types';
 import {User} from 'sentry/types';
 
 
+import {ThresholdsConfig} from './widgetBuilder/buildSteps/thresholdsStep/thresholdsStep';
+
 // Max widgets per dashboard we are currently willing
 // Max widgets per dashboard we are currently willing
 // to allow to limit the load on snuba from the
 // to allow to limit the load on snuba from the
 // parallel requests. Somewhat arbitrary
 // parallel requests. Somewhat arbitrary
@@ -51,6 +53,7 @@ export type Widget = {
   // Used to define 'topEvents' when fetching time-series data for a widget
   // Used to define 'topEvents' when fetching time-series data for a widget
   limit?: number;
   limit?: number;
   tempId?: string;
   tempId?: string;
+  thresholds?: ThresholdsConfig | null;
   widgetType?: WidgetType;
   widgetType?: WidgetType;
 };
 };
 
 

+ 30 - 0
static/app/views/dashboards/utils.tsx

@@ -26,6 +26,7 @@ import {parseSearch, Token} from 'sentry/components/searchSyntax/parser';
 import {Organization, PageFilters} from 'sentry/types';
 import {Organization, PageFilters} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import {defined} from 'sentry/utils';
 import {getUtcDateString, parsePeriodToHours} from 'sentry/utils/dates';
 import {getUtcDateString, parsePeriodToHours} from 'sentry/utils/dates';
+import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
 import EventView from 'sentry/utils/discover/eventView';
 import EventView from 'sentry/utils/discover/eventView';
 import {
 import {
   getAggregateAlias,
   getAggregateAlias,
@@ -38,6 +39,7 @@ import {
 import {DiscoverDatasets, DisplayModes} from 'sentry/utils/discover/types';
 import {DiscoverDatasets, DisplayModes} from 'sentry/utils/discover/types';
 import {getMeasurements} from 'sentry/utils/measurements/measurements';
 import {getMeasurements} from 'sentry/utils/measurements/measurements';
 import {decodeList} from 'sentry/utils/queryString';
 import {decodeList} from 'sentry/utils/queryString';
+import theme from 'sentry/utils/theme';
 import {
 import {
   DashboardDetails,
   DashboardDetails,
   DashboardFilterKeys,
   DashboardFilterKeys,
@@ -48,6 +50,11 @@ import {
   WidgetType,
   WidgetType,
 } from 'sentry/views/dashboards/types';
 } from 'sentry/views/dashboards/types';
 
 
+import {
+  ThresholdMaxKeys,
+  ThresholdsConfig,
+} from './widgetBuilder/buildSteps/thresholdsStep/thresholdsStep';
+
 export type ValidationError = {
 export type ValidationError = {
   [key: string]: string | string[] | ValidationError[] | ValidationError;
   [key: string]: string | string[] | ValidationError[] | ValidationError;
 };
 };
@@ -92,6 +99,29 @@ export function eventViewFromWidget(
   });
   });
 }
 }
 
 
+export function getWidgetIndicatorColor(
+  thresholds: ThresholdsConfig,
+  tableData: TableDataWithTitle[]
+): string {
+  const tableMeta = {...tableData[0].meta};
+  const fields = Object.keys(tableMeta);
+  const field = fields[0];
+  const data = Number(tableData[0].data[0][field]);
+  const {max_values} = thresholds;
+
+  const greenMax = max_values[ThresholdMaxKeys.MAX_1];
+  if (greenMax && data <= greenMax) {
+    return theme.green300;
+  }
+
+  const yellowMax = max_values[ThresholdMaxKeys.MAX_2];
+  if (yellowMax && data <= yellowMax) {
+    return theme.yellow300;
+  }
+
+  return theme.red300;
+}
+
 function coerceStringToArray(value?: string | string[] | null) {
 function coerceStringToArray(value?: string | string[] | null) {
   return typeof value === 'string' ? [value] : value;
   return typeof value === 'string' ? [value] : value;
 }
 }

+ 40 - 0
static/app/views/dashboards/widgetBuilder/buildSteps/thresholdsStep/thresholdsStep.spec.tsx

@@ -0,0 +1,40 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import ThresholdsStep, {ThresholdsConfig} from './thresholdsStep';
+
+const exampleThresholdsConfig: ThresholdsConfig = {
+  max_values: {
+    max_1: 100,
+    max_2: 200,
+  },
+  unit: null,
+};
+
+describe('Widget Builder > ThresholdsStep', function () {
+  it('renders thresholds step', function () {
+    const onChange = jest.fn();
+    render(
+      <ThresholdsStep thresholdsConfig={exampleThresholdsConfig} onChange={onChange} />
+    );
+
+    expect(screen.getByText('Set thresholds')).toBeInTheDocument();
+
+    // Check minimum value boxes are disabled
+    expect(screen.getByLabelText('First Minimum')).toBeDisabled();
+    expect(screen.getByLabelText('Second Minimum')).toBeDisabled();
+    expect(screen.getByLabelText('Third Minimum')).toBeDisabled();
+
+    // Check minimum values
+    expect(screen.getByLabelText('First Minimum', {selector: 'input'})).toHaveValue(0);
+    expect(screen.getByLabelText('Second Minimum', {selector: 'input'})).toHaveValue(100);
+    expect(screen.getByLabelText('Third Minimum', {selector: 'input'})).toHaveValue(200);
+
+    // Check max values
+    expect(screen.getByLabelText('First Maximum', {selector: 'input'})).toHaveValue(100);
+    expect(screen.getByLabelText('Second Maximum', {selector: 'input'})).toHaveValue(200);
+    expect(screen.getByLabelText('Third Maximum', {selector: 'input'})).toHaveAttribute(
+      'placeholder',
+      'No max'
+    );
+  });
+});

+ 157 - 0
static/app/views/dashboards/widgetBuilder/buildSteps/thresholdsStep/thresholdsStep.tsx

@@ -0,0 +1,157 @@
+import styled from '@emotion/styled';
+
+import CircleIndicator from 'sentry/components/circleIndicator';
+import FieldWrapper from 'sentry/components/forms/fieldGroup/fieldWrapper';
+import NumberField, {NumberFieldProps} from 'sentry/components/forms/fields/numberField';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import theme from 'sentry/utils/theme';
+
+import {BuildStep} from '../buildStep';
+
+type ThresholdsStepProps = {
+  onChange: (maxKey: ThresholdMaxKeys, value: string) => void;
+  thresholdsConfig: ThresholdsConfig | null;
+};
+
+type ThresholdRowProp = {
+  color: string;
+  maxInputProps: NumberFieldProps;
+  minInputProps: NumberFieldProps;
+  maxKey?: ThresholdMaxKeys;
+  onChange?: (maxKey: ThresholdMaxKeys, value: string) => void;
+};
+
+export enum ThresholdMaxKeys {
+  MAX_1 = 'max_1',
+  MAX_2 = 'max_2',
+}
+
+type ThresholdMaxValues = {
+  [K in ThresholdMaxKeys]?: number;
+};
+
+export type ThresholdsConfig = {
+  max_values: ThresholdMaxValues;
+  unit: string | null;
+};
+
+const WIDGET_INDICATOR_SIZE = 15;
+
+function ThresholdRow({
+  color,
+  minInputProps,
+  maxInputProps,
+  onChange,
+  maxKey,
+}: ThresholdRowProp) {
+  const handleChange = (val: string) => {
+    if (onChange && maxKey) {
+      onChange(maxKey, val);
+    }
+  };
+
+  return (
+    <ThresholdRowWrapper>
+      <CircleIndicator color={color} size={WIDGET_INDICATOR_SIZE} />
+      <NumberField {...minInputProps} inline={false} />
+      {t('to')}
+      <NumberField onChange={handleChange} {...maxInputProps} inline={false} />
+    </ThresholdRowWrapper>
+  );
+}
+
+function ThresholdsStep({thresholdsConfig, onChange}: ThresholdsStepProps) {
+  const maxOneValue = thresholdsConfig?.max_values[ThresholdMaxKeys.MAX_1] ?? '';
+  const maxTwoValue = thresholdsConfig?.max_values[ThresholdMaxKeys.MAX_2] ?? '';
+
+  return (
+    <BuildStep
+      title={t('Set thresholds')}
+      description={tct(
+        'Set thresholds to identify problematic widgets. For example: setting the max values, [thresholdValues] will display a green indicator for results in the range [greenRange], a yellow indicator for results in the range [yellowRange] and a red indicator for results above [redValue].',
+        {
+          thresholdValues: <HighlightedText>(green: 100, yellow: 200)</HighlightedText>,
+          greenRange: <HighlightedText>[0 - 100]</HighlightedText>,
+          yellowRange: <HighlightedText>(100 - 200]</HighlightedText>,
+          redValue: <HighlightedText>200</HighlightedText>,
+        }
+      )}
+    >
+      <ThresholdsContainer>
+        <ThresholdRow
+          maxKey={ThresholdMaxKeys.MAX_1}
+          minInputProps={{
+            name: 'firstMinimum',
+            disabled: true,
+            value: 0,
+            'aria-label': 'First Minimum',
+          }}
+          maxInputProps={{
+            name: 'firstMaximum',
+            value: maxOneValue,
+            'aria-label': 'First Maximum',
+          }}
+          color={theme.green300}
+          onChange={onChange}
+        />
+        <ThresholdRow
+          maxKey={ThresholdMaxKeys.MAX_2}
+          minInputProps={{
+            name: 'secondMinimum',
+            disabled: true,
+            value: maxOneValue,
+            'aria-label': 'Second Minimum',
+          }}
+          maxInputProps={{
+            name: 'secondMaximum',
+            value: maxTwoValue,
+            'aria-label': 'Second Maximum',
+          }}
+          color={theme.yellow300}
+          onChange={onChange}
+        />
+        <ThresholdRow
+          minInputProps={{
+            name: 'thirdMinimum',
+            disabled: true,
+            value: maxTwoValue,
+            'aria-label': 'Third Minimum',
+          }}
+          maxInputProps={{
+            name: 'thirdMaximum',
+            disabled: true,
+            placeholder: t('No max'),
+            'aria-label': 'Third Maximum',
+          }}
+          color={theme.red300}
+        />
+      </ThresholdsContainer>
+    </BuildStep>
+  );
+}
+
+const ThresholdRowWrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(2)};
+`;
+
+const ThresholdsContainer = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(2)};
+  margin-top: ${space(1)};
+
+  ${FieldWrapper} {
+    padding: 0;
+    border-bottom: none;
+  }
+`;
+
+const HighlightedText = styled('span')`
+  font-family: ${p => p.theme.text.familyMono};
+  color: ${p => p.theme.pink300};
+`;
+
+export default ThresholdsStep;

+ 45 - 0
static/app/views/dashboards/widgetBuilder/widgetBuilder.tsx

@@ -68,6 +68,10 @@ import {DataSetStep} from './buildSteps/dataSetStep';
 import {FilterResultsStep} from './buildSteps/filterResultsStep';
 import {FilterResultsStep} from './buildSteps/filterResultsStep';
 import {GroupByStep} from './buildSteps/groupByStep';
 import {GroupByStep} from './buildSteps/groupByStep';
 import {SortByStep} from './buildSteps/sortByStep';
 import {SortByStep} from './buildSteps/sortByStep';
+import ThresholdsStep, {
+  ThresholdMaxKeys,
+  ThresholdsConfig,
+} from './buildSteps/thresholdsStep/thresholdsStep';
 import {VisualizationStep} from './buildSteps/visualizationStep';
 import {VisualizationStep} from './buildSteps/visualizationStep';
 import {YAxisStep} from './buildSteps/yAxisStep';
 import {YAxisStep} from './buildSteps/yAxisStep';
 import {Footer} from './footer';
 import {Footer} from './footer';
@@ -136,6 +140,7 @@ interface State {
   description?: string;
   description?: string;
   errors?: Record<string, any>;
   errors?: Record<string, any>;
   selectedDashboard?: DashboardDetails['id'];
   selectedDashboard?: DashboardDetails['id'];
+  thresholds?: ThresholdsConfig | null;
   widgetToBeUpdated?: Widget;
   widgetToBeUpdated?: Widget;
 }
 }
 
 
@@ -212,6 +217,7 @@ function WidgetBuilder({
         DisplayType.TABLE,
         DisplayType.TABLE,
       interval: '5m',
       interval: '5m',
       queries: [],
       queries: [],
+      thresholds: null,
       limit: limit ? Number(limit) : undefined,
       limit: limit ? Number(limit) : undefined,
       errors: undefined,
       errors: undefined,
       description: undefined,
       description: undefined,
@@ -312,6 +318,7 @@ function WidgetBuilder({
         errors: undefined,
         errors: undefined,
         loading: false,
         loading: false,
         userHasModified: false,
         userHasModified: false,
+        thresholds: widgetFromDashboard.thresholds,
         dataSet: widgetFromDashboard.widgetType
         dataSet: widgetFromDashboard.widgetType
           ? WIDGET_TYPE_TO_DATA_SET[widgetFromDashboard.widgetType]
           ? WIDGET_TYPE_TO_DATA_SET[widgetFromDashboard.widgetType]
           : DataSet.EVENTS,
           : DataSet.EVENTS,
@@ -352,6 +359,7 @@ function WidgetBuilder({
     title: state.title,
     title: state.title,
     description: state.description,
     description: state.description,
     displayType: state.displayType,
     displayType: state.displayType,
+    thresholds: state.thresholds,
     interval: state.interval,
     interval: state.interval,
     queries: state.queries,
     queries: state.queries,
     limit: state.limit,
     limit: state.limit,
@@ -898,6 +906,34 @@ function WidgetBuilder({
     );
     );
   }
   }
 
 
+  function handleThresholdChange(maxKey: ThresholdMaxKeys, value: string) {
+    setState(prevState => {
+      const newState = cloneDeep(prevState);
+
+      if (value === '') {
+        delete newState.thresholds?.max_values[maxKey];
+
+        if (
+          newState.thresholds &&
+          Object.keys(newState.thresholds.max_values).length === 0
+        ) {
+          newState.thresholds = null;
+        }
+      } else {
+        if (!newState.thresholds) {
+          newState.thresholds = {
+            max_values: {},
+            unit: null,
+          };
+        }
+
+        newState.thresholds.max_values[maxKey] = Number(value);
+      }
+
+      return newState;
+    });
+  }
+
   function isFormInvalid() {
   function isFormInvalid() {
     if (
     if (
       (notDashboardsOrigin && !state.selectedDashboard) ||
       (notDashboardsOrigin && !state.selectedDashboard) ||
@@ -1123,6 +1159,15 @@ function WidgetBuilder({
                                   tags={tags}
                                   tags={tags}
                                 />
                                 />
                               )}
                               )}
+                              {state.displayType === 'big_number' &&
+                                organization.features.includes(
+                                  'dashboard-widget-indicators'
+                                ) && (
+                                  <ThresholdsStep
+                                    onChange={handleThresholdChange}
+                                    thresholdsConfig={state.thresholds ?? null}
+                                  />
+                                )}
                             </BuildSteps>
                             </BuildSteps>
                           </Main>
                           </Main>
                           <Footer
                           <Footer

+ 28 - 7
static/app/views/dashboards/widgetCard/index.tsx

@@ -10,6 +10,7 @@ import {Alert} from 'sentry/components/alert';
 import {Button} from 'sentry/components/button';
 import {Button} from 'sentry/components/button';
 import ErrorPanel from 'sentry/components/charts/errorPanel';
 import ErrorPanel from 'sentry/components/charts/errorPanel';
 import {HeaderTitle} from 'sentry/components/charts/styles';
 import {HeaderTitle} from 'sentry/components/charts/styles';
+import CircleIndicator from 'sentry/components/circleIndicator';
 import ErrorBoundary from 'sentry/components/errorBoundary';
 import ErrorBoundary from 'sentry/components/errorBoundary';
 import ExternalLink from 'sentry/components/links/externalLink';
 import ExternalLink from 'sentry/components/links/externalLink';
 import Panel from 'sentry/components/panels/panel';
 import Panel from 'sentry/components/panels/panel';
@@ -38,6 +39,7 @@ import withSentryRouter from 'sentry/utils/withSentryRouter';
 
 
 import {DRAG_HANDLE_CLASS} from '../dashboard';
 import {DRAG_HANDLE_CLASS} from '../dashboard';
 import {DashboardFilters, DisplayType, Widget, WidgetType} from '../types';
 import {DashboardFilters, DisplayType, Widget, WidgetType} from '../types';
+import {getWidgetIndicatorColor} from '../utils';
 import {DEFAULT_RESULTS_LIMIT} from '../widgetBuilder/utils';
 import {DEFAULT_RESULTS_LIMIT} from '../widgetBuilder/utils';
 
 
 import {DashboardsMEPConsumer, DashboardsMEPProvider} from './dashboardsMEPContext';
 import {DashboardsMEPConsumer, DashboardsMEPProvider} from './dashboardsMEPContext';
@@ -299,13 +301,26 @@ class WidgetCard extends Component<Props, State> {
               <WidgetCardPanel isDragging={false}>
               <WidgetCardPanel isDragging={false}>
                 <WidgetHeader>
                 <WidgetHeader>
                   <WidgetHeaderDescription>
                   <WidgetHeaderDescription>
-                    <Tooltip
-                      title={widget.title}
-                      containerDisplayMode="grid"
-                      showOnlyOnOverflow
-                    >
-                      <WidgetTitle>{widget.title}</WidgetTitle>
-                    </Tooltip>
+                    <WidgetTitleRow>
+                      <Tooltip
+                        title={widget.title}
+                        containerDisplayMode="grid"
+                        showOnlyOnOverflow
+                      >
+                        <WidgetTitle>{widget.title}</WidgetTitle>
+                      </Tooltip>
+                      {widget.thresholds &&
+                        this.state.tableData &&
+                        organization.features.includes('dashboard-widget-indicators') && (
+                          <CircleIndicator
+                            color={getWidgetIndicatorColor(
+                              widget.thresholds,
+                              this.state.tableData
+                            )}
+                            size={12}
+                          />
+                        )}
+                    </WidgetTitleRow>
                     {widget.description && (
                     {widget.description && (
                       <Tooltip
                       <Tooltip
                         title={widget.description}
                         title={widget.description}
@@ -508,6 +523,12 @@ const WidgetHeaderDescription = styled('div')`
   gap: ${space(0.5)};
   gap: ${space(0.5)};
 `;
 `;
 
 
+const WidgetTitleRow = styled('span')`
+  display: flex;
+  align-items: center;
+  gap: ${space(0.75)};
+`;
+
 export const WidgetDescription = styled('small')`
 export const WidgetDescription = styled('small')`
   ${p => p.theme.overflowEllipsis}
   ${p => p.theme.overflowEllipsis}
   color: ${p => p.theme.gray300};
   color: ${p => p.theme.gray300};