Browse Source

feat(workflow): Add team insights page (#28455)

Scott Cooper 3 years ago
parent
commit
b4bce19863

+ 2 - 0
src/sentry/conf/server.py

@@ -1068,6 +1068,8 @@ SENTRY_FEATURES = {
     "organizations:project-transaction-threshold-override": False,
     # Enable percent displays in issue stream
     "organizations:issue-percent-display": False,
+    # Enable team insights page
+    "organizations:team-insights": False,
     # Adds additional filters and a new section to issue alert rules.
     "projects:alert-filters": True,
     # Enable functionality to specify custom inbound filters on events.

+ 1 - 0
src/sentry/features/__init__.py

@@ -147,6 +147,7 @@ default_manager.add("organizations:sso-migration", OrganizationFeature)
 default_manager.add("organizations:sso-rippling", OrganizationFeature)
 default_manager.add("organizations:sso-saml2", OrganizationFeature)
 default_manager.add("organizations:sso-scim", OrganizationFeature, True)
+default_manager.add("organizations:team-insights", OrganizationFeature, True)
 default_manager.add("organizations:symbol-sources", OrganizationFeature)
 default_manager.add("organizations:transaction-comparison", OrganizationFeature, True)
 default_manager.add("organizations:transaction-events", OrganizationFeature, True)

+ 10 - 0
static/app/routes.tsx

@@ -878,6 +878,16 @@ function routes() {
             componentPromise={() => import('app/views/projectsDashboard')}
             component={SafeLazyLoad}
           />
+          <Route
+            path="/organizations/:orgId/teamInsights/"
+            componentPromise={() => import('app/views/teamInsights')}
+            component={SafeLazyLoad}
+          >
+            <IndexRoute
+              componentPromise={() => import('app/views/teamInsights/overview')}
+              component={SafeLazyLoad}
+            />
+          </Route>
           <Route
             path="/organizations/:orgId/dashboards/"
             componentPromise={() => import('app/views/dashboardsV2')}

+ 30 - 17
static/app/views/projectsDashboard/index.tsx

@@ -7,6 +7,7 @@ import flatten from 'lodash/flatten';
 import uniqBy from 'lodash/uniqBy';
 
 import {Client} from 'app/api';
+import Feature from 'app/components/acl/feature';
 import Button from 'app/components/button';
 import IdBadge from 'app/components/idBadge';
 import Link from 'app/components/links/link';
@@ -24,6 +25,7 @@ import {sortProjects} from 'app/utils';
 import withApi from 'app/utils/withApi';
 import withOrganization from 'app/utils/withOrganization';
 import withTeamsForUser from 'app/utils/withTeamsForUser';
+import TeamInsightsHeaderTabs from 'app/views/teamInsights/headerTabs';
 
 import Resources from './resources';
 import TeamSection from './teamSection';
@@ -77,23 +79,30 @@ function Dashboard({teams, params, organization, loadingTeams, error}: Props) {
     <Fragment>
       <SentryDocumentTitle title={t('Projects Dashboard')} orgSlug={organization.slug} />
       {projects.length > 0 && (
-        <ProjectsHeader>
-          <PageHeading>{t('Projects')}</PageHeading>
-          <Button
-            size="small"
-            disabled={!canCreateProjects}
-            title={
-              !canCreateProjects
-                ? t('You do not have permission to create projects')
-                : undefined
-            }
-            to={`/organizations/${organization.slug}/projects/new/`}
-            icon={<IconAdd size="xs" isCircled />}
-            data-test-id="create-project"
-          >
-            {t('Create Project')}
-          </Button>
-        </ProjectsHeader>
+        <Fragment>
+          <ProjectsHeader>
+            <PageHeading>{t('Projects')}</PageHeading>
+            <Button
+              size="small"
+              disabled={!canCreateProjects}
+              title={
+                !canCreateProjects
+                  ? t('You do not have permission to create projects')
+                  : undefined
+              }
+              to={`/organizations/${organization.slug}/projects/new/`}
+              icon={<IconAdd size="xs" isCircled />}
+              data-test-id="create-project"
+            >
+              {t('Create Project')}
+            </Button>
+          </ProjectsHeader>
+          <Feature organization={organization} features={['team-insights']}>
+            <TabsWrapper>
+              <TeamInsightsHeaderTabs organization={organization} activeTab="projects" />
+            </TabsWrapper>
+          </Feature>
+        </Fragment>
       )}
 
       {filteredTeams.map((team, index) => (
@@ -139,6 +148,10 @@ const ProjectsHeader = styled('div')`
   justify-content: space-between;
 `;
 
+const TabsWrapper = styled('div')`
+  padding: ${space(2)} ${space(4)} ${space(1)} ${space(4)};
+`;
+
 const OrganizationDashboardWrapper = styled('div')`
   display: flex;
   flex: 1;

+ 162 - 0
static/app/views/teamInsights/filter.tsx

@@ -0,0 +1,162 @@
+import {Component, Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import DropdownButton from 'app/components/dropdownButton';
+import DropdownControl, {Content} from 'app/components/dropdownControl';
+import {t} from 'app/locale';
+import overflowEllipsis from 'app/styles/overflowEllipsis';
+import space from 'app/styles/space';
+
+type DropdownButtonProps = React.ComponentProps<typeof DropdownButton>;
+
+type DropdownSection = {
+  id: string;
+  label: string;
+  items: Array<{label: string; value: string; checked: boolean; filtered: boolean}>;
+};
+
+type SectionProps = DropdownSection & {
+  toggleFilter: (value: string) => void;
+};
+
+function FilterSection({label, items, toggleFilter}: SectionProps) {
+  return (
+    <Fragment>
+      <Header>
+        <span>{label}</span>
+      </Header>
+      {items
+        .filter(item => !item.filtered)
+        .map(item => (
+          <ListItem
+            key={item.value}
+            isChecked={item.checked}
+            onClick={() => {
+              toggleFilter(item.value);
+            }}
+          >
+            <TeamName>{item.label}</TeamName>
+          </ListItem>
+        ))}
+    </Fragment>
+  );
+}
+
+type Props = {
+  header: React.ReactElement;
+  onFilterChange: (selectedValue: string) => void;
+  dropdownSection: DropdownSection;
+};
+
+class Filter extends Component<Props> {
+  toggleFilter = (value: string) => {
+    const {onFilterChange} = this.props;
+    onFilterChange(value);
+  };
+
+  render() {
+    const {dropdownSection, header} = this.props;
+    const selected = this.props.dropdownSection.items.find(item => item.checked);
+
+    const dropDownButtonProps: Pick<DropdownButtonProps, 'children' | 'priority'> & {
+      hasDarkBorderBottomColor: boolean;
+    } = {
+      priority: 'default',
+      hasDarkBorderBottomColor: false,
+    };
+
+    return (
+      <DropdownControl
+        menuWidth="240px"
+        blendWithActor
+        alwaysRenderMenu={false}
+        button={({isOpen, getActorProps}) => (
+          <StyledDropdownButton
+            {...getActorProps()}
+            isOpen={isOpen}
+            hasDarkBorderBottomColor={dropDownButtonProps.hasDarkBorderBottomColor}
+            priority={dropDownButtonProps.priority as DropdownButtonProps['priority']}
+            data-test-id="filter-button"
+          >
+            {t('Team: ')}
+            {selected?.label}
+          </StyledDropdownButton>
+        )}
+      >
+        {({isOpen, getMenuProps}) => (
+          <MenuContent
+            {...getMenuProps()}
+            isOpen={isOpen}
+            blendCorner
+            alignMenu="left"
+            width="240px"
+          >
+            <List>
+              {header}
+              <FilterSection {...dropdownSection} toggleFilter={this.toggleFilter} />
+            </List>
+          </MenuContent>
+        )}
+      </DropdownControl>
+    );
+  }
+}
+
+const MenuContent = styled(Content)`
+  max-height: 290px;
+  overflow-y: auto;
+`;
+
+const Header = styled('div')`
+  display: grid;
+  grid-template-columns: auto min-content;
+  grid-column-gap: ${space(1)};
+  align-items: center;
+
+  margin: 0;
+  background-color: ${p => p.theme.backgroundSecondary};
+  color: ${p => p.theme.gray300};
+  font-weight: normal;
+  font-size: ${p => p.theme.fontSizeMedium};
+  padding: ${space(1)} ${space(2)};
+  border-bottom: 1px solid ${p => p.theme.border};
+`;
+
+const StyledDropdownButton = styled(DropdownButton)<{hasDarkBorderBottomColor?: boolean}>`
+  white-space: nowrap;
+  max-width: 200px;
+  height: 42px;
+
+  z-index: ${p => p.theme.zIndex.dropdown};
+`;
+
+const List = styled('ul')`
+  list-style: none;
+  margin: 0;
+  padding: 0;
+`;
+
+const ListItem = styled('li')<{isChecked?: boolean}>`
+  display: grid;
+  grid-template-columns: 1fr max-content;
+  grid-column-gap: ${space(1)};
+  align-items: center;
+  padding: ${space(1)} ${space(2)};
+  border-bottom: 1px solid ${p => p.theme.border};
+  cursor: pointer;
+  :hover {
+    background-color: ${p => p.theme.backgroundSecondary};
+  }
+
+  &:hover span {
+    color: ${p => p.theme.blue300};
+    text-decoration: underline;
+  }
+`;
+
+const TeamName = styled('div')`
+  font-size: ${p => p.theme.fontSizeMedium};
+  ${overflowEllipsis};
+`;
+
+export default Filter;

+ 28 - 0
static/app/views/teamInsights/headerTabs.tsx

@@ -0,0 +1,28 @@
+import * as Layout from 'app/components/layouts/thirds';
+import Link from 'app/components/links/link';
+import {t} from 'app/locale';
+import {Organization} from 'app/types';
+
+type Props = {
+  organization: Organization;
+  activeTab: 'projects' | 'teamInsights';
+};
+
+function HeaderTabs({organization, activeTab}: Props) {
+  return (
+    <Layout.HeaderNavTabs underlined>
+      <li className={`${activeTab === 'projects' ? 'active' : ''}`}>
+        <Link to={`/organizations/${organization.slug}/projects/`}>
+          {t('Projects Overview')}
+        </Link>
+      </li>
+      <li className={`${activeTab === 'teamInsights' ? 'active' : ''}`}>
+        <Link to={`/organizations/${organization.slug}/teamInsights/`}>
+          {t('Team Insights')}
+        </Link>
+      </li>
+    </Layout.HeaderNavTabs>
+  );
+}
+
+export default HeaderTabs;

+ 26 - 0
static/app/views/teamInsights/index.tsx

@@ -0,0 +1,26 @@
+import {cloneElement, Fragment, isValidElement} from 'react';
+
+import Feature from 'app/components/acl/feature';
+import {Organization} from 'app/types';
+import withOrganization from 'app/utils/withOrganization';
+
+type Props = {
+  organization: Organization;
+  children?: React.ReactNode;
+};
+
+function TeamInsightsContainer({children, organization}: Props) {
+  return (
+    <Feature organization={organization} features={['team-insights']}>
+      <Fragment>
+        {children && isValidElement(children)
+          ? cloneElement(children, {
+              organization,
+            })
+          : children}
+      </Fragment>
+    </Feature>
+  );
+}
+
+export default withOrganization(TeamInsightsContainer);

+ 61 - 0
static/app/views/teamInsights/keyTransactions.tsx

@@ -0,0 +1,61 @@
+import {Location} from 'history';
+
+import {Organization, Project} from 'app/types';
+import EventView from 'app/utils/discover/eventView';
+import Table from 'app/views/performance/table';
+
+type Props = {
+  organization: Organization;
+  projects: Project[];
+  location: Location;
+  period?: string;
+  start?: string;
+  end?: string;
+};
+
+function TeamKeyTransactions({
+  organization,
+  projects,
+  location,
+  period,
+  start,
+  end,
+}: Props) {
+  const eventView = EventView.fromSavedQuery({
+    id: undefined,
+    name: 'Performance',
+    query: 'transaction.duration:<15m team_key_transaction:true',
+    projects: projects.map(project => Number(project.id)),
+    version: 2,
+    orderby: '-tpm',
+    range: period,
+    start,
+    end,
+    fields: [
+      'team_key_transaction',
+      'transaction',
+      'project',
+      'tpm()',
+      'p50()',
+      'p95()',
+      'failure_rate()',
+      'apdex()',
+      'count_unique(user)',
+      'count_miserable(user)',
+      'user_misery()',
+    ],
+  });
+
+  return (
+    <Table
+      eventView={eventView}
+      projects={projects}
+      organization={organization}
+      location={location}
+      setError={() => {}}
+      summaryConditions={eventView.getQueryWithAdditionalConditions()}
+    />
+  );
+}
+
+export default TeamKeyTransactions;

+ 224 - 0
static/app/views/teamInsights/overview.tsx

@@ -0,0 +1,224 @@
+import {Fragment} from 'react';
+import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+import {LocationDescriptorObject} from 'history';
+import omit from 'lodash/omit';
+import pick from 'lodash/pick';
+import moment from 'moment';
+
+import {Client} from 'app/api';
+import {DateTimeObject} from 'app/components/charts/utils';
+import * as Layout from 'app/components/layouts/thirds';
+import LoadingIndicator from 'app/components/loadingIndicator';
+import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
+import {ChangeData} from 'app/components/organizations/timeRangeSelector';
+import PageTimeRangeSelector from 'app/components/pageTimeRangeSelector';
+import {DEFAULT_RELATIVE_PERIODS, DEFAULT_STATS_PERIOD} from 'app/constants';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {DateString, Organization, RelativePeriod, TeamWithProjects} from 'app/types';
+import withApi from 'app/utils/withApi';
+import withOrganization from 'app/utils/withOrganization';
+import withTeamsForUser from 'app/utils/withTeamsForUser';
+
+import HeaderTabs from './headerTabs';
+import TeamKeyTransactions from './keyTransactions';
+import TeamDropdown from './teamDropdown';
+
+type Props = {
+  api: Client;
+  organization: Organization;
+  teams: TeamWithProjects[];
+  loadingTeams: boolean;
+  error: Error | null;
+} & RouteComponentProps<{orgId: string}, {}>;
+
+const PAGE_QUERY_PARAMS = [
+  'pageStatsPeriod',
+  'pageStart',
+  'pageEnd',
+  'pageUtc',
+  'dataCategory',
+  'transform',
+  'sort',
+  'query',
+  'cursor',
+  'team',
+];
+
+function TeamInsightsOverview({
+  organization,
+  teams,
+  loadingTeams,
+  location,
+  router,
+}: Props) {
+  const query = location?.query ?? {};
+  const currentTeamId = query.team ?? teams[0]?.id;
+  const currentTeam = teams.find(team => team.id === currentTeamId);
+  const projects = currentTeam?.projects ?? [];
+
+  function handleChangeTeam(teamId: string) {
+    setStateOnUrl({team: teamId});
+  }
+
+  function handleUpdateDatetime(datetime: ChangeData): LocationDescriptorObject {
+    const {start, end, relative, utc} = datetime;
+
+    if (start && end) {
+      const parser = utc ? moment.utc : moment;
+
+      return setStateOnUrl({
+        pageStatsPeriod: undefined,
+        pageStart: parser(start).format(),
+        pageEnd: parser(end).format(),
+        pageUtc: utc ?? undefined,
+      });
+    }
+
+    return setStateOnUrl({
+      pageStatsPeriod: (relative as RelativePeriod) || undefined,
+      pageStart: undefined,
+      pageEnd: undefined,
+      pageUtc: undefined,
+    });
+  }
+
+  function setStateOnUrl(nextState: {
+    pageStatsPeriod?: RelativePeriod;
+    pageStart?: DateString;
+    pageEnd?: DateString;
+    pageUtc?: boolean | null;
+    sort?: string;
+    query?: string;
+    cursor?: string;
+    team?: string;
+  }): LocationDescriptorObject {
+    const nextQueryParams = pick(nextState, PAGE_QUERY_PARAMS);
+
+    const nextLocation = {
+      ...location,
+      query: {
+        ...query,
+        ...nextQueryParams,
+      },
+    };
+
+    router.push(nextLocation);
+
+    return nextLocation;
+  }
+
+  function dataDatetime(): DateTimeObject {
+    const {
+      start,
+      end,
+      statsPeriod,
+      utc: utcString,
+    } = getParams(query, {
+      allowEmptyPeriod: true,
+      allowAbsoluteDatetime: true,
+      allowAbsolutePageDatetime: true,
+    });
+
+    if (!statsPeriod && !start && !end) {
+      return {period: DEFAULT_STATS_PERIOD};
+    }
+
+    // Following getParams, statsPeriod will take priority over start/end
+    if (statsPeriod) {
+      return {period: statsPeriod};
+    }
+
+    const utc = utcString === 'true';
+    if (start && end) {
+      return utc
+        ? {
+            start: moment.utc(start).format(),
+            end: moment.utc(end).format(),
+            utc,
+          }
+        : {
+            start: moment(start).utc().format(),
+            end: moment(end).utc().format(),
+            utc,
+          };
+    }
+
+    return {period: DEFAULT_STATS_PERIOD};
+  }
+  const {period, start, end, utc} = dataDatetime();
+
+  return (
+    <Fragment>
+      <BorderlessHeader>
+        <StyledHeaderContent>
+          <StyledLayoutTitle>{t('Team Insights')}</StyledLayoutTitle>
+        </StyledHeaderContent>
+      </BorderlessHeader>
+      <Layout.Header>
+        <HeaderTabs organization={organization} activeTab="teamInsights" />
+      </Layout.Header>
+
+      <Layout.Body>
+        {loadingTeams && <LoadingIndicator />}
+        {!loadingTeams && (
+          <Layout.Main fullWidth>
+            <ControlsWrapper>
+              <TeamDropdown
+                teams={teams}
+                selectedTeam={currentTeamId}
+                handleChangeTeam={handleChangeTeam}
+              />
+              <PageTimeRangeSelector
+                organization={organization}
+                relative={period ?? ''}
+                start={start ?? null}
+                end={end ?? null}
+                utc={utc ?? null}
+                onUpdate={handleUpdateDatetime}
+                relativeOptions={omit(DEFAULT_RELATIVE_PERIODS, ['1h'])}
+              />
+            </ControlsWrapper>
+
+            <SectionTitle>{t('Performance')}</SectionTitle>
+            <TeamKeyTransactions
+              organization={organization}
+              projects={projects}
+              period={period}
+              start={start?.toString()}
+              end={end?.toString()}
+              location={location}
+            />
+          </Layout.Main>
+        )}
+      </Layout.Body>
+    </Fragment>
+  );
+}
+
+export {TeamInsightsOverview};
+export default withApi(withOrganization(withTeamsForUser(TeamInsightsOverview)));
+
+const BorderlessHeader = styled(Layout.Header)`
+  border-bottom: 0;
+`;
+
+const StyledHeaderContent = styled(Layout.HeaderContent)`
+  margin-bottom: 0;
+`;
+
+const StyledLayoutTitle = styled(Layout.Title)`
+  margin-top: ${space(0.5)};
+`;
+
+const ControlsWrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(1)};
+  margin-bottom: ${space(2)};
+`;
+
+const SectionTitle = styled(Layout.Title)`
+  margin-bottom: ${space(1)} !important;
+`;

+ 77 - 0
static/app/views/teamInsights/teamDropdown.tsx

@@ -0,0 +1,77 @@
+import {useState} from 'react';
+import styled from '@emotion/styled';
+
+import Input from 'app/components/forms/input';
+import {t} from 'app/locale';
+import {Team} from 'app/types';
+
+import Filter from './filter';
+
+const ALERT_LIST_QUERY_DEFAULT_TEAMS = ['myteams', 'unassigned'];
+
+type Props = {
+  teams: Team[];
+  selectedTeam: string;
+  handleChangeTeam: (teamId: string) => void;
+};
+
+export function getTeamParams(team?: string | string[]): string[] {
+  if (team === undefined) {
+    return ALERT_LIST_QUERY_DEFAULT_TEAMS;
+  }
+
+  if (team === '') {
+    return [];
+  }
+
+  if (Array.isArray(team)) {
+    return team;
+  }
+
+  return [team];
+}
+
+function TeamDropdown({teams, selectedTeam, handleChangeTeam}: Props) {
+  const [teamFilterSearch, setTeamFilterSearch] = useState<string | undefined>();
+
+  const teamItems = teams.map(({id, name}) => ({
+    label: name,
+    value: id,
+    filtered: teamFilterSearch
+      ? !name.toLowerCase().includes(teamFilterSearch.toLowerCase())
+      : false,
+    checked: selectedTeam === id,
+  }));
+
+  return (
+    <Filter
+      header={
+        <StyledInput
+          autoFocus
+          placeholder={t('Filter by team name')}
+          onClick={event => {
+            event.stopPropagation();
+          }}
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+            setTeamFilterSearch(event.target.value);
+          }}
+          value={teamFilterSearch || ''}
+        />
+      }
+      onFilterChange={handleChangeTeam}
+      dropdownSection={{
+        id: 'teams',
+        label: t('Teams'),
+        items: teamItems,
+      }}
+    />
+  );
+}
+
+export default TeamDropdown;
+
+const StyledInput = styled(Input)`
+  border: none;
+  border-bottom: 1px solid ${p => p.theme.gray200};
+  border-radius: 0;
+`;

Some files were not shown because too many files changed in this diff