diff --git a/src/api/query.ts b/src/api/query.ts index d699ab6c..b74d066a 100644 --- a/src/api/query.ts +++ b/src/api/query.ts @@ -2,7 +2,7 @@ import { Axios } from './axios'; import { LOG_QUERY_URL } from './constants'; import { Log, LogsQuery, LogsResponseWithHeaders } from '@/@types/parseable/api/query'; import timeRangeUtils from '@/utils/timeRangeUtils'; -import { QueryBuilder } from '@/utils/queryBuilder'; +import { CorrelationQueryBuilder, QueryBuilder } from '@/utils/queryBuilder'; const { formatDateAsCastType } = timeRangeUtils; type QueryLogs = { @@ -13,6 +13,15 @@ type QueryLogs = { pageOffset: number; }; +type CorrelationLogs = { + streamNames: string[]; + startTime: Date; + endTime: Date; + limit: number; + correlationCondition?: string; + selectedFields?: string[]; +}; + // to optimize query performace, it has been decided to round off the time at the given level const optimizeTime = (date: Date) => { const tempDate = new Date(date); @@ -53,6 +62,18 @@ export const getQueryLogsWithHeaders = (logsQuery: QueryLogs) => { return Axios().post(endPoint, formQueryOpts(logsQuery), {}); }; +export const getCorrelationQueryLogsWithHeaders = (logsQuery: CorrelationLogs) => { + const queryBuilder = new CorrelationQueryBuilder(logsQuery); + const endPoint = LOG_QUERY_URL({ fields: true }, queryBuilder.getResourcePath()); + return Axios().post(endPoint, queryBuilder.getCorrelationQuery(), {}); +}; + +export const getStreamDataWithHeaders = (logsQuery: CorrelationLogs) => { + const queryBuilder = new CorrelationQueryBuilder(logsQuery); + const endPoint = LOG_QUERY_URL({ fields: true }, queryBuilder.getResourcePath()); + return Axios().post(endPoint, queryBuilder.getQuery(), {}); +}; + // ------ Custom sql query const makeCustomQueryRequestData = (logsQuery: LogsQuery, query: string) => { diff --git a/src/assets/images/correlation_placeholder.svg b/src/assets/images/correlation_placeholder.svg new file mode 100644 index 00000000..2fbabe01 --- /dev/null +++ b/src/assets/images/correlation_placeholder.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Navbar/components/CorrelationIcon.tsx b/src/components/Navbar/components/CorrelationIcon.tsx new file mode 100644 index 00000000..1962aab1 --- /dev/null +++ b/src/components/Navbar/components/CorrelationIcon.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from 'react'; + +export const CorrelationIcon = forwardRef< + SVGSVGElement, + { + stroke?: string; + strokeWidth?: number; + } +>(({ stroke, strokeWidth }, ref) => ( + + + + +)); + +CorrelationIcon.displayName = 'CorrelationIcon'; diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index 61186096..e1528f7d 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -13,7 +13,14 @@ import { FC, useCallback, useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom'; import { useDisclosure } from '@mantine/hooks'; -import { HOME_ROUTE, CLUSTER_ROUTE, USERS_MANAGEMENT_ROUTE, STREAM_ROUTE, DASHBOARDS_ROUTE } from '@/constants/routes'; +import { + HOME_ROUTE, + CLUSTER_ROUTE, + USERS_MANAGEMENT_ROUTE, + STREAM_ROUTE, + DASHBOARDS_ROUTE, + CORRELATION_ROUTE, +} from '@/constants/routes'; import InfoModal from './infoModal'; import { getStreamsSepcificAccess, getUserSepcificStreams } from './rolesHandler'; import Cookies from 'js-cookie'; @@ -26,6 +33,7 @@ import UserModal from './UserModal'; import { signOutHandler } from '@/utils'; import { appStoreReducers, useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; import _ from 'lodash'; +import { CorrelationIcon } from './components/CorrelationIcon'; const { setUserRoles, setUserSpecificStreams, setUserAccessMap, changeStream, setStreamSpecificUserAccess } = appStoreReducers; @@ -49,6 +57,12 @@ const navItems = [ path: '/explore', route: STREAM_ROUTE, }, + { + icon: CorrelationIcon, + label: 'Correlation', + path: '/correlation', + route: CORRELATION_ROUTE, + }, ]; const previlagedActions = [ @@ -167,7 +181,14 @@ const Navbar: FC = () => { onClick={() => navigateToPage(navItem.route)} key={index}> - + {navItem.label === 'Correlation' ? ( + + ) : ( + + )} ); diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 462827b6..60a6c4e7 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -11,6 +11,7 @@ export const OIDC_NOT_CONFIGURED_ROUTE = '/oidc-not-configured'; export const CLUSTER_ROUTE = '/cluster'; export const STREAM_ROUTE = '/:streamName/:view?'; export const DASHBOARDS_ROUTE = '/dashboards'; +export const CORRELATION_ROUTE = '/correlation'; export const STREAM_VIEWS = ['explore', 'manage', 'live-tail']; @@ -27,4 +28,5 @@ export const PATHS = { cluster: '/cluster', manage: '/:streamName/:view?', dashboards: '/dashboards', + correlation: '/correlation', } as { [key: string]: string }; diff --git a/src/hooks/useCorrelationQueryLogs.tsx b/src/hooks/useCorrelationQueryLogs.tsx new file mode 100644 index 00000000..8615e10a --- /dev/null +++ b/src/hooks/useCorrelationQueryLogs.tsx @@ -0,0 +1,85 @@ +import { getCorrelationQueryLogsWithHeaders } from '@/api/query'; +import { StatusCodes } from 'http-status-codes'; +import useMountedState from './useMountedState'; +import { useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; +import _ from 'lodash'; +import { AxiosError } from 'axios'; +import { useStreamStore } from '@/pages/Stream/providers/StreamProvider'; +import { + CORRELATION_LOAD_LIMIT, + correlationStoreReducers, + useCorrelationStore, +} from '@/pages/Correlation/providers/CorrelationProvider'; +import { notifyError } from '@/utils/notification'; +import { useQuery } from 'react-query'; +import { LogsResponseWithHeaders } from '@/@types/parseable/api/query'; + +const { setStreamData } = correlationStoreReducers; + +export const useCorrelationQueryLogs = () => { + const [error, setError] = useMountedState(null); + const [{ selectedFields, correlationCondition, fields }, setCorrelationStore] = useCorrelationStore((store) => store); + const [streamInfo] = useStreamStore((store) => store.info); + const [currentStream] = useAppStore((store) => store.currentStream); + const timePartitionColumn = _.get(streamInfo, 'time_partition', 'p_timestamp'); + const [timeRange] = useAppStore((store) => store.timeRange); + const [ + { + tableOpts: { currentOffset }, + }, + ] = useCorrelationStore((store) => store); + const streamNames = Object.keys(fields); + + const defaultQueryOpts = { + startTime: timeRange.startTime, + endTime: timeRange.endTime, + limit: CORRELATION_LOAD_LIMIT, + pageOffset: currentOffset, + timePartitionColumn, + selectedFields: _.flatMap(selectedFields, (values, key) => _.map(values, (value) => `${key}.${value}`)) || [], + correlationCondition: correlationCondition, + }; + + const { + isLoading: logsLoading, + isRefetching: logsRefetching, + refetch: getCorrelationData, + } = useQuery( + ['fetch-logs', defaultQueryOpts], + async () => { + const queryOpts = { ...defaultQueryOpts, streamNames }; + const response = await getCorrelationQueryLogsWithHeaders(queryOpts); + return [response]; + }, + { + enabled: false, + refetchOnWindowFocus: false, + onSuccess: async (responses) => { + responses.map((data: { data: LogsResponseWithHeaders; status: StatusCodes }) => { + const logs = data.data; + const isInvalidResponse = _.isEmpty(logs) || _.isNil(logs) || data.status !== StatusCodes.OK; + if (isInvalidResponse) return setError('Failed to query logs'); + + const { records, fields } = logs; + if (fields.length > 0 && !correlationCondition) { + return setCorrelationStore((store) => setStreamData(store, currentStream || '', records)); + } else if (fields.length > 0 && correlationCondition) { + return setCorrelationStore((store) => setStreamData(store, 'correlatedStream', records)); + } else { + notifyError({ message: `${currentStream} doesn't have any fields` }); + } + }); + }, + onError: (data: AxiosError) => { + const errorMessage = data.response?.data as string; + setError(_.isString(errorMessage) && !_.isEmpty(errorMessage) ? errorMessage : 'Failed to query logs'); + }, + }, + ); + + return { + error, + loading: logsLoading || logsRefetching, + getCorrelationData, + }; +}; diff --git a/src/hooks/useFetchStreamData.tsx b/src/hooks/useFetchStreamData.tsx new file mode 100644 index 00000000..0d0f3470 --- /dev/null +++ b/src/hooks/useFetchStreamData.tsx @@ -0,0 +1,104 @@ +import { getStreamDataWithHeaders } from '@/api/query'; +import { StatusCodes } from 'http-status-codes'; +import useMountedState from './useMountedState'; +import { useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; +import _ from 'lodash'; +import { AxiosError } from 'axios'; +import { useStreamStore } from '@/pages/Stream/providers/StreamProvider'; +import { + correlationStoreReducers, + CORRELATION_LOAD_LIMIT, + useCorrelationStore, +} from '@/pages/Correlation/providers/CorrelationProvider'; +import { notifyError } from '@/utils/notification'; +import { useQuery } from 'react-query'; +import { LogsResponseWithHeaders } from '@/@types/parseable/api/query'; +import { useRef, useEffect } from 'react'; + +const { setStreamData } = correlationStoreReducers; + +export const useFetchStreamData = () => { + const [error, setError] = useMountedState(null); + const [{ selectedFields, correlationCondition, fields, streamData }, setCorrelationStore] = useCorrelationStore( + (store) => store, + ); + const [streamInfo] = useStreamStore((store) => store.info); + const [currentStream] = useAppStore((store) => store.currentStream); + const timePartitionColumn = _.get(streamInfo, 'time_partition', 'p_timestamp'); + const [timeRange] = useAppStore((store) => store.timeRange); + const [ + { + tableOpts: { currentOffset }, + }, + ] = useCorrelationStore((store) => store); + const streamNames = Object.keys(fields); + + const prevTimeRangeRef = useRef({ startTime: timeRange.startTime, endTime: timeRange.endTime }); + + const hasTimeRangeChanged = + prevTimeRangeRef.current.startTime !== timeRange.startTime || + prevTimeRangeRef.current.endTime !== timeRange.endTime; + + useEffect(() => { + prevTimeRangeRef.current = { startTime: timeRange.startTime, endTime: timeRange.endTime }; + }, [timeRange.startTime, timeRange.endTime]); + + const defaultQueryOpts = { + startTime: timeRange.startTime, + endTime: timeRange.endTime, + limit: CORRELATION_LOAD_LIMIT, + pageOffset: currentOffset, + timePartitionColumn, + selectedFields: _.flatMap(selectedFields, (values, key) => _.map(values, (value) => `${key}.${value}`)) || [], + correlationCondition: correlationCondition, + }; + + const { + isLoading: logsLoading, + isRefetching: logsRefetching, + refetch: getFetchStreamData, + } = useQuery( + ['fetch-logs', defaultQueryOpts], + async () => { + const streamsToFetch = hasTimeRangeChanged + ? streamNames + : streamNames.filter((streamName) => !Object.keys(streamData).includes(streamName)); + + const fetchPromises = streamsToFetch.map((streamName) => { + const queryOpts = { ...defaultQueryOpts, streamNames: [streamName] }; + return getStreamDataWithHeaders(queryOpts); + }); + return Promise.all(fetchPromises); + }, + { + enabled: false, + refetchOnWindowFocus: false, + onSuccess: async (responses) => { + responses.map((data: { data: LogsResponseWithHeaders; status: StatusCodes }) => { + const logs = data.data; + const isInvalidResponse = _.isEmpty(logs) || _.isNil(logs) || data.status !== StatusCodes.OK; + if (isInvalidResponse) return setError('Failed to query logs'); + + const { records, fields } = logs; + if (fields.length > 0 && !correlationCondition) { + return setCorrelationStore((store) => setStreamData(store, currentStream || '', records)); + } else if (fields.length > 0 && correlationCondition) { + return setCorrelationStore((store) => setStreamData(store, 'correlatedStream', records)); + } else { + notifyError({ message: `${currentStream} doesn't have any fields` }); + } + }); + }, + onError: (data: AxiosError) => { + const errorMessage = data.response?.data as string; + setError(_.isString(errorMessage) && !_.isEmpty(errorMessage) ? errorMessage : 'Failed to query logs'); + }, + }, + ); + + return { + error, + loading: logsLoading || logsRefetching, + getFetchStreamData, + }; +}; diff --git a/src/hooks/useGetCorrelationStreamSchema.ts b/src/hooks/useGetCorrelationStreamSchema.ts new file mode 100644 index 00000000..f426c948 --- /dev/null +++ b/src/hooks/useGetCorrelationStreamSchema.ts @@ -0,0 +1,45 @@ +import { getLogStreamSchema } from '@/api/logStream'; +import { AxiosError, isAxiosError } from 'axios'; +import _ from 'lodash'; +import { useQuery } from 'react-query'; +import { useState } from 'react'; +import { correlationStoreReducers, useCorrelationStore } from '@/pages/Correlation/providers/CorrelationProvider'; + +const { setStreamSchema } = correlationStoreReducers; + +export const useGetStreamSchema = (opts: { streamName: string }) => { + const { streamName } = opts; + const [, setCorrelationStore] = useCorrelationStore((_store) => null); + + const [errorMessage, setErrorMesssage] = useState(null); + + const { isError, isSuccess, isLoading, isRefetching } = useQuery( + ['stream-schema', streamName], + () => getLogStreamSchema(streamName), + { + retry: false, + enabled: streamName !== '' && streamName !== 'correlatedStream', + refetchOnWindowFocus: false, + onSuccess: (data) => { + setErrorMesssage(null); + setCorrelationStore((store) => setStreamSchema(store, data.data, streamName)); + }, + onError: (data: AxiosError) => { + if (isAxiosError(data) && data.response) { + const error = data.response?.data as string; + typeof error === 'string' && setErrorMesssage(error); + } else if (data.message && typeof data.message === 'string') { + setErrorMesssage(data.message); + } + }, + }, + ); + + return { + isSuccess, + isError, + isLoading, + errorMessage, + isRefetching, + }; +}; diff --git a/src/pages/Correlation/Views/CorrelationFooter.tsx b/src/pages/Correlation/Views/CorrelationFooter.tsx new file mode 100644 index 00000000..35f89e78 --- /dev/null +++ b/src/pages/Correlation/Views/CorrelationFooter.tsx @@ -0,0 +1,165 @@ +import { FC, useCallback } from 'react'; +import { usePagination } from '@mantine/hooks'; +import { Box, Center, Group, Menu, Pagination, Stack } from '@mantine/core'; +import _ from 'lodash'; +import { Text } from '@mantine/core'; +import { IconSelector } from '@tabler/icons-react'; +import useMountedState from '@/hooks/useMountedState'; +import classes from '../styles/Footer.module.css'; +import { LOGS_FOOTER_HEIGHT } from '@/constants/theme'; +import { correlationStoreReducers, useCorrelationStore } from '@/pages/Correlation/providers/CorrelationProvider'; +import { LOG_QUERY_LIMITS, LOAD_LIMIT } from '@/pages/Stream/providers/LogsProvider'; + +const { setCurrentOffset, setCurrentPage, setPageAndPageData } = correlationStoreReducers; + +const LimitControl: FC = () => { + const [opened, setOpened] = useMountedState(false); + const [perPage, setCorrelationStore] = useCorrelationStore((store) => store.tableOpts.perPage); + + const toggle = () => { + setOpened(!opened); + }; + + const onSelect = (limit: number) => { + if (perPage !== limit) { + setCorrelationStore((store) => setPageAndPageData(store, 1, limit)); + } + }; + + return ( + + +
+ + + {perPage} + + + +
+ + {LOG_QUERY_LIMITS.map((limit) => { + return ( + onSelect(limit)}> +
+ {limit} +
+
+ ); + })} +
+
+
+ ); +}; + +const CorrelationFooter = (props: { loaded: boolean; hasNoData: boolean; isFetchingCount: boolean }) => { + const [tableOpts, setCorrelationStore] = useCorrelationStore((store) => store.tableOpts); + const { totalPages, currentOffset, currentPage, perPage, totalCount } = tableOpts; + + const onPageChange = useCallback((page: number) => { + setCorrelationStore((store) => setPageAndPageData(store, page)); + }, []); + + const pagination = usePagination({ total: totalPages ?? 1, initialPage: 1, onChange: onPageChange }); + const onChangeOffset = useCallback( + (key: 'prev' | 'next') => { + if (key === 'prev') { + const targetOffset = currentOffset - LOAD_LIMIT; + if (currentOffset < 0) return; + + if (targetOffset === 0 && currentOffset > 0) { + // hack to initiate fetch + setCorrelationStore((store) => setCurrentPage(store, 0)); + } + setCorrelationStore((store) => setCurrentOffset(store, targetOffset)); + } else { + const targetOffset = currentOffset + LOAD_LIMIT; + setCorrelationStore((store) => setCurrentOffset(store, targetOffset)); + } + }, + [currentOffset], + ); + + return ( + + + {/* */} + + + {props.loaded ? ( + { + pagination.setPage(page); + }} + size="sm"> + + { + currentOffset !== 0 && onChangeOffset('prev'); + }} + disabled={currentOffset === 0} + /> + + {pagination.range.map((page, index) => { + if (page === 'dots') { + return ; + } else { + return ( + { + pagination.setPage(page); + }}> + {(perPage ? Math.ceil(page + currentOffset / perPage) : page) ?? 1} + + ); + } + })} + + { + onChangeOffset('next'); + }} + disabled={!(currentOffset + LOAD_LIMIT < totalCount)} + /> + + + ) : null} + + + {/* {props.loaded && ( + + +
+ +
+
+ + exportHandler('CSV')} style={{ padding: '0.5rem 2.25rem 0.5rem 0.75rem' }}> + CSV + + exportHandler('JSON')} style={{ padding: '0.5rem 2.25rem 0.5rem 0.75rem' }}> + JSON + + +
+ )} */} + +
+
+ ); +}; + +export default CorrelationFooter; diff --git a/src/pages/Correlation/Views/CorrelationTable.tsx b/src/pages/Correlation/Views/CorrelationTable.tsx new file mode 100644 index 00000000..c34a33f9 --- /dev/null +++ b/src/pages/Correlation/Views/CorrelationTable.tsx @@ -0,0 +1,203 @@ +import { MantineReactTable, MRT_ColumnDef } from 'mantine-react-table'; +import { useCorrelationStore } from '../providers/CorrelationProvider'; +import { useCallback, useEffect, useState } from 'react'; +import tableStyles from '../styles/CorrelationLogs.module.css'; +import { formatLogTs } from '@/pages/Stream/providers/LogsProvider'; +import { CopyIcon } from '@/pages/Stream/Views/Explore/JSONView'; +import { useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; +import { LOGS_FOOTER_HEIGHT } from '@/constants/theme'; +import { Log } from '@/@types/parseable/api/query'; +import { FieldTypeMap } from '@/pages/Stream/providers/StreamProvider'; +import _ from 'lodash'; +import { Box } from '@mantine/core'; +import EmptyBox from '@/components/Empty'; +import { ErrorView, LoadingView } from '@/pages/Stream/Views/Explore/LoadingViews'; + +type CellType = string | number | boolean | null | undefined; + +const getSanitizedValue = (value: CellType, isTimestamp: boolean) => { + if (isTimestamp) { + const timestamp = String(value).trim(); + const isValidTimestamp = !isNaN(Date.parse(timestamp)); + + if (timestamp && isValidTimestamp) { + return formatLogTs(timestamp); + } else { + return ''; + } + } + + if (value === null || value === undefined) { + return ''; + } + + if (typeof value === 'boolean') { + return value.toString(); + } + + return String(value); +}; + +const makeColumnsFromSelectedFields = (pageData: Log[], isSecureHTTPContext: boolean, fieldTypeMap: FieldTypeMap) => { + if (!pageData || pageData.length === 0 || !pageData[0]) { + return []; + } + return Object.keys(pageData[0]).map((field: string) => ({ + id: field, + header: field, + accessorFn: (row: any) => { + try { + return _.get(row, field, ''); + } catch { + return ''; + } + }, + grow: true, + Cell: ({ cell }: { cell: any }) => { + const value = _.get(cell.row.original, field, ''); + const isTimestamp = _.get(fieldTypeMap, field, null) === 'timestamp'; + const sanitizedValue = getSanitizedValue(value, isTimestamp); + + return ( +
+ {sanitizedValue} +
+ {isSecureHTTPContext && sanitizedValue && } +
+
+ ); + }, + })); +}; + +const Table = (props: { + primaryHeaderHeight: number; + errorMessage: string | null; + logsLoading: boolean; + streamsLoading: boolean; + hasNoData: boolean; + showTable: boolean; +}) => { + const { errorMessage, logsLoading, streamsLoading, primaryHeaderHeight, showTable, hasNoData } = props; + const [{ pageData, wrapDisabledColumns }] = useCorrelationStore((store) => store.tableOpts); + const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext); + const [columns, setColumns] = useState[]>([]); + + const showTableOrLoader = logsLoading || streamsLoading || showTable || !errorMessage || !hasNoData; + + useEffect(() => { + const updatedColumns = makeColumnsFromSelectedFields(pageData, isSecureHTTPContext, { + datetime: 'text', + host: 'text', + id: 'text', + method: 'text', + p_metadata: 'text', + p_tags: 'text', + p_timestamp: 'timestamp', + referrer: 'text', + status: 'number', + 'user-identifier': 'text', + }); + setColumns(updatedColumns); + }, [pageData]); + + const makeCellCustomStyles = useCallback( + (columnName: string) => { + return { + className: tableStyles.customCell, + style: { + padding: '0.5rem 1rem', + fontSize: '0.6rem', + overflow: 'hidden', + textOverflow: 'ellipsis', + display: 'table-cell', + ...(!_.includes(wrapDisabledColumns, columnName) ? { whiteSpace: 'nowrap' as const } : {}), + }, + }; + }, + [wrapDisabledColumns], + ); + + return ( + + {!errorMessage ? ( + showTableOrLoader ? ( + + + + {logsLoading && } + + {hasNoData ? ( + + ) : ( + makeCellCustomStyles(id)} + mantineTableHeadRowProps={{ style: { border: 'none' } }} + mantineTableHeadCellProps={{ + style: { + fontWeight: 600, + fontSize: '0.65rem', + border: 'none', + padding: '0.5rem 1rem', + }, + }} + mantineTableBodyRowProps={({ row }) => ({ + 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={{}} + enableStickyHeader={true} + defaultColumn={{ minSize: 100 }} + layoutMode="grid" + state={{}} + mantineTableContainerProps={{ + style: { + height: `calc(100vh - ${primaryHeaderHeight + LOGS_FOOTER_HEIGHT}px )`, + }, + }} + /> + )} + + + ) : hasNoData ? ( + <> + ) : ( + + ) + ) : ( + + )} + + ); +}; + +export default Table; diff --git a/src/pages/Correlation/components/CorrelationEmptyPlaceholder.tsx b/src/pages/Correlation/components/CorrelationEmptyPlaceholder.tsx new file mode 100644 index 00000000..c5f8b345 --- /dev/null +++ b/src/pages/Correlation/components/CorrelationEmptyPlaceholder.tsx @@ -0,0 +1,77 @@ +import { FC } from 'react'; + +export const CorrelationEmptyPlaceholder: FC<{ + height?: number | string; + width?: number | string; +}> = ({ height, width }) => { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/pages/Correlation/components/CorrelationFieldItem.tsx b/src/pages/Correlation/components/CorrelationFieldItem.tsx new file mode 100644 index 00000000..0119cebd --- /dev/null +++ b/src/pages/Correlation/components/CorrelationFieldItem.tsx @@ -0,0 +1,79 @@ +import { + IconChartCircles, + IconClockHour5, + IconLetterASmall, + IconLetterLSmall, + IconNumber123, + IconX, +} from '@tabler/icons-react'; +import { Text, Tooltip } from '@mantine/core'; +import classes from '../styles/Correlation.module.css'; +import { useRef, useState, useEffect } from 'react'; + +const dataTypeIcons = (iconColor: string): Record => ({ + text: , + timestamp: , + number: , + boolean: , + list: , +}); + +interface CorrelationFieldItemProps { + headerColor: string; + fieldName: string; + backgroundColor: string; + iconColor: string; + dataType?: string; + isSelected?: boolean; + onClick?: () => void; + onDelete?: () => void; +} + +export const CorrelationFieldItem = ({ + headerColor, + fieldName, + backgroundColor, + iconColor, + dataType, + isSelected, + onClick, + onDelete, +}: CorrelationFieldItemProps) => { + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + useEffect(() => { + if (textRef.current) { + setIsOverflowing(textRef.current.scrollWidth > textRef.current.clientWidth); + } + }, [fieldName]); + + return ( +
+ {isOverflowing ? ( + + + {fieldName} + + + ) : ( + + {fieldName} + + )} + {!dataType && } + {dataType && dataTypeIcons(iconColor)[dataType]} +
+ ); +}; diff --git a/src/pages/Correlation/components/CorrelationFilters/index.tsx b/src/pages/Correlation/components/CorrelationFilters/index.tsx new file mode 100644 index 00000000..45f0579a --- /dev/null +++ b/src/pages/Correlation/components/CorrelationFilters/index.tsx @@ -0,0 +1,199 @@ +import { Group, Modal, Stack, Tabs } from '@mantine/core'; +import { IconFilter } from '@tabler/icons-react'; +import classes from '../../styles/CorrelationFilters.module.css'; +import { Text } from '@mantine/core'; +import { useCallback, useEffect, useRef } from 'react'; +import { appStoreReducers, useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; +import _ from 'lodash'; +import { QueryPills, FilterQueryBuilder } from '@/pages/Stream/components/Querier/FilterQueryBuilder'; +import { defaultCustSQLQuery } from '@/pages/Stream/components/Querier/QueryCodeEditor'; +import { filterStoreReducers, useFilterStore, noValueOperators } from '@/pages/Stream/providers/FilterProvider'; +import { logsStoreReducers, useLogsStore } from '@/pages/Stream/providers/LogsProvider'; +import { useStreamStore } from '@/pages/Stream/providers/StreamProvider'; + +const { setFields, parseQuery, storeAppliedQuery, resetFilters, toggleSubmitBtn } = filterStoreReducers; +const { toggleQueryBuilder, toggleCustQuerySearchViewMode, applyCustomQuery, resetCustQuerySearchState } = + logsStoreReducers; + +const { applyQueryWithResetTime } = appStoreReducers; + +const FilterPlaceholder = () => { + return ( + + + Click to add filter + + ); +}; + +const ModalTitle = ({ title }: { title: string }) => { + const [, setLogsStore] = useLogsStore((store) => store.custQuerySearchState); + const onChangeCustQueryViewMode = useCallback((mode: 'filters') => { + setLogsStore((store) => toggleCustQuerySearchViewMode(store, mode)); + }, []); + + return ( + + + onChangeCustQueryViewMode('filters')}> + Filters + + + + ); +}; + +const QuerierModal = (props: { onClear: () => void; onFiltersApply: () => void }) => { + const [currentStream] = useAppStore((store) => store.currentStream); + const [{ showQueryBuilder }, setLogsStore] = useLogsStore((store) => store.custQuerySearchState); + const [streamInfo] = useStreamStore((store) => store.info); + const [timeRange] = useAppStore((store) => store.timeRange); + const timePartitionColumn = _.get(streamInfo, 'time_partition', 'p_timestamp'); + const onClose = useCallback(() => { + setLogsStore((store) => toggleQueryBuilder(store, false)); + }, []); + const queryCodeEditorRef = useRef(''); // to store input value even after the editor unmounts + + useEffect(() => { + queryCodeEditorRef.current = defaultCustSQLQuery( + currentStream, + timeRange.startTime, + timeRange.endTime, + timePartitionColumn, + ); + }, [currentStream, timeRange.endTime, timeRange.startTime, timePartitionColumn]); + + return ( + }> + + + + + ); +}; + +const CorrelationFilters = () => { + const [custQuerySearchState, setLogsStore] = useLogsStore((store) => store.custQuerySearchState); + const [{ startTime, endTime }, setAppStore] = useAppStore((store) => store.timeRange); + const { isQuerySearchActive, viewMode, showQueryBuilder, activeMode, savedFilterId } = custQuerySearchState; + const [currentStream] = useAppStore((store) => store.currentStream); + const [activeSavedFilters] = useAppStore((store) => store.activeSavedFilters); + const openBuilderModal = useCallback(() => { + setLogsStore((store) => toggleQueryBuilder(store)); + }, []); + const [schema] = useStreamStore((store) => store.schema); + const [streamInfo] = useStreamStore((store) => store.info); + const [{ query, isSumbitDisabled }, setFilterStore] = useFilterStore((store) => store); + const timePartitionColumn = _.get(streamInfo, 'time_partition', 'p_timestamp'); + + useEffect(() => { + if (schema) { + setFilterStore((store) => setFields(store, schema)); + } + }, [schema]); + + useEffect(() => { + return setFilterStore(resetFilters); + }, [currentStream]); + + const triggerRefetch = useCallback( + (query: string, mode: 'filters' | 'sql', id?: string) => { + const time_filter = id ? _.find(activeSavedFilters, (filter) => filter.filter_id === id)?.time_filter : null; + setAppStore((store) => applyQueryWithResetTime(store, time_filter || null)); + setLogsStore((store) => applyCustomQuery(store, query, mode, id)); + }, + [activeSavedFilters], + ); + + const onFiltersApply = useCallback( + (opts?: { isUncontrolled?: boolean }) => { + if (!currentStream) return; + + const { isUncontrolled } = opts || {}; + const { parsedQuery } = parseQuery(query, currentStream, { + startTime, + endTime, + timePartitionColumn, + }); + setFilterStore((store) => storeAppliedQuery(store)); + triggerRefetch(parsedQuery, 'filters', isUncontrolled && savedFilterId ? savedFilterId : undefined); + }, + [query, currentStream, savedFilterId, endTime, startTime, timePartitionColumn], + ); + + const onClear = useCallback(() => { + setFilterStore((store) => resetFilters(store)); + setLogsStore((store) => resetCustQuerySearchState(store)); + }, []); + + useEffect(() => { + // toggle submit btn - enable / disable + // ----------------------------------- + const ruleSets = query.rules.map((r) => r.rules); + const allValues = ruleSets.flat().flatMap((rule) => { + return noValueOperators.indexOf(rule.operator) !== -1 ? [null] : [rule.value]; + }); + const shouldSumbitDisabled = allValues.length === 0 || allValues.some((value) => value === ''); + if (isSumbitDisabled !== shouldSumbitDisabled) { + setFilterStore((store) => toggleSubmitBtn(store, shouldSumbitDisabled)); + } + // ----------------------------------- + + // trigger query fetch if the rules were updated by the remove btn on pills + // ----------------------------------- + if (!showQueryBuilder && (activeMode === 'filters' || savedFilterId)) { + if (!shouldSumbitDisabled) { + onFiltersApply({ isUncontrolled: true }); + } else { + if (activeMode === 'filters') { + onClear(); + } + } + } + // ----------------------------------- + + // trigger reset when no active rules are available + if (isQuerySearchActive && allValues.length === 0) { + onClear(); + } + }, [query.rules, savedFilterId]); + + return ( + + + + + Filters + + + + {viewMode === 'filters' && (activeMode === 'filters' ? : )} + + + ); +}; + +export default CorrelationFilters; diff --git a/src/pages/Correlation/components/MultiEventTimeLineGraph.tsx b/src/pages/Correlation/components/MultiEventTimeLineGraph.tsx new file mode 100644 index 00000000..df0a389c --- /dev/null +++ b/src/pages/Correlation/components/MultiEventTimeLineGraph.tsx @@ -0,0 +1,443 @@ +import { Paper, Skeleton, Stack, Text } from '@mantine/core'; +import classes from '../styles/Correlation.module.css'; +import { useQueryResult } from '@/hooks/useQueryResult'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import dayjs from 'dayjs'; +import { AreaChart } from '@mantine/charts'; +import { HumanizeNumber } from '@/utils/formatBytes'; +import { appStoreReducers, useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; +import { LogsResponseWithHeaders } from '@/@types/parseable/api/query'; +import _ from 'lodash'; +import timeRangeUtils from '@/utils/timeRangeUtils'; +import { filterStoreReducers, useFilterStore } from '@/pages/Stream/providers/FilterProvider'; +import { useCorrelationStore } from '../providers/CorrelationProvider'; + +const { parseQuery } = filterStoreReducers; +const { makeTimeRangeLabel } = timeRangeUtils; +const { setTimeRange } = appStoreReducers; + +type CompactInterval = 'minute' | 'day' | 'hour' | 'quarter-hour' | 'half-hour' | 'month'; + +function removeOffsetFromQuery(query: string): string { + const offsetRegex = /\sOFFSET\s+\d+/i; + return query.replace(offsetRegex, ''); +} + +const getCompactType = (interval: number): CompactInterval => { + const totalMinutes = interval / (1000 * 60); + if (totalMinutes <= 60) { + // upto 1 hour + return 'minute'; + } else if (totalMinutes <= 300) { + // upto 5 hours + return 'quarter-hour'; + } else if (totalMinutes <= 1440) { + // upto 5 hours + return 'half-hour'; + } else if (totalMinutes <= 4320) { + // upto 3 days + return 'hour'; + } else if (totalMinutes <= 259200) { + return 'day'; + } else { + return 'month'; + } +}; + +const getStartOfTs = (time: Date, compactType: CompactInterval): Date => { + if (compactType === 'minute') { + return time; + } else if (compactType === 'hour') { + return new Date(time.getFullYear(), time.getMonth(), time.getDate(), time.getHours()); + } else if (compactType === 'quarter-hour') { + const roundOff = 1000 * 60 * 15; + return new Date(Math.floor(time.getTime() / roundOff) * roundOff); + } else if (compactType === 'half-hour') { + const roundOff = 1000 * 60 * 30; + return new Date(Math.floor(time.getTime() / roundOff) * roundOff); + } else if (compactType === 'day') { + return new Date(time.getFullYear(), time.getMonth(), time.getDate()); + } else { + return new Date(time.getFullYear(), time.getMonth()); + } +}; + +const getEndOfTs = (time: Date, compactType: CompactInterval): Date => { + if (compactType === 'minute') { + return time; + } else if (compactType === 'hour') { + return new Date(time.getFullYear(), time.getMonth(), time.getDate(), time.getHours() + 1); + } else if (compactType === 'quarter-hour') { + const roundOff = 1000 * 60 * 15; + return new Date(Math.round(time.getTime() / roundOff) * roundOff); + } else if (compactType === 'half-hour') { + const roundOff = 1000 * 60 * 30; + return new Date(Math.round(time.getTime() / roundOff) * roundOff); + } else if (compactType === 'day') { + return new Date(time.getFullYear(), time.getMonth(), time.getDate() + 1); + } else { + return new Date(time.getFullYear(), time.getMonth() + 1); + } +}; + +const getModifiedTimeRange = ( + startTime: Date, + endTime: Date, + interval: number, +): { modifiedStartTime: Date; modifiedEndTime: Date; compactType: CompactInterval } => { + const compactType = getCompactType(interval); + const modifiedStartTime = getStartOfTs(startTime, compactType); + const modifiedEndTime = getEndOfTs(endTime, compactType); + return { modifiedEndTime, modifiedStartTime, compactType }; +}; + +const compactTypeIntervalMap = { + minute: '1 minute', + hour: '1 hour', + day: '24 hour', + 'quarter-hour': '15 minute', + 'half-hour': '30 minute', + month: '1 month', +}; + +const generateCountQuery = ( + streamName: string, + startTime: Date, + endTime: Date, + compactType: CompactInterval, + whereClause: string, +) => { + const range = compactTypeIntervalMap[compactType]; + /* eslint-disable no-useless-escape */ + return `SELECT DATE_BIN('${range}', p_timestamp, '${startTime.toISOString()}') AS date_bin_timestamp, COUNT(*) AS log_count FROM \"${streamName}\" WHERE p_timestamp BETWEEN '${startTime.toISOString()}' AND '${endTime.toISOString()}' AND ${whereClause} GROUP BY date_bin_timestamp ORDER BY date_bin_timestamp`; +}; + +const NoDataView = (props: { isError: boolean }) => { + return ( + + + + {props.isError ? 'Failed to fetch data' : ' No new events in the selected time range.'} + + + + ); +}; + +const calcAverage = (data: LogsResponseWithHeaders | undefined) => { + if (!data || !Array.isArray(data?.records)) return 0; + + const { fields, records } = data; + if (_.isEmpty(records) || !_.includes(fields, 'log_count')) return 0; + + const total = records.reduce((acc, d) => { + return acc + _.toNumber(d.log_count) || 0; + }, 0); + return parseInt(Math.abs(total / records.length).toFixed(0)); +}; + +const getAllIntervals = (start: Date, end: Date, compactType: CompactInterval): Date[] => { + const result = []; + let currentDate = new Date(start); + + while (currentDate <= end) { + result.push(new Date(currentDate)); + currentDate = incrementDateByCompactType(currentDate, compactType); + } + + return result; +}; + +const incrementDateByCompactType = (date: Date, type: CompactInterval): Date => { + const tempDate = new Date(date); + if (type === 'minute') { + tempDate.setMinutes(tempDate.getMinutes() + 1); + } else if (type === 'hour') { + tempDate.setHours(tempDate.getHours() + 1); + } else if (type === 'day') { + tempDate.setDate(tempDate.getDate() + 1); + } else if (type === 'quarter-hour') { + tempDate.setMinutes(tempDate.getMinutes() + 15); + } else if (type === 'half-hour') { + tempDate.setMinutes(tempDate.getMinutes() + 30); + } else if (type === 'month') { + tempDate.setMonth(tempDate.getMonth() + 1); + } else { + tempDate; + } + return new Date(tempDate); +}; + +type GraphTickItem = { + events: number; + minute: Date; + aboveAvgPercent: number; + compactType: CompactInterval; + startTime: dayjs.Dayjs; + endTime: dayjs.Dayjs; +}; + +interface ChartTooltipProps { + payload?: { + name: string; + value: number; + color: string; + payload: { + startTime: dayjs.Dayjs; + endTime: dayjs.Dayjs; + }; + }[]; + series: { name: string; label: string }[]; +} + +function ChartTooltip({ payload, series }: ChartTooltipProps) { + if (!payload || (Array.isArray(payload) && payload.length === 0)) return null; + + const { startTime, endTime } = payload[0].payload; + + // Convert Dayjs to Date + const label = makeTimeRangeLabel(startTime.toDate(), endTime.toDate()); + + // Map payload data to corresponding series labels + const data = series + .map((seriesItem) => { + const matchingPayload = payload.find((item) => item.name === seriesItem.name); + return matchingPayload + ? { + label: seriesItem.label, + value: matchingPayload.value, + color: matchingPayload.color, + } + : null; + }) + .filter((item): item is { label: string; value: number; color: string } => item !== null); + + return ( + + + {label} + + + {data.map(({ label, value, color }, index) => ( + +
+ {label} + {value} +
+ ))} +
+
+ ); +} + +// date_bin removes tz info +// filling data with empty values where there is no rec +const parseGraphData = ( + dataSets: (LogsResponseWithHeaders | undefined)[], + startTime: Date, + endTime: Date, + interval: number, +) => { + if (!dataSets || !Array.isArray(dataSets)) return []; + + const firstResponse = dataSets[0]?.records || []; + const secondResponse = dataSets[1]?.records || []; + + const { modifiedEndTime, modifiedStartTime, compactType } = getModifiedTimeRange(startTime, endTime, interval); + const allTimestamps = getAllIntervals(modifiedStartTime, modifiedEndTime, compactType); + + const hasSecondDataset = dataSets[1] !== undefined; + + const secondResponseMap = + secondResponse.length > 0 + ? new Map( + secondResponse.map((entry) => [new Date(`${entry.date_bin_timestamp}Z`).toISOString(), entry.log_count]), + ) + : new Map(); + const calculateTimeRange = (timestamp: Date | string) => { + const startTime = dayjs(timestamp); + const endTimeByCompactType = incrementDateByCompactType(startTime.toDate(), compactType); + const endTime = dayjs(endTimeByCompactType); + return { startTime, endTime }; + }; + const combinedData = allTimestamps.map((ts) => { + const firstRecord = firstResponse.find((record) => { + const recordTimestamp = new Date(`${record.date_bin_timestamp}Z`).toISOString(); + const tsISO = ts.toISOString(); + return recordTimestamp === tsISO; + }); + + const secondCount = secondResponseMap?.get(ts.toISOString()) ?? 0; + const { startTime, endTime } = calculateTimeRange(ts); + + const defaultOpts: Record = { + stream: firstRecord?.log_count || 0, + minute: ts, + compactType, + startTime, + endTime, + }; + + if (hasSecondDataset) { + defaultOpts.stream1 = secondCount; + } + + return defaultOpts; + }); + + return combinedData; +}; + +const MultiEventTimeLineGraph = () => { + const { fetchQueryMutation } = useQueryResult(); + const [fields] = useCorrelationStore((store) => store.fields); + const [appliedQuery] = useFilterStore((store) => store.appliedQuery); + const [streamData] = useCorrelationStore((store) => store.streamData); + const [timeRange] = useAppStore((store) => store.timeRange); + const [multipleStreamData, setMultipleStreamData] = useState<{ [key: string]: any }>({}); + + const { interval, startTime, endTime } = timeRange; + + const streamGraphData = Object.values(multipleStreamData); + + useEffect(() => { + setMultipleStreamData((prevData) => { + const newData = { ...prevData }; + const streamDataKeys = Object.keys(streamData); + Object.keys(newData).forEach((key) => { + if (!streamDataKeys.includes(key)) { + delete newData[key]; + } + }); + return newData; + }); + }, [streamData]); + + useEffect(() => { + if (!fields || Object.keys(fields).length === 0) { + setMultipleStreamData({}); + return; + } + + const streamNames = Object.keys(fields); + const streamsToFetch = streamNames.filter((streamName) => !Object.keys(streamData).includes(streamName)); + const queries = streamsToFetch.map((streamKey) => { + const { modifiedEndTime, modifiedStartTime, compactType } = getModifiedTimeRange(startTime, endTime, interval); + const logsQuery = { + startTime: modifiedStartTime, + endTime: modifiedEndTime, + access: [], + }; + const whereClause = parseQuery(appliedQuery, streamKey).where; + const query = generateCountQuery(streamKey, modifiedStartTime, modifiedEndTime, compactType, whereClause); + const graphQuery = removeOffsetFromQuery(query); + + return { + queryEngine: 'Parseable', + logsQuery, + query: graphQuery, + streamKey, + }; + }); + Promise.all(queries.map((queryData: any) => fetchQueryMutation.mutateAsync(queryData))) + .then((results) => { + setMultipleStreamData((prevData: any) => { + const newData = { ...prevData }; + results.forEach((result, index) => { + newData[queries[index].streamKey] = result; + }); + return newData; + }); + }) + .catch((error) => { + console.error('Error fetching queries:', error); + }); + }, [fields, timeRange]); + + const isLoading = fetchQueryMutation.isLoading; + const avgEventCount = useMemo(() => calcAverage(fetchQueryMutation?.data), [fetchQueryMutation?.data]); + const graphData = useMemo(() => { + if (!streamGraphData || streamGraphData.length === 0 || streamGraphData.length !== Object.keys(fields).length) + return []; + return parseGraphData(streamGraphData, startTime, endTime, interval); + }, [streamGraphData]); + + const hasData = Array.isArray(graphData) && graphData.length !== 0; + const [, setLogsStore] = useAppStore((store) => store.timeRange); + const setTimeRangeFromGraph = useCallback((barValue: any) => { + const activePayload = barValue?.activePayload; + if (!Array.isArray(activePayload) || activePayload.length === 0) return; + + const currentPayload = activePayload[0]; + if (!currentPayload || typeof currentPayload !== 'object') return; + + const graphTickItem = currentPayload.payload as GraphTickItem; + if (!graphTickItem || typeof graphTickItem !== 'object' || _.isEmpty(graphTickItem)) return; + + const { startTime, endTime } = graphTickItem; + setLogsStore((store) => setTimeRange(store, { type: 'custom', startTime: startTime, endTime: endTime })); + }, []); + + return ( + + + {hasData ? ( + ( + + ), + position: { y: -20 }, + }} + valueFormatter={(value) => new Intl.NumberFormat('en-US').format(value)} + withXAxis={false} + withYAxis={hasData} + yAxisProps={{ tickCount: 2, tickFormatter: (value) => `${HumanizeNumber(value)}` }} + referenceLines={[{ y: avgEventCount, color: 'gray.5', label: 'Avg' }]} + tickLine="none" + areaChartProps={{ onClick: setTimeRangeFromGraph, style: { cursor: 'pointer' } }} + gridAxis="xy" + fillOpacity={0.5} + strokeWidth={1.25} + dotProps={{ strokeWidth: 1, r: 2.5 }} + /> + ) : ( + + )} + + + ); +}; + +export default MultiEventTimeLineGraph; diff --git a/src/pages/Correlation/components/ShareButton.tsx b/src/pages/Correlation/components/ShareButton.tsx new file mode 100644 index 00000000..b28e3594 --- /dev/null +++ b/src/pages/Correlation/components/ShareButton.tsx @@ -0,0 +1,96 @@ +import { Stack, Menu, px } from '@mantine/core'; +import { IconCopy, IconShare, IconFileTypeCsv, IconBraces } from '@tabler/icons-react'; +import { useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; +import { useCallback } from 'react'; +import { copyTextToClipboard } from '@/utils'; +import { downloadDataAsCSV, downloadDataAsJson } from '@/utils/exportHelpers'; +import { makeExportData } from '@/pages/Stream/providers/LogsProvider'; +import IconButton from '@/components/Button/IconButton'; +import { filterAndSortData, useCorrelationStore } from '../providers/CorrelationProvider'; + +const renderShareIcon = () => ; + +export default function ShareButton() { + const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext); + const [currentStream] = useAppStore((store) => store.currentStream); + const [{ streamData, selectedFields }] = useCorrelationStore((store) => store); + const [{ tableOpts, fields }] = useCorrelationStore((store) => store); + + const streamNames = Object.keys(fields); + + const { pageData } = tableOpts; + + const generateAllPageData = (selectedFields: Record) => { + return Array.from({ length: 1000 }) + .map((_record, offset) => { + const combinedRecord: any = {}; + + Object.entries(selectedFields).forEach(([stream, fields]) => { + const filteredStreamData = filterAndSortData(tableOpts, streamData[stream]?.logData || []); + const streamRecord = filteredStreamData[offset]; + + if (streamRecord && Array.isArray(fields)) { + fields.forEach((field) => { + combinedRecord[`${stream}.${field}`] = streamRecord[field]; + }); + } + }); + + return combinedRecord; + }) + .filter(Boolean); + }; + + const exportHandler = useCallback( + (fileType: string | null) => { + let filename = 'correlation-logs'; + if (streamNames.length === 1) { + filename = `correlation-${streamNames[0]}-logs`; + } else if (streamNames.length > 1) { + filename = `correlation-${streamNames[0]}-${streamNames[1]}-logs`; + } + + if (pageData.length === 0) { + console.error('No data to export'); + return; + } + + const keys = Object.keys(pageData[0]); + const exportData = makeExportData(generateAllPageData(selectedFields), keys, fileType || ''); + + if (fileType === 'CSV') { + downloadDataAsCSV(exportData, filename); + } else if (fileType === 'JSON') { + downloadDataAsJson(exportData, filename); + } + }, + [currentStream, pageData, selectedFields], + ); + + const copyUrl = useCallback(() => { + copyTextToClipboard(window.location.href); + }, []); + + return ( + + + + + + + + } onClick={() => exportHandler('CSV')}> + Export CSV + + } onClick={() => exportHandler('JSON')}> + Export JSON + + {isSecureHTTPContext && ( + } onClick={copyUrl}> + Copy URL + + )} + + + ); +} diff --git a/src/pages/Correlation/components/StreamSelectBox.tsx b/src/pages/Correlation/components/StreamSelectBox.tsx new file mode 100644 index 00000000..b848e2c7 --- /dev/null +++ b/src/pages/Correlation/components/StreamSelectBox.tsx @@ -0,0 +1,43 @@ +import { Select, Text } from '@mantine/core'; +import classes from '../styles/Correlation.module.css'; +import { FC } from 'react'; + +type StreamSelectBoxProps = { + label: string; + placeholder: string; + disabled: boolean; + onChange: (value: string | null) => void; + data: { value: string; label: string }[]; + isFirst: boolean; +}; + +export const StreamSelectBox: FC = ({ + label, + placeholder, + disabled, + onChange, + data, + isFirst, +}) => { + return ( +
+ 0 + ? Object.keys(fields[streamNames[0]].fieldTypeMap).filter( + (key) => fields[streamNames[0]].fieldTypeMap[key] !== 'list', + ) + : [] + } + value={select1Value} + onChange={(value) => handleFieldChange(value, true)} + /> +
+ = +
+