Skip to content

Commit 86115fa

Browse files
authored
feat: Add click + sidepanel support to items within surrounding context (hyperdxio#989)
Fixes HDX-1951
1 parent 229b511 commit 86115fa

4 files changed

Lines changed: 322 additions & 81 deletions

File tree

.changeset/sharp-snails-warn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
feat: Add click + sidepanel support to items within surrounding context

packages/app/src/components/ContextSidePanel.tsx

Lines changed: 161 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useMemo, useState } from 'react';
22
import { sq } from 'date-fns/locale';
33
import ms from 'ms';
4+
import { parseAsString, useQueryState } from 'nuqs';
45
import { useForm } from 'react-hook-form';
56
import { tcFromSource } from '@hyperdx/common-utils/dist/metadata';
67
import {
@@ -13,8 +14,14 @@ import { useDebouncedValue } from '@mantine/hooks';
1314
import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
1415
import WhereLanguageControlled from '@/components/WhereLanguageControlled';
1516
import SearchInputV2 from '@/SearchInputV2';
17+
import { useSource } from '@/source';
1618
import { formatAttributeClause } from '@/utils';
1719

20+
import DBRowSidePanel from './DBRowSidePanel';
21+
import {
22+
BreadcrumbNavigationCallback,
23+
BreadcrumbPath,
24+
} from './DBRowSidePanelHeader';
1825
import { DBSqlRowTable } from './DBRowTable';
1926

2027
enum ContextBy {
@@ -31,13 +38,42 @@ interface ContextSubpanelProps {
3138
dbSqlRowTableConfig: ChartConfigWithDateRange | undefined;
3239
rowData: Record<string, any>;
3340
rowId: string | undefined;
41+
breadcrumbPath?: BreadcrumbPath;
42+
onBreadcrumbClick?: BreadcrumbNavigationCallback;
43+
}
44+
45+
// Custom hook to manage nested panel state
46+
function useNestedPanelState(isNested: boolean) {
47+
// Query state (URL-based) for root level
48+
const queryState = {
49+
contextRowId: useQueryState('contextRowId', parseAsString),
50+
contextRowSource: useQueryState('contextRowSource', parseAsString),
51+
};
52+
53+
// Local state for nested levels
54+
const localState = {
55+
contextRowId: useState<string | null>(null),
56+
contextRowSource: useState<string | null>(null),
57+
};
58+
59+
// Choose which state to use based on nesting level
60+
const activeState = isNested ? localState : queryState;
61+
62+
return {
63+
contextRowId: activeState.contextRowId[0],
64+
contextRowSource: activeState.contextRowSource[0],
65+
setContextRowId: activeState.contextRowId[1],
66+
setContextRowSource: activeState.contextRowSource[1],
67+
};
3468
}
3569

3670
export default function ContextSubpanel({
3771
source,
3872
dbSqlRowTableConfig,
3973
rowData,
4074
rowId,
75+
breadcrumbPath = [],
76+
onBreadcrumbClick,
4177
}: ContextSubpanelProps) {
4278
const QUERY_KEY_PREFIX = 'context';
4379
const { Timestamp: origTimestamp } = rowData;
@@ -55,6 +91,33 @@ export default function ContextSubpanel({
5591
const formWhere = watch('where');
5692
const [debouncedWhere] = useDebouncedValue(formWhere, 1000);
5793

94+
// State management for nested panels
95+
const isNested = breadcrumbPath.length > 0;
96+
97+
const {
98+
contextRowId,
99+
contextRowSource,
100+
setContextRowId,
101+
setContextRowSource,
102+
} = useNestedPanelState(isNested);
103+
104+
const { data: contextRowSidePanelSource } = useSource({
105+
id: contextRowSource || '',
106+
});
107+
108+
const handleContextSidePanelClose = useCallback(() => {
109+
setContextRowId(null);
110+
setContextRowSource(null);
111+
}, [setContextRowId, setContextRowSource]);
112+
113+
const handleRowExpandClick = useCallback(
114+
(rowWhere: string) => {
115+
setContextRowId(rowWhere);
116+
setContextRowSource(source.id);
117+
},
118+
[source.id, setContextRowId, setContextRowSource],
119+
);
120+
58121
const date = useMemo(() => new Date(origTimestamp), [origTimestamp]);
59122

60123
const newDateRange = useMemo(
@@ -176,88 +239,107 @@ export default function ContextSubpanel({
176239
]);
177240

178241
return (
179-
config && (
180-
<Flex direction="column" mih="0px" style={{ flexGrow: 1 }}>
181-
<Group justify="space-between" p="sm">
182-
<SegmentedControl
183-
bg="dark.7"
184-
color="dark.5"
185-
size="xs"
186-
data={generateSegmentedControlData()}
187-
value={contextBy}
188-
onChange={v => setContextBy(v as ContextBy)}
189-
/>
190-
{contextBy === ContextBy.Custom && (
191-
<WhereLanguageControlled
192-
name="whereLanguage"
193-
control={control}
194-
sqlInput={
195-
originalLanguage === 'lucene' ? null : (
196-
<SQLInlineEditorControlled
197-
tableConnections={tcFromSource(source)}
198-
control={control}
199-
name="where"
200-
placeholder="SQL WHERE clause (ex. column = 'foo')"
201-
language="sql"
202-
enableHotkey
203-
size="sm"
204-
/>
205-
)
206-
}
207-
luceneInput={
208-
originalLanguage === 'sql' ? null : (
209-
<SearchInputV2
210-
tableConnections={tcFromSource(source)}
211-
control={control}
212-
name="where"
213-
language="lucene"
214-
placeholder="Lucene where clause (ex. column:value)"
215-
enableHotkey
216-
size="sm"
217-
/>
218-
)
219-
}
242+
<>
243+
{config && (
244+
<Flex direction="column" mih="0px" style={{ flexGrow: 1 }}>
245+
<Group justify="space-between" p="sm">
246+
<SegmentedControl
247+
bg="dark.7"
248+
color="dark.5"
249+
size="xs"
250+
data={generateSegmentedControlData()}
251+
value={contextBy}
252+
onChange={v => setContextBy(v as ContextBy)}
220253
/>
221-
)}
222-
<SegmentedControl
223-
bg="dark.7"
224-
color="dark.5"
225-
size="xs"
226-
data={[
227-
{ label: '100ms', value: ms('100ms').toString() },
228-
{ label: '500ms', value: ms('500ms').toString() },
229-
{ label: '1s', value: ms('1s').toString() },
230-
{ label: '5s', value: ms('5s').toString() },
231-
{ label: '30s', value: ms('30s').toString() },
232-
{ label: '1m', value: ms('1m').toString() },
233-
{ label: '5m', value: ms('5m').toString() },
234-
{ label: '15m', value: ms('15m').toString() },
235-
]}
236-
value={range.toString()}
237-
onChange={value => setRange(Number(value))}
238-
/>
239-
</Group>
240-
<Group p="sm">
241-
<div>
242-
{contextBy !== ContextBy.All && (
254+
{contextBy === ContextBy.Custom && (
255+
<WhereLanguageControlled
256+
name="whereLanguage"
257+
control={control}
258+
sqlInput={
259+
originalLanguage === 'lucene' ? null : (
260+
<SQLInlineEditorControlled
261+
tableConnections={tcFromSource(source)}
262+
control={control}
263+
name="where"
264+
placeholder="SQL WHERE clause (ex. column = 'foo')"
265+
language="sql"
266+
enableHotkey
267+
size="sm"
268+
/>
269+
)
270+
}
271+
luceneInput={
272+
originalLanguage === 'sql' ? null : (
273+
<SearchInputV2
274+
tableConnections={tcFromSource(source)}
275+
control={control}
276+
name="where"
277+
language="lucene"
278+
placeholder="Lucene where clause (ex. column:value)"
279+
enableHotkey
280+
size="sm"
281+
/>
282+
)
283+
}
284+
/>
285+
)}
286+
<SegmentedControl
287+
bg="dark.7"
288+
color="dark.5"
289+
size="xs"
290+
data={[
291+
{ label: '100ms', value: ms('100ms').toString() },
292+
{ label: '500ms', value: ms('500ms').toString() },
293+
{ label: '1s', value: ms('1s').toString() },
294+
{ label: '5s', value: ms('5s').toString() },
295+
{ label: '30s', value: ms('30s').toString() },
296+
{ label: '1m', value: ms('1m').toString() },
297+
{ label: '5m', value: ms('5m').toString() },
298+
{ label: '15m', value: ms('15m').toString() },
299+
]}
300+
value={range.toString()}
301+
onChange={value => setRange(Number(value))}
302+
/>
303+
</Group>
304+
<Group p="sm">
305+
<div>
306+
{contextBy !== ContextBy.All && (
307+
<Badge size="md" variant="default">
308+
{contextBy}:{CONTEXT_MAPPING[contextBy].value}
309+
</Badge>
310+
)}
243311
<Badge size="md" variant="default">
244-
{contextBy}:{CONTEXT_MAPPING[contextBy].value}
312+
Time range: ±{ms(range / 2)}
245313
</Badge>
246-
)}
247-
<Badge size="md" variant="default">
248-
Time range: ±{ms(range / 2)}
249-
</Badge>
314+
</div>
315+
</Group>
316+
<div style={{ height: '100%', overflow: 'auto' }}>
317+
<DBSqlRowTable
318+
highlightedLineId={rowId}
319+
isLive={false}
320+
config={config}
321+
queryKeyPrefix={QUERY_KEY_PREFIX}
322+
onRowExpandClick={handleRowExpandClick}
323+
/>
250324
</div>
251-
</Group>
252-
<div style={{ height: '100%', overflow: 'auto' }}>
253-
<DBSqlRowTable
254-
highlightedLineId={rowId}
255-
isLive={false}
256-
config={config}
257-
queryKeyPrefix={QUERY_KEY_PREFIX}
258-
/>
259-
</div>
260-
</Flex>
261-
)
325+
</Flex>
326+
)}
327+
{contextRowId && contextRowSidePanelSource && (
328+
<DBRowSidePanel
329+
source={contextRowSidePanelSource}
330+
rowId={contextRowId}
331+
onClose={handleContextSidePanelClose}
332+
isNestedPanel={true}
333+
breadcrumbPath={[
334+
...breadcrumbPath,
335+
{
336+
label: `Surrounding Context`,
337+
rowData,
338+
},
339+
]}
340+
onBreadcrumbClick={onBreadcrumbClick}
341+
/>
342+
)}
343+
</>
262344
);
263345
}

packages/app/src/components/DBRowSidePanel.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
1818
import { Box, Stack } from '@mantine/core';
1919
import { useClickOutside } from '@mantine/hooks';
2020

21-
import DBRowSidePanelHeader from '@/components/DBRowSidePanelHeader';
21+
import DBRowSidePanelHeader, {
22+
BreadcrumbNavigationCallback,
23+
BreadcrumbPath,
24+
} from '@/components/DBRowSidePanelHeader';
2225
import useResizable from '@/hooks/useResizable';
2326
import { LogSidePanelKbdShortcuts } from '@/LogSidePanelElements';
2427
import { getEventBody } from '@/source';
@@ -71,13 +74,18 @@ type DBRowSidePanelProps = {
7174
rowId: string | undefined;
7275
onClose: () => void;
7376
isNestedPanel?: boolean;
77+
breadcrumbPath?: BreadcrumbPath;
78+
onBreadcrumbClick?: BreadcrumbNavigationCallback;
7479
};
7580

7681
const DBRowSidePanel = ({
7782
rowId: rowId,
7883
source,
7984
isNestedPanel = false,
8085
setSubDrawerOpen,
86+
onClose,
87+
breadcrumbPath = [],
88+
onBreadcrumbClick,
8189
}: DBRowSidePanelProps & {
8290
setSubDrawerOpen: Dispatch<SetStateAction<boolean>>;
8391
}) => {
@@ -92,6 +100,34 @@ const DBRowSidePanel = ({
92100

93101
const { dbSqlRowTableConfig } = useContext(RowSidePanelContext);
94102

103+
const handleBreadcrumbClick = useCallback(
104+
(targetLevel: number) => {
105+
// Current panel's level in the hierarchy
106+
const currentLevel = breadcrumbPath.length;
107+
108+
// The target panel level corresponds to the breadcrumb index:
109+
// - targetLevel 0 = root panel (breadcrumbPath.length = 0)
110+
// - targetLevel 1 = first nested panel (breadcrumbPath.length = 1)
111+
// - etc.
112+
113+
// If our current level is greater than the target panel level, close this panel
114+
if (currentLevel > targetLevel) {
115+
onClose();
116+
onBreadcrumbClick?.(targetLevel);
117+
}
118+
// If our current level equals the target panel level, we're the target - don't close
119+
else if (currentLevel === targetLevel) {
120+
// This is the panel the user wants to navigate to - do nothing (stay open)
121+
return;
122+
}
123+
// If our current level is less than target, propagate up (this panel should stay open)
124+
else {
125+
onBreadcrumbClick?.(targetLevel);
126+
}
127+
},
128+
[breadcrumbPath.length, onBreadcrumbClick, onClose],
129+
);
130+
95131
const hasOverviewPanel = useMemo(() => {
96132
if (
97133
source.resourceAttributesExpression ||
@@ -230,6 +266,8 @@ const DBRowSidePanel = ({
230266
mainContent={mainContent}
231267
mainContentHeader={mainContentColumn}
232268
severityText={severityText}
269+
breadcrumbPath={breadcrumbPath}
270+
onBreadcrumbClick={handleBreadcrumbClick}
233271
/>
234272
</Box>
235273
{/* <SidePanelHeader
@@ -349,6 +387,8 @@ const DBRowSidePanel = ({
349387
dbSqlRowTableConfig={dbSqlRowTableConfig}
350388
rowData={normalizedRow}
351389
rowId={rowId}
390+
breadcrumbPath={breadcrumbPath}
391+
onBreadcrumbClick={handleBreadcrumbClick}
352392
/>
353393
</ErrorBoundary>
354394
)}
@@ -405,6 +445,8 @@ export default function DBRowSidePanelErrorBoundary({
405445
rowId,
406446
source,
407447
isNestedPanel,
448+
breadcrumbPath = [],
449+
onBreadcrumbClick,
408450
}: DBRowSidePanelProps) {
409451
const contextZIndex = useZIndex();
410452
const drawerZIndex = contextZIndex + 10;
@@ -474,7 +516,9 @@ export default function DBRowSidePanelErrorBoundary({
474516
rowId={rowId}
475517
onClose={_onClose}
476518
isNestedPanel={isNestedPanel}
519+
breadcrumbPath={breadcrumbPath}
477520
setSubDrawerOpen={setSubDrawerOpen}
521+
onBreadcrumbClick={onBreadcrumbClick}
478522
/>
479523
</ErrorBoundary>
480524
</div>

0 commit comments

Comments
 (0)