diff --git a/src/pages/Stream/Views/Explore/Footer.tsx b/src/pages/Stream/Views/Explore/Footer.tsx index 75467748..724165b7 100644 --- a/src/pages/Stream/Views/Explore/Footer.tsx +++ b/src/pages/Stream/Views/Explore/Footer.tsx @@ -10,7 +10,7 @@ import useMountedState from '@/hooks/useMountedState'; import classes from '../../styles/Footer.module.css'; import { LOGS_FOOTER_HEIGHT } from '@/constants/theme'; -const { setPageAndPageData, setCurrentPage, setCurrentOffset } = logsStoreReducers; +const { setPageAndPageData, setCurrentPage, setCurrentOffset, setRowNumber } = logsStoreReducers; const TotalCount = (props: { totalCount: number }) => { return ( @@ -93,6 +93,7 @@ const Footer = (props: { loaded: boolean; hasNoData: boolean; isFetchingCount: b const { totalPages, currentOffset, currentPage, perPage, totalCount, targetPage } = tableOpts; const onPageChange = useCallback((page: number) => { + setLogsStore((store) => setRowNumber(store, '')); setLogsStore((store) => setPageAndPageData(store, page)); }, []); diff --git a/src/pages/Stream/Views/Explore/StaticLogTable.tsx b/src/pages/Stream/Views/Explore/StaticLogTable.tsx index e9b638da..15236d27 100644 --- a/src/pages/Stream/Views/Explore/StaticLogTable.tsx +++ b/src/pages/Stream/Views/Explore/StaticLogTable.tsx @@ -1,6 +1,6 @@ -import { Box } from '@mantine/core'; -import { useCallback, useMemo } from 'react'; -import type { ReactNode } from 'react'; +import { Box, Menu } from '@mantine/core'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Dispatch, ReactNode, SetStateAction } from 'react'; import EmptyBox from '@/components/Empty'; import FilterPills from '../../components/FilterPills'; import tableStyles from '../../styles/Logs.module.css'; @@ -21,8 +21,11 @@ import { Log } from '@/@types/parseable/api/query'; import { CopyIcon } from './JSONView'; import { FieldTypeMap, useStreamStore } from '../../providers/StreamProvider'; import timeRangeUtils from '@/utils/timeRangeUtils'; +import { IconDotsVertical } from '@tabler/icons-react'; +import { copyTextToClipboard } from '@/utils'; +import { notifySuccess } from '@/utils/notification'; -const { setSelectedLog } = logsStoreReducers; +const { setSelectedLog, setRowNumber } = logsStoreReducers; const TableContainer = (props: { children: ReactNode }) => { return {props.children}; }; @@ -54,10 +57,23 @@ const getSanitizedValue = (value: CellType, isTimestamp: boolean) => { return String(value); }; -const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTypeMap: FieldTypeMap) => { +type ContextMenuState = { + visible: boolean; + x: number; + y: number; + row: Log | null; +}; + +const makeHeaderOpts = ( + headers: string[], + isSecureHTTPContext: boolean, + fieldTypeMap: FieldTypeMap, + rowNumber: string, + setContextMenu: Dispatch>, +) => { return _.reduce( headers, - (acc: { accessorKey: string; header: string; grow: boolean }[], header) => { + (acc: { accessorKey: string; header: string; grow: boolean }[], header, index) => { const isTimestamp = _.get(fieldTypeMap, header, null) === 'timestamp'; return [ @@ -76,8 +92,38 @@ const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTy }) .value(); const sanitizedValue = getSanitizedValue(value, isTimestamp); + let isFirstSelectedRow = false; + if (rowNumber) { + const [start] = rowNumber.split(':').map(Number); + isFirstSelectedRow = cell.row.index === start; + } + const isFirstColumn = index === 0; return ( -
+
+
{ + event.stopPropagation(); + setContextMenu({ + visible: true, + x: event.pageX, + y: event.pageY, + row: cell.row.original, + }); + }} + style={{ + display: isFirstSelectedRow && isFirstColumn ? 'flex' : '', + }}> + {isSecureHTTPContext + ? sanitizedValue && + : null} +
{sanitizedValue}
{isSecureHTTPContext ? sanitizedValue && : null} @@ -91,19 +137,31 @@ const makeHeaderOpts = (headers: string[], isSecureHTTPContext: boolean, fieldTy [], ); }; - const makeColumnVisiblityOpts = (columns: string[]) => { return _.reduce(columns, (acc, column) => ({ ...acc, [column]: false }), {}); }; const Table = (props: { primaryHeaderHeight: number }) => { - const [{ orderedHeaders, disabledColumns, pinnedColumns, pageData, wrapDisabledColumns }, setLogsStore] = - useLogsStore((store) => store.tableOpts); + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + row: null, + }); + + const contextMenuRef = useRef(null); + const [{ orderedHeaders, disabledColumns, pageData, wrapDisabledColumns, rowNumber }, setLogsStore] = useLogsStore( + (store) => store.tableOpts, + ); const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext); const [fieldTypeMap] = useStreamStore((store) => store.fieldTypeMap); - const columns = useMemo(() => makeHeaderOpts(orderedHeaders, isSecureHTTPContext, fieldTypeMap), [orderedHeaders]); + const columns = useMemo( + () => makeHeaderOpts(orderedHeaders, isSecureHTTPContext, fieldTypeMap, rowNumber, setContextMenu), + [orderedHeaders, rowNumber], + ); const columnVisibility = useMemo(() => makeColumnVisiblityOpts(disabledColumns), [disabledColumns, orderedHeaders]); - const selectLog = useCallback((log: Log) => { + const selectLog = useCallback((log: Log | null) => { + if (!log) return; const selectedText = window.getSelection()?.toString(); if (selectedText !== undefined && selectedText?.length > 0) return; @@ -126,67 +184,179 @@ const Table = (props: { primaryHeaderHeight: number }) => { }, [wrapDisabledColumns], ); + 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 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]); + + 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 ( - makeCellCustomStyles(id)} - mantineTableHeadRowProps={{ style: { border: 'none' } }} - mantineTableHeadCellProps={{ - style: { - fontWeight: 600, - fontSize: '0.65rem', - border: 'none', - padding: '0.5rem 1rem', - }, - }} - mantineTableBodyRowProps={({ row }) => { - return { - onClick: () => { - selectLog(row.original); + <> + makeCellCustomStyles(id)} + mantineTableHeadRowProps={{ style: { border: 'none' } }} + mantineTableHeadCellProps={{ + style: { + fontWeight: 600, + fontSize: '0.65rem', + border: 'none', + padding: '0.5rem 1rem', }, + }} + mantineTableBodyRowProps={({ row }) => { + return { + onClick: (event) => { + event.preventDefault(); + handleRowClick(row.index, event); + }, + style: { + border: 'none', + background: row.index % 2 === 0 ? '#f8f9fa' : 'white', + backgroundColor: + rowNumber && + (() => { + const [start, end] = rowNumber.split(':').map(Number); + return row.index >= start && row.index <= end; + })() + ? '#E8EDFE' + : '', + }, + }; + }} + mantineTableProps={{ highlightOnHover: false }} + mantineTableHeadProps={{ style: { border: 'none', - background: row.index % 2 === 0 ? '#f8f9fa' : 'white', }, - }; - }} - mantineTableHeadProps={{ - style: { - border: 'none', - }, - }} - columns={columns} - data={pageData} - mantinePaperProps={{ style: { border: 'none' } }} - enablePagination={false} - enableColumnPinning={true} - initialState={{ - columnPinning: { - left: pinnedColumns, - }, - }} - enableStickyHeader={true} - defaultColumn={{ minSize: 100 }} - layoutMode="grid" - state={{ - columnPinning: { - left: pinnedColumns, - }, - columnVisibility, - columnOrder: orderedHeaders, - }} - mantineTableContainerProps={{ - style: { - height: `calc(100vh - ${props.primaryHeaderHeight + LOGS_FOOTER_HEIGHT}px )`, - }, - }} - renderColumnActionsMenuItems={({ column }) => { - return ; - }} - /> + }} + columns={columns} + data={pageData} + mantinePaperProps={{ style: { border: 'none' } }} + enablePagination={false} + enableColumnPinning + initialState={{ + columnPinning: { + left: ['rowNumber'], + }, + }} + enableStickyHeader + defaultColumn={{ minSize: 100 }} + layoutMode="grid" + state={{ + columnPinning: { + left: ['rowNumber'], + }, + columnVisibility, + columnOrder: orderedHeaders, + }} + mantineTableContainerProps={{ + style: { + height: `calc(100vh - ${props.primaryHeaderHeight + LOGS_FOOTER_HEIGHT}px )`, + }, + }} + renderColumnActionsMenuItems={({ column }) => { + return ; + }} + /> + {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; + })()} + { + copyJSON(); + closeContextMenu(); + }}> + Copy JSON + + { + copyUrl(); + closeContextMenu(); + }}> + Copy permalink + + +
+ )} + ); }; diff --git a/src/pages/Stream/hooks/useParamsController.ts b/src/pages/Stream/hooks/useParamsController.ts index ca4526d9..12efc497 100644 --- a/src/pages/Stream/hooks/useParamsController.ts +++ b/src/pages/Stream/hooks/useParamsController.ts @@ -13,14 +13,22 @@ import { appStoreReducers, TimeRange, useAppStore } from '@/layouts/MainLayout/p import { getOffset, joinOrSplit } from '@/utils'; const { getRelativeStartAndEndDate, formatDateWithTimezone, getLocalTimezone } = timeRangeUtils; + const { setTimeRange, syncTimeRange } = appStoreReducers; -const { onToggleView, setPerPage, setCustQuerySearchState, setTargetPage, setCurrentOffset, setTargetColumns } = - logsStoreReducers; +const { + onToggleView, + setPerPage, + setCustQuerySearchState, + setTargetPage, + setCurrentOffset, + setTargetColumns, + setRowNumber, +} = logsStoreReducers; const { toogleQueryParamsFlag, setAppliedFilterQuery, applySavedFilters, updateQuery, updateAppliedQuery } = filterStoreReducers; const timeRangeFormat = 'DD-MMM-YYYY_HH-mmz'; -const keys = ['view', 'rows', 'page', 'interval', 'from', 'to', 'query', 'filterType', 'fields']; +const keys = ['view', 'rows', 'page', 'interval', 'from', 'to', 'query', 'filterType', 'fields', 'rowNumber']; const dateToParamString = (date: Date) => { return formatDateWithTimezone(date, timeRangeFormat); @@ -56,6 +64,14 @@ const deriveTimeRangeParams = (timerange: TimeRange): { interval: string } | { f } }; +const deriveRowNumber = (rowNumber: string) => { + if (rowNumber.length > 0) { + return { + rowNumber, + }; + } +}; + const storeToParamsObj = (opts: { timeRange: TimeRange; view: string; @@ -64,11 +80,13 @@ const storeToParamsObj = (opts: { query: string; filterType: string; fields: string; + rowNumber: string; }): Record => { - const { timeRange, page, view, rows, query, filterType, fields } = opts; + const { timeRange, page, view, rows, query, filterType, fields, rowNumber } = opts; const params: Record = { ...deriveTimeRangeParams(timeRange), + ...deriveRowNumber(rowNumber), view, rows, page, @@ -99,12 +117,21 @@ const useParamsController = () => { const [, setLogsStore] = useLogsStore(() => null); const [, setFilterStore] = useFilterStore((store) => store); - const { currentOffset, currentPage, targetPage, perPage, headers, disabledColumns, targetColumns } = tableOpts; + const { currentOffset, currentPage, targetPage, perPage, headers, disabledColumns, targetColumns, rowNumber } = + tableOpts; const visibleHeaders = headers.filter((el) => !columnsToSkip.includes(el)); const activeHeaders = visibleHeaders.filter((el) => !disabledColumns.includes(el)); const [searchParams, setSearchParams] = useSearchParams(); + + const syncRowNumber = useCallback((storeAsParams: Record, presentParams: Record) => { + if (_.has(presentParams, 'rowNumber')) { + if (storeAsParams.rowNumber !== presentParams.rowNumber) { + setLogsStore((store) => setRowNumber(store, presentParams.rowNumber)); + } + } + }, []); const pageOffset = Math.ceil(currentOffset / perPage); useEffect(() => { @@ -116,6 +143,7 @@ const useParamsController = () => { query: custQuerySearchState.custSearchQuery, filterType: custQuerySearchState.viewMode, fields: `${joinOrSplit(!_.isEmpty(targetColumns) ? targetColumns : activeHeaders)}`, + rowNumber, }); const presentParams = paramsStringToParamsObj(searchParams); if (storeAsParams.query !== presentParams.query) { @@ -160,6 +188,7 @@ const useParamsController = () => { } } + syncRowNumber(storeAsParams, presentParams); setStoreSynced(true); }, []); @@ -173,6 +202,7 @@ const useParamsController = () => { query: custQuerySearchState.custSearchQuery, filterType: custQuerySearchState.viewMode, fields: `${joinOrSplit(!_.isEmpty(targetColumns) ? targetColumns : activeHeaders)}`, + rowNumber, }); const presentParams = paramsStringToParamsObj(searchParams); @@ -199,6 +229,7 @@ const useParamsController = () => { query: custQuerySearchState.custSearchQuery, filterType: custQuerySearchState.viewMode, fields: `${joinOrSplit(!_.isEmpty(targetColumns) ? targetColumns : activeHeaders)}`, + rowNumber, }); const presentParams = paramsStringToParamsObj(searchParams); @@ -221,6 +252,7 @@ const useParamsController = () => { setLogsStore((store) => setCustQuerySearchState(store, presentParams.query, presentParams.filterType)); } syncTimeRangeToStore(storeAsParams, presentParams); + syncRowNumber(storeAsParams, presentParams); }, [searchParams]); const syncTimeRangeToStore = useCallback( diff --git a/src/pages/Stream/providers/LogsProvider.tsx b/src/pages/Stream/providers/LogsProvider.tsx index 31eb9194..77a5d8e0 100644 --- a/src/pages/Stream/providers/LogsProvider.tsx +++ b/src/pages/Stream/providers/LogsProvider.tsx @@ -189,6 +189,7 @@ type LogsStore = { instantSearchValue: string; configViewType: 'schema' | 'columns'; enableWordWrap: boolean; + rowNumber: string; }; data: LogQueryData; @@ -255,6 +256,7 @@ type LogsStoreReducers = { setTargetColumns: (store: LogsStore, columms: string[]) => ReducerOutput; setOrderedHeaders: (store: LogsStore, columns: string[]) => ReducerOutput; toggleWordWrap: (store: LogsStore) => ReducerOutput; + setRowNumber: (store: LogsStore, rowNumber: string) => ReducerOutput; }; const defaultSortKey = 'p_timestamp'; @@ -296,6 +298,7 @@ const initialState: LogsStore = { instantSearchValue: '', configViewType: 'columns', enableWordWrap: true, + rowNumber: '', }, // data @@ -476,6 +479,16 @@ const togglePinnedColumns = (store: LogsStore, columnName: string) => { }; }; +const setRowNumber = (store: LogsStore, rowNumber: string) => { + const { tableOpts } = store; + return { + tableOpts: { + ...tableOpts, + rowNumber, + }, + }; +}; + const filterAndSortData = ( opts: { sortOrder: 'asc' | 'desc'; sortKey: string; filters: Record }, data: Log[], @@ -885,6 +898,7 @@ const logsStoreReducers: LogsStoreReducers = { toggleDeleteModal, toggleDisabledColumns, togglePinnedColumns, + setRowNumber, setLogData, setStreamSchema, setPerPage, diff --git a/src/pages/Stream/styles/Logs.module.css b/src/pages/Stream/styles/Logs.module.css index cd2308d8..384b27c4 100644 --- a/src/pages/Stream/styles/Logs.module.css +++ b/src/pages/Stream/styles/Logs.module.css @@ -272,5 +272,25 @@ right: 0; display: none; } + .actionIconContainer { + position: absolute; + top: 25%; + left: 3px; + cursor: pointer; + padding: 1px 0px; + border-radius: 4px; + background-color: white; + border: 0.8px solid #545beb; + display: none; + } } } + +.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); +}