diff --git a/.changeset/sharp-snails-warn.md b/.changeset/sharp-snails-warn.md new file mode 100644 index 000000000..673c69cb7 --- /dev/null +++ b/.changeset/sharp-snails-warn.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: Add click + sidepanel support to items within surrounding context diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 541fe13fa..0917c903d 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; import { sq } from 'date-fns/locale'; import ms from 'ms'; +import { parseAsString, useQueryState } from 'nuqs'; import { useForm } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/metadata'; import { @@ -13,8 +14,14 @@ import { useDebouncedValue } from '@mantine/hooks'; import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor'; import WhereLanguageControlled from '@/components/WhereLanguageControlled'; import SearchInputV2 from '@/SearchInputV2'; +import { useSource } from '@/source'; import { formatAttributeClause } from '@/utils'; +import DBRowSidePanel from './DBRowSidePanel'; +import { + BreadcrumbNavigationCallback, + BreadcrumbPath, +} from './DBRowSidePanelHeader'; import { DBSqlRowTable } from './DBRowTable'; enum ContextBy { @@ -31,6 +38,33 @@ interface ContextSubpanelProps { dbSqlRowTableConfig: ChartConfigWithDateRange | undefined; rowData: Record; rowId: string | undefined; + breadcrumbPath?: BreadcrumbPath; + onBreadcrumbClick?: BreadcrumbNavigationCallback; +} + +// Custom hook to manage nested panel state +function useNestedPanelState(isNested: boolean) { + // Query state (URL-based) for root level + const queryState = { + contextRowId: useQueryState('contextRowId', parseAsString), + contextRowSource: useQueryState('contextRowSource', parseAsString), + }; + + // 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], + }; } export default function ContextSubpanel({ @@ -38,6 +72,8 @@ export default function ContextSubpanel({ dbSqlRowTableConfig, rowData, rowId, + breadcrumbPath = [], + onBreadcrumbClick, }: ContextSubpanelProps) { const QUERY_KEY_PREFIX = 'context'; const { Timestamp: origTimestamp } = rowData; @@ -55,6 +91,33 @@ export default function ContextSubpanel({ const formWhere = watch('where'); const [debouncedWhere] = useDebouncedValue(formWhere, 1000); + // State management for nested panels + const isNested = breadcrumbPath.length > 0; + + const { + contextRowId, + contextRowSource, + setContextRowId, + setContextRowSource, + } = useNestedPanelState(isNested); + + const { data: contextRowSidePanelSource } = useSource({ + id: contextRowSource || '', + }); + + const handleContextSidePanelClose = useCallback(() => { + setContextRowId(null); + setContextRowSource(null); + }, [setContextRowId, setContextRowSource]); + + const handleRowExpandClick = useCallback( + (rowWhere: string) => { + setContextRowId(rowWhere); + setContextRowSource(source.id); + }, + [source.id, setContextRowId, setContextRowSource], + ); + const date = useMemo(() => new Date(origTimestamp), [origTimestamp]); const newDateRange = useMemo( @@ -176,88 +239,107 @@ export default function ContextSubpanel({ ]); return ( - config && ( - - - setContextBy(v as ContextBy)} - /> - {contextBy === ContextBy.Custom && ( - - ) - } - luceneInput={ - originalLanguage === 'sql' ? null : ( - - ) - } + <> + {config && ( + + + setContextBy(v as ContextBy)} /> - )} - setRange(Number(value))} - /> - - -
- {contextBy !== ContextBy.All && ( + {contextBy === ContextBy.Custom && ( + + ) + } + luceneInput={ + originalLanguage === 'sql' ? null : ( + + ) + } + /> + )} + setRange(Number(value))} + /> + + +
+ {contextBy !== ContextBy.All && ( + + {contextBy}:{CONTEXT_MAPPING[contextBy].value} + + )} - {contextBy}:{CONTEXT_MAPPING[contextBy].value} + Time range: ±{ms(range / 2)} - )} - - Time range: ±{ms(range / 2)} - +
+
+
+
- -
- -
- - ) + + )} + {contextRowId && contextRowSidePanelSource && ( + + )} + ); } diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 520c49b53..cd24817e4 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -18,7 +18,10 @@ import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; import { Box, Stack } from '@mantine/core'; import { useClickOutside } from '@mantine/hooks'; -import DBRowSidePanelHeader from '@/components/DBRowSidePanelHeader'; +import DBRowSidePanelHeader, { + BreadcrumbNavigationCallback, + BreadcrumbPath, +} from '@/components/DBRowSidePanelHeader'; import useResizable from '@/hooks/useResizable'; import { LogSidePanelKbdShortcuts } from '@/LogSidePanelElements'; import { getEventBody } from '@/source'; @@ -71,6 +74,8 @@ type DBRowSidePanelProps = { rowId: string | undefined; onClose: () => void; isNestedPanel?: boolean; + breadcrumbPath?: BreadcrumbPath; + onBreadcrumbClick?: BreadcrumbNavigationCallback; }; const DBRowSidePanel = ({ @@ -78,6 +83,9 @@ const DBRowSidePanel = ({ source, isNestedPanel = false, setSubDrawerOpen, + onClose, + breadcrumbPath = [], + onBreadcrumbClick, }: DBRowSidePanelProps & { setSubDrawerOpen: Dispatch>; }) => { @@ -92,6 +100,34 @@ const DBRowSidePanel = ({ const { dbSqlRowTableConfig } = useContext(RowSidePanelContext); + const handleBreadcrumbClick = useCallback( + (targetLevel: number) => { + // Current panel's level in the hierarchy + const currentLevel = breadcrumbPath.length; + + // 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 hasOverviewPanel = useMemo(() => { if ( source.resourceAttributesExpression || @@ -230,6 +266,8 @@ const DBRowSidePanel = ({ mainContent={mainContent} mainContentHeader={mainContentColumn} severityText={severityText} + breadcrumbPath={breadcrumbPath} + onBreadcrumbClick={handleBreadcrumbClick} /> {/* )} @@ -405,6 +445,8 @@ export default function DBRowSidePanelErrorBoundary({ rowId, source, isNestedPanel, + breadcrumbPath = [], + onBreadcrumbClick, }: DBRowSidePanelProps) { const contextZIndex = useZIndex(); const drawerZIndex = contextZIndex + 10; @@ -474,7 +516,9 @@ export default function DBRowSidePanelErrorBoundary({ rowId={rowId} onClose={_onClose} isNestedPanel={isNestedPanel} + breadcrumbPath={breadcrumbPath} setSubDrawerOpen={setSubDrawerOpen} + onBreadcrumbClick={onBreadcrumbClick} />
diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx index e7ba19e09..1f36e323b 100644 --- a/packages/app/src/components/DBRowSidePanelHeader.tsx +++ b/packages/app/src/components/DBRowSidePanelHeader.tsx @@ -2,10 +2,20 @@ import React, { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from 'react'; -import { Button, Flex, Paper, Text } from '@mantine/core'; +import { + Box, + Breadcrumbs, + Button, + Flex, + Paper, + Text, + Tooltip, + UnstyledButton, +} from '@mantine/core'; import EventTag from '@/components/EventTag'; import { FormatTime } from '@/useFormatTime'; @@ -19,18 +29,111 @@ 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({ tags, mainContent = '', mainContentHeader, date, severityText, + breadcrumbPath = [], + onBreadcrumbClick, }: { date: Date; mainContent?: string; mainContentHeader?: string; tags: Record; severityText?: string; + breadcrumbPath?: BreadcrumbPath; + onBreadcrumbClick?: BreadcrumbNavigationCallback; }) { const [bodyExpanded, setBodyExpanded] = React.useState(false); const { onPropertyAddClick, generateSearchUrl } = @@ -85,6 +188,13 @@ export default function DBRowSidePanelHeader({ return ( <> + {/* Breadcrumb navigation */} + + + {/* Event timestamp and severity */} {severityText && } {severityText && isValidDate(date) && (