diff --git a/.changeset/single-drawer-navigation.md b/.changeset/single-drawer-navigation.md new file mode 100644 index 0000000000..f6f6995e14 --- /dev/null +++ b/.changeset/single-drawer-navigation.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": minor +--- + +Redesign the event side panel into a single right-hand drawer with breadcrumb-stack navigation. Logs, traces, and sessions now navigate in-place (surrounding-context drilldowns, log → trace via a new "View Trace" action, and session → event) instead of stacking layered drawers, with a unified `SidePanelBreadcrumbs` trail that is restorable from the URL. The header gains a metadata row (timestamp, service, duration, status, Copy Trace ID) and the session drawer's close button now closes the entire panel. diff --git a/packages/app/src/NamespaceDetailsSidePanel.tsx b/packages/app/src/NamespaceDetailsSidePanel.tsx index d174eac198..61c0734371 100644 --- a/packages/app/src/NamespaceDetailsSidePanel.tsx +++ b/packages/app/src/NamespaceDetailsSidePanel.tsx @@ -181,8 +181,6 @@ function NamespaceLogs({ - {/* + {/* Search - + */} @@ -194,8 +194,6 @@ function NodeLogs({ @@ -460,7 +458,6 @@ export default function PodDetailsSidePanel({ rowId={rowId} aliasWith={aliasWith} onClose={handleCloseRowSidePanel} - isNestedPanel={true} /> )} diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 2cee84a423..87032e49a4 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -1,33 +1,61 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useQueryState } from 'nuqs'; +import { ErrorBoundary } from 'react-error-boundary'; import { useHotkeys } from 'react-hotkeys-hook'; import { DateRange, SearchCondition, SearchConditionLanguage, + SourceKind, TSessionSource, TTraceSource, } from '@hyperdx/common-utils/dist/types'; -import { ActionIcon, Box, Button, Drawer, Group } from '@mantine/core'; +import { + ActionIcon, + Box, + Button, + Drawer, + Flex, + Group, + Stack, + Text, + Tooltip, +} from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { IconLink, IconX } from '@tabler/icons-react'; +import { + DBRowSidePanelInner, + RowSidePanelContext, +} from '@/components/DBRowSidePanel'; import { DrawerFullWidthToggle, INITIAL_DRAWER_WIDTH_PERCENT, } from '@/components/DrawerUtils'; +import SidePanelBreadcrumbs, { + BreadcrumbItem, +} from '@/components/SidePanelBreadcrumbs'; import useResizable from '@/hooks/useResizable'; +import { WithClause } from '@/hooks/useRowWhere'; import { CLIPBOARD_ERROR_MESSAGE, copyTextToClipboard, } from '@/utils/clipboard'; +import { parseAsJsonEncoded } from '@/utils/queryParsers'; +import { ZIndexContext } from '@/zIndex'; import { Session } from './sessions'; import SessionSubpanel from './SessionSubpanel'; import { formatDistanceToNowStrictShort } from './utils'; -import { ZIndexContext } from './zIndex'; import styles from '@/../styles/LogSidePanel.module.scss'; +type SelectedEvent = { + rowId: string; + aliasWith: WithClause[]; + sessionId: string; +}; + export default function SessionSidePanel({ traceSource, sessionSource, @@ -50,8 +78,27 @@ export default function SessionSidePanel({ onClose: () => void; zIndex?: number; }) { - // Keep track of sub-drawers so we can disable closing this root drawer - const [subDrawerOpen, setSubDrawerOpen] = useState(false); + // A single in-place event view (session → event), persisted to the URL so it + // survives reload and shared links. Deeper navigation (View Trace, + // surrounding context) is handled by `DBRowSidePanelInner`, which persists + // its own state to the shared `sidePanel*` params below. + const [persistedEvent, setSelectedEvent] = useQueryState( + 'sessionPanelEvent', + parseAsJsonEncoded(), + ); + + const selectedEvent = + persistedEvent != null && persistedEvent.sessionId === sessionId + ? persistedEvent + : null; + + // The embedded `DBRowSidePanelInner` owns these shared params. We clear them + // whenever the session-level selection changes so a stale inner stack can't + // leak across events or sessions. + const [, setSourceStackParam] = useQueryState('sidePanelSourceStack'); + const [, setNavStackParam] = useQueryState('sidePanelNavStack'); + const [, setSidePanelTab] = useQueryState('sidePanelTab'); + const [, setStackRootParam] = useQueryState('sidePanelStackRoot'); const { size, setSize, startResize } = useResizable( INITIAL_DRAWER_WIDTH_PERCENT, @@ -61,14 +108,74 @@ export default function SessionSidePanel({ setSize(isFullWidth ? INITIAL_DRAWER_WIDTH_PERCENT : 100); }, [isFullWidth, setSize]); - useHotkeys( - ['esc'], - () => { - onClose(); - }, - { - enabled: subDrawerOpen === false, + const sessionLabel = session?.userEmail || `Anonymous Session ${sessionId}`; + + const clearInnerNavigation = useCallback(() => { + setSourceStackParam(null); + setNavStackParam(null); + setSidePanelTab(null); + setStackRootParam(null); + }, [ + setSourceStackParam, + setNavStackParam, + setSidePanelTab, + setStackRootParam, + ]); + + // Evict a persisted event that belongs to a different session from the URL so + // a stale link can't linger after switching sessions. + useEffect(() => { + if (persistedEvent != null && persistedEvent.sessionId !== sessionId) { + setSelectedEvent(null); + clearInnerNavigation(); + } + }, [persistedEvent, sessionId, setSelectedEvent, clearInnerNavigation]); + + const handleBackToSession = useCallback(() => { + setSelectedEvent(null); + clearInnerNavigation(); + }, [setSelectedEvent, clearInnerNavigation]); + + const handleEventNavigate = useCallback( + (rowId: string, aliasWith: WithClause[]) => { + clearInnerNavigation(); + setSelectedEvent({ rowId, aliasWith, sessionId }); }, + [setSelectedEvent, clearInnerNavigation, sessionId], + ); + + // X / Esc-at-root closes the whole panel and clears the session-panel params. + const handleClose = useCallback(() => { + setSelectedEvent(null); + clearInnerNavigation(); + onClose(); + }, [setSelectedEvent, clearInnerNavigation, onClose]); + + // Back pops to the session root; X always closes the whole panel. + const handleNavigateBack = useCallback(() => { + if (selectedEvent) { + handleBackToSession(); + } else { + handleClose(); + } + }, [selectedEvent, handleBackToSession, handleClose]); + + useHotkeys(['esc'], handleNavigateBack); + + const shareSession = useCallback(async () => { + const ok = await copyTextToClipboard(window.location.href); + notifications.show( + ok + ? { color: 'green', message: 'Copied link to clipboard' } + : { color: 'red', message: CLIPBOARD_ERROR_MESSAGE }, + ); + }, []); + + const breadcrumbs = useMemo( + (): BreadcrumbItem[] => [ + { label: sessionLabel, sourceKind: SourceKind.Session }, + ], + [sessionLabel], ); const timeAgo = useMemo(() => { @@ -81,91 +188,131 @@ export default function SessionSidePanel({ return ( { - if (!subDrawerOpen) { - onClose(); - } - }} + onClose={handleClose} position="right" size={`${size}vw`} withCloseButton={false} + closeOnEscape={false} + lockScroll={false} + withOverlay={false} + trapFocus={false} zIndex={zIndex} styles={{ + content: { + border: 'none', + boxShadow: 'var(--shadow-drawer)', + }, body: { padding: 0, - height: '100vh', + height: '100%', }, }} - className="border-start" >
-
-
-
- {session?.userEmail || `Anonymous Session ${sessionId}`} -
- Last active {timeAgo} ago - · - {Number.parseInt(session?.errorCount ?? '0') > 0 ? ( + {selectedEvent ? ( + + ( + +
+ An error occurred while rendering this event. +
+
+ {error?.error?.message} +
+
+ )} + > + +
+
+ ) : ( + <> + + + + + + + + + + + + + + + Last active {timeAgo} ago + {Number.parseInt(session?.errorCount ?? '0') > 0 && ( <> - + {' · '} + {session?.errorCount} Errors - - · + - ) : null} - {session?.sessionCount} Events -
-
- - + +
+ - - - - - -
-
- {sessionId != null ? ( - - ) : null} +
+ + )}
diff --git a/packages/app/src/SessionSubpanel.tsx b/packages/app/src/SessionSubpanel.tsx index abbca03ab5..c5ef31a75d 100644 --- a/packages/app/src/SessionSubpanel.tsx +++ b/packages/app/src/SessionSubpanel.tsx @@ -17,7 +17,6 @@ import { Button, Divider, Group, - Portal, SegmentedControl, Tooltip, } from '@mantine/core'; @@ -31,9 +30,7 @@ import { IconToggleRight, } from '@tabler/icons-react'; -import DBRowSidePanel from '@/components/DBRowSidePanel'; import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; -import { useZIndex, ZIndexContext } from '@/zIndex'; import SearchWhereInput from './components/SearchInput/SearchWhereInput'; import useFieldExpressionGenerator from './hooks/useFieldExpressionGenerator'; @@ -149,12 +146,12 @@ function useSessionChartConfigs({ type: 'lucene' as const, condition: `${traceSource.resourceAttributesExpression}.rum.sessionId:"${rumSessionId}" AND ( - ${traceSource.eventAttributesExpression}.http.status_code:>299 - OR ${traceSource.eventAttributesExpression}.component:"error" - OR ${traceSource.spanNameExpression}:"routeChange" - OR ${traceSource.spanNameExpression}:"documentLoad" - OR ${traceSource.spanNameExpression}:"intercom.onShow" - OR ScopeName:"custom-action" + ${traceSource.eventAttributesExpression}.http.status_code:>299 + OR ${traceSource.eventAttributesExpression}.component:"error" + OR ${traceSource.spanNameExpression}:"routeChange" + OR ${traceSource.spanNameExpression}:"documentLoad" + OR ${traceSource.spanNameExpression}:"intercom.onShow" + OR ScopeName:"custom-action" )`, }), [traceSource, rumSessionId], @@ -166,13 +163,13 @@ function useSessionChartConfigs({ type: 'lucene' as const, condition: `${traceSource.resourceAttributesExpression}.rum.sessionId:"${rumSessionId}" AND ( - ${traceSource.eventAttributesExpression}.http.status_code:* - OR ${traceSource.eventAttributesExpression}.component:"console" - OR ${traceSource.eventAttributesExpression}.component:"error" - OR ${traceSource.spanNameExpression}:"routeChange" - OR ${traceSource.spanNameExpression}:"documentLoad" - OR ${traceSource.spanNameExpression}:"intercom.onShow" - OR ScopeName:"custom-action" + ${traceSource.eventAttributesExpression}.http.status_code:* + OR ${traceSource.eventAttributesExpression}.component:"console" + OR ${traceSource.eventAttributesExpression}.component:"error" + OR ${traceSource.spanNameExpression}:"routeChange" + OR ${traceSource.spanNameExpression}:"documentLoad" + OR ${traceSource.spanNameExpression}:"intercom.onShow" + OR ScopeName:"custom-action" )`, }; }, [traceSource, rumSessionId, getTraceSourceFieldExpression]); @@ -235,30 +232,25 @@ export default function SessionSubpanel({ traceSource, sessionSource, session, - setDrawerOpen, rumSessionId, start, end, initialTs, whereLanguage = 'lucene', onLanguageChange, + onEventNavigate, }: { traceSource: TTraceSource; sessionSource: TSessionSource; session: { serviceName: string }; - setDrawerOpen: (open: boolean) => void; rumSessionId: string; start: Date; end: Date; initialTs?: number; whereLanguage?: SearchConditionLanguage; onLanguageChange?: (lang: 'sql' | 'lucene') => void; + onEventNavigate?: (rowId: string, aliasWith: WithClause[]) => void; }) { - const contextZIndex = useZIndex(); - - const [rowId, setRowId] = useState(undefined); - const [aliasWith, setAliasWith] = useState([]); - const [tsQuery, setTsQuery] = useQueryState( 'ts', parseAsInteger.withOptions({ history: 'replace' }), @@ -430,11 +422,9 @@ export default function SessionSubpanel({ ); const onSessionEventClick = useCallback( (rowWhere: RowWhereResult) => { - setDrawerOpen(true); - setRowId(rowWhere.where); - setAliasWith(rowWhere.aliasWith); + onEventNavigate?.(rowWhere.where, rowWhere.aliasWith); }, - [setDrawerOpen, setRowId, setAliasWith], + [onEventNavigate], ); const onSessionEventTimeClick = useCallback( (ts: number) => { @@ -453,22 +443,6 @@ export default function SessionSubpanel({ return (
- {rowId != null && traceSource && ( - - - { - setDrawerOpen(false); - setRowId(undefined); - }} - /> - - - )}
({ default: () =>
, })); +jest.mock('@/components/DBRowSidePanel', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + DBRowSidePanelInner: (props: { rowId: string }) => ( +
{props.rowId}
+ ), + RowSidePanelContext: ReactActual.createContext({}), + }; +}); + +// Controllable state for the `sessionPanelEvent` query param so tests can +// simulate a value left in the URL by a previously selected session. +const mockNuqs: { + sessionPanelEvent: unknown; + setSessionPanelEvent: jest.Mock; +} = { + sessionPanelEvent: null, + setSessionPanelEvent: jest.fn(), +}; + +jest.mock('nuqs', () => { + const actual = jest.requireActual('nuqs'); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const noop = () => {}; + return { + ...actual, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useQueryState: (key: string) => + key === 'sessionPanelEvent' + ? [mockNuqs.sessionPanelEvent, mockNuqs.setSessionPanelEvent] + : [null, noop], + }; +}); + jest.mock('../utils/clipboard', () => ({ __esModule: true, CLIPBOARD_ERROR_MESSAGE: @@ -72,6 +107,8 @@ describe('SessionSidePanel - Share Session', () => { beforeEach(() => { mockedCopy.mockReset(); mockedShow.mockReset(); + mockNuqs.sessionPanelEvent = null; + mockNuqs.setSessionPanelEvent.mockReset(); setLocationHref('/sessions?sessionSource=src&from=1&to=2'); }); @@ -116,3 +153,52 @@ describe('SessionSidePanel - Share Session', () => { ); }); }); + +describe('SessionSidePanel - persisted event session ownership', () => { + beforeEach(() => { + mockNuqs.sessionPanelEvent = null; + mockNuqs.setSessionPanelEvent.mockReset(); + setLocationHref('/sessions?sessionSource=src&from=1&to=2'); + }); + + it('renders a persisted event that belongs to the current session', () => { + mockNuqs.sessionPanelEvent = { + rowId: 'row-current', + aliasWith: [], + sessionId: 'sid-abc', + }; + + renderPanel(); + + // The event view is shown and the param is left untouched. + expect(screen.getByTestId('event-view-mock')).toHaveTextContent( + 'row-current', + ); + expect( + screen.queryByTestId('session-subpanel-mock'), + ).not.toBeInTheDocument(); + expect(mockNuqs.setSessionPanelEvent).not.toHaveBeenCalled(); + }); + + it('ignores and evicts a persisted event owned by a different session', async () => { + // Simulates: opened an event in another session, then clicked this session + // card (which only updates sid/sfrom/sto and remounts the drawer). + mockNuqs.sessionPanelEvent = { + rowId: 'row-stale', + aliasWith: [], + sessionId: 'some-other-session', + }; + + renderPanel(); + + // The stale event must NOT render inside the newly selected session; we fall + // back to the session root instead. + expect(screen.getByTestId('session-subpanel-mock')).toBeInTheDocument(); + expect(screen.queryByTestId('event-view-mock')).not.toBeInTheDocument(); + + // ...and the stale param is cleared from the URL. + await waitFor(() => + expect(mockNuqs.setSessionPanelEvent).toHaveBeenCalledWith(null), + ); + }); +}); diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index e73f5a9532..a50a326515 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -1,12 +1,12 @@ import { useCallback, useContext, useMemo, useState } from 'react'; import ms from 'ms'; -import { useQueryState } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { BuilderChartConfigWithDateRange, isLogSource, isTraceSource, + SourceKind, TSource, } from '@hyperdx/common-utils/dist/types'; import { Badge, Flex, Group, SegmentedControl } from '@mantine/core'; @@ -16,16 +16,10 @@ import SearchWhereInput, { getStoredLanguage, } from '@/components/SearchInput/SearchWhereInput'; import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; -import { useSource } from '@/source'; import { formatAttributeClause } from '@/utils'; -import { parseAsStringEncoded } from '@/utils/queryParsers'; import { ROW_DATA_ALIASES } from './DBRowDataPanel'; -import DBRowSidePanel, { RowSidePanelContext } from './DBRowSidePanel'; -import { - BreadcrumbNavigationCallback, - BreadcrumbPath, -} from './DBRowSidePanelHeader'; +import { RowSidePanelContext } from './DBRowSidePanel'; import { DBSqlRowTable } from './DBRowTable'; enum ContextBy { @@ -42,35 +36,13 @@ interface ContextSubpanelProps { dbSqlRowTableConfig: BuilderChartConfigWithDateRange | undefined; rowData: Record; rowId: string | undefined; - breadcrumbPath?: BreadcrumbPath; - onBreadcrumbClick?: BreadcrumbNavigationCallback; -} - -// Custom hook to manage nested panel state -export function useNestedPanelState(isNested?: boolean) { - // Query state (URL-based) for root level - const queryState = { - contextRowId: useQueryState('contextRowId', parseAsStringEncoded), - // Source IDs are MongoDB ObjectIDs (hex strings) and contain no special - // characters, so no encoding is needed here. - contextRowSource: useQueryState('contextRowSource'), - }; - - // Local state for nested levels - const localState = { - contextRowId: useState(null), - contextRowSource: useState(null), - }; - - // Choose which state to use based on nesting level - const activeState = isNested ? localState : queryState; - - return { - contextRowId: activeState.contextRowId[0], - contextRowSource: activeState.contextRowSource[0], - setContextRowId: activeState.contextRowId[1], - setContextRowSource: activeState.contextRowSource[1], - }; + onNavigateToRow?: ( + rowId: string, + aliasWith: WithClause[], + label: string, + sourceKind?: SourceKind, + ) => void; + 'data-testid'?: string; } export default function ContextSubpanel({ @@ -78,8 +50,8 @@ export default function ContextSubpanel({ dbSqlRowTableConfig, rowData, rowId, - breadcrumbPath, - onBreadcrumbClick, + onNavigateToRow, + 'data-testid': dataTestId, }: ContextSubpanelProps) { const QUERY_KEY_PREFIX = 'context'; const origTimestamp = rowData[ROW_DATA_ALIASES.TIMESTAMP]; @@ -100,36 +72,21 @@ export default function ContextSubpanel({ const formWhere = useWatch({ control, name: 'where' }); const [debouncedWhere] = useDebouncedValue(formWhere, 1000); - // State management for nested panels - const isNested = !!breadcrumbPath?.length; - - const { - contextRowId, - contextRowSource, - setContextRowId, - setContextRowSource, - } = useNestedPanelState(isNested); - - const { data: contextRowSidePanelSource } = useSource({ - id: contextRowSource || '', - }); - - const [contextAliasWith, setContextAliasWith] = useState([]); - - const handleContextSidePanelClose = useCallback(() => { - setContextRowId(null); - setContextRowSource(null); - }, [setContextRowId, setContextRowSource]); - const { setChildModalOpen } = useContext(RowSidePanelContext); const handleRowExpandClick = useCallback( - (rowWhere: RowWhereResult) => { - setContextRowId(rowWhere.where); - setContextAliasWith(rowWhere.aliasWith); - setContextRowSource(source.id); + (rowWhere: RowWhereResult, row: Record) => { + const body = row?.[ROW_DATA_ALIASES.BODY]; + const fallback = isTraceSource(source) ? 'Span' : 'Log'; + const label = + typeof body === 'string' && body.length > 0 + ? body + : body != null + ? JSON.stringify(body) + : fallback; + onNavigateToRow?.(rowWhere.where, rowWhere.aliasWith, label, source.kind); }, - [source.id, setContextRowId, setContextRowSource], + [onNavigateToRow, source], ); const date = useMemo(() => new Date(origTimestamp), [origTimestamp]); @@ -144,7 +101,7 @@ export default function ContextSubpanel({ /* Functions to help generate WHERE clause based on which Context the user chooses (All, Host, Node, etc...). - Since we support lucene and sql, we need to format the condition + Since we support lucene and sql, we need to format the condition based on the language */ const { @@ -259,7 +216,12 @@ export default function ContextSubpanel({ return ( <> {config && ( - + )} - {contextRowId && contextRowSidePanelSource && ( - - )} ); } diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index e91abd5e31..1f631148ae 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -11,7 +11,11 @@ import { Box } from '@mantine/core'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { WithClause } from '@/hooks/useRowWhere'; -import { getDisplayedTimestampValueExpression, getEventBody } from '@/source'; +import { + getDisplayedTimestampValueExpression, + getDurationMsExpression, + getEventBody, +} from '@/source'; import { getSelectExpressionsForHighlightedAttributes } from '@/utils/highlightedAttributes'; import { DBRowJsonViewer } from './DBRowJsonViewer'; @@ -28,6 +32,8 @@ export enum ROW_DATA_ALIASES { EVENT_ATTRIBUTES = '__hdx_event_attributes', EVENTS_EXCEPTION_ATTRIBUTES = '__hdx_events_exception_attributes', SPAN_EVENTS = '__hdx_span_events', + DURATION_MS = '__hdx_duration_ms', + SPAN_KIND = '__hdx_span_kind', } export function useRowData({ @@ -154,6 +160,22 @@ export function useRowData({ }, ] : []), + ...(source.kind === SourceKind.Trace && source.durationExpression + ? [ + { + valueExpression: getDurationMsExpression(source), + alias: ROW_DATA_ALIASES.DURATION_MS, + }, + ] + : []), + ...(source.kind === SourceKind.Trace && source.spanKindExpression + ? [ + { + valueExpression: source.spanKindExpression, + alias: ROW_DATA_ALIASES.SPAN_KIND, + }, + ] + : []), ...selectHighlightedRowAttributes, ], where: rowId ?? '0=1', diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 2d8560fff9..1ea1e945dd 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -196,7 +196,6 @@ export function RowOverviewPanel({ void; + isFullWidth?: boolean; + onToggleFullWidth?: () => void; +}) { + const [shortcutsOpen, setShortcutsOpen] = useState(false); + + return ( + <> + + {onToggleFullWidth && ( + + )} + + setShortcutsOpen(true)} + aria-label="Keyboard shortcuts" + > + + + + + {({ copied, copy }) => ( + + + + + + )} + + + + + + + + setShortcutsOpen(false)} + /> + + ); +} + +/** A same-source row navigation (e.g. surrounding-context drilldown). */ +type NavEntry = { + rowId: string; + aliasWith?: WithClause[]; + label: string; + sourceKind?: SourceKind; + originTab?: Tab; +}; + +/** A cross-source navigation frame (e.g. log → trace via "View Trace") */ +type SourceFrame = { + sourceId: string; + rowId: string; + aliasWith?: WithClause[]; + label: string; + sourceKind?: SourceKind; + originTab?: Tab; +}; + +const SPAN_KIND_LABELS: Record = { + '1': 'Internal', + '2': 'Server', + '3': 'Client', + '4': 'Producer', + '5': 'Consumer', + Internal: 'Internal', + Server: 'Server', + Client: 'Client', + Producer: 'Producer', + Consumer: 'Consumer', + SPAN_KIND_INTERNAL: 'Internal', + SPAN_KIND_SERVER: 'Server', + SPAN_KIND_CLIENT: 'Client', + SPAN_KIND_PRODUCER: 'Producer', + SPAN_KIND_CONSUMER: 'Consumer', +}; + type DBRowSidePanelProps = { source: TSource; rowId: string | undefined; aliasWith?: WithClause[]; onClose: () => void; - isNestedPanel?: boolean; - breadcrumbPath?: BreadcrumbPath; - onBreadcrumbClick?: BreadcrumbNavigationCallback; }; -const DBRowSidePanel = ({ - rowId: rowId, - aliasWith, - source, - isNestedPanel = false, - setSubDrawerOpen, +type DBRowSidePanelInnerProps = DBRowSidePanelProps & { + isFullWidth?: boolean; + onToggleFullWidth?: () => void; + persistStacksInUrl?: boolean; + parentBreadcrumbs?: BreadcrumbItem[]; + onNavigateToParent?: () => void; +}; + +const EMPTY_SOURCE_STACK: SourceFrame[] = []; +const EMPTY_NAV_STACK: NavEntry[] = []; + +export const DBRowSidePanelInner = ({ + rowId: initialRowId, + aliasWith: initialAliasWith, + source: rootSource, onClose, - breadcrumbPath, - onBreadcrumbClick, isFullWidth, onToggleFullWidth, -}: DBRowSidePanelProps & { - setSubDrawerOpen: Dispatch>; - isFullWidth?: boolean; - onToggleFullWidth?: () => void; -}) => { + parentBreadcrumbs, + onNavigateToParent, +}: DBRowSidePanelInnerProps) => { + const [sourceStack, setSourceStack] = useQueryState( + 'sidePanelSourceStack', + parseAsJsonEncoded().withDefault(EMPTY_SOURCE_STACK), + ); + + const [navStack, setNavStack] = useQueryState( + 'sidePanelNavStack', + parseAsJsonEncoded().withDefault(EMPTY_NAV_STACK), + ); + + // Records the root row id that the current stacks were built from, so a + // freshly mounted panel can tell a genuine deep-link (stacks belong to + // `initialRowId`) apart from stale leftovers (stacks belong to a previous + // root that survived an unclosed-drawer / cross-table / route-change path). + const [stackRoot, setStackRoot] = useQueryState( + 'sidePanelStackRoot', + parseAsStringEncoded, + ); + + const hasStacks = sourceStack.length > 0 || navStack.length > 0; + const stacksAreStale = + hasStacks && stackRoot != null && stackRoot !== initialRowId; + + const activeSourceFrame = + !stacksAreStale && sourceStack.length > 0 + ? sourceStack[sourceStack.length - 1] + : null; + + // Resolve the leaf source (cross-source navigation). Intermediate frames only + // need their stored label/kind for breadcrumbs. + const { data: activeStackSource } = useSource({ + id: activeSourceFrame?.sourceId ?? null, + }); + const isResolvingSource = + activeSourceFrame != null && activeStackSource == null; + const source = activeStackSource ?? rootSource; + + const baseRowId = activeSourceFrame?.rowId ?? initialRowId; + const baseAliasWith = activeSourceFrame?.aliasWith ?? initialAliasWith; + + const leafNav = + !stacksAreStale && navStack.length > 0 + ? navStack[navStack.length - 1] + : null; + const resolvedRowId = leafNav?.rowId ?? baseRowId; + const resolvedAliasWith = leafNav?.aliasWith ?? baseAliasWith; + + // Avoid querying the (transiently wrong) root source with a leaf row id. + const activeRowId = isResolvingSource ? undefined : resolvedRowId; + const activeAliasWith = isResolvingSource ? undefined : resolvedAliasWith; + const { data: rowData, isLoading: isRowLoading, @@ -125,39 +311,17 @@ const DBRowSidePanel = ({ error: rowError, } = useRowData({ source, - rowId, - aliasWith, + rowId: activeRowId, + aliasWith: activeAliasWith, }); - const { dbSqlRowTableConfig } = useContext(RowSidePanelContext); - - const handleBreadcrumbClick = useCallback( - (targetLevel: number) => { - // Current panel's level in the hierarchy - const currentLevel = breadcrumbPath?.length ?? 0; + const hasActiveStacks = activeSourceFrame != null || leafNav != null; - // The target panel level corresponds to the breadcrumb index: - // - targetLevel 0 = root panel (breadcrumbPath.length = 0) - // - targetLevel 1 = first nested panel (breadcrumbPath.length = 1) - // - etc. - - // If our current level is greater than the target panel level, close this panel - if (currentLevel > targetLevel) { - onClose(); - onBreadcrumbClick?.(targetLevel); - } - // If our current level equals the target panel level, we're the target - don't close - else if (currentLevel === targetLevel) { - // This is the panel the user wants to navigate to - do nothing (stay open) - return; - } - // If our current level is less than target, propagate up (this panel should stay open) - else { - onBreadcrumbClick?.(targetLevel); - } - }, - [breadcrumbPath?.length, onBreadcrumbClick, onClose], - ); + const parentContext = useContext(RowSidePanelContext); + // Nested rows shouldn't inherit the parent table's row config. + const dbSqlRowTableConfig = hasActiveStacks + ? undefined + : parentContext.dbSqlRowTableConfig; const hasOverviewPanel = useMemo(() => { if (isLogSource(source) || isTraceSource(source)) { @@ -178,30 +342,148 @@ const DBRowSidePanel = ({ return false; }, [source]); - const defaultTab = - source.kind === 'trace' - ? Tab.Trace - : hasOverviewPanel - ? Tab.Overview - : Tab.Parsed; + const sourceIsTrace = source.kind === SourceKind.Trace; + + const defaultTab = sourceIsTrace + ? Tab.Trace + : hasOverviewPanel + ? Tab.Overview + : Tab.Parsed; const [queryTab, setQueryTab] = useQueryState( 'sidePanelTab', parseAsStringEnum(Object.values(Tab)).withDefault(defaultTab), ); - const [stateTab, setStateTab] = useState(defaultTab); - // Nested panels can't share the query param or else they'll conflict, so we'll use local state for nested panels - // We'll need to handle this properly eventually... - const tab = isNestedPanel ? stateTab : queryTab; - const setTab = isNestedPanel ? setStateTab : setQueryTab; + const handleNavigateToRow = useCallback( + ( + rowId: string, + aliasWith: WithClause[], + label: string, + sourceKind?: SourceKind, + ) => { + setStackRoot(initialRowId ?? null); + setNavStack(prev => [ + ...prev, + { rowId, aliasWith, label, sourceKind, originTab: queryTab }, + ]); + }, + [setNavStack, setStackRoot, initialRowId, queryTab], + ); + + const handleSourceStackPush = useCallback( + (frame: SourceFrame) => { + // Record which root row owns the growing stack (see handleNavigateToRow). + setStackRoot(initialRowId ?? null); + setSourceStack(prev => [...prev, { ...frame, originTab: queryTab }]); + setNavStack([]); + }, + [setSourceStack, setNavStack, setStackRoot, initialRowId, queryTab], + ); + + const handlePanelBack = useCallback(() => { + if (navStack.length > 0) { + // Returning from a same-source drilldown — restore the tab the user was + // on before drilling into the row we're leaving (e.g. back to + // Surrounding Context after viewing a related row). + const restoreTab = navStack[navStack.length - 1]?.originTab; + setNavStack(prev => prev.slice(0, -1)); + if (restoreTab) { + setQueryTab(restoreTab); + } + } else if (sourceStack.length > 0) { + // Returning from a cross-source drilldown (e.g. "View Trace") — restore + // the tab the user was on before pushing that source. + const restoreTab = sourceStack[sourceStack.length - 1]?.originTab; + setSourceStack(prev => prev.slice(0, -1)); + setNavStack([]); + if (restoreTab) { + setQueryTab(restoreTab); + } + } else if (onNavigateToParent) { + onNavigateToParent(); + } else { + onClose(); + } + }, [ + navStack, + sourceStack, + onNavigateToParent, + onClose, + setNavStack, + setSourceStack, + setQueryTab, + ]); + + // Esc pops one level (nav → source → parent → close), mirroring the Back + // button. Disabled when embedded (e.g. in SessionSidePanel), where the + // parent owns Esc — otherwise both handlers fire on a single keypress. + useHotkeys(['esc'], handlePanelBack, { enabled: !onNavigateToParent }); + + const handleBreadcrumbNavigation = useCallback( + (sourceLevel: number, navLevel: number) => { + // Restore the tab that was active at the level we're returning to: the + // first source frame being dropped, or — if we're staying within the same + // source — the first nav entry being dropped. + let restoreTab: Tab | undefined; + if (sourceLevel < sourceStack.length) { + restoreTab = sourceStack[sourceLevel]?.originTab; + } else if (navLevel < navStack.length) { + restoreTab = navStack[navLevel]?.originTab; + } + setSourceStack(prev => prev.slice(0, sourceLevel)); + setNavStack(prev => prev.slice(0, navLevel)); + if (restoreTab) { + setQueryTab(restoreTab); + } + }, + [setSourceStack, setNavStack, navStack, sourceStack, setQueryTab], + ); + + // Jump to the destination's default tab when a frame is *pushed* onto either + // stack (e.g. push a trace source → Trace). The refs are seeded to the + // *initial* (possibly URL-restored) stack lengths so this does not clobber a + // `sidePanelTab` value present in the URL on first mount. + const prevSourceStackLengthRef = useRef(sourceStack.length); + const prevNavStackLengthRef = useRef(navStack.length); + useEffect(() => { + const sourcePushed = sourceStack.length > prevSourceStackLengthRef.current; + const navPushed = navStack.length > prevNavStackLengthRef.current; + + // Only *pushes* jump to the destination's default tab. Pops and breadcrumb + // truncations are handled by the navigation handlers, which restore the + // tab the user was on before drilling in (see `originTab`). + if (sourcePushed) { + const leafKind = sourceStack[sourceStack.length - 1].sourceKind; + setQueryTab(leafKind === SourceKind.Trace ? Tab.Trace : Tab.Overview); + } else if (navPushed) { + const navDefault = sourceIsTrace + ? Tab.Trace + : hasOverviewPanel + ? Tab.Overview + : Tab.Parsed; + setQueryTab(navDefault); + } + + prevSourceStackLengthRef.current = sourceStack.length; + prevNavStackLengthRef.current = navStack.length; + }, [sourceStack, navStack, setQueryTab, sourceIsTrace, hasOverviewPanel]); + + useEffect(() => { + if (stacksAreStale) { + setSourceStack(null); + setNavStack(null); + setQueryTab(null); + setStackRoot(null); + } + }, [stacksAreStale, setSourceStack, setNavStack, setQueryTab, setStackRoot]); - const displayedTab = tab; + const displayedTab = queryTab; + const setTab = setQueryTab; const normalizedRow = rowData?.data?.[0]; const timestampValue = normalizedRow?.['__hdx_timestamp']; - // TODO: Improve parsing let timestampDate: Date; if (typeof timestampValue === 'number') { timestampDate = new Date(timestampValue * 1000); @@ -218,6 +500,18 @@ const DBRowSidePanel = ({ const severityText: string | undefined = normalizedRow?.['__hdx_severity_text']; + // Capture the root event body once for the root breadcrumb label. + const [initialMainContent, setInitialMainContent] = useState< + string | undefined + >(undefined); + + useEffect(() => { + if (mainContent != null && initialMainContent == null && !hasActiveStacks) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setInitialMainContent(mainContent); + } + }, [mainContent, initialMainContent, hasActiveStacks]); + const highlightedAttributeValues = useMemo(() => { const attributeExpressions: NonNullable< (TLogSource | TTraceSource)['highlightedRowAttributeExpressions'] @@ -266,7 +560,10 @@ const DBRowSidePanel = ({ }, [timestampDate]); const focusDate = timestampDate; - const traceId: string | undefined = normalizedRow?.['__hdx_trace_id']; + // Coerce empty / falsy trace ids to undefined so "View Trace" / Trace ID + // is hidden for logs without trace context. + const traceId: string | undefined = + normalizedRow?.['__hdx_trace_id'] || undefined; const childSourceId = isLogSource(source) ? source.traceSourceId @@ -278,17 +575,61 @@ const DBRowSidePanel = ({ ? source.id : isLogSource(source) ? source.traceSourceId - : isSessionSource(source) + : source.kind === SourceKind.Session ? source.traceSourceId : undefined; const enableServiceMap = traceId && traceSourceId; + const { data: traceSourceData } = useSource({ id: traceSourceId }); + + const spanId = normalizedRow?.['__hdx_span_id']; + const traceIdExpression = + traceSourceData?.kind === SourceKind.Log || + traceSourceData?.kind === SourceKind.Trace + ? traceSourceData.traceIdExpression + : undefined; + const spanIdExpression = + traceSourceData?.kind === SourceKind.Log || + traceSourceData?.kind === SourceKind.Trace + ? traceSourceData.spanIdExpression + : undefined; + + const traceSpanRowId = useMemo(() => { + const clauses: string[] = []; + if (traceIdExpression && traceId) { + clauses.push( + SqlString.format('?=?', [SqlString.raw(traceIdExpression), traceId]), + ); + } + if (spanIdExpression && spanId) { + clauses.push( + SqlString.format('?=?', [SqlString.raw(spanIdExpression), spanId]), + ); + } + return clauses.length > 0 ? clauses.join(' AND ') : undefined; + }, [traceIdExpression, traceId, spanIdExpression, spanId]); + + const handleSessionEventNavigate = useCallback( + (rowId: string, aliasWith: WithClause[]) => { + if (traceSourceData) { + handleSourceStackPush({ + sourceId: traceSourceData.id, + rowId, + aliasWith, + label: mainContent || 'Session Replay', + sourceKind: traceSourceData.kind as SourceKind, + }); + } + }, + [traceSourceData, handleSourceStackPush, mainContent], + ); + const { rumSessionId, rumServiceName } = useSessionId({ sourceId: traceSourceId, traceId, dateRange: oneHourRange, - enabled: rowId != null, + enabled: activeRowId != null, }); const hasK8sContext = useMemo( @@ -306,7 +647,90 @@ const DBRowSidePanel = ({ } }, [normalizedRow]); - if (isRowLoading) { + const durationMs = normalizedRow?.[ROW_DATA_ALIASES.DURATION_MS]; + const spanKind = normalizedRow?.[ROW_DATA_ALIASES.SPAN_KIND]; + const serviceName = normalizedRow?.[ROW_DATA_ALIASES.SERVICE_NAME]; + const statusCode = normalizedRow?.[ROW_DATA_ALIASES.SEVERITY_TEXT]; + + const formattedDuration = useMemo(() => { + if (durationMs == null || isNaN(Number(durationMs))) return undefined; + return renderMs(Number(durationMs)); + }, [durationMs]); + + const spanKindLabel = useMemo(() => { + if (spanKind == null) return undefined; + return SPAN_KIND_LABELS[String(spanKind)] ?? String(spanKind); + }, [spanKind]); + + const allBreadcrumbs = useMemo((): BreadcrumbItem[] => { + const items: BreadcrumbItem[] = []; + + if (parentBreadcrumbs) { + items.push(...parentBreadcrumbs); + } + + // Ignore stale stacks so the trail collapses to the root row in the same + // render the row content does (the URL clear lands a tick later). + const crumbSourceStack = stacksAreStale ? EMPTY_SOURCE_STACK : sourceStack; + const crumbNavStack = stacksAreStale ? EMPTY_NAV_STACK : navStack; + const hasStack = crumbSourceStack.length > 0 || crumbNavStack.length > 0; + const rootLabel = + initialMainContent || + (rootSource.kind === SourceKind.Trace ? 'Trace' : 'Log'); + + if (hasStack) { + items.push({ + label: rootLabel, + sourceKind: rootSource.kind as SourceKind, + onClick: () => handleBreadcrumbNavigation(0, 0), + }); + } + + crumbSourceStack.forEach((entry, i) => { + const isLeafSource = i === crumbSourceStack.length - 1; + const isCurrent = isLeafSource && crumbNavStack.length === 0; + items.push({ + label: entry.label, + sourceKind: entry.sourceKind, + onClick: isCurrent + ? undefined + : () => handleBreadcrumbNavigation(i + 1, 0), + }); + }); + + crumbNavStack.forEach((entry, i) => { + const isCurrent = i === crumbNavStack.length - 1; + items.push({ + label: entry.label, + sourceKind: entry.sourceKind, + onClick: isCurrent + ? undefined + : () => handleBreadcrumbNavigation(crumbSourceStack.length, i + 1), + }); + }); + + if (!hasStack) { + items.push({ + label: mainContent || (sourceIsTrace ? 'Trace' : 'Log'), + sourceKind: source.kind as SourceKind, + }); + } + + return items; + }, [ + sourceStack, + navStack, + stacksAreStale, + rootSource.kind, + sourceIsTrace, + mainContent, + initialMainContent, + source.kind, + handleBreadcrumbNavigation, + parentBreadcrumbs, + ]); + + if (isRowLoading || isResolvingSource) { return
Loading...
; } @@ -321,34 +745,150 @@ const DBRowSidePanel = ({ return
Error loading row data
; } + const showLogTraceActions = !sourceIsTrace && traceId && traceSourceId; + return ( <> - + + + + + + + {!sourceIsTrace && severityText && } + {timestampDate && !isNaN(timestampDate.getTime()) && ( + + ·{' '} + {formatDistanceToNowStrictShort(timestampDate)} ago + + )} + {serviceName && ( + <> + + · + + + + Service + + + {serviceName} + + + + )} + {sourceIsTrace && formattedDuration && ( + <> + + · + + + + Duration + + + {formattedDuration} + + + + )} + {sourceIsTrace && statusCode && ( + <> + + · + + + + Status + + + {statusCode} + + + + )} + {sourceIsTrace && spanKindLabel && ( + + {spanKindLabel} + + )} + {(sourceIsTrace ? traceId : showLogTraceActions) && ( + <> + + · + + + {({ copied, copy }) => ( + + + + + Trace ID + + + + )} + + + )} + {showLogTraceActions && ( + + )} + - {/* */} )} - {displayedTab === Tab.Trace && ( + {displayedTab === Tab.Trace && sourceIsTrace && ( { console.error(err); @@ -474,8 +1022,8 @@ const DBRowSidePanel = ({ )} @@ -495,9 +1043,8 @@ const DBRowSidePanel = ({ source={source} dbSqlRowTableConfig={dbSqlRowTableConfig} rowData={normalizedRow} - rowId={rowId} - breadcrumbPath={breadcrumbPath} - onBreadcrumbClick={handleBreadcrumbClick} + rowId={activeRowId} + onNavigateToRow={handleNavigateToRow} /> )} @@ -519,7 +1066,7 @@ const DBRowSidePanel = ({ (Object.values(Tab)), ); + const [, setSourceStackParam] = useQueryState( + 'sidePanelSourceStack', + parseAsJsonEncoded(), + ); + const [, setNavStackParam] = useQueryState( + 'sidePanelNavStack', + parseAsJsonEncoded(), + ); + const [, setStackRootParam] = useQueryState( + 'sidePanelStackRoot', + parseAsStringEncoded, + ); const { clear: clearTraceWaterfallSearchState } = useWaterfallSearchState({}); const _onClose = useCallback(() => { - // Reset tab to undefined when unmounting, so that when we open the drawer again, it doesn't open to the last tab - // (which might not be valid, ex session replay) - if (!isNestedPanel) { - setQueryTab(null); - } + // Reset the tab and navigation stacks so re-opening the drawer starts fresh. + setSidePanelTab(null); + setSourceStackParam(null); + setNavStackParam(null); + setStackRootParam(null); // Clear waterfall search state on close, so that filters don't // persist when reopening another trace. clearTraceWaterfallSearchState(); onClose(); - }, [setQueryTab, isNestedPanel, onClose, clearTraceWaterfallSearchState]); - - useHotkeys(['esc'], _onClose, { enabled: subDrawerOpen === false }); + }, [ + setSidePanelTab, + setSourceStackParam, + setNavStackParam, + setStackRootParam, + onClose, + clearTraceWaterfallSearchState, + ]); return ( { - if (!subDrawerOpen) { - _onClose(); - } - }} + closeOnEscape={false} + lockScroll={false} + withOverlay={false} + trapFocus={false} + onClose={_onClose} position="right" size={`${size}vw`} styles={{ + content: { + border: 'none', + boxShadow: 'var(--shadow-drawer)', + }, body: { padding: '0', height: '100%', @@ -631,17 +1193,13 @@ export default function DBRowSidePanelErrorBoundary({ )} > -
diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx index b37a1f383a..a253d71fe9 100644 --- a/packages/app/src/components/DBRowSidePanelHeader.tsx +++ b/packages/app/src/components/DBRowSidePanelHeader.tsx @@ -1,135 +1,20 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; -import { - ActionIcon, - Box, - Breadcrumbs, - Button, - Flex, - Group, - Paper, - Text, - Tooltip, - UnstyledButton, -} from '@mantine/core'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Box, Button, Flex, Paper, Text } from '@mantine/core'; import { IconArrowsDiagonal, IconArrowsDiagonalMinimize2, - IconKeyboard, } from '@tabler/icons-react'; -import { KeyboardShortcutsModal } from '@/LogSidePanelElements'; -import { FormatTime } from '@/useFormatTime'; import { useUserPreferences } from '@/useUserPreferences'; -import { formatDistanceToNowStrictShort } from '@/utils'; import AISummarizeButton from './AISummarizeButton'; import { DBHighlightedAttributesList, HighlightedAttribute, } from './DBHighlightedAttributesList'; -import { RowSidePanelContext } from './DBRowSidePanel'; -import { DrawerFullWidthToggle } from './DrawerUtils'; -import LogLevel from './LogLevel'; - -const isValidDate = (date: Date) => 'getTime' in date && !isNaN(date.getTime()); const MAX_MAIN_CONTENT_LENGTH = 2000; -// Types for breadcrumb navigation -export type BreadcrumbEntry = { - label: string; - rowData?: Record; -}; - -export type BreadcrumbPath = BreadcrumbEntry[]; - -// Navigation callback type - called when user wants to navigate to a specific level -export type BreadcrumbNavigationCallback = (targetLevel: number) => void; - -function getBodyTextForBreadcrumb(rowData: Record): string { - const bodyText = (rowData.__hdx_body || '').trim(); - const BREADCRUMB_TOOLTIP_MAX_LENGTH = 200; - const BREADCRUMB_TOOLTIP_TRUNCATED_LENGTH = 197; - - return bodyText.length > BREADCRUMB_TOOLTIP_MAX_LENGTH - ? `${bodyText.substring(0, BREADCRUMB_TOOLTIP_TRUNCATED_LENGTH)}...` - : bodyText; -} - -function BreadcrumbNavigation({ - breadcrumbPath, - onNavigateToLevel, -}: { - breadcrumbPath: BreadcrumbPath; - onNavigateToLevel?: BreadcrumbNavigationCallback; -}) { - const handleBreadcrumbItemClick = useCallback( - (clickedIndex: number) => { - // Navigate to the clicked breadcrumb level - // This will close all panels above this level - onNavigateToLevel?.(clickedIndex); - }, - [onNavigateToLevel], - ); - - const breadcrumbItems = useMemo(() => { - if (breadcrumbPath.length === 0) return []; - - const items = []; - - // Add all previous levels from breadcrumbPath - breadcrumbPath.forEach((crumb, index) => { - const tooltipText = crumb.rowData - ? getBodyTextForBreadcrumb(crumb.rowData) - : ''; - - items.push( - - handleBreadcrumbItemClick(index)} - style={{ textDecoration: 'none' }} - > - - {index === 0 ? 'Original Event' : crumb.label} - - - , - ); - }); - - // Add current level - items.push( - - Selected Event - , - ); - - return items; - }, [breadcrumbPath, handleBreadcrumbItemClick]); - - if (breadcrumbPath.length === 0) return null; - - return ( - - - {breadcrumbItems} - - - ); -} - export default function DBRowSidePanelHeader({ attributes, mainContent = '', @@ -137,32 +22,19 @@ export default function DBRowSidePanelHeader({ // When `true`, the source has a body column configured. An empty value // for that column renders a soft empty-state paper. When `false` (the // source has neither body nor implicit column configured), the body - // paper is suppressed entirely; the header still shows timestamp, - // severity, and highlighted attributes. + // paper is suppressed entirely; the highlighted attributes still render. bodyConfigured = true, - date, severityText, rowData, - breadcrumbPath, - onBreadcrumbClick, - isFullWidth, - onToggleFullWidth, }: { - date: Date; mainContent?: string; mainContentHeader?: string; bodyConfigured?: boolean; attributes?: HighlightedAttribute[]; severityText?: string; rowData?: Record; - breadcrumbPath?: BreadcrumbPath; - onBreadcrumbClick?: BreadcrumbNavigationCallback; - isFullWidth?: boolean; - onToggleFullWidth?: () => void; }) { const [bodyExpanded, setBodyExpanded] = React.useState(false); - const [shortcutsOpen, setShortcutsOpen] = useState(false); - const { generateSearchUrl } = useContext(RowSidePanelContext); const isContentTruncated = mainContent.length > MAX_MAIN_CONTENT_LENGTH; const mainContentDisplayed = React.useMemo( @@ -200,74 +72,19 @@ export default function DBRowSidePanelHeader({ const { expandSidebarHeader } = userPreferences; const maxBoxHeight = 120; - const _generateSearchUrl = useCallback( - (query?: string, queryLanguage?: 'sql' | 'lucene') => { - return ( - generateSearchUrl?.({ - where: query, - whereLanguage: queryLanguage, - }) ?? '/' - ); - }, - [generateSearchUrl], - ); - - const breadCrumbPathWithDefault = useMemo(() => { - return breadcrumbPath ?? []; - }, [breadcrumbPath]); - const attributesWithDefault = useMemo(() => { return attributes ?? []; }, [attributes]); + const toggleExpandSidebarHeader = useCallback(() => { + setUserPreference({ + ...userPreferences, + expandSidebarHeader: !expandSidebarHeader, + }); + }, [expandSidebarHeader, setUserPreference, userPreferences]); + return ( <> - {/* Breadcrumb navigation */} - - - {/* Event timestamp and severity */} - - - {severityText && } - {severityText && isValidDate(date) && ( - - · - - )} - {isValidDate(date) && ( - - ·{' '} - {formatDistanceToNowStrictShort(date)} ago - - )} - - - - setShortcutsOpen(true)} - aria-label="Keyboard shortcuts" - > - - - - {onToggleFullWidth && ( - - )} - - - setShortcutsOpen(false)} - /> {!bodyConfigured ? null : mainContent ? ( - setUserPreference({ - ...userPreferences, - expandSidebarHeader: !expandSidebarHeader, - }) - } + onClick={toggleExpandSidebarHeader} > - {/* TODO: Only show expand button when maxHeight = 120? */} {expandSidebarHeader ? ( ) : ( diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 89b1ae768b..4aa39c5b76 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -1524,7 +1524,10 @@ function DBSqlRowTableComponent({ }: { config: BuilderChartConfigWithDateRange; sourceId?: string; - onRowDetailsClick?: (rowWhere: RowWhereResult) => void; + onRowDetailsClick?: ( + rowWhere: RowWhereResult, + row: Record, + ) => void; highlightedLineId?: string; queryKeyPrefix?: string; enabled?: boolean; @@ -1707,7 +1710,7 @@ function DBSqlRowTableComponent({ const _onRowDetailsClick = useCallback( (row: Record) => { - return onRowDetailsClick?.(getRowWhere(row)); + return onRowDetailsClick?.(getRowWhere(row), row); }, [onRowDetailsClick, getRowWhere], ); diff --git a/packages/app/src/components/DBSessionPanel.tsx b/packages/app/src/components/DBSessionPanel.tsx index b248fd35f3..c9d84a20e2 100644 --- a/packages/app/src/components/DBSessionPanel.tsx +++ b/packages/app/src/components/DBSessionPanel.tsx @@ -4,6 +4,7 @@ import { isTraceSource, SourceKind } from '@hyperdx/common-utils/dist/types'; import { Loader } from '@mantine/core'; import useFieldExpressionGenerator from '@/hooks/useFieldExpressionGenerator'; +import { WithClause } from '@/hooks/useRowWhere'; import SessionSubpanel from '@/SessionSubpanel'; import { useSource } from '@/source'; @@ -93,14 +94,14 @@ export const DBSessionPanel = ({ dateRange, focusDate, serviceName, - setSubDrawerOpen, + onEventNavigate, }: { traceSourceId?: string; rumSessionId: string; dateRange: [Date, Date]; focusDate: Date; serviceName: string; - setSubDrawerOpen: (open: boolean) => void; + onEventNavigate?: (rowId: string, aliasWith: WithClause[]) => void; }) => { const { data: traceSource } = useSource({ id: traceSourceId, @@ -136,7 +137,7 @@ export const DBSessionPanel = ({ session={{ serviceName }} sessionSource={sessionSource} rumSessionId={rumSessionId} - setDrawerOpen={setSubDrawerOpen} + onEventNavigate={onEventNavigate} initialTs={focusDate.getTime()} /> ) : ( diff --git a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx index b197486669..b1a4ab6983 100644 --- a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx +++ b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx @@ -17,14 +17,12 @@ import { useLocalStorage } from '@/utils'; import { parseAsStringEncoded } from '@/utils/queryParsers'; import { ChartErrorStateVariant } from './charts/ChartErrorState'; -import { useNestedPanelState } from './ContextSidePanel'; import { RowDataPanel } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; import DBRowSidePanel, { RowSidePanelContext, RowSidePanelContextProps, } from './DBRowSidePanel'; -import { BreadcrumbEntry } from './DBRowSidePanelHeader'; import { DBRowTableVariant, DBSqlRowTable } from './DBRowTable'; interface Props { @@ -41,8 +39,6 @@ interface Props { queryKeyPrefix?: string; denoiseResults?: boolean; collapseAllRows?: boolean; - isNestedPanel?: boolean; - breadcrumbPath?: BreadcrumbEntry[]; onSortingChange?: (v: SortingState | null) => void; initialSortBy?: SortingState; variant?: DBRowTableVariant; @@ -63,8 +59,6 @@ export default function DBSqlRowTableWithSideBar({ collapseAllRows, isLive, enabled, - isNestedPanel, - breadcrumbPath, onSidebarOpen, onSortingChange, initialSortBy, @@ -78,7 +72,6 @@ export default function DBSqlRowTableWithSideBar({ const [rowId, setRowId] = useQueryState('rowWhere', parseAsStringEncoded); const [rowSource, setRowSource] = useQueryState('rowSource'); const [aliasWith, setAliasWith] = useState([]); - const { setContextRowId, setContextRowSource } = useNestedPanelState(); const onOpenSidebar = useCallback( (rowWhere: RowWhereResult) => { @@ -93,19 +86,7 @@ export default function DBSqlRowTableWithSideBar({ const onCloseSidebar = useCallback(() => { setRowId(null); setRowSource(null); - // When closing the main drawer, clear the nested panel state - // this ensures that re-opening the main drawer will not open the nested panel - if (!isNestedPanel) { - setContextRowId(null); - setContextRowSource(null); - } - }, [ - setRowId, - setRowSource, - isNestedPanel, - setContextRowId, - setContextRowSource, - ]); + }, [setRowId, setRowSource]); const renderRowDetails = useCallback( (r: { id: string; aliasWith?: WithClause[]; [key: string]: unknown }) => { if (!sourceData) { @@ -129,8 +110,6 @@ export default function DBSqlRowTableWithSideBar({ source={sourceData} rowId={rowId ?? undefined} aliasWith={aliasWith} - isNestedPanel={isNestedPanel} - breadcrumbPath={breadcrumbPath} onClose={onCloseSidebar} /> )} diff --git a/packages/app/src/components/PatternSidePanel.tsx b/packages/app/src/components/PatternSidePanel.tsx index 994437c78b..2982ba2b35 100644 --- a/packages/app/src/components/PatternSidePanel.tsx +++ b/packages/app/src/components/PatternSidePanel.tsx @@ -176,8 +176,6 @@ export default function PatternSidePanel({ rowId={selectedRowWhere.where} aliasWith={selectedRowWhere.aliasWith} onClose={handleCloseRowSidePanel} - isNestedPanel={true} - breadcrumbPath={[{ label: 'Pattern Overview' }]} /> )}
diff --git a/packages/app/src/components/Search/DirectTraceSidePanel.tsx b/packages/app/src/components/Search/DirectTraceSidePanel.tsx index 6e38791916..7add520964 100644 --- a/packages/app/src/components/Search/DirectTraceSidePanel.tsx +++ b/packages/app/src/components/Search/DirectTraceSidePanel.tsx @@ -94,6 +94,9 @@ export default function DirectTraceSidePanel({ onClose={onClose} position="right" size="75vw" + lockScroll={false} + withOverlay={false} + trapFocus={false} title={ diff --git a/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx b/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx index 32e8823079..934d8e5f4f 100644 --- a/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx +++ b/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx @@ -86,8 +86,6 @@ export default function SlowestEventsTile({ expressions && ( <> void; +}; + +function SourceIcon({ kind }: { kind?: SourceKind }) { + if (kind === SourceKind.Trace) { + return ; + } + if (kind === SourceKind.Log) { + return ; + } + if (kind === SourceKind.Session) { + return ; + } + return null; +} + +function truncate(text: string, max: number) { + if (text.length <= max) return text; + return `${text.slice(0, max)}…`; +} + +function SidePanelBreadcrumbs({ + items, + onBack, +}: { + items: BreadcrumbItem[]; + onBack: () => void; +}) { + const breadcrumbElements = useMemo(() => { + return items.map((item, i) => { + const isLast = i === items.length - 1; + const isSingle = items.length === 1; + const maxLen = isSingle + ? MAX_LABEL_LENGTH_SINGLE + : isLast + ? MAX_LABEL_LENGTH_CURRENT + : MAX_LABEL_LENGTH_PREVIOUS; + const truncatedLabel = truncate(item.label, maxLen); + const needsTooltip = item.label.length > maxLen; + + const content = ( + + {i === 0 && } + + {truncatedLabel} + + + ); + + const wrapped = needsTooltip ? ( + {item.label}} + position="bottom" + multiline + maw={400} + > + {content} + + ) : ( + content + ); + + if (isLast || !item.onClick) { + return ( + + {wrapped} + + ); + } + + return ( + + + {wrapped} + + + ); + }); + }, [items]); + + return ( + + + + + + + {items.length > 0 ? ( + + {breadcrumbElements} + + ) : null} + + ); +} + +export default memo(SidePanelBreadcrumbs); diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.staleStack.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.staleStack.test.tsx new file mode 100644 index 0000000000..74d6643e5e --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.staleStack.test.tsx @@ -0,0 +1,249 @@ +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import { render } from '@testing-library/react'; + +// Controlled, in-memory replacement for nuqs' useQueryState so each side-panel +// URL param can be seeded and its setter inspected independently. Values are +// the already-parsed shapes the component consumes (arrays / strings), not URL +// strings. Prefixed with `mock` so jest.mock's factory may reference them. +const mockQueryStore: Record = {}; +const mockSetters: Record = {}; + +function seedParam(key: string, value: unknown) { + mockQueryStore[key] = value; +} +function setterFor(key: string) { + if (!mockSetters[key]) mockSetters[key] = jest.fn(); + return mockSetters[key]; +} +function resetQueryState() { + Object.keys(mockQueryStore).forEach(k => delete mockQueryStore[k]); + Object.keys(mockSetters).forEach(k => delete mockSetters[k]); +} + +jest.mock('nuqs', () => { + const actual = jest.requireActual('nuqs'); + return { + ...actual, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + useQueryState: (key: string, parser?: { defaultValue?: unknown }) => { + const hasValue = Object.prototype.hasOwnProperty.call( + mockQueryStore, + key, + ); + const fallback = + parser && 'defaultValue' in parser ? parser.defaultValue : null; + const value = hasValue ? mockQueryStore[key] : (fallback ?? null); + const setters = mockSetters; + if (!setters[key]) setters[key] = jest.fn(); + return [value, setters[key]]; + }, + }; +}); + +// Capture what row the panel actually resolves to. Returning isLoading short- +// circuits the render before the heavy body/header, so those children never +// mount and the `rowId` arg is the single source of truth for "what is shown". +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + useRowData: (args: unknown) => mockUseRowData(args), + ROW_DATA_ALIASES: { + DURATION_MS: '__hdx_duration', + SPAN_KIND: '__hdx_span_kind', + SERVICE_NAME: '__hdx_service_name', + SEVERITY_TEXT: '__hdx_severity_text', + }, + rowHasK8sContext: () => false, + RowDataPanel: () => null, +})); + +jest.mock('@/source', () => ({ + __esModule: true, + getEventBody: () => '__hdx_body', + // A non-null id resolves to a trace source so `isResolvingSource` is false and + // the leaf frame's row id is used; a null id (no active frame) resolves to + // undefined so the panel falls back to the root source. + useSource: ({ id }: { id: string | null }) => + id ? { data: { id, kind: 'trace' } } : { data: undefined }, +})); + +jest.mock('../DBSessionPanel', () => ({ + __esModule: true, + useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }), + DBSessionPanel: () => null, +})); + +jest.mock('@/utils/highlightedAttributes', () => ({ + __esModule: true, + getHighlightedAttributesFromData: () => [], +})); + +// Heavy leaf components / chart deps that the panel imports but never renders +// under isLoading. Stub them so the module graph stays cheap to load. +jest.mock('../DBTracePanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ContextSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../DBInfraPanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../DBRowOverviewPanel', () => ({ + __esModule: true, + RowOverviewPanel: () => null, +})); +jest.mock('../DBRowSidePanelErrorState', () => ({ + __esModule: true, + DBRowSidePanelErrorState: () => null, +})); +jest.mock('../DBRowSidePanelHeader', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../SidePanelBreadcrumbs', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../LogLevel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ServiceMap/ServiceMapSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../TimelineChart/utils', () => ({ + __esModule: true, + renderMs: () => '', +})); +jest.mock('../DrawerUtils', () => ({ + __esModule: true, + DrawerFullWidthToggle: () => null, + INITIAL_DRAWER_WIDTH_PERCENT: 50, +})); +jest.mock('@/LogSidePanelElements', () => ({ + __esModule: true, + KeyboardShortcutsModal: () => null, +})); +jest.mock('@/TabBar', () => ({ __esModule: true, default: () => null })); +jest.mock('@/useFormatTime', () => ({ + __esModule: true, + FormatTime: () => null, +})); + +// NOTE: this import is intentionally placed after the mock factories above, +// which close over the `mock*` helpers declared at the top of this file. +import { DBRowSidePanelInner } from '@/components/DBRowSidePanel'; + +const ROOT_SOURCE = { id: 'log-src', kind: 'log', traceSourceId: 'trace-src' }; + +const TRACE_FRAME = { + sourceId: 'trace-src', + rowId: 'leaf-row', + aliasWith: [], + label: 'Trace', + sourceKind: 'trace', +}; + +function renderInner(rowId: string) { + return render( + + + , + ); +} + +function lastResolvedRowId() { + return mockUseRowData.mock.calls.at(-1)?.[0]?.rowId; +} + +describe('DBRowSidePanelInner — stale stack handling (HDX-3942 "Stale Stack Remains")', () => { + beforeEach(() => { + resetQueryState(); + mockUseRowData.mockReset(); + mockUseRowData.mockReturnValue({ + data: undefined, + isLoading: true, + isSuccess: false, + isError: false, + error: null, + }); + }); + + it('preserves a deep-linked trail when the stack root matches the mounted row', () => { + // A shared URL: stacks + a stackRoot that belongs to the mounted root row. + seedParam('sidePanelSourceStack', [TRACE_FRAME]); + seedParam('sidePanelNavStack', []); + seedParam('sidePanelStackRoot', 'root-row'); + + renderInner('root-row'); + + // Trail is honoured: the drawer resolves the leaf frame's row, not the root. + expect(lastResolvedRowId()).toBe('leaf-row'); + // ...and nothing is cleared. + expect(setterFor('sidePanelSourceStack')).not.toHaveBeenCalled(); + expect(setterFor('sidePanelNavStack')).not.toHaveBeenCalled(); + expect(setterFor('sidePanelStackRoot')).not.toHaveBeenCalled(); + }); + + it('clears a stale trail left in the URL when a different root row mounts', () => { + // Stacks survived an unclosed drawer / cross-table switch: their stackRoot + // points at a *previous* root, but a different row is now mounted. + seedParam('sidePanelSourceStack', [TRACE_FRAME]); + seedParam('sidePanelNavStack', []); + seedParam('sidePanelStackRoot', 'old-root'); + + renderInner('new-root'); + + // The row the user actually opened wins over the stale leaf... + expect(lastResolvedRowId()).toBe('new-root'); + // ...and the stale params are cleared from the URL. + expect(setterFor('sidePanelSourceStack')).toHaveBeenCalledWith(null); + expect(setterFor('sidePanelNavStack')).toHaveBeenCalledWith(null); + expect(setterFor('sidePanelTab')).toHaveBeenCalledWith(null); + expect(setterFor('sidePanelStackRoot')).toHaveBeenCalledWith(null); + }); + + it('clears the trail when the root row changes while the panel stays mounted', () => { + seedParam('sidePanelSourceStack', [TRACE_FRAME]); + seedParam('sidePanelNavStack', []); + seedParam('sidePanelStackRoot', 'root-1'); + + const { rerender } = renderInner('root-1'); + // Same-root render keeps the trail. + expect(lastResolvedRowId()).toBe('leaf-row'); + expect(setterFor('sidePanelSourceStack')).not.toHaveBeenCalled(); + + // The mounted panel now points at a different root row (e.g. the user + // clicked another row in the same table); the trail is stale and dropped. + rerender( + + + , + ); + + expect(lastResolvedRowId()).toBe('root-2'); + expect(setterFor('sidePanelSourceStack')).toHaveBeenCalledWith(null); + expect(setterFor('sidePanelStackRoot')).toHaveBeenCalledWith(null); + }); + + it('does not treat a trail with no recorded root as stale (safe fallback)', () => { + // Legacy / hand-crafted URL: stacks present but no stackRoot token. We must + // not clobber a trail we cannot prove is stale. + seedParam('sidePanelSourceStack', [TRACE_FRAME]); + seedParam('sidePanelNavStack', []); + // sidePanelStackRoot intentionally unseeded (null). + + renderInner('some-row'); + + expect(lastResolvedRowId()).toBe('leaf-row'); + expect(setterFor('sidePanelSourceStack')).not.toHaveBeenCalled(); + expect(setterFor('sidePanelStackRoot')).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx index 939a1f62d9..0bd32b7f3c 100644 --- a/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx +++ b/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx @@ -42,7 +42,6 @@ describe('DBRowSidePanelHeader: body section (HDX-4373)', () => { it('renders the configured body content when bodyConfigured + mainContent are truthy', () => { renderWithMantine( { it('renders the softened empty state when body is configured but value is empty', () => { renderWithMantine( { it('suppresses the body paper entirely when body is not configured on the source', () => { renderWithMantine( { }); it('defaults bodyConfigured to true (back-compat with callers that do not pass it)', () => { - renderWithMantine( - , - ); + renderWithMantine(); expect(screen.queryByText('No body for this event.')).toBeInTheDocument(); }); }); diff --git a/packages/app/styles/LogSidePanel.module.scss b/packages/app/styles/LogSidePanel.module.scss index e899b6b223..6a3e3d1c43 100644 --- a/packages/app/styles/LogSidePanel.module.scss +++ b/packages/app/styles/LogSidePanel.module.scss @@ -4,7 +4,6 @@ font-size: 12px; height: 100%; background: var(--color-bg-body); - box-shadow: 0 0 100px 40px rgb(0 0 0 / 70%); border-left: 1px solid var(--color-border); } diff --git a/packages/app/tests/e2e/features/search/search.spec.ts b/packages/app/tests/e2e/features/search/search.spec.ts index 5ca57692ff..3c282ca1a6 100644 --- a/packages/app/tests/e2e/features/search/search.spec.ts +++ b/packages/app/tests/e2e/features/search/search.spec.ts @@ -53,7 +53,7 @@ test.describe('Search', { tag: '@search' }, () => { }); await test.step('Navigate through all side panel tabs', async () => { - const tabs = ['parsed', 'trace', 'context', 'overview']; + const tabs = ['parsed', 'context', 'overview']; // Use side panel component to navigate tabs for (const tabName of tabs) { @@ -104,7 +104,11 @@ test.describe('Search', { tag: '@search' }, () => { }); await test.step('Navigate through all side panel tabs', async () => { - const tabs = ['trace', 'context', 'infrastructure', 'overview']; + // Logs sources no longer render a Trace tab (it's gated behind + // source.kind === Trace). For a Kubernetes log row the available tabs + // are Surrounding Context (always present), Infrastructure (k8s + // context) and Overview. + const tabs = ['context', 'infrastructure', 'overview']; // Use side panel component with proper waiting for (const tabName of tabs) { @@ -154,10 +158,10 @@ test.describe('Search', { tag: '@search' }, () => { }); await test.step('Infrastructure tab is not offered', async () => { - // The tab bar rendered (an always-present tab is visible), but the gate - // omits Infrastructure because the row carries no k8s correlation - // attributes. - await expect(searchPage.sidePanel.getTab('trace')).toBeVisible(); + // The tab bar rendered (the always-present Column Values tab is + // visible), but the gate omits Infrastructure because the row carries + // no k8s correlation attributes. + await expect(searchPage.sidePanel.getTab('parsed')).toBeVisible(); await expect(searchPage.sidePanel.getTab('infrastructure')).toHaveCount( 0, ); diff --git a/packages/app/tests/e2e/features/sessions.spec.ts b/packages/app/tests/e2e/features/sessions.spec.ts index aec7b09f9a..8386a5c2ef 100644 --- a/packages/app/tests/e2e/features/sessions.spec.ts +++ b/packages/app/tests/e2e/features/sessions.spec.ts @@ -46,8 +46,9 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => { async ({ page }) => { await test.step('Navigate and open a session (with sidePanelTab=replay pre-set in URL to simulate search-page flow)', async () => { // Pre-set sidePanelTab=replay in the URL to simulate navigating from a search page - // row detail panel that had the Session Replay tab open. Without isNestedPanel=true, - // the inner DBRowSidePanel would inherit this URL param and open to the Replay tab again. + // row detail panel that had the Session Replay tab open. Selecting a session event + // must clear this param (clearInnerNavigation) so the in-place event detail opens to + // its default tab instead of re-rendering the Session Replay tab. await page.goto('/search'); await sessionsPage.goto(); await sessionsPage.selectDataSource(); @@ -75,43 +76,47 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => { await sessionsPage.clickFirstSessionEvent(); }); - await test.step('Event detail panel opens alongside the session replay — not replacing it', async () => { - // The row-side-panel must be visible (event detail drawer opened on top of session replay) - await expect(sessionsPage.rowSidePanel).toBeVisible(); - - // The original session replay panel must still be open (not replaced/closed) + await test.step('Event detail opens in place inside the single session drawer — not as a second drawer', async () => { + // Single-drawer navigation: the event detail renders in place inside the + // existing session-side-panel (via DBRowSidePanelInner). No second + // row-side-panel Drawer is opened. await expect(sessionsPage.sessionSidePanel).toBeVisible(); - // Only one session-side-panel must exist (not a second replay opened inside the detail panel) + // Exactly one session drawer exists, and no separate row-side-panel + // drawer was stacked on top of it. await expect(page.getByTestId('session-side-panel')).toHaveCount(1); + await expect(page.getByTestId('row-side-panel')).toHaveCount(0); - // The row-side-panel must show the event detail TabBar (Overview, Trace, etc.) - // This guards against the regression where the inner panel re-opened session replay - // instead of showing event details (which has no TabBar, just the replay player) + // The session drawer now shows the event detail TabBar (Overview, Trace, + // etc.). This guards against the regression where selecting an event + // re-opened session replay instead of showing event details (which has + // no TabBar, just the replay player). await expect( - sessionsPage.rowSidePanel.getByTestId('side-panel-tabs'), + sessionsPage.sessionSidePanel.getByTestId('side-panel-tabs'), ).toBeVisible(); - // The inner panel must NOT be showing the Session Replay tab content. - // Without isNestedPanel=true (broken), the inner DBRowSidePanel reads sidePanelTab=replay - // from the URL (injected above) and renders the Session Replay tab content (side-panel-tab-replay). - // With isNestedPanel=true (fixed), the inner panel uses local state and ignores the URL, - // opening to its default tab (Trace/Overview) instead. + // The event detail must NOT land on the Session Replay tab. Selecting an + // event clears the sidePanelTab=replay param injected above + // (clearInnerNavigation), so the inner panel opens to its default tab + // (Trace/Overview) and the replay tab content is not rendered. await expect( - sessionsPage.rowSidePanel.getByTestId('side-panel-tab-replay'), + sessionsPage.sessionSidePanel.getByTestId('side-panel-tab-replay'), ).toHaveCount(0); }); - await test.step('Clicking the overlay closes the event detail panel but keeps the session replay open', async () => { - // Without the fix, withOverlay={!isNestedPanel} removed the overlay on nested panels, - // so there was nothing to click to close the panel (it had to be ESC only). - // With the fix (withOverlay always true), clicking the Mantine overlay dismisses the inner panel. - await sessionsPage.clickTopmostDrawerOverlay(); + await test.step('Pressing Escape returns to the session event list within the same drawer', async () => { + // SessionSidePanel owns the Esc hotkey: when an event is selected it + // pops back to the session root (handleNavigateBack) instead of closing + // the whole drawer. + await page.keyboard.press('Escape'); - // The event detail panel must close - await expect(sessionsPage.rowSidePanel).toBeHidden(); + // The event detail TabBar collapses back to the session event list. + await expect( + sessionsPage.sessionSidePanel.getByTestId('side-panel-tabs'), + ).toBeHidden(); + await expect(sessionsPage.getSessionEventRows().first()).toBeVisible(); - // The session replay drawer must still be open + // The session drawer itself stays open. await expect(sessionsPage.sessionSidePanel).toBeVisible(); }); }, diff --git a/packages/app/tests/e2e/page-objects/SessionsPage.ts b/packages/app/tests/e2e/page-objects/SessionsPage.ts index 274f563a10..9d13088a63 100644 --- a/packages/app/tests/e2e/page-objects/SessionsPage.ts +++ b/packages/app/tests/e2e/page-objects/SessionsPage.ts @@ -89,25 +89,6 @@ export class SessionsPage { await this.getSessionEventRows().first().click(); } - /** - * Get the row side panel (event detail drawer opened from within session replay) - */ - get rowSidePanel() { - return this.page.getByTestId('row-side-panel'); - } - - /** - * Click the Mantine overlay of the topmost open drawer to close it. - * Mantine renders one overlay per open Drawer. The last one belongs to - * the innermost (topmost) drawer. - */ - async clickTopmostDrawerOverlay() { - // Mantine overlays are siblings of the drawer content inside the portal root. - // Use the last one since the inner panel's overlay is rendered on top. - const overlay = this.page.locator('.mantine-Drawer-overlay').last(); - await overlay.click({ position: { x: 10, y: 10 } }); - } - // Getters for assertions get form() {