mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Overhaul Property JSON viewer (#150)
This commit is contained in:
parent
eb70f053c6
commit
60ee49af89
6 changed files with 1045 additions and 260 deletions
5
.changeset/rich-squids-exercise.md
Normal file
5
.changeset/rich-squids-exercise.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hyperdx/app': minor
|
||||
---
|
||||
|
||||
Overhaul Properties viewer
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { JSONTree } from 'react-json-tree';
|
||||
|
||||
const JSON_TREE_THEME = {
|
||||
base00: 'rgba(0,0,0,0)',
|
||||
base02: '#515151',
|
||||
base03: '#747369',
|
||||
base04: '#a09f93',
|
||||
base05: '#d3d0c8',
|
||||
base06: '#e8e6df',
|
||||
base07: '#f2f0ec',
|
||||
base08: '#f2777a',
|
||||
base09: '#f99157',
|
||||
base0A: '#ffcc66',
|
||||
base0F: '#d27b53',
|
||||
base0E: '#cc99cc',
|
||||
base0D: '#6699cc',
|
||||
base0C: '#66cccc',
|
||||
base0B: '#99cc99',
|
||||
};
|
||||
|
||||
export default function DSJSONTree({
|
||||
data,
|
||||
keyPath,
|
||||
hideRoot = false,
|
||||
}: {
|
||||
data: any;
|
||||
keyPath?: string[];
|
||||
hideRoot?: boolean;
|
||||
}) {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<JSONTree
|
||||
hideRoot={hideRoot}
|
||||
data={data}
|
||||
keyPath={keyPath}
|
||||
shouldExpandNode={() => false}
|
||||
theme={JSON_TREE_THEME}
|
||||
invertTheme={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import cx from 'classnames';
|
||||
import { add, format } from 'date-fns';
|
||||
import Fuse from 'fuse.js';
|
||||
import get from 'lodash/get';
|
||||
import isPlainObject from 'lodash/isPlainObject';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import { Form, OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
|
@ -19,7 +19,15 @@ import { StringParam, withDefault } from 'serialize-query-params';
|
|||
import stripAnsi from 'strip-ansi';
|
||||
import Timestamp from 'timestamp-nano';
|
||||
import { useQueryParam } from 'use-query-params';
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Menu,
|
||||
SegmentedControl,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
|
||||
import HyperJson, { GetLineActions, LineAction } from './components/HyperJson';
|
||||
import { Table } from './components/Table';
|
||||
import api from './api';
|
||||
import { CurlGenerator } from './curlGenerator';
|
||||
|
|
@ -100,22 +108,7 @@ function useParsedLogProperties(logData: any): { [key: string]: any } {
|
|||
|
||||
return {
|
||||
// TODO: Users can't search on this via property search so we need to figure out a nice way to handle those search actions...
|
||||
// TODO: Probably move this into the render below
|
||||
...mapValues(mergedKvPairs, value => {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
value.length > 2 &&
|
||||
((value[0] === '{' && value[value.length - 1] === '}') ||
|
||||
(value[0] === '[' && value[value.length - 1] === ']'))
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
...mergedKvPairs,
|
||||
...addIfTruthy('span_id', span_id),
|
||||
...addIfTruthy('trace_id', trace_id),
|
||||
...addIfTruthy('parent_span_id', parent_span_id),
|
||||
|
|
@ -640,7 +633,7 @@ function TraceSubpanel({
|
|||
<div className="border-top border-dark mb-4">
|
||||
{selectedLogData != null ? (
|
||||
<>
|
||||
<div className="my-3">
|
||||
<div className="my-3 text-break">
|
||||
<div className="text-slate-200 fs-7 mb-2 mt-3">
|
||||
{selectedLogData.type === 'span' ? 'Span' : 'Log'} Details
|
||||
</div>
|
||||
|
|
@ -1241,6 +1234,7 @@ function PropertySubpanel({
|
|||
displayedColumns?: string[];
|
||||
toggleColumn?: (column: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [propertySearchValue, setPropertySearchValue] = useState('');
|
||||
const [isNestedView, setIsNestedView] = useLocalStorage(
|
||||
'propertySubPanelNestedView',
|
||||
|
|
@ -1342,10 +1336,156 @@ function PropertySubpanel({
|
|||
}, {} as any);
|
||||
}, [displayedParsedProperties, propertySearchValue, search]);
|
||||
|
||||
const events: any[] | undefined = parsedProperties?.__events;
|
||||
let events: any[] | undefined;
|
||||
if (parsedProperties?.__events) {
|
||||
try {
|
||||
events = JSON.parse(parsedProperties?.__events);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [jsonOptions, setJsonOptions] = useLocalStorage(
|
||||
'logviewer.jsonviewer.options',
|
||||
{
|
||||
normallyExpanded: true,
|
||||
tabulate: true,
|
||||
lineWrap: true,
|
||||
useLegacyViewer: false,
|
||||
},
|
||||
);
|
||||
|
||||
const getLineActions = useCallback<GetLineActions>(
|
||||
({ keyPath, value }) => {
|
||||
const actions: LineAction[] = [];
|
||||
|
||||
if (onPropertyAddClick != null && typeof value !== 'object') {
|
||||
actions.push({
|
||||
key: 'add-to-search',
|
||||
label: <i className="bi bi-plus-circle" />,
|
||||
title: 'Add to Search',
|
||||
onClick: () => {
|
||||
onPropertyAddClick(`${keyPath.join('.')}`, value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
actions.push({
|
||||
key: 'search',
|
||||
label: <i className="bi bi-search" />,
|
||||
title: 'Search for this value only',
|
||||
onClick: () => {
|
||||
router.push(
|
||||
generateSearchUrl(
|
||||
`${keyPath.join('.')}:${
|
||||
typeof value === 'string' ? `"${value}"` : value
|
||||
}`,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* TODO: Handle bools properly (they show up as number...) */
|
||||
if (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: [],
|
||||
table: 'logs',
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (toggleColumn && typeof value !== 'object') {
|
||||
const keyPathString = keyPath.join('.');
|
||||
actions.push({
|
||||
key: 'toggle-column',
|
||||
label: <i className="bi bi-table" />,
|
||||
title: displayedColumns?.includes(keyPathString)
|
||||
? `Remove ${keyPathString} column from results table`
|
||||
: `Add ${keyPathString} column to results table`,
|
||||
onClick: () => toggleColumn(keyPathString),
|
||||
});
|
||||
}
|
||||
|
||||
const handleCopyObject = () => {
|
||||
const shouldCopyParent = !isNestedView;
|
||||
const parentKeyPath = keyPath.slice(0, -1);
|
||||
const copiedObj = shouldCopyParent
|
||||
? parentKeyPath.length === 0
|
||||
? nestedProperties
|
||||
: get(nestedProperties, parentKeyPath)
|
||||
: keyPath.length === 0
|
||||
? nestedProperties
|
||||
: get(nestedProperties, keyPath);
|
||||
window.navigator.clipboard.writeText(
|
||||
JSON.stringify(copiedObj, null, 2),
|
||||
);
|
||||
toast.success(
|
||||
`Copied ${shouldCopyParent ? 'parent' : 'object'} to clipboard`,
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof value === 'object') {
|
||||
actions.push(
|
||||
isNestedView
|
||||
? {
|
||||
key: 'copy-object',
|
||||
label: 'Copy Object',
|
||||
onClick: handleCopyObject,
|
||||
}
|
||||
: {
|
||||
key: 'copy-parent',
|
||||
label: 'Copy Parent',
|
||||
onClick: handleCopyObject,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (!isNestedView) {
|
||||
actions.push({
|
||||
key: 'copy-parent',
|
||||
label: 'Copy Parent',
|
||||
onClick: handleCopyObject,
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
key: 'copy-value',
|
||||
label: 'Copy Value',
|
||||
onClick: () => {
|
||||
window.navigator.clipboard.writeText(
|
||||
JSON.stringify(value, null, 2),
|
||||
);
|
||||
toast.success(`Value copied to clipboard`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
[
|
||||
onPropertyAddClick,
|
||||
toggleColumn,
|
||||
isNestedView,
|
||||
router,
|
||||
generateSearchUrl,
|
||||
generateChartUrl,
|
||||
displayedColumns,
|
||||
nestedProperties,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{events != null && events.length > 0 && (
|
||||
|
|
@ -1412,210 +1552,330 @@ function PropertySubpanel({
|
|||
})}
|
||||
</>
|
||||
)}
|
||||
<div className="fw-bold fs-8 mt-4 d-flex align-items-center mb-2">
|
||||
<span>Properties</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 text-muted-hover fs-8 ms-2"
|
||||
onClick={() => setIsNestedView(!isNestedView)}
|
||||
>
|
||||
Switch to {isNestedView ? 'Flat View' : 'Nested JSON View'}
|
||||
</Button>
|
||||
</div>
|
||||
{isNestedView === false && (
|
||||
<Form.Control
|
||||
ref={searchInputRef}
|
||||
size="sm"
|
||||
type="text"
|
||||
placeholder={'Search properties by key or value'}
|
||||
className="border-0 fs-7.5 mt-2"
|
||||
value={propertySearchValue}
|
||||
onChange={e => setPropertySearchValue(e.target.value)}
|
||||
// autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
searchInputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="d-flex flex-wrap mt-1 react-json-tree"
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
<JSONTree
|
||||
hideRoot={true}
|
||||
shouldExpandNode={() => true}
|
||||
data={isNestedView ? nestedProperties : filteredProperties}
|
||||
invertTheme={false}
|
||||
labelRenderer={keyPath => {
|
||||
const shouldCopyParent = !isNestedView;
|
||||
|
||||
const [key] = keyPath;
|
||||
const parsedKeyPath = isNestedView
|
||||
? keyPath
|
||||
.slice()
|
||||
.reverse()
|
||||
.flatMap(key => {
|
||||
return `${key}`.split('.');
|
||||
})
|
||||
: keyPath;
|
||||
<CollapsibleSection title="Properties" initiallyCollapsed={false}>
|
||||
<SectionWrapper>
|
||||
<div
|
||||
className="px-3 py-1"
|
||||
style={{
|
||||
borderBottom: '1px solid #21262C',
|
||||
}}
|
||||
>
|
||||
<Group align="center" position="apart">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
data={[
|
||||
{
|
||||
label: 'Flat view',
|
||||
value: 'flat',
|
||||
},
|
||||
{
|
||||
label: 'Nested view',
|
||||
value: 'nested',
|
||||
},
|
||||
]}
|
||||
value={isNestedView ? 'nested' : 'flat'}
|
||||
onChange={value => {
|
||||
setIsNestedView(value === 'nested');
|
||||
}}
|
||||
/>
|
||||
|
||||
const parentKeyPath = parsedKeyPath.slice(0, -1);
|
||||
const copiedObj = shouldCopyParent
|
||||
? parentKeyPath.length === 0
|
||||
? nestedProperties
|
||||
: get(nestedProperties, parentKeyPath)
|
||||
: parsedKeyPath.length === 0
|
||||
? nestedProperties
|
||||
: get(nestedProperties, parsedKeyPath);
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
overlay={
|
||||
<Tooltip id={`tooltip`}>
|
||||
<CopyToClipboard
|
||||
text={JSON.stringify(copiedObj, null, 2)}
|
||||
onCopy={() => {
|
||||
toast.success(
|
||||
`${
|
||||
shouldCopyParent ? 'Parent object' : 'Object'
|
||||
} copied to clipboard`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="p-0 fs-8 text-muted-hover child-hover-trigger me-2"
|
||||
variant="link"
|
||||
title={`Copy ${
|
||||
shouldCopyParent ? 'parent' : ''
|
||||
} object`}
|
||||
>
|
||||
<i className="bi bi-clipboard" /> Copy{' '}
|
||||
{shouldCopyParent ? 'Parent ' : ''}Object (
|
||||
{(shouldCopyParent
|
||||
? parentKeyPath
|
||||
: parsedKeyPath
|
||||
).join('.')}
|
||||
)
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<span className="cursor-pointer">{key}</span>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}}
|
||||
valueRenderer={(raw, value, ...rawKeyPath) => {
|
||||
const keyPath = rawKeyPath.slice().reverse();
|
||||
const keyPathString = keyPath.join('.');
|
||||
|
||||
return (
|
||||
<div className="parent-hover-trigger d-inline-block px-2">
|
||||
<pre
|
||||
className="d-inline text-break"
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{raw}
|
||||
</pre>
|
||||
<span className="me-2" />
|
||||
{onPropertyAddClick != null ? (
|
||||
<Button
|
||||
className="p-0 fs-8 text-muted-hover child-hover-trigger me-2"
|
||||
variant="link"
|
||||
title="Add to search"
|
||||
onClick={() => {
|
||||
onPropertyAddClick(`${keyPath.join('.')}`, value);
|
||||
<Group position="right" spacing="xs" style={{ flex: 1 }}>
|
||||
{isNestedView === false && (
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
maw={400}
|
||||
ref={searchInputRef}
|
||||
size="xs"
|
||||
variant="filled"
|
||||
type="text"
|
||||
placeholder={'Search properties by key or value'}
|
||||
value={propertySearchValue}
|
||||
onChange={e => setPropertySearchValue(e.target.value)}
|
||||
// autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
searchInputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
style={{ width: 20 }}
|
||||
>
|
||||
<i className="bi bi-plus-circle" />
|
||||
</Button>
|
||||
) : null}
|
||||
{/* The styling here is a huge mess and I'm not sure why its not working */}
|
||||
<Link
|
||||
href={generateSearchUrl(
|
||||
`${keyPath.join('.')}:${
|
||||
typeof value === 'string' ? `"${value}"` : value
|
||||
}`,
|
||||
)}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
className="fs-8 text-muted-hover child-hover-trigger p-0"
|
||||
variant="link"
|
||||
as="a"
|
||||
title="Search for this value only"
|
||||
style={{ width: 22 }}
|
||||
>
|
||||
<i className="bi bi-search" />
|
||||
</Button>
|
||||
</Link>
|
||||
{/* TODO: Handle bools properly (they show up as number...) */}
|
||||
{typeof value === 'number' ? (
|
||||
<Link
|
||||
href={generateChartUrl({
|
||||
aggFn: 'avg',
|
||||
field: `${keyPath.join('.')}`,
|
||||
groupBy: [],
|
||||
table: 'logs',
|
||||
})}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
className="fs-8 text-muted-hover child-hover-trigger p-0"
|
||||
variant="link"
|
||||
as="a"
|
||||
title="Chart this value"
|
||||
style={{ width: 20 }}
|
||||
/>
|
||||
)}
|
||||
<Menu width={240}>
|
||||
<Menu.Target>
|
||||
<ActionIcon size="md" variant="filled">
|
||||
<i className="bi bi-gear" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label lh={1} py={6}>
|
||||
Properties view options
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
disabled={jsonOptions.useLegacyViewer}
|
||||
onClick={() =>
|
||||
setJsonOptions({
|
||||
...jsonOptions,
|
||||
normallyExpanded: !jsonOptions.normallyExpanded,
|
||||
})
|
||||
}
|
||||
lh="1"
|
||||
py={8}
|
||||
rightSection={
|
||||
jsonOptions.normallyExpanded ? (
|
||||
<i className="ps-2 bi bi-check2" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<i className="bi bi-graph-up" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
Expand all properties
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={jsonOptions.useLegacyViewer}
|
||||
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}
|
||||
disabled={jsonOptions.useLegacyViewer}
|
||||
rightSection={
|
||||
jsonOptions.tabulate ? (
|
||||
<i className="ps-2 bi bi-check2" />
|
||||
) : null
|
||||
}
|
||||
onClick={() =>
|
||||
setJsonOptions({
|
||||
...jsonOptions,
|
||||
tabulate: !jsonOptions.tabulate,
|
||||
})
|
||||
}
|
||||
>
|
||||
Tabulate
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
lh="1"
|
||||
py={8}
|
||||
rightSection={
|
||||
jsonOptions.useLegacyViewer ? (
|
||||
<i className="ps-2 bi bi-check2" />
|
||||
) : null
|
||||
}
|
||||
onClick={() =>
|
||||
setJsonOptions({
|
||||
...jsonOptions,
|
||||
useLegacyViewer: !jsonOptions.useLegacyViewer,
|
||||
})
|
||||
}
|
||||
>
|
||||
Use legacy viewer
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</div>
|
||||
<div
|
||||
className="d-flex flex-wrap react-json-tree p-3"
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
{/* TODO: Remove old viewer once it passes the test of time... */}
|
||||
{jsonOptions.useLegacyViewer ? (
|
||||
<JSONTree
|
||||
hideRoot={true}
|
||||
shouldExpandNode={() => true}
|
||||
data={isNestedView ? nestedProperties : filteredProperties}
|
||||
invertTheme={false}
|
||||
labelRenderer={keyPath => {
|
||||
const shouldCopyParent = !isNestedView;
|
||||
|
||||
{!!toggleColumn && keyPath.length === 1 ? (
|
||||
<Button
|
||||
className="fs-8 text-muted-hover child-hover-trigger p-0"
|
||||
variant="link"
|
||||
as="a"
|
||||
title={
|
||||
displayedColumns?.includes(keyPathString)
|
||||
? `Remove ${keyPathString} column from results table`
|
||||
: `Add ${keyPathString} column to results table`
|
||||
}
|
||||
style={{ width: 20 }}
|
||||
onClick={() => toggleColumn(keyPathString)}
|
||||
>
|
||||
<i className="bi bi-table" />
|
||||
</Button>
|
||||
) : null}
|
||||
const [key] = keyPath;
|
||||
const parsedKeyPath = isNestedView
|
||||
? keyPath
|
||||
.slice()
|
||||
.reverse()
|
||||
.flatMap(key => {
|
||||
return `${key}`.split('.');
|
||||
})
|
||||
: keyPath;
|
||||
|
||||
<CopyToClipboard
|
||||
text={value}
|
||||
onCopy={() => {
|
||||
toast.success(`Value copied to clipboard`);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="fs-8 text-muted-hover child-hover-trigger p-0"
|
||||
title="Copy value to clipboard"
|
||||
variant="link"
|
||||
>
|
||||
<i className="bi bi-clipboard" />
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
theme={JSON_TREE_THEME}
|
||||
/>
|
||||
</div>
|
||||
const parentKeyPath = parsedKeyPath.slice(0, -1);
|
||||
const copiedObj = shouldCopyParent
|
||||
? parentKeyPath.length === 0
|
||||
? nestedProperties
|
||||
: get(nestedProperties, parentKeyPath)
|
||||
: parsedKeyPath.length === 0
|
||||
? nestedProperties
|
||||
: get(nestedProperties, parsedKeyPath);
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
overlay={
|
||||
<Tooltip id={`tooltip`}>
|
||||
<CopyToClipboard
|
||||
text={JSON.stringify(copiedObj, null, 2)}
|
||||
onCopy={() => {
|
||||
toast.success(
|
||||
`${
|
||||
shouldCopyParent ? 'Parent object' : 'Object'
|
||||
} copied to clipboard`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="p-0 fs-8 text-muted-hover child-hover-trigger me-2"
|
||||
variant="link"
|
||||
title={`Copy ${
|
||||
shouldCopyParent ? 'parent' : ''
|
||||
} object`}
|
||||
>
|
||||
<i className="bi bi-clipboard" /> Copy{' '}
|
||||
{shouldCopyParent ? 'Parent ' : ''}Object (
|
||||
{(shouldCopyParent
|
||||
? parentKeyPath
|
||||
: parsedKeyPath
|
||||
).join('.')}
|
||||
)
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<span className="cursor-pointer">{key}</span>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}}
|
||||
valueRenderer={(raw, value, ...rawKeyPath) => {
|
||||
const keyPath = rawKeyPath.slice().reverse();
|
||||
const keyPathString = keyPath.join('.');
|
||||
|
||||
return (
|
||||
<div className="parent-hover-trigger d-inline-block px-2">
|
||||
<pre
|
||||
className="d-inline text-break"
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{raw}
|
||||
</pre>
|
||||
<span className="me-2" />
|
||||
{onPropertyAddClick != null ? (
|
||||
<Button
|
||||
className="p-0 fs-8 text-muted-hover child-hover-trigger me-2"
|
||||
variant="link"
|
||||
title="Add to search"
|
||||
onClick={() => {
|
||||
onPropertyAddClick(`${keyPath.join('.')}`, value);
|
||||
}}
|
||||
style={{ width: 20 }}
|
||||
>
|
||||
<i className="bi bi-plus-circle" />
|
||||
</Button>
|
||||
) : null}
|
||||
{/* The styling here is a huge mess and I'm not sure why its not working */}
|
||||
<Link
|
||||
href={generateSearchUrl(
|
||||
`${keyPath.join('.')}:${
|
||||
typeof value === 'string' ? `"${value}"` : value
|
||||
}`,
|
||||
)}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
className="fs-8 text-muted-hover child-hover-trigger p-0"
|
||||
variant="link"
|
||||
as="a"
|
||||
title="Search for this value only"
|
||||
style={{ width: 22 }}
|
||||
>
|
||||
<i className="bi bi-search" />
|
||||
</Button>
|
||||
</Link>
|
||||
{/* TODO: Handle bools properly (they show up as number...) */}
|
||||
{typeof value === 'number' ? (
|
||||
<Link
|
||||
href={generateChartUrl({
|
||||
aggFn: 'avg',
|
||||
field: `${keyPath.join('.')}`,
|
||||
groupBy: [],
|
||||
table: 'logs',
|
||||
})}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
className="fs-8 text-muted-hover child-hover-trigger p-0"
|
||||
variant="link"
|
||||
as="a"
|
||||
title="Chart this value"
|
||||
style={{ width: 20 }}
|
||||
>
|
||||
<i className="bi bi-graph-up" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{!!toggleColumn && keyPath.length === 1 ? (
|
||||
<Button
|
||||
className="fs-8 text-muted-hover child-hover-trigger p-0"
|
||||
variant="link"
|
||||
as="a"
|
||||
title={
|
||||
displayedColumns?.includes(keyPathString)
|
||||
? `Remove ${keyPathString} column from results table`
|
||||
: `Add ${keyPathString} column to results table`
|
||||
}
|
||||
style={{ width: 20 }}
|
||||
onClick={() => toggleColumn(keyPathString)}
|
||||
>
|
||||
<i className="bi bi-table" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<CopyToClipboard
|
||||
text={value}
|
||||
onCopy={() => {
|
||||
toast.success(`Value copied to clipboard`);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="fs-8 text-muted-hover child-hover-trigger p-0"
|
||||
title="Copy value to clipboard"
|
||||
variant="link"
|
||||
>
|
||||
<i className="bi bi-clipboard" />
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
theme={JSON_TREE_THEME}
|
||||
/>
|
||||
) : (
|
||||
<HyperJson
|
||||
data={isNestedView ? nestedProperties : filteredProperties}
|
||||
normallyExpanded={jsonOptions.normallyExpanded}
|
||||
tabulate={jsonOptions.tabulate}
|
||||
lineWrap={jsonOptions.lineWrap}
|
||||
getLineActions={getLineActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const CollapsibleSection = ({
|
|||
return (
|
||||
<div className="my-3">
|
||||
<div
|
||||
className={`d-flex align-items-center mb-1 text-white-hover`}
|
||||
className={`d-flex align-items-center mb-1 text-white-hover w-50`}
|
||||
role="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
|
|
|
|||
230
packages/app/src/components/HyperJson.module.scss
Normal file
230
packages/app/src/components/HyperJson.module.scss
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
padding: 0px 4px;
|
||||
}
|
||||
|
||||
.withTabulate {
|
||||
.key {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.withLineWrap {
|
||||
.valueContainer {
|
||||
.string {
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.treeNode {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 2px 24px;
|
||||
// margin: 0 -10px;
|
||||
align-items: flex-start;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: #26282b;
|
||||
.lineMenu {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
.object,
|
||||
.array {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
&.expandable {
|
||||
cursor: pointer;
|
||||
|
||||
.key {
|
||||
margin-left: -10px;
|
||||
}
|
||||
.valueContainer {
|
||||
margin-left: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
&:active {
|
||||
background-color: #1e1f21;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lineMenu {
|
||||
display: none;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
height: calc(100% + 1px);
|
||||
max-height: 24px;
|
||||
border-bottom: 1px solid #34373e;
|
||||
}
|
||||
|
||||
.lineMenuBtn {
|
||||
border: 0;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #b8b7c9;
|
||||
border-left: 1px solid #444;
|
||||
padding: 0px 8px;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: #34373e;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #1e1f21;
|
||||
}
|
||||
}
|
||||
|
||||
.nestedLine {
|
||||
// margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
&:active {
|
||||
background-color: #1e1f21;
|
||||
}
|
||||
}
|
||||
|
||||
.hoverable {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0px 4px;
|
||||
margin: 0px -4px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #26282b;
|
||||
.hoverContent {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.hoverContent {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
background-color: #26282b;
|
||||
padding-right: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.hoverContent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jsonBtn {
|
||||
display: block;
|
||||
color: #b8b7c9;
|
||||
cursor: pointer;
|
||||
background-color: #26282b;
|
||||
font-size: 11px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 0px 6px;
|
||||
margin-bottom: 2px;
|
||||
&:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
&:active {
|
||||
background-color: #1e1f21;
|
||||
}
|
||||
}
|
||||
|
||||
.keyContainer {
|
||||
// min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.key {
|
||||
align-items: center;
|
||||
width: auto;
|
||||
color: #8378ff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
// @extend .hoverable;
|
||||
// @extend .clickable;
|
||||
|
||||
i {
|
||||
color: #555;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.valueContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.value {
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: anywhere;
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
@extend .hoverable;
|
||||
@extend .clickable;
|
||||
}
|
||||
|
||||
.string {
|
||||
color: #a6e22e;
|
||||
word-break: break-all;
|
||||
// margin-left: -6px;
|
||||
&::before,
|
||||
&::after {
|
||||
content: '"';
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
color: #ff9900;
|
||||
}
|
||||
|
||||
.boolean {
|
||||
color: #ff9900;
|
||||
}
|
||||
|
||||
.object {
|
||||
color: #a6e22e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.array {
|
||||
color: #a6e22e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.expandMoreProps {
|
||||
color: #ff9900;
|
||||
font-weight: 500;
|
||||
i {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
331
packages/app/src/components/HyperJson.tsx
Normal file
331
packages/app/src/components/HyperJson.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import * as React from 'react';
|
||||
import cx from 'classnames';
|
||||
import { atom, Provider, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useHydrateAtoms } from 'jotai/utils';
|
||||
import {
|
||||
isArray,
|
||||
isBoolean,
|
||||
isNull,
|
||||
isNumber,
|
||||
isPlainObject,
|
||||
isString,
|
||||
} from 'lodash';
|
||||
import { useHover } from '@mantine/hooks';
|
||||
|
||||
import styles from './HyperJson.module.scss';
|
||||
|
||||
export type LineAction = {
|
||||
key: string;
|
||||
title?: string;
|
||||
label: React.ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export type GetLineActions = (arg0: {
|
||||
key: string;
|
||||
keyPath: string[];
|
||||
value: any;
|
||||
}) => LineAction[];
|
||||
|
||||
// Store common state in an atom so that it can be shared between components
|
||||
// to avoid prop drilling
|
||||
type HyperJsonAtom = {
|
||||
normallyExpanded: boolean;
|
||||
getLineActions?: GetLineActions;
|
||||
};
|
||||
const hyperJsonAtom = atom<HyperJsonAtom>({
|
||||
normallyExpanded: false,
|
||||
});
|
||||
|
||||
const ValueRenderer = React.memo(({ value }: { value: any }) => {
|
||||
if (isNull(value)) {
|
||||
return <span className={styles.null}>null</span>;
|
||||
}
|
||||
if (isString(value)) {
|
||||
return <span className={styles.string}>{value}</span>;
|
||||
}
|
||||
if (isNumber(value)) {
|
||||
return <span className={styles.number}>{value}</span>;
|
||||
}
|
||||
if (isBoolean(value)) {
|
||||
return <span className={styles.boolean}>{value ? 'true' : 'false'}</span>;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return (
|
||||
<span className={styles.object}>
|
||||
{'{}'} {Object.keys(value).length} keys
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isArray(value)) {
|
||||
return (
|
||||
<span className={styles.array}>
|
||||
{'[]'} {value.length} items
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const LineMenu = React.memo(
|
||||
({
|
||||
keyName,
|
||||
keyPath,
|
||||
value,
|
||||
}: {
|
||||
keyName: string;
|
||||
keyPath: string[];
|
||||
value: any;
|
||||
}) => {
|
||||
const { getLineActions } = useAtomValue(hyperJsonAtom);
|
||||
|
||||
const lineActions = React.useMemo(() => {
|
||||
if (getLineActions) {
|
||||
return getLineActions({ key: keyName, keyPath, value });
|
||||
}
|
||||
return [];
|
||||
}, [getLineActions, keyName, keyPath, value]);
|
||||
|
||||
return (
|
||||
<div className={styles.lineMenu}>
|
||||
{lineActions.map(action => (
|
||||
<button
|
||||
key={action.key}
|
||||
title={action.title}
|
||||
className={styles.lineMenuBtn}
|
||||
onClick={e => {
|
||||
action.onClick();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const Line = React.memo(
|
||||
({
|
||||
keyName,
|
||||
keyPath: parentKeyPath,
|
||||
value,
|
||||
disableMenu,
|
||||
}: {
|
||||
keyName: string;
|
||||
keyPath: string[];
|
||||
value: any;
|
||||
disableMenu: boolean;
|
||||
}) => {
|
||||
const { normallyExpanded } = useAtomValue(hyperJsonAtom);
|
||||
|
||||
// For performance reasons, render LineMenu only when hovered instead of
|
||||
// mounting it for potentially hundreds of lines
|
||||
const { ref, hovered } = useHover<HTMLDivElement>();
|
||||
|
||||
const isStringValueJsonLike = React.useMemo(() => {
|
||||
if (!isString(value)) return false;
|
||||
return (
|
||||
(value.startsWith('{') && value.endsWith('}')) ||
|
||||
(value.startsWith('[') && value.endsWith(']'))
|
||||
);
|
||||
}, [value]);
|
||||
|
||||
const [isExpanded, setIsExpanded] = React.useState(
|
||||
normallyExpanded && !isStringValueJsonLike,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsExpanded(normallyExpanded && !isStringValueJsonLike);
|
||||
}, [isStringValueJsonLike, normallyExpanded]);
|
||||
|
||||
const isExpandable = React.useMemo(
|
||||
() =>
|
||||
(isPlainObject(value) && Object.keys(value).length > 0) ||
|
||||
(isArray(value) && value.length > 0) ||
|
||||
isStringValueJsonLike,
|
||||
[isStringValueJsonLike, value],
|
||||
);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
if (!isExpandable) return;
|
||||
setIsExpanded(prev => !prev);
|
||||
}, [isExpandable]);
|
||||
|
||||
const expandedData = React.useMemo(() => {
|
||||
if (isStringValueJsonLike) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}, [isStringValueJsonLike, value]);
|
||||
|
||||
const nestedLevel = parentKeyPath.length;
|
||||
const keyPath = React.useMemo(
|
||||
() => [...parentKeyPath, keyName],
|
||||
[keyName, parentKeyPath],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={handleToggle}
|
||||
className={cx(styles.line, {
|
||||
[styles.nestedLine]: nestedLevel > 0,
|
||||
[styles.expanded]: isExpanded,
|
||||
[styles.expandable]: isExpandable,
|
||||
})}
|
||||
style={{ marginLeft: nestedLevel * 16 }}
|
||||
key={keyName}
|
||||
>
|
||||
<div className={styles.keyContainer}>
|
||||
<div className={styles.key}>
|
||||
{isExpandable &&
|
||||
(isExpanded ? (
|
||||
<i className="bi bi-caret-down-fill fs-9"></i>
|
||||
) : (
|
||||
<i className="bi bi-caret-right-fill fs-9"></i>
|
||||
))}
|
||||
{keyName}
|
||||
<div className={styles.hoverContent}>
|
||||
<i className="bi bi-clipboard" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.valueContainer}>
|
||||
{isStringValueJsonLike ? (
|
||||
isExpanded ? (
|
||||
<div className={styles.object}>{'{}'} Parsed JSON</div>
|
||||
) : (
|
||||
<>
|
||||
<ValueRenderer value={value} />
|
||||
<div className={styles.jsonBtn}>Looks like JSON. Parse?</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<ValueRenderer value={value} />
|
||||
)}
|
||||
</div>
|
||||
{hovered && !disableMenu && (
|
||||
<LineMenu keyName={keyName} keyPath={keyPath} value={value} />
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && isExpandable && (
|
||||
<TreeNode
|
||||
data={expandedData}
|
||||
keyPath={keyPath}
|
||||
disableMenu={isStringValueJsonLike}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const MAX_TREE_NODE_ITEMS = 50;
|
||||
function TreeNode({
|
||||
data,
|
||||
keyPath = [],
|
||||
disableMenu = false,
|
||||
}: {
|
||||
data: object;
|
||||
keyPath?: string[];
|
||||
disableMenu?: boolean;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
const originalLength = React.useMemo(() => Object.keys(data).length, [data]);
|
||||
const visibleLines = React.useMemo(() => {
|
||||
return isExpanded
|
||||
? Object.entries(data)
|
||||
: Object.entries(data).slice(0, MAX_TREE_NODE_ITEMS);
|
||||
}, [data, isExpanded]);
|
||||
const nestedLevel = keyPath?.length || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleLines.map(([key, value]) => (
|
||||
<Line
|
||||
key={key}
|
||||
keyName={key}
|
||||
value={value}
|
||||
keyPath={keyPath}
|
||||
disableMenu={disableMenu}
|
||||
/>
|
||||
))}
|
||||
{originalLength > MAX_TREE_NODE_ITEMS && !isExpanded && (
|
||||
<div
|
||||
className={cx(styles.line, styles.nestedLine, styles.expandable)}
|
||||
style={{ marginLeft: nestedLevel * 16 }}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<div className={styles.keyContainer}>
|
||||
<div className={styles.jsonBtn}>
|
||||
Expand {originalLength - MAX_TREE_NODE_ITEMS} more properties
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Hydrate + allow to use this component multiple times on the same page
|
||||
const HydrateAtoms = ({
|
||||
children,
|
||||
initialValues,
|
||||
}: {
|
||||
initialValues: HyperJsonAtom;
|
||||
children: React.ReactElement;
|
||||
}) => {
|
||||
useHydrateAtoms([[hyperJsonAtom, initialValues]]);
|
||||
const set = useSetAtom(hyperJsonAtom);
|
||||
React.useEffect(() => {
|
||||
set(initialValues);
|
||||
}, [initialValues, set]);
|
||||
return children;
|
||||
};
|
||||
|
||||
type HyperJsonProps = {
|
||||
data: object;
|
||||
normallyExpanded?: boolean;
|
||||
tabulate?: boolean;
|
||||
lineWrap?: boolean;
|
||||
getLineActions?: GetLineActions;
|
||||
};
|
||||
|
||||
const HyperJson = ({
|
||||
data,
|
||||
normallyExpanded = false,
|
||||
tabulate = false,
|
||||
lineWrap,
|
||||
getLineActions,
|
||||
}: HyperJsonProps) => {
|
||||
const isEmpty = React.useMemo(() => Object.keys(data).length === 0, [data]);
|
||||
|
||||
return (
|
||||
<Provider>
|
||||
<HydrateAtoms initialValues={{ normallyExpanded, getLineActions }}>
|
||||
<div
|
||||
className={cx(styles.container, {
|
||||
[styles.withTabulate]: tabulate,
|
||||
[styles.withLineWrap]: lineWrap,
|
||||
})}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="text-slate-400">Empty</div>
|
||||
) : (
|
||||
<TreeNode data={data} />
|
||||
)}
|
||||
</div>
|
||||
</HydrateAtoms>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default HyperJson;
|
||||
Loading…
Reference in a new issue