Browse Source

feat(profiling): Add breadcrumbs to flamegraph (#33863)

This adds in the breadcrumbs onto the flamegraph to allow navigation back to the
landing page.
Tony Xiao 2 years ago
parent
commit
626ed6f22e

+ 84 - 0
static/app/components/profiling/breadcrumb.tsx

@@ -0,0 +1,84 @@
+import {useMemo} from 'react';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import Breadcrumbs, {Crumb} from 'sentry/components/breadcrumbs';
+import {t} from 'sentry/locale';
+import {Organization} from 'sentry/types';
+import {
+  flamegraphRouteWithQuery,
+  profilingRouteWithQuery,
+} from 'sentry/views/profiling/routes';
+
+type ProfilingTrail = {
+  type: 'profiling';
+};
+
+type FlamegraphTrail = {
+  payload: {
+    interactionName: string;
+    profileId: string;
+    projectSlug: string;
+  };
+  type: 'flamegraph';
+};
+
+type Trail = ProfilingTrail | FlamegraphTrail;
+
+interface BreadcrumbProps {
+  location: Location;
+  organization: Organization;
+  trails: Trail[];
+}
+
+function Breadcrumb({location, organization, trails}: BreadcrumbProps) {
+  const crumbs = useMemo(
+    () => trails.map(trail => trailToCrumb(trail, {location, organization})),
+    [location, organization, trails]
+  );
+  return <StyledBreadcrumbs crumbs={crumbs} />;
+}
+
+function trailToCrumb(
+  trail: Trail,
+  {
+    location,
+    organization,
+  }: {
+    location: Location;
+    organization: Organization;
+  }
+): Crumb {
+  switch (trail.type) {
+    case 'profiling': {
+      return {
+        to: profilingRouteWithQuery({
+          location,
+          orgSlug: organization.slug,
+        }),
+        label: t('Profiling'),
+        preservePageFilters: true,
+      };
+    }
+    case 'flamegraph': {
+      return {
+        to: flamegraphRouteWithQuery({
+          location,
+          orgSlug: organization.slug,
+          projectSlug: trail.payload.projectSlug,
+          profileId: trail.payload.profileId,
+        }),
+        label: trail.payload.interactionName,
+        preservePageFilters: true,
+      };
+    }
+    default:
+      throw new Error(`Unknown breadcrumb type: ${JSON.stringify(trail)}`);
+  }
+}
+
+const StyledBreadcrumbs = styled(Breadcrumbs)`
+  padding: 0;
+`;
+
+export {Breadcrumb};

+ 44 - 20
static/app/views/profiling/flamegraph.tsx

@@ -1,9 +1,12 @@
 import {Fragment, useEffect, useState} from 'react';
 import styled from '@emotion/styled';
+import {Location} from 'history';
 
 import {Client} from 'sentry/api';
 import Alert from 'sentry/components/alert';
+import * as Layout from 'sentry/components/layouts/thirds';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {Breadcrumb} from 'sentry/components/profiling/breadcrumb';
 import {Flamegraph} from 'sentry/components/profiling/flamegraph';
 import {FullScreenFlamegraphContainer} from 'sentry/components/profiling/fullScreenFlamegraphContainer';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
@@ -79,26 +82,47 @@ function FlamegraphView(props: FlamegraphViewProps): React.ReactElement {
 
   return (
     <SentryDocumentTitle title={t('Profiling')} orgSlug={organization.slug}>
-      <FlamegraphStateProvider>
-        <FlamegraphThemeProvider>
-          <FullScreenFlamegraphContainer>
-            {requestState === 'errored' ? (
-              <Alert type="error" showIcon>
-                {t('Unable to load profiles')}
-              </Alert>
-            ) : requestState === 'loading' ? (
-              <Fragment>
-                <Flamegraph profiles={LoadingGroup} />
-                <LoadingIndicatorContainer>
-                  <LoadingIndicator />
-                </LoadingIndicatorContainer>
-              </Fragment>
-            ) : requestState === 'resolved' && profiles ? (
-              <Flamegraph profiles={profiles} />
-            ) : null}
-          </FullScreenFlamegraphContainer>
-        </FlamegraphThemeProvider>
-      </FlamegraphStateProvider>
+      <Fragment>
+        <Layout.Header>
+          <Layout.HeaderContent>
+            <Breadcrumb
+              location={props.location}
+              organization={organization}
+              trails={[
+                {type: 'profiling'},
+                {
+                  type: 'flamegraph',
+                  payload: {
+                    interactionName: profiles?.name ?? '',
+                    profileId: props.params.eventId ?? '',
+                    projectSlug: props.params.projectId ?? '',
+                  },
+                },
+              ]}
+            />
+          </Layout.HeaderContent>
+        </Layout.Header>
+        <FlamegraphStateProvider>
+          <FlamegraphThemeProvider>
+            <FullScreenFlamegraphContainer>
+              {requestState === 'errored' ? (
+                <Alert type="error" showIcon>
+                  {t('Unable to load profiles')}
+                </Alert>
+              ) : requestState === 'loading' ? (
+                <Fragment>
+                  <Flamegraph profiles={LoadingGroup} />
+                  <LoadingIndicatorContainer>
+                    <LoadingIndicator />
+                  </LoadingIndicatorContainer>
+                </Fragment>
+              ) : requestState === 'resolved' && profiles ? (
+                <Flamegraph profiles={profiles} />
+              ) : null}
+            </FullScreenFlamegraphContainer>
+          </FlamegraphThemeProvider>
+        </FlamegraphStateProvider>
+      </Fragment>
     </SentryDocumentTitle>
   );
 }

+ 5 - 2
static/app/views/profiling/landing/profilingTableCell.tsx

@@ -7,10 +7,11 @@ import {t} from 'sentry/locale';
 import {defined} from 'sentry/utils';
 import {Container, NumberContainer} from 'sentry/utils/discover/styles';
 import {getShortEventId} from 'sentry/utils/events';
+import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
 
-import {generateFlamegraphRoute} from '../routes';
+import {flamegraphRouteWithQuery} from '../routes';
 
 import {TableColumn, TableDataRow} from './types';
 
@@ -24,6 +25,7 @@ interface ProfilingTableCellProps {
 function ProfilingTableCell({column, dataRow}: ProfilingTableCellProps) {
   const organization = useOrganization();
   const {projects} = useProjects();
+  const location = useLocation();
 
   const project =
     column.key === 'id' || column.key === 'project_id'
@@ -39,7 +41,8 @@ function ProfilingTableCell({column, dataRow}: ProfilingTableCellProps) {
         return <Container>{t('n/a')}</Container>;
       }
 
-      const target = generateFlamegraphRoute({
+      const target = flamegraphRouteWithQuery({
+        location,
         orgSlug: organization.slug,
         projectSlug: project.slug,
         profileId: dataRow.id,

+ 47 - 1
static/app/views/profiling/routes.tsx

@@ -1,6 +1,16 @@
+import {Location, LocationDescriptor} from 'history';
+
 import {Organization, Project} from 'sentry/types';
 import {Trace} from 'sentry/types/profiling/core';
 
+export function generateProfilingRoute({
+  orgSlug,
+}: {
+  orgSlug: Organization['slug'];
+}): string {
+  return `/organizations/${orgSlug}/profiling/`;
+}
+
 export function generateFlamegraphRoute({
   orgSlug,
   projectSlug,
@@ -9,6 +19,42 @@ export function generateFlamegraphRoute({
   orgSlug: Organization['slug'];
   profileId: Trace['id'];
   projectSlug: Project['slug'];
-}) {
+}): string {
   return `/organizations/${orgSlug}/profiling/flamegraph/${projectSlug}/${profileId}/`;
 }
+
+export function profilingRouteWithQuery({
+  location,
+  orgSlug,
+}: {
+  location: Location;
+  orgSlug: Organization['slug'];
+}): LocationDescriptor {
+  const pathname = generateProfilingRoute({orgSlug});
+  return {
+    pathname,
+    query: {
+      ...location.query,
+    },
+  };
+}
+
+export function flamegraphRouteWithQuery({
+  location,
+  orgSlug,
+  projectSlug,
+  profileId,
+}: {
+  location: Location;
+  orgSlug: Organization['slug'];
+  profileId: Trace['id'];
+  projectSlug: Project['slug'];
+}): LocationDescriptor {
+  const pathname = generateFlamegraphRoute({orgSlug, projectSlug, profileId});
+  return {
+    pathname,
+    query: {
+      ...location.query,
+    },
+  };
+}

+ 40 - 0
tests/js/spec/components/profiling/breadcrumb.spec.tsx

@@ -0,0 +1,40 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {Breadcrumb} from 'sentry/components/profiling/breadcrumb';
+
+describe('Breadcrumb', function () {
+  let location, organization;
+
+  beforeEach(function () {
+    location = TestStubs.location();
+    const context = initializeOrg();
+    organization = context.organization;
+  });
+
+  it('renders the profiling link', function () {
+    render(
+      <Breadcrumb
+        location={location}
+        organization={organization}
+        trails={[
+          {type: 'profiling'},
+          {
+            type: 'flamegraph',
+            payload: {
+              interactionName: 'foo',
+              profileId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+              projectSlug: 'bar',
+            },
+          },
+        ]}
+      />
+    );
+    expect(screen.getByText('Profiling')).toBeInTheDocument();
+    expect(screen.getByRole('link', {name: 'Profiling'})).toHaveAttribute(
+      'href',
+      `/organizations/${organization.slug}/profiling/`
+    );
+    expect(screen.getByText('foo')).toBeInTheDocument();
+  });
+});