diff --git a/.changeset/perfect-nails-doubt.md b/.changeset/perfect-nails-doubt.md new file mode 100644 index 00000000..dfb84836 --- /dev/null +++ b/.changeset/perfect-nails-doubt.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Introduce event panel overview tab diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 3d82ab3f..b0f65d80 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -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 = {}; - 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 ( - - - - - - - - - Properties view options - - - setJsonOptions({ - ...jsonOptions, - normallyExpanded: !jsonOptions.normallyExpanded, - }) - } - lh="1" - py={8} - rightSection={ - jsonOptions.normallyExpanded ? ( - - ) : null - } - > - Expand all properties - - - setJsonOptions({ - ...jsonOptions, - lineWrap: !jsonOptions.lineWrap, - }) - } - lh="1" - py={8} - rightSection={ - jsonOptions.lineWrap ? : null - } - > - Preserve line breaks - - : null - } - onClick={() => - setJsonOptions({ - ...jsonOptions, - tabulate: !jsonOptions.tabulate, - }) - } - > - Tabulate - - - - ); -} +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(''); - 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( - ({ keyPath, value }) => { - const actions: LineAction[] = []; - - // only strings for now - if (onPropertyAddClick != null && typeof value === 'string' && value) { - actions.push({ - key: 'add-to-search', - label: ( - <> - - 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: ( - <> - - 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: , - 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 ? ( - <> - - Column - - ) : ( - <> - - 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 (
- - - setFilter(e.currentTarget.value)} - leftSection={} - /> - {filter && ( - - )} -
- - - - - {rowData != null ? ( - - ) : ( - No data - )} - +
); } diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx new file mode 100644 index 00000000..4fd3f3c0 --- /dev/null +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -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 = {}; + 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 ( + + + + + + + + + Properties view options + + + setJsonOptions({ + ...jsonOptions, + normallyExpanded: !jsonOptions.normallyExpanded, + }) + } + lh="1" + py={8} + rightSection={ + jsonOptions.normallyExpanded ? ( + + ) : null + } + > + Expand all properties + + + setJsonOptions({ + ...jsonOptions, + lineWrap: !jsonOptions.lineWrap, + }) + } + lh="1" + py={8} + rightSection={ + jsonOptions.lineWrap ? : null + } + > + Preserve line breaks + + : null + } + onClick={() => + setJsonOptions({ + ...jsonOptions, + tabulate: !jsonOptions.tabulate, + }) + } + > + Tabulate + + + + ); +} + +export function DBRowJsonViewer({ data }: { data: any }) { + const { + onPropertyAddClick, + generateSearchUrl, + generateChartUrl, + displayedColumns, + toggleColumn, + } = useContext(RowSidePanelContext); + + const [filter, setFilter] = useState(''); + 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( + ({ keyPath, value }) => { + const actions: LineAction[] = []; + + // only strings for now + if (onPropertyAddClick != null && typeof value === 'string' && value) { + actions.push({ + key: 'add-to-search', + label: ( + <> + + 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: ( + <> + + 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: , + 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 ? ( + <> + + Column + + ) : ( + <> + + 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 ( +
+ + + setFilter(e.currentTarget.value)} + leftSection={} + /> + {filter && ( + + )} +
+ + + + + {rowData != null ? ( + + ) : ( + No data + )} + +
+ ); +} diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx new file mode 100644 index 00000000..acc3343f --- /dev/null +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -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 ( +
+ + + Resource Attributes + + + + + + + + {source.kind === 'log' ? 'Log' : 'Span'} Attributes + + + + + + +
+ ); +} diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 16bb6630..53826cdc 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -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(Object.values(Tab)).withDefault(Tab.Parsed), + parseAsStringEnum(Object.values(Tab)).withDefault(Tab.Overview), ); const [panelWidth, setPanelWidth] = useState( @@ -216,6 +218,10 @@ export default function DBRowSidePanel({ setTab(v)} /> + {displayedTab === Tab.Overview && ( + { + console.error(err); + }} + fallbackRender={() => ( +
+ An error occurred while rendering this event. +
+ )} + > + +
+ )} {displayedTab === Tab.Trace && ( {