Skip to content

feat: Add click + sidepanel support to items within surrounding context #989

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 30, 2025
Merged
5 changes: 5 additions & 0 deletions .changeset/sharp-snails-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

feat: Add click + sidepanel support to items within surrounding context
240 changes: 161 additions & 79 deletions packages/app/src/components/ContextSidePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -31,13 +38,42 @@ interface ContextSubpanelProps {
dbSqlRowTableConfig: ChartConfigWithDateRange | undefined;
rowData: Record<string, any>;
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<string | null>(null),
contextRowSource: useState<string | null>(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,
rowData,
rowId,
breadcrumbPath = [],
onBreadcrumbClick,
}: ContextSubpanelProps) {
const QUERY_KEY_PREFIX = 'context';
const { Timestamp: origTimestamp } = rowData;
Expand All @@ -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],
);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: URL state is used for the side panel. This allows us to expand into local state for nested panels as to not add additional complexity into url state

const date = useMemo(() => new Date(origTimestamp), [origTimestamp]);

const newDateRange = useMemo(
Expand Down Expand Up @@ -176,88 +239,107 @@ export default function ContextSubpanel({
]);

return (
config && (
<Flex direction="column" mih="0px" style={{ flexGrow: 1 }}>
<Group justify="space-between" p="sm">
<SegmentedControl
bg="dark.7"
color="dark.5"
size="xs"
data={generateSegmentedControlData()}
value={contextBy}
onChange={v => setContextBy(v as ContextBy)}
/>
{contextBy === ContextBy.Custom && (
<WhereLanguageControlled
name="whereLanguage"
control={control}
sqlInput={
originalLanguage === 'lucene' ? null : (
<SQLInlineEditorControlled
tableConnections={tcFromSource(source)}
control={control}
name="where"
placeholder="SQL WHERE clause (ex. column = 'foo')"
language="sql"
enableHotkey
size="sm"
/>
)
}
luceneInput={
originalLanguage === 'sql' ? null : (
<SearchInputV2
tableConnections={tcFromSource(source)}
control={control}
name="where"
language="lucene"
placeholder="Lucene where clause (ex. column:value)"
enableHotkey
size="sm"
/>
)
}
<>
{config && (
<Flex direction="column" mih="0px" style={{ flexGrow: 1 }}>
<Group justify="space-between" p="sm">
<SegmentedControl
bg="dark.7"
color="dark.5"
size="xs"
data={generateSegmentedControlData()}
value={contextBy}
onChange={v => setContextBy(v as ContextBy)}
/>
)}
<SegmentedControl
bg="dark.7"
color="dark.5"
size="xs"
data={[
{ label: '100ms', value: ms('100ms').toString() },
{ label: '500ms', value: ms('500ms').toString() },
{ label: '1s', value: ms('1s').toString() },
{ label: '5s', value: ms('5s').toString() },
{ label: '30s', value: ms('30s').toString() },
{ label: '1m', value: ms('1m').toString() },
{ label: '5m', value: ms('5m').toString() },
{ label: '15m', value: ms('15m').toString() },
]}
value={range.toString()}
onChange={value => setRange(Number(value))}
/>
</Group>
<Group p="sm">
<div>
{contextBy !== ContextBy.All && (
{contextBy === ContextBy.Custom && (
<WhereLanguageControlled
name="whereLanguage"
control={control}
sqlInput={
originalLanguage === 'lucene' ? null : (
<SQLInlineEditorControlled
tableConnections={tcFromSource(source)}
control={control}
name="where"
placeholder="SQL WHERE clause (ex. column = 'foo')"
language="sql"
enableHotkey
size="sm"
/>
)
}
luceneInput={
originalLanguage === 'sql' ? null : (
<SearchInputV2
tableConnections={tcFromSource(source)}
control={control}
name="where"
language="lucene"
placeholder="Lucene where clause (ex. column:value)"
enableHotkey
size="sm"
/>
)
}
/>
)}
<SegmentedControl
bg="dark.7"
color="dark.5"
size="xs"
data={[
{ label: '100ms', value: ms('100ms').toString() },
{ label: '500ms', value: ms('500ms').toString() },
{ label: '1s', value: ms('1s').toString() },
{ label: '5s', value: ms('5s').toString() },
{ label: '30s', value: ms('30s').toString() },
{ label: '1m', value: ms('1m').toString() },
{ label: '5m', value: ms('5m').toString() },
{ label: '15m', value: ms('15m').toString() },
]}
value={range.toString()}
onChange={value => setRange(Number(value))}
/>
</Group>
<Group p="sm">
<div>
{contextBy !== ContextBy.All && (
<Badge size="md" variant="default">
{contextBy}:{CONTEXT_MAPPING[contextBy].value}
</Badge>
)}
<Badge size="md" variant="default">
{contextBy}:{CONTEXT_MAPPING[contextBy].value}
Time range: ±{ms(range / 2)}
</Badge>
)}
<Badge size="md" variant="default">
Time range: ±{ms(range / 2)}
</Badge>
</div>
</Group>
<div style={{ height: '100%', overflow: 'auto' }}>
<DBSqlRowTable
highlightedLineId={rowId}
isLive={false}
config={config}
queryKeyPrefix={QUERY_KEY_PREFIX}
onRowExpandClick={handleRowExpandClick}
/>
</div>
</Group>
<div style={{ height: '100%', overflow: 'auto' }}>
<DBSqlRowTable
highlightedLineId={rowId}
isLive={false}
config={config}
queryKeyPrefix={QUERY_KEY_PREFIX}
/>
</div>
</Flex>
)
</Flex>
)}
{contextRowId && contextRowSidePanelSource && (
<DBRowSidePanel
source={contextRowSidePanelSource}
rowId={contextRowId}
onClose={handleContextSidePanelClose}
isNestedPanel={true}
breadcrumbPath={[
...breadcrumbPath,
{
label: `Surrounding Context`,
rowData,
},
]}
onBreadcrumbClick={onBreadcrumbClick}
/>
)}
</>
);
}
46 changes: 45 additions & 1 deletion packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,13 +74,18 @@ type DBRowSidePanelProps = {
rowId: string | undefined;
onClose: () => void;
isNestedPanel?: boolean;
breadcrumbPath?: BreadcrumbPath;
onBreadcrumbClick?: BreadcrumbNavigationCallback;
};

