From d3d2f45cb9f5d70267e962c245aab235cc796b8c Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Wed, 9 Jul 2025 23:09:35 -0400 Subject: [PATCH 1/6] feat: Allow surrounding context items to be clicked + show breadcrumbs --- .../app/src/components/ContextSidePanel.tsx | 235 ++++++++++++------ .../app/src/components/DBRowSidePanel.tsx | 17 +- .../src/components/DBRowSidePanelHeader.tsx | 82 +++++- 3 files changed, 252 insertions(+), 82 deletions(-) diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 541fe13fa..1c49743a6 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,10 @@ 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 { DBSqlRowTable } from './DBRowTable'; enum ContextBy { @@ -31,6 +34,7 @@ interface ContextSubpanelProps { dbSqlRowTableConfig: ChartConfigWithDateRange | undefined; rowData: Record; rowId: string | undefined; + breadcrumbPath?: Array<{ label: string; rowData?: Record }>; } export default function ContextSubpanel({ @@ -38,6 +42,7 @@ export default function ContextSubpanel({ dbSqlRowTableConfig, rowData, rowId, + breadcrumbPath = [], }: ContextSubpanelProps) { const QUERY_KEY_PREFIX = 'context'; const { Timestamp: origTimestamp } = rowData; @@ -55,6 +60,56 @@ export default function ContextSubpanel({ const formWhere = watch('where'); const [debouncedWhere] = useDebouncedValue(formWhere, 1000); + // State management for nested panels + const isNested = breadcrumbPath.length > 0; + + // For root level, use query state (URL-based) + const [queryContextRowId, setQueryContextRowId] = useQueryState( + 'contextRowId', + parseAsString, + ); + const [queryContextRowSource, setQueryContextRowSource] = useQueryState( + 'contextRowSource', + parseAsString, + ); + + // For nested levels, use local state + const [localContextRowId, setLocalContextRowId] = useState( + null, + ); + const [localContextRowSource, setLocalContextRowSource] = useState< + string | null + >(null); + + // Choose which state to use based on nesting level + const contextRowId = isNested ? localContextRowId : queryContextRowId; + const contextRowSource = isNested + ? localContextRowSource + : queryContextRowSource; + const setContextRowId = isNested + ? setLocalContextRowId + : setQueryContextRowId; + const setContextRowSource = isNested + ? setLocalContextRowSource + : setQueryContextRowSource; + + 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( @@ -175,89 +230,109 @@ export default function ContextSubpanel({ contextBy, ]); - return ( - config && ( - - - setContextBy(v as ContextBy)} - /> - {contextBy === ContextBy.Custom && ( - - ) - } - luceneInput={ - originalLanguage === 'sql' ? null : ( - - ) - } - /> - )} - setRange(Number(value))} + const contextComponent = config && ( + + + setContextBy(v as ContextBy)} + /> + {contextBy === ContextBy.Custom && ( + + ) + } + luceneInput={ + originalLanguage === 'sql' ? null : ( + + ) + } /> - - -
- {contextBy !== ContextBy.All && ( - - {contextBy}:{CONTEXT_MAPPING[contextBy].value} - - )} + )} + setRange(Number(value))} + /> + + +
+ {contextBy !== ContextBy.All && ( - Time range: ±{ms(range / 2)} + {contextBy}:{CONTEXT_MAPPING[contextBy].value} -
-
-
- + )} + + Time range: ±{ms(range / 2)} +
- - ) + +
+ +
+ + ); + + return ( + <> + {contextComponent} + {contextRowId && contextRowSidePanelSource && ( + + )} + ); } diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 520c49b53..d9054d00a 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -18,7 +18,9 @@ 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, { + BreadcrumbPath, +} from '@/components/DBRowSidePanelHeader'; import useResizable from '@/hooks/useResizable'; import { LogSidePanelKbdShortcuts } from '@/LogSidePanelElements'; import { getEventBody } from '@/source'; @@ -66,11 +68,17 @@ enum Tab { Infrastructure = 'infrastructure', } +export type { + BreadcrumbEntry, + BreadcrumbPath, +} from '@/components/DBRowSidePanelHeader'; + type DBRowSidePanelProps = { source: TSource; rowId: string | undefined; onClose: () => void; isNestedPanel?: boolean; + breadcrumbPath?: BreadcrumbPath; }; const DBRowSidePanel = ({ @@ -78,6 +86,8 @@ const DBRowSidePanel = ({ source, isNestedPanel = false, setSubDrawerOpen, + onClose, + breadcrumbPath = [], }: DBRowSidePanelProps & { setSubDrawerOpen: Dispatch>; }) => { @@ -230,6 +240,8 @@ const DBRowSidePanel = ({ mainContent={mainContent} mainContentHeader={mainContentColumn} severityText={severityText} + breadcrumbPath={breadcrumbPath} + onBreadcrumbClick={onClose} /> {/* )} @@ -405,6 +418,7 @@ export default function DBRowSidePanelErrorBoundary({ rowId, source, isNestedPanel, + breadcrumbPath = [], }: DBRowSidePanelProps) { const contextZIndex = useZIndex(); const drawerZIndex = contextZIndex + 10; @@ -474,6 +488,7 @@ export default function DBRowSidePanelErrorBoundary({ rowId={rowId} onClose={_onClose} isNestedPanel={isNestedPanel} + breadcrumbPath={breadcrumbPath} setSubDrawerOpen={setSubDrawerOpen} /> diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx index e7ba19e09..b5f1e2900 100644 --- a/packages/app/src/components/DBRowSidePanelHeader.tsx +++ b/packages/app/src/components/DBRowSidePanelHeader.tsx @@ -5,7 +5,15 @@ import React, { useRef, useState, } from 'react'; -import { Button, Flex, Paper, Text } from '@mantine/core'; +import { + Box, + Breadcrumbs, + Button, + Flex, + Paper, + Text, + UnstyledButton, +} from '@mantine/core'; import EventTag from '@/components/EventTag'; import { FormatTime } from '@/useFormatTime'; @@ -19,18 +27,44 @@ 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[]; + +// Function to extract clean body text for hover tooltip +const getBodyTextForTooltip = (rowData: Record): string => { + const body = rowData.__hdx_body || rowData.Body || rowData.body || ''; + const bodyText = typeof body === 'string' ? body : String(body); + const cleanBody = bodyText.trim(); + + if (!cleanBody) return ''; + + // Truncate to ~200 characters for tooltip readability + return cleanBody.length > 200 + ? `${cleanBody.substring(0, 197)}...` + : cleanBody; +}; + export default function DBRowSidePanelHeader({ tags, mainContent = '', mainContentHeader, date, severityText, + breadcrumbPath = [], + onBreadcrumbClick, }: { date: Date; mainContent?: string; mainContentHeader?: string; tags: Record; severityText?: string; + breadcrumbPath?: BreadcrumbPath; + onBreadcrumbClick?: () => void; }) { const [bodyExpanded, setBodyExpanded] = React.useState(false); const { onPropertyAddClick, generateSearchUrl } = @@ -83,8 +117,54 @@ export default function DBRowSidePanelHeader({ [generateSearchUrl], ); + // Create breadcrumb navigation + const breadcrumbItems = React.useMemo(() => { + if (breadcrumbPath.length === 0) return []; + + const items = []; + + // Add all previous levels from breadcrumbPath + breadcrumbPath.forEach((crumb, index) => { + const tooltipText = crumb.rowData + ? getBodyTextForTooltip(crumb.rowData) + : ''; + + items.push( + onBreadcrumbClick?.()} + style={{ textDecoration: 'none' }} + title={tooltipText} + > + + {crumb.label} + + , + ); + }); + + // Add current level + items.push( + + Event Details + , + ); + + return items; + }, [breadcrumbPath, onBreadcrumbClick]); + return ( <> + {/* Breadcrumb navigation */} + {breadcrumbPath.length > 0 && ( + + + {breadcrumbItems} + + + )} + + {/* Event timestamp and severity */} {severityText && } {severityText && isValidDate(date) && ( From 48e03ed54882552c04ab43c33fdab223ae49ce1a Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 10 Jul 2025 09:36:16 -0400 Subject: [PATCH 2/6] cleaner breadcumb component --- .../src/components/DBRowSidePanelHeader.tsx | 117 ++++++++++-------- 1 file changed, 63 insertions(+), 54 deletions(-) diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx index b5f1e2900..d33486c3c 100644 --- a/packages/app/src/components/DBRowSidePanelHeader.tsx +++ b/packages/app/src/components/DBRowSidePanelHeader.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -35,19 +36,66 @@ export type BreadcrumbEntry = { export type BreadcrumbPath = BreadcrumbEntry[]; -// Function to extract clean body text for hover tooltip -const getBodyTextForTooltip = (rowData: Record): string => { - const body = rowData.__hdx_body || rowData.Body || rowData.body || ''; - const bodyText = typeof body === 'string' ? body : String(body); - const cleanBody = bodyText.trim(); +function BreadcrumbNavigation({ + breadcrumbPath, + onBreadcrumbClick, +}: { + breadcrumbPath: BreadcrumbPath; + onBreadcrumbClick?: () => void; +}) { + // Function to extract clean body text for hover tooltip + const getBodyTextForBreadcrumb = (rowData: Record): string => { + const bodyText = (rowData.__hdx_body || '').trim(); + + return bodyText.length > 200 + ? `${bodyText.substring(0, 197)}...` + : bodyText; + }; + const breadcrumbItems = useMemo(() => { + if (breadcrumbPath.length === 0) return []; - if (!cleanBody) return ''; + const items = []; - // Truncate to ~200 characters for tooltip readability - return cleanBody.length > 200 - ? `${cleanBody.substring(0, 197)}...` - : cleanBody; -}; + // Add all previous levels from breadcrumbPath + breadcrumbPath.forEach((crumb, index) => { + const tooltipText = crumb.rowData + ? getBodyTextForBreadcrumb(crumb.rowData) + : ''; + + items.push( + onBreadcrumbClick?.()} + style={{ textDecoration: 'none' }} + title={tooltipText} + > + + {crumb.label} + + , + ); + }); + + // Add current level + items.push( + + Event Details + , + ); + + return items; + }, [breadcrumbPath, onBreadcrumbClick]); + + if (breadcrumbPath.length === 0) return null; + + return ( + + + {breadcrumbItems} + + + ); +} export default function DBRowSidePanelHeader({ tags, @@ -117,52 +165,13 @@ export default function DBRowSidePanelHeader({ [generateSearchUrl], ); - // Create breadcrumb navigation - const breadcrumbItems = React.useMemo(() => { - if (breadcrumbPath.length === 0) return []; - - const items = []; - - // Add all previous levels from breadcrumbPath - breadcrumbPath.forEach((crumb, index) => { - const tooltipText = crumb.rowData - ? getBodyTextForTooltip(crumb.rowData) - : ''; - - items.push( - onBreadcrumbClick?.()} - style={{ textDecoration: 'none' }} - title={tooltipText} - > - - {crumb.label} - - , - ); - }); - - // Add current level - items.push( - - Event Details - , - ); - - return items; - }, [breadcrumbPath, onBreadcrumbClick]); - return ( <> {/* Breadcrumb navigation */} - {breadcrumbPath.length > 0 && ( - - - {breadcrumbItems} - - - )} + {/* Event timestamp and severity */} From 5c1c255b6c95cc7582d552c0342b08c9d94f1d89 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 10 Jul 2025 09:51:13 -0400 Subject: [PATCH 3/6] Moar cleanup/refactor --- .../app/src/components/ContextSidePanel.tsx | 62 ++++++++++--------- .../app/src/components/DBRowSidePanel.tsx | 5 -- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 1c49743a6..e9ba337fe 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -37,6 +37,31 @@ interface ContextSubpanelProps { breadcrumbPath?: Array<{ label: string; rowData?: Record }>; } +// 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({ source, dbSqlRowTableConfig, @@ -63,35 +88,12 @@ export default function ContextSubpanel({ // State management for nested panels const isNested = breadcrumbPath.length > 0; - // For root level, use query state (URL-based) - const [queryContextRowId, setQueryContextRowId] = useQueryState( - 'contextRowId', - parseAsString, - ); - const [queryContextRowSource, setQueryContextRowSource] = useQueryState( - 'contextRowSource', - parseAsString, - ); - - // For nested levels, use local state - const [localContextRowId, setLocalContextRowId] = useState( - null, - ); - const [localContextRowSource, setLocalContextRowSource] = useState< - string | null - >(null); - - // Choose which state to use based on nesting level - const contextRowId = isNested ? localContextRowId : queryContextRowId; - const contextRowSource = isNested - ? localContextRowSource - : queryContextRowSource; - const setContextRowId = isNested - ? setLocalContextRowId - : setQueryContextRowId; - const setContextRowSource = isNested - ? setLocalContextRowSource - : setQueryContextRowSource; + const { + contextRowId, + contextRowSource, + setContextRowId, + setContextRowSource, + } = useNestedPanelState(isNested); const { data: contextRowSidePanelSource } = useSource({ id: contextRowSource || '', @@ -327,7 +329,7 @@ export default function ContextSubpanel({ breadcrumbPath={[ ...breadcrumbPath, { - label: `Surrounding Context (${new Date().toLocaleTimeString()})`, + label: `Surrounding Context`, rowData, }, ]} diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index d9054d00a..a331e19ae 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -68,11 +68,6 @@ enum Tab { Infrastructure = 'infrastructure', } -export type { - BreadcrumbEntry, - BreadcrumbPath, -} from '@/components/DBRowSidePanelHeader'; - type DBRowSidePanelProps = { source: TSource; rowId: string | undefined; From 8c3bf60d5b039c024abde185a555eb38c64a2c84 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 10 Jul 2025 09:59:20 -0400 Subject: [PATCH 4/6] Add changeset --- .changeset/sharp-snails-warn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sharp-snails-warn.md 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 From 096b3b58256c1cc932f944ca4eb6b59aef992750 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 10 Jul 2025 10:03:28 -0400 Subject: [PATCH 5/6] Add real tooltip component --- .../src/components/DBRowSidePanelHeader.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx index d33486c3c..efddb8fde 100644 --- a/packages/app/src/components/DBRowSidePanelHeader.tsx +++ b/packages/app/src/components/DBRowSidePanelHeader.tsx @@ -13,6 +13,7 @@ import { Flex, Paper, Text, + Tooltip, UnstyledButton, } from '@mantine/core'; @@ -63,16 +64,22 @@ function BreadcrumbNavigation({ : ''; items.push( - onBreadcrumbClick?.()} - style={{ textDecoration: 'none' }} - title={tooltipText} + label={tooltipText} + disabled={!tooltipText} + position="bottom" + withArrow > - - {crumb.label} - - , + onBreadcrumbClick?.()} + style={{ textDecoration: 'none' }} + > + + {crumb.label} + + + , ); }); From 1a70cf7a8155453d987b0e330e744bb79e6a6ea5 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 10 Jul 2025 10:17:09 -0400 Subject: [PATCH 6/6] cleanup --- .../app/src/components/ContextSidePanel.tsx | 3 ++- .../src/components/DBRowSidePanelHeader.tsx | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index e9ba337fe..99667e464 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -18,6 +18,7 @@ import { useSource } from '@/source'; import { formatAttributeClause } from '@/utils'; import DBRowSidePanel from './DBRowSidePanel'; +import { BreadcrumbPath } from './DBRowSidePanelHeader'; import { DBSqlRowTable } from './DBRowTable'; enum ContextBy { @@ -34,7 +35,7 @@ interface ContextSubpanelProps { dbSqlRowTableConfig: ChartConfigWithDateRange | undefined; rowData: Record; rowId: string | undefined; - breadcrumbPath?: Array<{ label: string; rowData?: Record }>; + breadcrumbPath?: BreadcrumbPath; } // Custom hook to manage nested panel state diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx index efddb8fde..eccadf27f 100644 --- a/packages/app/src/components/DBRowSidePanelHeader.tsx +++ b/packages/app/src/components/DBRowSidePanelHeader.tsx @@ -37,6 +37,16 @@ export type BreadcrumbEntry = { export type BreadcrumbPath = BreadcrumbEntry[]; +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, onBreadcrumbClick, @@ -44,14 +54,6 @@ function BreadcrumbNavigation({ breadcrumbPath: BreadcrumbPath; onBreadcrumbClick?: () => void; }) { - // Function to extract clean body text for hover tooltip - const getBodyTextForBreadcrumb = (rowData: Record): string => { - const bodyText = (rowData.__hdx_body || '').trim(); - - return bodyText.length > 200 - ? `${bodyText.substring(0, 197)}...` - : bodyText; - }; const breadcrumbItems = useMemo(() => { if (breadcrumbPath.length === 0) return [];