123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630 |
- import {useCallback, useMemo, useRef, useState} from 'react';
- import {type Theme, useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import pick from 'lodash/pick';
- import type {Tag} from 'sentry/actionCreators/events';
- import {Button} from 'sentry/components/button';
- import {IconChevron, IconPanel, IconPin} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {EventTransaction, Organization} from 'sentry/types';
- import type EventView from 'sentry/utils/discover/eventView';
- import {PERFORMANCE_URL_PARAM} from 'sentry/utils/performance/constants';
- import type {
- TraceFullDetailed,
- TraceSplitResults,
- } from 'sentry/utils/performance/quickTrace/types';
- import {useApiQuery, type UseApiQueryResult} from 'sentry/utils/queryClient';
- import type RequestError from 'sentry/utils/requestError/requestError';
- import {useLocation} from 'sentry/utils/useLocation';
- import {
- useResizableDrawer,
- type UseResizableDrawerOptions,
- } from 'sentry/utils/useResizableDrawer';
- import {TraceVitals} from 'sentry/views/performance/newTraceDetails/traceDrawer/tabs/traceVitals';
- import {
- getTraceTabTitle,
- type TraceTabsReducerAction,
- type TraceTabsReducerState,
- } from 'sentry/views/performance/newTraceDetails/traceTabs';
- import type {VirtualizedViewManager} from 'sentry/views/performance/newTraceDetails/virtualizedViewManager';
- import {makeTraceNodeBarColor, type TraceTree, type TraceTreeNode} from '../traceTree';
- import {TraceDetails} from './tabs/trace';
- import {TraceTreeNodeDetails} from './tabs/traceTreeNodeDetails';
- const MIN_TRACE_DRAWER_DIMENSTIONS: [number, number] = [480, 27];
- type TraceDrawerProps = {
- drawerSize: number;
- layout: 'drawer bottom' | 'drawer left' | 'drawer right';
- manager: VirtualizedViewManager;
- onDrawerResize: (size: number) => void;
- onLayoutChange: (layout: 'drawer bottom' | 'drawer left' | 'drawer right') => void;
- organization: Organization;
- rootEventResults: UseApiQueryResult<EventTransaction, RequestError>;
- scrollToNode: (node: TraceTreeNode<TraceTree.NodeValue>) => void;
- tabs: TraceTabsReducerState;
- tabsDispatch: React.Dispatch<TraceTabsReducerAction>;
- trace: TraceTree;
- traceEventView: EventView;
- traces: TraceSplitResults<TraceFullDetailed> | null;
- };
- function getUninitializedDrawerSize(layout: TraceDrawerProps['layout']): number {
- return layout === 'drawer bottom'
- ? // 36 of the screen height
- Math.max(window.innerHeight * 0.36, MIN_TRACE_DRAWER_DIMENSTIONS[1])
- : // Half the screen minus the ~sidebar width
- Math.max(window.innerWidth * 0.5 - 220, MIN_TRACE_DRAWER_DIMENSTIONS[0]);
- }
- function getDrawerInitialSize(
- layout: TraceDrawerProps['layout'],
- drawerSize: number
- ): number {
- return drawerSize > 0 ? drawerSize : getUninitializedDrawerSize(layout);
- }
- function getDrawerMinSize(layout: TraceDrawerProps['layout']): number {
- return layout === 'drawer left' || layout === 'drawer right'
- ? MIN_TRACE_DRAWER_DIMENSTIONS[0]
- : MIN_TRACE_DRAWER_DIMENSTIONS[1];
- }
- const LAYOUT_STORAGE: Partial<Record<TraceDrawerProps['layout'], number>> = {};
- export function TraceDrawer(props: TraceDrawerProps) {
- const theme = useTheme();
- const location = useLocation();
- const panelRef = useRef<HTMLDivElement>(null);
- const [minimized, setMinimized] = useState(
- Math.round(props.drawerSize) <= getDrawerMinSize(props.layout)
- );
- // The /events-facets/ endpoint used to fetch tags for the trace tab is slow. Therefore,
- // we try to prefetch the tags as soon as the drawer loads, hoping that the tags will be loaded
- // by the time the user clicks on the trace tab. Also prevents the tags from being refetched.
- const urlParams = pick(location.query, [
- ...Object.values(PERFORMANCE_URL_PARAM),
- 'cursor',
- ]);
- const tagsQueryResults = useApiQuery<Tag[]>(
- [
- `/organizations/${props.organization.slug}/events-facets/`,
- {
- query: {
- ...urlParams,
- ...props.traceEventView.getFacetsAPIPayload(location),
- cursor: undefined,
- },
- },
- ],
- {
- staleTime: Infinity,
- }
- );
- const minimizedRef = useRef(minimized);
- minimizedRef.current = minimized;
- const lastNonMinimizedSizeRef =
- useRef<Partial<Record<TraceDrawerProps['layout'], number>>>(LAYOUT_STORAGE);
- const lastLayoutRef = useRef<TraceDrawerProps['layout']>(props.layout);
- const onDrawerResize = props.onDrawerResize;
- const onResize = useCallback(
- (newSize: number, _oldSize: number | undefined, userEvent: boolean) => {
- const min = getDrawerMinSize(props.layout);
- // Round to nearest pixel value
- newSize = Math.round(newSize);
- if (userEvent) {
- lastNonMinimizedSizeRef.current[props.layout] = newSize;
- // Track the value to see if the user manually minimized or expanded the drawer
- if (!minimizedRef.current && newSize <= min) {
- setMinimized(true);
- } else if (minimizedRef.current && newSize > min) {
- setMinimized(false);
- }
- } else {
- setMinimized(newSize <= min);
- }
- onDrawerResize(newSize);
- lastLayoutRef.current = props.layout;
- if (!panelRef.current) {
- return;
- }
- if (props.layout === 'drawer left' || props.layout === 'drawer right') {
- panelRef.current.style.width = `${newSize}px`;
- panelRef.current.style.height = `100%`;
- } else {
- panelRef.current.style.height = `${newSize}px`;
- panelRef.current.style.width = `100%`;
- }
- // @TODO This can visual delays as the rest of the view uses a resize observer
- // to adjust the layout. We should force a sync layout update + draw here to fix that.
- },
- [onDrawerResize, props.layout]
- );
- const resizableDrawerOptions: UseResizableDrawerOptions = useMemo(() => {
- return {
- initialSize:
- lastNonMinimizedSizeRef[props.layout] ??
- getDrawerInitialSize(props.layout, props.drawerSize),
- min: getDrawerMinSize(props.layout),
- onResize,
- direction:
- props.layout === 'drawer left'
- ? 'left'
- : props.layout === 'drawer right'
- ? 'right'
- : 'up',
- };
- }, [props.layout, onResize, props.drawerSize]);
- const {onMouseDown, setSize} = useResizableDrawer(resizableDrawerOptions);
- const onMinimize = useCallback(
- (value: boolean) => {
- minimizedRef.current = value;
- setMinimized(value);
- if (!value) {
- const lastUserSize = lastNonMinimizedSizeRef.current[props.layout];
- const min = getDrawerMinSize(props.layout);
- // If the user has minimized the drawer to the minimum size, we should
- // restore the drawer to the initial size instead of the last user size.
- if (lastUserSize === undefined || lastUserSize <= min) {
- setSize(getUninitializedDrawerSize(props.layout), true);
- return;
- }
- setSize(lastUserSize, false);
- return;
- }
- setSize(
- props.layout === 'drawer bottom'
- ? MIN_TRACE_DRAWER_DIMENSTIONS[1]
- : MIN_TRACE_DRAWER_DIMENSTIONS[0],
- false
- );
- },
- [props.layout, setSize]
- );
- const onParentClick = useCallback(
- (node: TraceTreeNode<TraceTree.NodeValue>) => {
- props.scrollToNode(node);
- props.tabsDispatch({
- type: 'activate tab',
- payload: node,
- pin_previous: true,
- });
- },
- [props]
- );
- return (
- <PanelWrapper layout={props.layout} ref={panelRef}>
- <ResizeableHandle layout={props.layout} onMouseDown={onMouseDown} />
- <TabsLayout
- hasIndicators={
- // Syncs the height of the tabs with the trace indicators
- props.trace.indicators.length > 0 && props.layout !== 'drawer bottom'
- }
- >
- <TabActions>
- <TabLayoutControlItem>
- <TabIconButton
- size="xs"
- active={minimized}
- onClick={() => onMinimize(!minimized)}
- aria-label={t('Minimize')}
- icon={
- <SmallerChevronIcon
- size="sm"
- isCircled
- direction={
- props.layout === 'drawer bottom'
- ? minimized
- ? 'up'
- : 'down'
- : props.layout === 'drawer left'
- ? minimized
- ? 'right'
- : 'left'
- : minimized
- ? 'left'
- : 'right'
- }
- />
- }
- />
- </TabLayoutControlItem>
- </TabActions>
- <TabsContainer
- style={{
- gridTemplateColumns: `repeat(${props.tabs.tabs.length + (props.tabs.last_clicked ? 1 : 0)}, minmax(0, min-content))`,
- }}
- >
- {props.tabs.tabs.map((n, i) => {
- return (
- <TraceDrawerTab
- key={i}
- tab={n}
- index={i}
- theme={theme}
- tabs={props.tabs}
- tabsDispatch={props.tabsDispatch}
- scrollToNode={props.scrollToNode}
- trace={props.trace}
- pinned
- />
- );
- })}
- {props.tabs.last_clicked ? (
- <TraceDrawerTab
- pinned={false}
- key="last-clicked"
- tab={props.tabs.last_clicked}
- index={props.tabs.tabs.length}
- theme={theme}
- tabs={props.tabs}
- tabsDispatch={props.tabsDispatch}
- scrollToNode={props.scrollToNode}
- trace={props.trace}
- />
- ) : null}
- </TabsContainer>
- <TabActions>
- <TabLayoutControlItem>
- <TabIconButton
- active={props.layout === 'drawer left'}
- onClick={() => props.onLayoutChange('drawer left')}
- size="xs"
- aria-label={t('Drawer left')}
- icon={<IconPanel size="xs" direction="left" />}
- />
- </TabLayoutControlItem>
- <TabLayoutControlItem>
- <TabIconButton
- active={props.layout === 'drawer bottom'}
- onClick={() => props.onLayoutChange('drawer bottom')}
- size="xs"
- aria-label={t('Drawer bottom')}
- icon={<IconPanel size="xs" direction="down" />}
- />
- </TabLayoutControlItem>
- <TabLayoutControlItem>
- <TabIconButton
- active={props.layout === 'drawer right'}
- onClick={() => props.onLayoutChange('drawer right')}
- size="xs"
- aria-label={t('Drawer right')}
- icon={<IconPanel size="xs" direction="right" />}
- />
- </TabLayoutControlItem>
- </TabActions>
- </TabsLayout>
- <Content layout={props.layout}>
- <ContentWrapper>
- {props.tabs.current ? (
- props.tabs.current.node === 'trace' ? (
- <TraceDetails
- tagsQueryResults={tagsQueryResults}
- tree={props.trace}
- node={props.trace.root.children[0]}
- rootEventResults={props.rootEventResults}
- organization={props.organization}
- traces={props.traces}
- traceEventView={props.traceEventView}
- />
- ) : props.tabs.current.node === 'vitals' ? (
- <TraceVitals trace={props.trace} />
- ) : (
- <TraceTreeNodeDetails
- node={props.tabs.current.node}
- organization={props.organization}
- manager={props.manager}
- scrollToNode={props.scrollToNode}
- onParentClick={onParentClick}
- />
- )
- ) : null}
- </ContentWrapper>
- </Content>
- </PanelWrapper>
- );
- }
- interface TraceDrawerTabProps {
- index: number;
- pinned: boolean;
- scrollToNode: (node: TraceTreeNode<TraceTree.NodeValue>) => void;
- tab: TraceTabsReducerState['tabs'][number];
- tabs: TraceTabsReducerState;
- tabsDispatch: React.Dispatch<TraceTabsReducerAction>;
- theme: Theme;
- trace: TraceTree;
- }
- function TraceDrawerTab(props: TraceDrawerTabProps) {
- const node = props.tab.node;
- if (typeof node === 'string') {
- const root = props.trace.root.children[0];
- return (
- <Tab
- className={typeof props.tab.node === 'string' ? 'Static' : ''}
- active={props.tab === props.tabs.current}
- onClick={() => {
- if (props.tab.node !== 'vitals') {
- props.scrollToNode(root);
- }
- props.tabsDispatch({type: 'activate tab', payload: props.index});
- }}
- >
- {/* A trace is technically an entry in the list, so it has a color */}
- {props.tab.node === 'trace' ? null : (
- <TabButtonIndicator
- backgroundColor={makeTraceNodeBarColor(props.theme, root)}
- />
- )}
- <TabButton>{props.tab.label ?? props.tab.node}</TabButton>
- </Tab>
- );
- }
- return (
- <Tab
- active={props.tab === props.tabs.current}
- onClick={() => {
- props.scrollToNode(node);
- props.tabsDispatch({type: 'activate tab', payload: props.index});
- }}
- >
- <TabButtonIndicator backgroundColor={makeTraceNodeBarColor(props.theme, node)} />
- <TabButton>{getTraceTabTitle(node)}</TabButton>
- <TabPinButton
- pinned={props.pinned}
- onClick={e => {
- e.stopPropagation();
- props.pinned
- ? props.tabsDispatch({type: 'unpin tab', payload: props.index})
- : props.tabsDispatch({type: 'pin tab'});
- }}
- />
- </Tab>
- );
- }
- const ResizeableHandle = styled('div')<{
- layout: 'drawer bottom' | 'drawer left' | 'drawer right';
- }>`
- width: ${p => (p.layout === 'drawer bottom' ? '100%' : '12px')};
- height: ${p => (p.layout === 'drawer bottom' ? '12px' : '100%')};
- cursor: ${p => (p.layout === 'drawer bottom' ? 'ns-resize' : 'ew-resize')};
- position: absolute;
- top: ${p => (p.layout === 'drawer bottom' ? '-6px' : 0)};
- left: ${p =>
- p.layout === 'drawer bottom' ? 0 : p.layout === 'drawer right' ? '-6px' : 'initial'};
- right: ${p => (p.layout === 'drawer left' ? '-6px' : 0)};
- z-index: 1;
- `;
- const PanelWrapper = styled('div')<{
- layout: 'drawer bottom' | 'drawer left' | 'drawer right';
- }>`
- grid-area: drawer;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- width: 100%;
- border-top: ${p =>
- p.layout === 'drawer bottom' ? `1px solid ${p.theme.border}` : 'none'};
- border-left: ${p =>
- p.layout === 'drawer right' ? `1px solid ${p.theme.border}` : 'none'};
- border-right: ${p =>
- p.layout === 'drawer left' ? `1px solid ${p.theme.border}` : 'none'};
- bottom: 0;
- right: 0;
- position: relative;
- background: ${p => p.theme.background};
- color: ${p => p.theme.textColor};
- text-align: left;
- z-index: 10;
- `;
- const SmallerChevronIcon = styled(IconChevron)`
- width: 13px;
- height: 13px;
- transition: none;
- `;
- const TabsLayout = styled('div')<{hasIndicators: boolean}>`
- display: grid;
- grid-template-columns: auto 1fr auto;
- border-bottom: 1px solid ${p => p.theme.border};
- background-color: ${p => p.theme.backgroundSecondary};
- height: ${p => (p.hasIndicators ? '44px' : '26px')};
- padding-left: ${space(0.25)};
- padding-right: ${space(0.5)};
- `;
- const TabsContainer = styled('ul')`
- display: grid;
- list-style-type: none;
- width: 100%;
- align-items: center;
- justify-content: left;
- gap: ${space(0.5)};
- padding-left: 0;
- margin-bottom: 0;
- `;
- const TabActions = styled('ul')`
- list-style-type: none;
- padding-left: 0;
- margin-bottom: 0;
- flex: none;
- button {
- padding: 0 ${space(0.5)};
- }
- `;
- const TabLayoutControlItem = styled('li')`
- display: inline-block;
- margin: 0;
- `;
- const Tab = styled('li')<{active: boolean}>`
- height: 100%;
- border-top: 2px solid transparent;
- display: flex;
- align-items: center;
- border-bottom: 2px solid ${p => (p.active ? p.theme.blue400 : 'transparent')};
- padding: 0 ${space(0.25)};
- position: relative;
- &.Static + li:not(.Static) {
- margin-left: ${space(2)};
- &:after {
- display: block;
- content: '';
- position: absolute;
- left: -14px;
- top: 50%;
- transform: translateY(-50%);
- height: 72%;
- width: 1px;
- background-color: ${p => p.theme.border};
- }
- }
- &:hover {
- border-bottom: 2px solid ${p => (p.active ? p.theme.blue400 : p.theme.blue200)};
- button:last-child {
- transition: all 0.3s ease-in-out 500ms;
- transform: scale(1);
- opacity: 1;
- }
- }
- `;
- const TabButtonIndicator = styled('div')<{backgroundColor: string}>`
- width: 12px;
- height: 12px;
- min-width: 12px;
- border-radius: 2px;
- margin-right: ${space(0.25)};
- background-color: ${p => p.backgroundColor};
- `;
- const TabButton = styled('button')`
- height: 100%;
- border: none;
- max-width: 66ch;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- border-radius: 0;
- margin: 0;
- padding: 0 ${space(0.25)};
- font-size: ${p => p.theme.fontSizeSmall};
- color: ${p => p.theme.textColor};
- background: transparent;
- `;
- const Content = styled('div')<{layout: 'drawer bottom' | 'drawer left' | 'drawer right'}>`
- position: relative;
- overflow: auto;
- padding: ${space(1)};
- flex: 1;
- td {
- max-width: 100% !important;
- }
- ${p =>
- p.layout !== 'drawer bottom' &&
- `
- table {
- display: flex;
- }
- tbody {
- flex: 1;
- }
- tr {
- display: grid;
- }
- `}
- `;
- const TabIconButton = styled(Button)<{active: boolean}>`
- border: none;
- background-color: transparent;
- box-shadow: none;
- transition: none !important;
- opacity: ${p => (p.active ? 0.7 : 0.5)};
- &:not(:last-child) {
- margin-right: ${space(1)};
- }
- &:hover {
- border: none;
- background-color: transparent;
- box-shadow: none;
- opacity: ${p => (p.active ? 0.6 : 0.5)};
- }
- `;
- function TabPinButton(props: {
- pinned: boolean;
- onClick?: (e: React.MouseEvent<HTMLElement>) => void;
- }) {
- return (
- <PinButton size="zero" onClick={props.onClick}>
- <StyledIconPin size="xs" isSolid={props.pinned} />
- </PinButton>
- );
- }
- const PinButton = styled(Button)`
- padding: ${space(0.5)};
- margin: 0;
- background-color: transparent;
- border: none;
- &:hover {
- background-color: transparent;
- }
- `;
- const StyledIconPin = styled(IconPin)`
- background-color: transparent;
- border: none;
- `;
- const ContentWrapper = styled('div')`
- inset: ${space(1)};
- position: absolute;
- `;
|