[feat] Add Overview Panel to Log Side Panel (#549)

![Screenshot 2025-01-09 at 6 10 15 PM](https://github.com/user-attachments/assets/e11401f2-8876-4bbb-a83f-067165cff543)

Add an `Overview` tab with `Resource Attributes` section for now. It can be extended in the future.

Co-authored-by: Warren <5959690+wrn14897@users.noreply.github.com>
This commit is contained in:
Ernest Iliiasov 2025-01-23 11:53:49 -06:00 committed by GitHub
parent af4faa4611
commit b690db8e7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 489 additions and 322 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
Introduce event panel overview tab

View file

@ -1,128 +1,10 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import router from 'next/router';
import { useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import get from 'lodash/get';
import { useMemo } from 'react';
import { TSource } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Button,
Group,
Input,
Menu,
Paper,
Text,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { getEventBody, getFirstTimestampValueExpression } from '@/source';
import { mergePath } from '@/utils';
import { RowSidePanelContext } from './DBRowSidePanel';
function filterObjectRecursively(obj: any, filter: string): any {
if (typeof obj !== 'object' || obj === null || filter === '') {
return obj;
}
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
if (value === null) {
continue;
}
if (
key.toLowerCase().includes(filter.toLowerCase()) ||
(typeof value === 'string' &&
value.toLowerCase().includes(filter.toLowerCase()))
) {
result[key] = value;
}
if (typeof value === 'object') {
const v = filterObjectRecursively(value, filter);
// Skip empty objects
if (Object.keys(v).length > 0) {
result[key] = v;
}
}
}
return result;
}
const viewerOptionsAtom = atomWithStorage('hdx_json_viewer_options', {
normallyExpanded: true,
lineWrap: true,
tabulate: true,
});
function HyperJsonMenu() {
const [jsonOptions, setJsonOptions] = useAtom(viewerOptionsAtom);
return (
<Menu width={240} withinPortal={false}>
<Menu.Target>
<ActionIcon size="md" variant="filled" color="gray">
<i className="bi bi-gear" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label lh={1} py={6}>
Properties view options
</Menu.Label>
<Menu.Item
onClick={() =>
setJsonOptions({
...jsonOptions,
normallyExpanded: !jsonOptions.normallyExpanded,
})
}
lh="1"
py={8}
rightSection={
jsonOptions.normallyExpanded ? (
<i className="ps-2 bi bi-check2" />
) : null
}
>
Expand all properties
</Menu.Item>
<Menu.Item
onClick={() =>
setJsonOptions({
...jsonOptions,
lineWrap: !jsonOptions.lineWrap,
})
}
lh="1"
py={8}
rightSection={
jsonOptions.lineWrap ? <i className="ps-2 bi bi-check2" /> : null
}
>
Preserve line breaks
</Menu.Item>
<Menu.Item
lh="1"
py={8}
rightSection={
jsonOptions.tabulate ? <i className="ps-2 bi bi-check2" /> : null
}
onClick={() =>
setJsonOptions({
...jsonOptions,
tabulate: !jsonOptions.tabulate,
})
}
>
Tabulate
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
import { DBRowJsonViewer } from './DBRowJsonViewer';
export function useRowData({
source,
@ -195,215 +77,18 @@ export function RowDataPanel({
rowId: string | undefined | null;
}) {
const { data, isLoading, isError } = useRowData({ source, rowId });
const {
onPropertyAddClick,
generateSearchUrl,
generateChartUrl,
displayedColumns,
toggleColumn,
} = useContext(RowSidePanelContext);
const [filter, setFilter] = useState<string>('');
const [debouncedFilter] = useDebouncedValue(filter, 100);
const rowData = useMemo(() => {
const firstRow = useMemo(() => {
const firstRow = { ...(data?.data?.[0] ?? {}) };
if (!firstRow) {
return null;
}
// Remove internal aliases
delete firstRow['__hdx_timestamp'];
delete firstRow['__hdx_trace_id'];
delete firstRow['__hdx_body'];
return filterObjectRecursively(firstRow, debouncedFilter);
}, [data, debouncedFilter]);
const getLineActions = useCallback<GetLineActions>(
({ keyPath, value }) => {
const actions: LineAction[] = [];
// only strings for now
if (onPropertyAddClick != null && typeof value === 'string' && value) {
actions.push({
key: 'add-to-search',
label: (
<>
<i className="bi bi-funnel-fill me-1" />
Add to Filters
</>
),
title: 'Add to Filters',
onClick: () => {
onPropertyAddClick(mergePath(keyPath), value);
notifications.show({
color: 'green',
message: `Added "${mergePath(keyPath)} = ${value}" to filters`,
});
},
});
}
if (generateSearchUrl && typeof value !== 'object') {
actions.push({
key: 'search',
label: (
<>
<i className="bi bi-search me-1" />
Search
</>
),
title: 'Search for this value only',
onClick: () => {
router.push(
generateSearchUrl(
`${mergePath(keyPath)} = ${
typeof value === 'string' ? `'${value}'` : value
}`,
),
);
},
});
}
/* TODO: Handle bools properly (they show up as number...) */
if (generateChartUrl && typeof value === 'number') {
actions.push({
key: 'chart',
label: <i className="bi bi-graph-up" />,
title: 'Chart',
onClick: () => {
router.push(
generateChartUrl({
aggFn: 'avg',
field: `${keyPath.join('.')}`,
groupBy: [],
}),
);
},
});
}
if (toggleColumn && typeof value !== 'object') {
const keyPathString = mergePath(keyPath);
const isIncluded = displayedColumns?.includes(keyPathString);
actions.push({
key: 'toggle-column',
label: isIncluded ? (
<>
<i className="bi bi-dash fs-7 me-1" />
Column
</>
) : (
<>
<i className="bi bi-plus fs-7 me-1" />
Column
</>
),
title: isIncluded
? `Remove ${keyPathString} column from results table`
: `Add ${keyPathString} column to results table`,
onClick: () => {
toggleColumn(keyPathString);
notifications.show({
color: 'green',
message: `Column "${keyPathString}" ${
isIncluded ? 'removed from' : 'added to'
} results table`,
});
},
});
}
const handleCopyObject = () => {
const copiedObj =
keyPath.length === 0 ? rowData : get(rowData, keyPath);
window.navigator.clipboard.writeText(
JSON.stringify(copiedObj, null, 2),
);
notifications.show({
color: 'green',
message: `Copied object to clipboard`,
});
};
if (typeof value === 'object') {
actions.push({
key: 'copy-object',
label: 'Copy Object',
onClick: handleCopyObject,
});
} else {
actions.push({
key: 'copy-value',
label: 'Copy Value',
onClick: () => {
window.navigator.clipboard.writeText(
typeof value === 'string'
? value
: JSON.stringify(value, null, 2),
);
notifications.show({
color: 'green',
message: `Value copied to clipboard`,
});
},
});
}
return actions;
},
[
displayedColumns,
generateChartUrl,
generateSearchUrl,
onPropertyAddClick,
rowData,
toggleColumn,
],
);
const jsonOptions = useAtomValue(viewerOptionsAtom);
return firstRow;
}, [data]);
return (
<div className="flex-grow-1 bg-body overflow-auto">
<Paper py="xs" withBorder>
<Group mx="xl" gap="xs">
<Input
size="xs"
w="100%"
maw="400px"
placeholder="Search properties by key or value"
value={filter}
onChange={e => setFilter(e.currentTarget.value)}
leftSection={<i className="bi bi-search" />}
/>
{filter && (
<Button
variant="filled"
color="gray"
size="xs"
onClick={() => setFilter('')}
>
Clear
</Button>
)}
<div className="flex-grow-1" />
<HyperJsonMenu />
</Group>
</Paper>
<Paper bg="transparent" mt="sm">
{rowData != null ? (
<HyperJson
data={rowData}
getLineActions={getLineActions}
{...jsonOptions}
/>
) : (
<Text>No data</Text>
)}
</Paper>
<DBRowJsonViewer data={firstRow} />
</div>
);
}

View file

@ -0,0 +1,335 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import router from 'next/router';
import { useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import get from 'lodash/get';
import {
ActionIcon,
Button,
Group,
Input,
Menu,
Paper,
Text,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
import { mergePath } from '@/utils';
import { RowSidePanelContext } from './DBRowSidePanel';
function filterObjectRecursively(obj: any, filter: string): any {
if (typeof obj !== 'object' || obj === null || filter === '') {
return obj;
}
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
if (value === null) {
continue;
}
if (
key.toLowerCase().includes(filter.toLowerCase()) ||
(typeof value === 'string' &&
value.toLowerCase().includes(filter.toLowerCase()))
) {
result[key] = value;
}
if (typeof value === 'object') {
const v = filterObjectRecursively(value, filter);
// Skip empty objects
if (Object.keys(v).length > 0) {
result[key] = v;
}
}
}
return result;
}
const viewerOptionsAtom = atomWithStorage('hdx_json_viewer_options', {
normallyExpanded: true,
lineWrap: true,
tabulate: true,
});
function HyperJsonMenu() {
const [jsonOptions, setJsonOptions] = useAtom(viewerOptionsAtom);
return (
<Menu width={240} withinPortal={false}>
<Menu.Target>
<ActionIcon size="md" variant="filled" color="gray">
<i className="bi bi-gear" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label lh={1} py={6}>
Properties view options
</Menu.Label>
<Menu.Item
onClick={() =>
setJsonOptions({
...jsonOptions,
normallyExpanded: !jsonOptions.normallyExpanded,
})
}
lh="1"
py={8}
rightSection={
jsonOptions.normallyExpanded ? (
<i className="ps-2 bi bi-check2" />
) : null
}
>
Expand all properties
</Menu.Item>
<Menu.Item
onClick={() =>
setJsonOptions({
...jsonOptions,
lineWrap: !jsonOptions.lineWrap,
})
}
lh="1"
py={8}
rightSection={
jsonOptions.lineWrap ? <i className="ps-2 bi bi-check2" /> : null
}
>
Preserve line breaks
</Menu.Item>
<Menu.Item
lh="1"
py={8}
rightSection={
jsonOptions.tabulate ? <i className="ps-2 bi bi-check2" /> : null
}
onClick={() =>
setJsonOptions({
...jsonOptions,
tabulate: !jsonOptions.tabulate,
})
}
>
Tabulate
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
export function DBRowJsonViewer({ data }: { data: any }) {
const {
onPropertyAddClick,
generateSearchUrl,
generateChartUrl,
displayedColumns,
toggleColumn,
} = useContext(RowSidePanelContext);
const [filter, setFilter] = useState<string>('');
const [debouncedFilter] = useDebouncedValue(filter, 100);
const rowData = useMemo(() => {
if (!data) {
return null;
}
// Remove internal aliases
delete data['__hdx_timestamp'];
delete data['__hdx_trace_id'];
delete data['__hdx_body'];
return filterObjectRecursively(data, debouncedFilter);
}, [data, debouncedFilter]);
const getLineActions = useCallback<GetLineActions>(
({ keyPath, value }) => {
const actions: LineAction[] = [];
// only strings for now
if (onPropertyAddClick != null && typeof value === 'string' && value) {
actions.push({
key: 'add-to-search',
label: (
<>
<i className="bi bi-funnel-fill me-1" />
Add to Filters
</>
),
title: 'Add to Filters',
onClick: () => {
onPropertyAddClick(mergePath(keyPath), value);
notifications.show({
color: 'green',
message: `Added "${mergePath(keyPath)} = ${value}" to filters`,
});
},
});
}
if (generateSearchUrl && typeof value !== 'object') {
actions.push({
key: 'search',
label: (
<>
<i className="bi bi-search me-1" />
Search
</>
),
title: 'Search for this value only',
onClick: () => {
router.push(
generateSearchUrl(
`${mergePath(keyPath)} = ${
typeof value === 'string' ? `'${value}'` : value
}`,
),
);
},
});
}
/* TODO: Handle bools properly (they show up as number...) */
if (generateChartUrl && typeof value === 'number') {
actions.push({
key: 'chart',
label: <i className="bi bi-graph-up" />,
title: 'Chart',
onClick: () => {
router.push(
generateChartUrl({
aggFn: 'avg',
field: `${keyPath.join('.')}`,
groupBy: [],
}),
);
},
});
}
if (toggleColumn && typeof value !== 'object') {
const keyPathString = mergePath(keyPath);
const isIncluded = displayedColumns?.includes(keyPathString);
actions.push({
key: 'toggle-column',
label: isIncluded ? (
<>
<i className="bi bi-dash fs-7 me-1" />
Column
</>
) : (
<>
<i className="bi bi-plus fs-7 me-1" />
Column
</>
),
title: isIncluded
? `Remove ${keyPathString} column from results table`
: `Add ${keyPathString} column to results table`,
onClick: () => {
toggleColumn(keyPathString);
notifications.show({
color: 'green',
message: `Column "${keyPathString}" ${
isIncluded ? 'removed from' : 'added to'
} results table`,
});
},
});
}
const handleCopyObject = () => {
const copiedObj =
keyPath.length === 0 ? rowData : get(rowData, keyPath);
window.navigator.clipboard.writeText(
JSON.stringify(copiedObj, null, 2),
);
notifications.show({
color: 'green',
message: `Copied object to clipboard`,
});
};
if (typeof value === 'object') {
actions.push({
key: 'copy-object',
label: 'Copy Object',
onClick: handleCopyObject,
});
} else {
actions.push({
key: 'copy-value',
label: 'Copy Value',
onClick: () => {
window.navigator.clipboard.writeText(
typeof value === 'string'
? value
: JSON.stringify(value, null, 2),
);
notifications.show({
color: 'green',
message: `Value copied to clipboard`,
});
},
});
}
return actions;
},
[
displayedColumns,
generateChartUrl,
generateSearchUrl,
onPropertyAddClick,
rowData,
toggleColumn,
],
);
const jsonOptions = useAtomValue(viewerOptionsAtom);
return (
<div className="flex-grow-1 bg-body overflow-auto">
<Paper py="xs">
<Group mx="xl" gap="xs">
<Input
size="xs"
w="100%"
maw="400px"
placeholder="Search properties by key or value"
value={filter}
onChange={e => setFilter(e.currentTarget.value)}
leftSection={<i className="bi bi-search" />}
/>
{filter && (
<Button
variant="filled"
color="gray"
size="xs"
onClick={() => setFilter('')}
>
Clear
</Button>
)}
<div className="flex-grow-1" />
<HyperJsonMenu />
</Group>
</Paper>
<Paper bg="transparent" mt="sm">
{rowData != null ? (
<HyperJson
data={rowData}
getLineActions={getLineActions}
{...jsonOptions}
/>
) : (
<Text>No data</Text>
)}
</Paper>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useMemo } from 'react';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { Accordion } from '@mantine/core';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { getEventBody, getFirstTimestampValueExpression } from '@/source';
import { DBRowJsonViewer } from './DBRowJsonViewer';
export function useRowData({
source,
rowId,
}: {
source: TSource;
rowId: string | undefined | null;
}) {
const eventBodyExpr = getEventBody(source);
const searchedTraceIdExpr = source.traceIdExpression;
const severityTextExpr =
source.severityTextExpression || source.statusCodeExpression;
return useQueriedChartConfig(
{
connection: source.connection,
select: [
{
valueExpression: '*',
},
{
valueExpression: getFirstTimestampValueExpression(
source.timestampValueExpression,
),
alias: '__hdx_timestamp',
},
...(eventBodyExpr
? [
{
valueExpression: eventBodyExpr,
alias: '__hdx_body',
},
]
: []),
...(searchedTraceIdExpr
? [
{
valueExpression: searchedTraceIdExpr,
alias: '__hdx_trace_id',
},
]
: []),
...(severityTextExpr
? [
{
valueExpression: severityTextExpr,
alias: '__hdx_severity_text',
},
]
: []),
],
where: rowId ?? '0=1',
from: source.from,
limit: { limit: 1 },
},
{
queryKey: ['row_side_panel', rowId, source],
enabled: rowId != null,
},
);
}
export function RowOverviewPanel({
source,
rowId,
}: {
source: TSource;
rowId: string | undefined | null;
}) {
const { data, isLoading, isError } = useRowData({ source, rowId });
const firstRow = useMemo(() => {
const firstRow = { ...(data?.data?.[0] ?? {}) };
if (!firstRow) {
return null;
}
return firstRow;
}, [data]);
const resourceAttributes = useMemo(() => {
return firstRow[source.resourceAttributesExpression!] || {};
}, [firstRow, source.resourceAttributesExpression]);
const eventAttributes = useMemo(() => {
return firstRow[source.eventAttributesExpression!] || {};
}, [firstRow, source.eventAttributesExpression]);
return (
<div className="flex-grow-1 bg-body overflow-auto">
<Accordion
defaultValue={['resourceAttributes', 'eventAttributes']}
multiple
>
<Accordion.Item value="resourceAttributes">
<Accordion.Control>Resource Attributes</Accordion.Control>
<Accordion.Panel>
<DBRowJsonViewer data={resourceAttributes} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="eventAttributes">
<Accordion.Control>
{source.kind === 'log' ? 'Log' : 'Span'} Attributes
</Accordion.Control>
<Accordion.Panel>
<DBRowJsonViewer data={eventAttributes} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</div>
);
}

View file

@ -23,6 +23,7 @@ import TabBar from '@/TabBar';
import { useZIndex, ZIndexContext } from '@/zIndex';
import { RowDataPanel, useRowData } from './DBRowDataPanel';
import { RowOverviewPanel } from './DBRowOverviewPanel';
import DBTracePanel from './DBTracePanel';
import 'react-modern-drawer/dist/index.css';
@ -69,6 +70,7 @@ export default function DBRowSidePanel({
});
enum Tab {
Overview = 'overview',
Parsed = 'parsed',
Debug = 'debug',
Trace = 'trace',
@ -78,7 +80,7 @@ export default function DBRowSidePanel({
const [queryTab, setQueryTab] = useQueryState(
'tab',
parseAsStringEnum<Tab>(Object.values(Tab)).withDefault(Tab.Parsed),
parseAsStringEnum<Tab>(Object.values(Tab)).withDefault(Tab.Overview),
);
const [panelWidth, setPanelWidth] = useState(
@ -216,6 +218,10 @@ export default function DBRowSidePanel({
<TabBar
className="fs-8 mt-2"
items={[
{
text: 'Overview',
value: Tab.Overview,
},
{
text: 'Column Values',
value: Tab.Parsed,
@ -228,6 +234,20 @@ export default function DBRowSidePanel({
activeItem={displayedTab}
onClick={(v: any) => setTab(v)}
/>
{displayedTab === Tab.Overview && (
<ErrorBoundary
onError={err => {
console.error(err);
}}
fallbackRender={() => (
<div className="text-danger px-2 py-1 m-2 fs-7 font-monospace bg-danger-transparent p-4">
An error occurred while rendering this event.
</div>
)}
>
<RowOverviewPanel source={source} rowId={rowId} />
</ErrorBoundary>
)}
{displayedTab === Tab.Trace && (
<ErrorBoundary
onError={err => {