Skip to content
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