diff --git a/src/pages/Stream/Views/Explore/JSONView.tsx b/src/pages/Stream/Views/Explore/JSONView.tsx index 058459f4..2ce15d6c 100644 --- a/src/pages/Stream/Views/Explore/JSONView.tsx +++ b/src/pages/Stream/Views/Explore/JSONView.tsx @@ -1,5 +1,5 @@ -import { Box, Loader, Stack, Text, TextInput } from '@mantine/core'; -import { ChangeEvent, ReactNode, useCallback, useRef, useState } from 'react'; +import { Box, Button, Loader, Menu, Stack, Text, TextInput } from '@mantine/core'; +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import classes from '../../styles/JSONView.module.css'; import EmptyBox from '@/components/Empty'; import { ErrorView, LoadingView } from './LoadingViews'; @@ -14,20 +14,29 @@ import { useLogsStore, logsStoreReducers, isJqSearch, formatLogTs } from '../../ import { Log } from '@/@types/parseable/api/query'; import _ from 'lodash'; import jqSearch from '@/utils/jqSearch'; -import { IconCheck, IconCopy, IconSearch } from '@tabler/icons-react'; +import { IconCheck, IconCopy, IconDotsVertical, IconSearch } from '@tabler/icons-react'; import { copyTextToClipboard } from '@/utils'; import { useStreamStore } from '../../providers/StreamProvider'; import timeRangeUtils from '@/utils/timeRangeUtils'; import { AxiosError } from 'axios'; +import { useHotkeys } from '@mantine/hooks'; +import { notifySuccess } from '@/utils/notification'; +import { isFirstRowInRange, isRowHighlighted } from '../../utils'; -const { setInstantSearchValue, applyInstantSearch, applyJqSearch } = logsStoreReducers; +type ContextMenuState = { + visible: boolean; + x: number; + y: number; + row: Log | null; +}; + +const { setInstantSearchValue, applyInstantSearch, applyJqSearch, setRowNumber, setSelectedLog } = logsStoreReducers; const Item = (props: { header: string | null; value: string; highlight: boolean }) => { return ( - {props.header && {props.header}: } - - {props.value}{' '} + + {props.header}: {props.value} ); @@ -70,14 +79,35 @@ const Row = (props: { log: Log; searchValue: string; disableHighlight: boolean; - shouldHighlight: (val: number | string | Date | null) => boolean; + isRowHighlighted: boolean; + showEllipses: boolean; + setContextMenu: any; + shouldHighlight: (header: string | null, val: number | string | Date | null) => boolean; }) => { const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext); const [fieldTypeMap] = useStreamStore((store) => store.fieldTypeMap); - const { log, disableHighlight, shouldHighlight } = props; + const { log, disableHighlight, shouldHighlight, isRowHighlighted, showEllipses, setContextMenu } = props; return ( - + + {showEllipses && ( +
{ + event.stopPropagation(); + setContextMenu({ + visible: true, + x: event.pageX, + y: event.pageY, + row: log, + }); + }}> + +
+ )} {_.isObject(log) ? ( _.map(log, (value, key) => { @@ -90,7 +120,7 @@ const Row = (props: { header={key} key={key} value={sanitizedValue} - highlight={disableHighlight ? false : shouldHighlight(value)} + highlight={disableHighlight ? false : shouldHighlight(key, value)} /> ); }) @@ -98,7 +128,7 @@ const Row = (props: { )} @@ -107,79 +137,163 @@ const Row = (props: { ); }; -const JsonRows = (props: { isSearching: boolean }) => { - const [{ pageData, instantSearchValue }] = useLogsStore((store) => store.tableOpts); +const JsonRows = (props: { isSearching: boolean; setContextMenu: any }) => { + const [{ pageData, instantSearchValue, rowNumber }, setLogsStore] = useLogsStore((store) => store.tableOpts); const disableHighlight = props.isSearching || _.isEmpty(instantSearchValue) || isJqSearch(instantSearchValue); - const regExp = disableHighlight ? null : new RegExp(instantSearchValue, 'i'); const shouldHighlight = useCallback( - (val: number | string | Date | null) => { - return !!regExp?.test(_.toString(val)); + (header: string | null, val: number | string | Date | null) => { + return String(val).includes(instantSearchValue) || String(header).includes(instantSearchValue); }, - [regExp], + [instantSearchValue], ); + const handleRowClick = (index: number, event: React.MouseEvent) => { + let newRange = `${index}:${index}`; + + if ((event.ctrlKey || event.metaKey) && rowNumber) { + const [start, end] = rowNumber.split(':').map(Number); + const lastIndex = Math.max(start, end); + + const startIndex = Math.min(lastIndex, index); + const endIndex = Math.max(lastIndex, index); + newRange = `${startIndex}:${endIndex}`; + setLogsStore((store) => setRowNumber(store, newRange)); + } else { + if (rowNumber) { + const [start, end] = rowNumber.split(':').map(Number); + if (index >= start && index <= end) { + setLogsStore((store) => setRowNumber(store, '')); + return; + } + } + + setLogsStore((store) => setRowNumber(store, newRange)); + } + }; + return ( {_.map(pageData, (d, index) => ( - + onClick={(event) => { + event.preventDefault(); + handleRowClick(index, event); + }}> + + ))} ); }; -const Toolbar = (props: { isSearching: boolean; setSearching: React.Dispatch> }) => { - const { isSearching, setSearching } = props; +const Toolbar = ({ + isSearching, + setSearching, +}: { + isSearching: boolean; + setSearching: React.Dispatch>; +}) => { + const [localSearchValue, setLocalSearchValue] = useState(''); + const searchInputRef = useRef(null); + const [searchValue, setLogsStore] = useLogsStore((store) => store.tableOpts.instantSearchValue); const [{ rawData, filteredData }] = useLogsStore((store) => store.data); const debouncedSearch = useCallback( _.debounce(async (val: string) => { - const isJq = isJqSearch(val); - if (isJq) { - const jqResult = await jqSearch(rawData, val); - setLogsStore((store) => applyJqSearch(store, jqResult)); - } else { + if (val.trim() === '') { + setLogsStore((store) => setInstantSearchValue(store, '')); setLogsStore(applyInstantSearch); + } else { + const isJq = isJqSearch(val); + if (isJq) { + const jqResult = await jqSearch(rawData, val); + setLogsStore((store) => applyJqSearch(store, jqResult)); + } else { + setLogsStore(applyInstantSearch); + } } setSearching(false); - }, 1000), + }, 500), [rawData], ); - const onChange = useCallback((e: ChangeEvent) => { - setLogsStore((store) => setInstantSearchValue(store, e.target.value)); - debouncedSearch(e.target.value); - setSearching(true); - }, []); + const handleSearch = useCallback(() => { + if (localSearchValue.trim()) { + setSearching(true); + setLogsStore((store) => setInstantSearchValue(store, localSearchValue)); + debouncedSearch(localSearchValue); + } + }, [localSearchValue, debouncedSearch, setSearching]); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setLocalSearchValue(value); + if (value.trim() === '') { + debouncedSearch(value); + } + }, + [debouncedSearch], + ); + + useHotkeys([['mod+K', () => searchInputRef.current?.focus()]]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !isSearching && localSearchValue.trim()) { + handleSearch(); + } + }, + [isSearching, localSearchValue], + ); if (_.isEmpty(rawData)) return null; + const inputStyles = { + '--input-left-section-width': '2rem', + '--input-right-section-width': '6rem', + width: '100%', + } as React.CSSProperties; + return ( - +
: } placeholder="Search loaded data with text or jq. For jq input try `jq .[]`" - value={searchValue} - onChange={onChange} + value={localSearchValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + ref={searchInputRef} classNames={{ input: classes.inputField }} - style={{ '--input-left-section-width': '2rem', '--input-right-section-width': '6rem' }} + style={inputStyles} rightSection={ - !_.isEmpty(searchValue) && - !isSearching && ( + searchValue && !isSearching ? ( - {_.size(filteredData)} Matches + {filteredData.length} Matches - ) + ) : null } /> - + +
); }; @@ -194,13 +308,63 @@ const JsonView = (props: { isFetchingCount: boolean; }) => { const [maximized] = useAppStore((store) => store.maximized); + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + row: null, + }); + const contextMenuRef = useRef(null); const { errorMessage, hasNoData, showTable, isFetchingCount } = props; const [isSearching, setSearching] = useState(false); + const [rowNumber, setLogsStore] = useLogsStore((store) => store.tableOpts.rowNumber); + const [pageData] = useLogsStore((store) => store.tableOpts.pageData); + const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext); const primaryHeaderHeight = !maximized ? PRIMARY_HEADER_HEIGHT + STREAM_PRIMARY_TOOLBAR_CONTAINER_HEIGHT + STREAM_SECONDARY_TOOLBAR_HRIGHT : 0; + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (contextMenuRef.current && !contextMenuRef.current.contains(event.target as Node)) { + closeContextMenu(); + } + }; + + if (contextMenu.visible) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [contextMenu.visible]); + + const closeContextMenu = () => setContextMenu({ visible: false, x: 0, y: 0, row: null }); + + const selectLog = useCallback((log: Log | null) => { + if (!log) return; + const selectedText = window.getSelection()?.toString(); + if (selectedText !== undefined && selectedText?.length > 0) return; + + setLogsStore((store) => setSelectedLog(store, log)); + }, []); + + const copyUrl = useCallback(() => { + copyTextToClipboard(window.location.href); + notifySuccess({ message: 'Link Copied!' }); + }, [window.location.href]); + + const copyJSON = useCallback(() => { + const [start, end] = rowNumber.split(':').map(Number); + + const rowsToCopy = pageData.slice(start, end + 1); + + copyTextToClipboard(rowsToCopy); + notifySuccess({ message: 'JSON Copied!' }); + }, [rowNumber]); + return ( @@ -212,10 +376,59 @@ const JsonView = (props: { style={{ display: 'flex', flexDirection: 'row', maxHeight: `calc(100vh - ${primaryHeaderHeight}px )` }}> - + + {contextMenu.visible && ( +
+ + {(() => { + const [start, end] = rowNumber.split(':').map(Number); + const rowCount = end - start + 1; + + if (rowCount === 1) { + return ( + { + selectLog(contextMenu.row); + closeContextMenu(); + }}> + View JSON + + ); + } + + return null; + })()} + {isSecureHTTPContext && ( + <> + { + copyJSON(); + closeContextMenu(); + }}> + Copy JSON + + { + copyUrl(); + closeContextMenu(); + }}> + Copy permalink + + + )} + +
+ )} ) : hasNoData ? ( <> diff --git a/src/pages/Stream/components/PrimaryToolbar.tsx b/src/pages/Stream/components/PrimaryToolbar.tsx index ad50af00..f56208d7 100644 --- a/src/pages/Stream/components/PrimaryToolbar.tsx +++ b/src/pages/Stream/components/PrimaryToolbar.tsx @@ -1,6 +1,6 @@ -import { Button, SegmentedControl, Stack, Tooltip, px, rem } from '@mantine/core'; +import { Button, Stack, px, rem } from '@mantine/core'; import IconButton from '@/components/Button/IconButton'; -import { IconBraces, IconFilterHeart, IconMaximize, IconTable, IconTrash } from '@tabler/icons-react'; +import { IconFilterHeart, IconMaximize, IconTable, IconTrash } from '@tabler/icons-react'; import { STREAM_PRIMARY_TOOLBAR_CONTAINER_HEIGHT, STREAM_PRIMARY_TOOLBAR_HEIGHT } from '@/constants/theme'; import TimeRange from '@/components/Header/TimeRange'; import RefreshInterval from '@/components/Header/RefreshInterval'; @@ -70,36 +70,23 @@ const ViewToggle = () => { style: { width: rem(20), height: rem(20), display: 'block' }, stroke: 1.8, }; - const onChange = useCallback((val: string) => { - if (_.includes(['json', 'table'], val)) { - setLogsStore((store) => onToggleView(store, val as 'json' | 'table')); - } - }, []); + const onToggle = useCallback(() => { + setLogsStore((store) => onToggleView(store, viewMode === 'table' ? 'json' : 'table')); + }, [viewMode]); + + const isActive = viewMode === 'table'; return ( - - - - ), - }, - { - value: 'json', - label: ( - - - - ), - }, - ]} - /> + ); }; diff --git a/src/pages/Stream/providers/LogsProvider.tsx b/src/pages/Stream/providers/LogsProvider.tsx index 77a5d8e0..9a85b9e2 100644 --- a/src/pages/Stream/providers/LogsProvider.tsx +++ b/src/pages/Stream/providers/LogsProvider.tsx @@ -269,7 +269,7 @@ const initialState: LogsStore = { selectedLog: null, custQuerySearchState: defaultCustQuerySearchState, sideBarOpen: false, - viewMode: 'table', + viewMode: 'json', modalOpts: { deleteModalOpen: false, alertsModalOpen: false, @@ -504,17 +504,21 @@ const filterAndSortData = ( const searchAndSortData = (opts: { searchValue: string }, data: Log[]) => { const { searchValue } = opts; - const regExp = new RegExp(searchValue, 'i'); const filteredData = _.isEmpty(searchValue) ? data : (_.reduce( data, (acc: Log[], d: Log) => { const allValues = _.chain(d) - .values() - .map((e) => _.toString(e)) + .entries() + .map(([key, value]) => [key, _.toString(value)]) .value(); - const doesMatch = _.some(allValues, (str) => regExp.test(str)); + + const doesMatch = _.some( + allValues, + ([key, value]) => key.includes(searchValue) || value.includes(searchValue), + ); + return doesMatch ? [...acc, d] : acc; }, [], diff --git a/src/pages/Stream/styles/JSONView.module.css b/src/pages/Stream/styles/JSONView.module.css index a8cd9fed..4aa03a39 100644 --- a/src/pages/Stream/styles/JSONView.module.css +++ b/src/pages/Stream/styles/JSONView.module.css @@ -14,6 +14,17 @@ overflow: hidden; } +.actionIconContainer { + position: absolute; + left: 0px; + cursor: pointer; + padding: 1px 0px; + border-radius: 4px; + background-color: white; + border: 0.8px solid #545beb; + display: flex; +} + .rowContainer { border-bottom: 1px solid var(--mantine-color-gray-1); padding: 0.25rem 1rem; @@ -54,4 +65,24 @@ .inputField { border-radius: rem(8px); + width: 100%; +} + +.headerWrapper { + height: 50px; + padding: 1rem; + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 10px; +} + +.contextMenuContainer { + position: fixed; + z-index: 1000; + background-color: white; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } diff --git a/src/pages/Stream/utils.ts b/src/pages/Stream/utils.ts index 8a89ab5f..68efeca6 100644 --- a/src/pages/Stream/utils.ts +++ b/src/pages/Stream/utils.ts @@ -74,3 +74,15 @@ export const genColumnsToShow = (opts: { ]; return headers.filter((header) => !columnsToIgnore.includes(header)); }; + +export const isRowHighlighted = (index: number, rowNumber: string) => { + if (!rowNumber) return false; + const [start, end] = rowNumber.split(':').map(Number); + return index >= start && index <= end; +}; + +export const isFirstRowInRange = (index: number, rowNumber: string) => { + if (!rowNumber) return false; + const [start] = rowNumber.split(':').map(Number); + return index === start; +};