const DBRowSidePanel = ({
rowId: rowId,
source,
isNestedPanel = false,
setSubDrawerOpen,
onClose,
breadcrumbPath = [],
onBreadcrumbClick,
}: DBRowSidePanelProps & {
setSubDrawerOpen: Dispatch<SetStateAction<boolean>>;
}) => {
Expand All @@ -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 ||
Expand Down Expand Up @@ -230,6 +266,8 @@ const DBRowSidePanel = ({
mainContent={mainContent}
mainContentHeader={mainContentColumn}
severityText={severityText}
breadcrumbPath={breadcrumbPath}
onBreadcrumbClick={handleBreadcrumbClick}
/>
</Box>
{/* <SidePanelHeader
Expand Down Expand Up @@ -349,6 +387,8 @@ const DBRowSidePanel = ({
dbSqlRowTableConfig={dbSqlRowTableConfig}
rowData={normalizedRow}
rowId={rowId}
breadcrumbPath={breadcrumbPath}
onBreadcrumbClick={handleBreadcrumbClick}
/>
</ErrorBoundary>
)}
Expand Down Expand Up @@ -405,6 +445,8 @@ export default function DBRowSidePanelErrorBoundary({
rowId,
source,
isNestedPanel,
breadcrumbPath = [],
onBreadcrumbClick,
}: DBRowSidePanelProps) {
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10;
Expand Down Expand Up @@ -474,7 +516,9 @@ export default function DBRowSidePanelErrorBoundary({
rowId={rowId}
onClose={_onClose}
isNestedPanel={isNestedPanel}
breadcrumbPath={breadcrumbPath}
setSubDrawerOpen={setSubDrawerOpen}
onBreadcrumbClick={onBreadcrumbClick}
/>
</ErrorBoundary>
</div>
Expand Down
Loading