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; scrollToNode: (node: TraceTreeNode) => void; tabs: TraceTabsReducerState; tabsDispatch: React.Dispatch; trace: TraceTree; traceEventView: EventView; traces: TraceSplitResults | 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> = {}; export function TraceDrawer(props: TraceDrawerProps) { const theme = useTheme(); const location = useLocation(); const panelRef = useRef(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( [ `/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>>(LAYOUT_STORAGE); const lastLayoutRef = useRef(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) => { props.scrollToNode(node); props.tabsDispatch({ type: 'activate tab', payload: node, pin_previous: true, }); }, [props] ); return ( 0 && props.layout !== 'drawer bottom' } > onMinimize(!minimized)} aria-label={t('Minimize')} icon={ } /> {props.tabs.tabs.map((n, i) => { return ( ); })} {props.tabs.last_clicked ? ( ) : null} props.onLayoutChange('drawer left')} size="xs" aria-label={t('Drawer left')} icon={} /> props.onLayoutChange('drawer bottom')} size="xs" aria-label={t('Drawer bottom')} icon={} /> props.onLayoutChange('drawer right')} size="xs" aria-label={t('Drawer right')} icon={} /> {props.tabs.current ? ( props.tabs.current.node === 'trace' ? ( ) : props.tabs.current.node === 'vitals' ? ( ) : ( ) ) : null} ); } interface TraceDrawerTabProps { index: number; pinned: boolean; scrollToNode: (node: TraceTreeNode) => void; tab: TraceTabsReducerState['tabs'][number]; tabs: TraceTabsReducerState; tabsDispatch: React.Dispatch; 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 ( { 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 : ( )} {props.tab.label ?? props.tab.node} ); } return ( { props.scrollToNode(node); props.tabsDispatch({type: 'activate tab', payload: props.index}); }} > {getTraceTabTitle(node)} { e.stopPropagation(); props.pinned ? props.tabsDispatch({type: 'unpin tab', payload: props.index}) : props.tabsDispatch({type: 'pin 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) => void; }) { return ( ); } 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; `;