diff --git a/frontend/public/locales/en-GB/logs.json b/frontend/public/locales/en-GB/logs.json new file mode 100644 index 0000000000..804f66f494 --- /dev/null +++ b/frontend/public/locales/en-GB/logs.json @@ -0,0 +1 @@ +{ "fetching_log_lines": "Fetching log lines" } diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 53ac325f11..f6ba0b816c 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -29,6 +29,7 @@ "NOT_FOUND": "SigNoz | Page Not Found", "LOGS": "SigNoz | Logs", "LOGS_EXPLORER": "SigNoz | Logs Explorer", + "LIVE_LOGS": "SigNoz | Live Logs", "HOME_PAGE": "Open source Observability Platform | SigNoz", "PASSWORD_RESET": "SigNoz | Password Reset", "LIST_LICENSES": "SigNoz | List of Licenses", diff --git a/frontend/public/locales/en/logs.json b/frontend/public/locales/en/logs.json new file mode 100644 index 0000000000..804f66f494 --- /dev/null +++ b/frontend/public/locales/en/logs.json @@ -0,0 +1 @@ +{ "fetching_log_lines": "Fetching log lines" } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 53ac325f11..f6ba0b816c 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -29,6 +29,7 @@ "NOT_FOUND": "SigNoz | Page Not Found", "LOGS": "SigNoz | Logs", "LOGS_EXPLORER": "SigNoz | Logs Explorer", + "LIVE_LOGS": "SigNoz | Live Logs", "HOME_PAGE": "Open source Observability Platform | SigNoz", "PASSWORD_RESET": "SigNoz | Password Reset", "LIST_LICENSES": "SigNoz | List of Licenses", diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index ba9e4eb617..9b72092342 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -110,6 +110,10 @@ export const LogsExplorer = Loadable( () => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsExplorer'), ); +export const LiveLogs = Loadable( + () => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'), +); + export const Login = Loadable( () => import(/* webpackChunkName: "Login" */ 'pages/Login'), ); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index ecf74b5253..ed19a96d6d 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -14,6 +14,7 @@ import { GettingStarted, LicensePage, ListAllALertsPage, + LiveLogs, Login, Logs, LogsExplorer, @@ -234,6 +235,13 @@ const routes: AppRoutes[] = [ key: 'LOGS_EXPLORER', isPrivate: true, }, + { + path: ROUTES.LIVE_LOGS, + exact: true, + component: LiveLogs, + key: 'LIVE_LOGS', + isPrivate: true, + }, { path: ROUTES.LOGIN, exact: true, diff --git a/frontend/src/api/metrics/getQueryRange.ts b/frontend/src/api/metrics/getQueryRange.ts index bc70d19832..bee657d904 100644 --- a/frontend/src/api/metrics/getQueryRange.ts +++ b/frontend/src/api/metrics/getQueryRange.ts @@ -4,11 +4,11 @@ import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { MetricRangePayloadV3, - MetricsRangeProps, + QueryRangePayload, } from 'types/api/metrics/getQueryRange'; export const getMetricsQueryRange = async ( - props: MetricsRangeProps, + props: QueryRangePayload, ): Promise | ErrorResponse> => { try { const response = await axios.post('/query_range', props); diff --git a/frontend/src/constants/liveTail.ts b/frontend/src/constants/liveTail.ts new file mode 100644 index 0000000000..07813a248b --- /dev/null +++ b/frontend/src/constants/liveTail.ts @@ -0,0 +1,5 @@ +export const LIVE_TAIL_HEARTBEAT_TIMEOUT = 600000; + +export const LIVE_TAIL_GRAPH_INTERVAL = 60000; + +export const MAX_LOGS_LIST_SIZE = 1000; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 65b9fd6477..ada1875c0c 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -29,6 +29,7 @@ const ROUTES = { NOT_FOUND: '/not-found', LOGS: '/logs', LOGS_EXPLORER: '/logs-explorer', + LIVE_LOGS: '/logs-explorer/live', HOME_PAGE: '/', PASSWORD_RESET: '/password-reset', LIST_LICENSES: '/licenses', diff --git a/frontend/src/constants/theme.ts b/frontend/src/constants/theme.ts index 18b7db2b18..be46d0d342 100644 --- a/frontend/src/constants/theme.ts +++ b/frontend/src/constants/theme.ts @@ -52,6 +52,8 @@ const themeColors = { gamboge: '#D89614', bckgGrey: '#1d1d1d', lightBlue: '#177ddc', + buttonSuccessRgb: '73, 170, 25', + red: '#E84749', }; export { themeColors }; diff --git a/frontend/src/container/LiveLogs/BackButton/index.tsx b/frontend/src/container/LiveLogs/BackButton/index.tsx new file mode 100644 index 0000000000..574ba0d637 --- /dev/null +++ b/frontend/src/container/LiveLogs/BackButton/index.tsx @@ -0,0 +1,35 @@ +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import { initialQueriesMap } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; + +function BackButton(): JSX.Element { + const history = useHistory(); + + const { resetQuery } = useQueryBuilder(); + + const handleBack = useCallback(() => { + const compositeQuery = initialQueriesMap.logs; + + const JSONCompositeQuery = encodeURIComponent(JSON.stringify(compositeQuery)); + + const path = `${ROUTES.LOGS_EXPLORER}?${JSONCompositeQuery}`; + + const { queryType, ...queryState } = initialQueriesMap.logs; + + resetQuery(queryState); + + history.push(path); + }, [history, resetQuery]); + + return ( + + ); +} + +export default BackButton; diff --git a/frontend/src/container/LiveLogs/FiltersInput/index.tsx b/frontend/src/container/LiveLogs/FiltersInput/index.tsx new file mode 100644 index 0000000000..6acd638d5f --- /dev/null +++ b/frontend/src/container/LiveLogs/FiltersInput/index.tsx @@ -0,0 +1,78 @@ +import { Col } from 'antd'; +import { initialQueriesMap } from 'constants/queryBuilder'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useEventSource } from 'providers/EventSource'; +import { useCallback, useMemo } from 'react'; +import { + IBuilderQuery, + Query, + TagFilter, +} from 'types/api/queryBuilder/queryBuilderData'; + +import { getQueryWithoutFilterId } from '../utils'; +import { + ContainerStyled, + FilterSearchInputStyled, + SearchButtonStyled, +} from './styles'; + +function FiltersInput(): JSX.Element { + const { + stagedQuery, + handleSetQueryData, + redirectWithQueryBuilderData, + currentQuery, + } = useQueryBuilder(); + + const { initialLoading, handleSetInitialLoading } = useEventSource(); + + const handleChange = useCallback( + (filters: TagFilter) => { + const listQueryData = stagedQuery?.builder.queryData[0]; + + if (!listQueryData) return; + + const queryData: IBuilderQuery = { + ...listQueryData, + filters, + }; + + handleSetQueryData(0, queryData); + }, + [stagedQuery, handleSetQueryData], + ); + + const query = useMemo(() => { + if (stagedQuery && stagedQuery.builder.queryData.length > 0) { + return stagedQuery?.builder.queryData[0]; + } + + return initialQueriesMap.logs.builder.queryData[0]; + }, [stagedQuery]); + + const handleSearch = useCallback(() => { + if (initialLoading) { + handleSetInitialLoading(false); + } + + const preparedQuery: Query = getQueryWithoutFilterId(currentQuery); + + redirectWithQueryBuilderData(preparedQuery); + }, [ + initialLoading, + currentQuery, + redirectWithQueryBuilderData, + handleSetInitialLoading, + ]); + + return ( + + + + + + + ); +} + +export default FiltersInput; diff --git a/frontend/src/container/LiveLogs/FiltersInput/styles.ts b/frontend/src/container/LiveLogs/FiltersInput/styles.ts new file mode 100644 index 0000000000..ed53f261b0 --- /dev/null +++ b/frontend/src/container/LiveLogs/FiltersInput/styles.ts @@ -0,0 +1,24 @@ +import { Input, Row } from 'antd'; +import { themeColors } from 'constants/theme'; +import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch'; +import styled from 'styled-components'; + +export const FilterSearchInputStyled = styled(QueryBuilderSearch)` + z-index: 1; + .ant-select-selector { + width: 100%; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +`; + +export const ContainerStyled = styled(Row)` + color: ${themeColors.white}; +`; + +export const SearchButtonStyled = styled(Input.Search)` + width: 2rem; + .ant-input { + display: none; + } +`; diff --git a/frontend/src/container/LiveLogs/ListViewPanel/index.tsx b/frontend/src/container/LiveLogs/ListViewPanel/index.tsx new file mode 100644 index 0000000000..360dac36c0 --- /dev/null +++ b/frontend/src/container/LiveLogs/ListViewPanel/index.tsx @@ -0,0 +1,58 @@ +import { Button, Popover, Select, Space } from 'antd'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import { useOptionsMenu } from 'container/OptionsMenu'; +import { + defaultSelectStyle, + logsOptions, + viewModeOptionList, +} from 'pages/Logs/config'; +import PopoverContent from 'pages/Logs/PopoverContent'; +import { useCallback } from 'react'; +import { DataSource, StringOperators } from 'types/common/queryBuilder'; + +function ListViewPanel(): JSX.Element { + const { config } = useOptionsMenu({ + storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS, + dataSource: DataSource.LOGS, + aggregateOperator: StringOperators.NOOP, + }); + + const isFormatButtonVisible = logsOptions.includes(config.format?.value); + + const renderPopoverContent = useCallback(() => { + if (!config.maxLines) return null; + const linedPerRow = config.maxLines.value as number; + const handleLinesPerRowChange = config.maxLines.onChange as ( + value: unknown, + ) => void; + + return ( + + ); + }, [config]); + + return ( + + + + {isFormatButtonVisible && ( + + + + )} + + ); +} + +export default ListViewPanel; diff --git a/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx b/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx new file mode 100644 index 0000000000..1d8b67f394 --- /dev/null +++ b/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx @@ -0,0 +1,167 @@ +import { Col } from 'antd'; +import Spinner from 'components/Spinner'; +import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { themeColors } from 'constants/theme'; +import GoToTop from 'container/GoToTop'; +import FiltersInput from 'container/LiveLogs/FiltersInput'; +import LiveLogsTopNav from 'container/LiveLogsTopNav'; +import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import useDebouncedFn from 'hooks/useDebouncedFunction'; +import { useEventSourceEvent } from 'hooks/useEventSourceEvent'; +import { useNotifications } from 'hooks/useNotifications'; +import { useEventSource } from 'providers/EventSource'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { prepareQueryRangePayload } from 'store/actions/dashboard/prepareQueryRangePayload'; +import { AppState } from 'store/reducers'; +import { ILog } from 'types/api/logs/log'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +import { idObject } from '../constants'; +import ListViewPanel from '../ListViewPanel'; +import LiveLogsList from '../LiveLogsList'; +import { prepareQueryByFilter } from '../utils'; +import { ContentWrapper, LiveLogsChart, Wrapper } from './styles'; + +function LiveLogsContainer(): JSX.Element { + const [logs, setLogs] = useState([]); + + const { stagedQuery } = useQueryBuilder(); + + const batchedEventsRef = useRef([]); + + const { notifications } = useNotifications(); + + const { selectedTime: globalSelectedTime } = useSelector< + AppState, + GlobalReducer + >((state) => state.globalTime); + + const { + handleStartOpenConnection, + handleCloseConnection, + initialLoading, + } = useEventSource(); + + const compositeQuery = useGetCompositeQueryParam(); + + const updateLogs = useCallback(() => { + const reversedData = batchedEventsRef.current.reverse(); + setLogs((prevState) => + [...reversedData, ...prevState].slice(0, MAX_LOGS_LIST_SIZE), + ); + + batchedEventsRef.current = []; + }, []); + + const debouncedUpdateLogs = useDebouncedFn(updateLogs, 500); + + const batchLiveLog = useCallback( + (log: ILog): void => { + batchedEventsRef.current.push(log); + + debouncedUpdateLogs(); + }, + [debouncedUpdateLogs], + ); + + const handleGetLiveLogs = useCallback( + (event: MessageEvent) => { + const data: ILog = JSON.parse(event.data); + + batchLiveLog(data); + }, + [batchLiveLog], + ); + + const handleError = useCallback(() => { + notifications.error({ message: 'Sorry, something went wrong' }); + }, [notifications]); + + useEventSourceEvent('message', handleGetLiveLogs); + useEventSourceEvent('error', handleError); + + const getPreparedQuery = useCallback( + (query: Query): Query => { + const firstLogId: string | null = logs.length ? logs[0].id : null; + + const preparedQuery: Query = prepareQueryByFilter( + query, + idObject, + firstLogId, + ); + + return preparedQuery; + }, + [logs], + ); + + const handleStartNewConnection = useCallback(() => { + if (!compositeQuery) return; + + handleCloseConnection(); + + const preparedQuery = getPreparedQuery(compositeQuery); + + const { queryPayload } = prepareQueryRangePayload({ + query: preparedQuery, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + globalSelectedInterval: globalSelectedTime, + }); + + const encodedQueryPayload = encodeURIComponent(JSON.stringify(queryPayload)); + + const queryString = `q=${encodedQueryPayload}`; + + handleStartOpenConnection({ queryString }); + }, [ + compositeQuery, + globalSelectedTime, + getPreparedQuery, + handleCloseConnection, + handleStartOpenConnection, + ]); + + useEffect(() => { + if (!compositeQuery || !stagedQuery) return; + + if (compositeQuery.id !== stagedQuery.id || initialLoading) { + handleStartNewConnection(); + } + }, [stagedQuery, initialLoading, compositeQuery, handleStartNewConnection]); + + return ( + + + + + + + {initialLoading ? ( + + + + ) : ( + <> + + + + + + + + + + + )} + + + + ); +} + +export default LiveLogsContainer; diff --git a/frontend/src/container/LiveLogs/LiveLogsContainer/styles.ts b/frontend/src/container/LiveLogs/LiveLogsContainer/styles.ts new file mode 100644 index 0000000000..0f336f2405 --- /dev/null +++ b/frontend/src/container/LiveLogs/LiveLogsContainer/styles.ts @@ -0,0 +1,17 @@ +import { Row } from 'antd'; +import { themeColors } from 'constants/theme'; +import styled from 'styled-components'; + +import LiveLogsListChart from '../LiveLogsListChart'; + +export const LiveLogsChart = styled(LiveLogsListChart)` + margin-bottom: 0.5rem; +`; + +export const ContentWrapper = styled(Row)` + color: rgba(${(themeColors.white, 0.85)}); +`; + +export const Wrapper = styled.div` + padding-bottom: 4rem; +`; diff --git a/frontend/src/container/LiveLogs/LiveLogsList/index.tsx b/frontend/src/container/LiveLogs/LiveLogsList/index.tsx new file mode 100644 index 0000000000..da4414e444 --- /dev/null +++ b/frontend/src/container/LiveLogs/LiveLogsList/index.tsx @@ -0,0 +1,131 @@ +import { Card, Typography } from 'antd'; +import ListLogView from 'components/Logs/ListLogView'; +import RawLogView from 'components/Logs/RawLogView'; +import Spinner from 'components/Spinner'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import { OptionFormatTypes } from 'constants/optionsFormatTypes'; +import InfinityTableView from 'container/LogsExplorerList/InfinityTableView'; +import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles'; +import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils'; +import { Heading } from 'container/LogsTable/styles'; +import { useOptionsMenu } from 'container/OptionsMenu'; +import { contentStyle } from 'container/Trace/Search/config'; +import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; +import useFontFaceObserver from 'hooks/useFontObserver'; +import { useEventSource } from 'providers/EventSource'; +import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; +// interfaces +import { ILog } from 'types/api/logs/log'; +import { DataSource, StringOperators } from 'types/common/queryBuilder'; + +import { LiveLogsListProps } from './types'; + +function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element { + const ref = useRef(null); + + const { t } = useTranslation(['logs']); + + const { isConnectionError, isConnectionLoading } = useEventSource(); + + const { activeLogId } = useCopyLogLink(); + + const { options } = useOptionsMenu({ + storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS, + dataSource: DataSource.LOGS, + aggregateOperator: StringOperators.NOOP, + }); + + const activeLogIndex = useMemo( + () => logs.findIndex(({ id }) => id === activeLogId), + [logs, activeLogId], + ); + + useFontFaceObserver( + [ + { + family: 'Fira Code', + weight: '300', + }, + ], + options.format === 'raw', + { + timeout: 5000, + }, + ); + + const selectedFields = convertKeysToColumnFields(options.selectColumns); + + const getItemContent = useCallback( + (_: number, log: ILog): JSX.Element => { + if (options.format === 'raw') { + return ( + + ); + } + + return ( + + ); + }, + [options.format, options.maxLines, selectedFields], + ); + + useEffect(() => { + if (!activeLogId || activeLogIndex < 0) return; + + ref?.current?.scrollToIndex({ + index: activeLogIndex, + align: 'start', + behavior: 'smooth', + }); + }, [activeLogId, activeLogIndex]); + + const isLoadingList = isConnectionLoading && logs.length === 0; + + if (isLoadingList) { + return ; + } + + return ( + <> + {options.format !== OptionFormatTypes.TABLE && ( + + Event + + )} + + {logs.length === 0 && {t('fetching_log_lines')}} + + {!isConnectionError && logs.length !== 0 && ( + + {options.format === 'table' ? ( + + ) : ( + + + + )} + + )} + + ); +} + +export default memo(LiveLogsList); diff --git a/frontend/src/container/LiveLogs/LiveLogsList/types.ts b/frontend/src/container/LiveLogs/LiveLogsList/types.ts new file mode 100644 index 0000000000..ba6871c9d6 --- /dev/null +++ b/frontend/src/container/LiveLogs/LiveLogsList/types.ts @@ -0,0 +1,5 @@ +import { ILog } from 'types/api/logs/log'; + +export type LiveLogsListProps = { + logs: ILog[]; +}; diff --git a/frontend/src/container/LiveLogs/LiveLogsListChart/index.tsx b/frontend/src/container/LiveLogs/LiveLogsListChart/index.tsx new file mode 100644 index 0000000000..3a7fc2e258 --- /dev/null +++ b/frontend/src/container/LiveLogs/LiveLogsListChart/index.tsx @@ -0,0 +1,70 @@ +import { LIVE_TAIL_GRAPH_INTERVAL } from 'constants/liveTail'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import LogsExplorerChart from 'container/LogsExplorerChart'; +import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useEventSource } from 'providers/EventSource'; +import { useMemo } from 'react'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { QueryData } from 'types/api/widgets/getQuery'; +import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder'; + +import { LiveLogsListChartProps } from './types'; + +function LiveLogsListChart({ className }: LiveLogsListChartProps): JSX.Element { + const { stagedQuery } = useQueryBuilder(); + const { isConnectionOpen, isConnectionLoading } = useEventSource(); + + const listChartQuery: Query | null = useMemo(() => { + if (!stagedQuery) return null; + + return { + ...stagedQuery, + builder: { + ...stagedQuery.builder, + queryData: stagedQuery.builder.queryData.map((item) => ({ + ...item, + disabled: false, + aggregateOperator: LogsAggregatorOperator.COUNT, + filters: { + ...item.filters, + items: item.filters.items.filter((item) => item.key?.key !== 'id'), + }, + })), + }, + }; + }, [stagedQuery]); + + const { data, isFetching } = useGetExplorerQueryRange( + listChartQuery, + PANEL_TYPES.TIME_SERIES, + { + enabled: isConnectionOpen, + refetchInterval: LIVE_TAIL_GRAPH_INTERVAL, + keepPreviousData: true, + }, + { dataSource: DataSource.LOGS }, + ); + + const chartData: QueryData[] = useMemo(() => { + if (!data) return []; + + return data.payload.data.result; + }, [data]); + + const isLoading = useMemo( + () => isFetching || (isConnectionLoading && !isConnectionOpen), + [isConnectionLoading, isConnectionOpen, isFetching], + ); + + return ( + + ); +} + +export default LiveLogsListChart; diff --git a/frontend/src/container/LiveLogs/LiveLogsListChart/types.ts b/frontend/src/container/LiveLogs/LiveLogsListChart/types.ts new file mode 100644 index 0000000000..8867304b42 --- /dev/null +++ b/frontend/src/container/LiveLogs/LiveLogsListChart/types.ts @@ -0,0 +1,3 @@ +export type LiveLogsListChartProps = { + className?: string; +}; diff --git a/frontend/src/container/LiveLogs/constants.ts b/frontend/src/container/LiveLogs/constants.ts new file mode 100644 index 0000000000..46d5916a51 --- /dev/null +++ b/frontend/src/container/LiveLogs/constants.ts @@ -0,0 +1,31 @@ +import { + initialQueriesMap, + initialQueryBuilderFormValuesMap, +} from 'constants/queryBuilder'; +import { FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { LogsAggregatorOperator } from 'types/common/queryBuilder'; + +export const liveLogsCompositeQuery: Query = { + ...initialQueriesMap.logs, + builder: { + ...initialQueriesMap.logs.builder, + queryData: [ + { + ...initialQueryBuilderFormValuesMap.logs, + aggregateOperator: LogsAggregatorOperator.NOOP, + disabled: true, + pageSize: 10, + orderBy: [{ columnName: 'timestamp', order: FILTERS.DESC }], + }, + ], + }, +}; + +export const idObject: BaseAutocompleteData = { + key: 'id', + type: '', + dataType: 'string', + isColumn: true, +}; diff --git a/frontend/src/container/LiveLogs/utils.ts b/frontend/src/container/LiveLogs/utils.ts new file mode 100644 index 0000000000..8e41db49e3 --- /dev/null +++ b/frontend/src/container/LiveLogs/utils.ts @@ -0,0 +1,71 @@ +import { OPERATORS } from 'constants/queryBuilder'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { + Query, + TagFilter, + TagFilterItem, +} from 'types/api/queryBuilder/queryBuilderData'; +import { v4 as uuid } from 'uuid'; + +const getIdFilter = (filtersItems: TagFilterItem[]): TagFilterItem | null => + filtersItems.find((item) => item.key?.key === 'id') || null; + +const getFilter = ( + filters: TagFilter, + tagFilter: BaseAutocompleteData, + value: string, +): TagFilter => { + let newItems = filters.items; + + const isExistIdFilter = getIdFilter(newItems); + + if (isExistIdFilter) { + newItems = newItems.map((item) => + item.key?.key === 'id' ? { ...item, value } : item, + ); + } else { + newItems = [ + ...newItems, + { value, key: tagFilter, op: OPERATORS['>'], id: uuid() }, + ]; + } + + return { items: newItems, op: filters.op }; +}; + +export const prepareQueryByFilter = ( + query: Query, + tagFilter: BaseAutocompleteData, + value: string | null, +): Query => { + const preparedQuery: Query = { + ...query, + builder: { + ...query.builder, + queryData: query.builder.queryData.map((item) => ({ + ...item, + filters: value ? getFilter(item.filters, tagFilter, value) : item.filters, + })), + }, + }; + + return preparedQuery; +}; + +export const getQueryWithoutFilterId = (query: Query): Query => { + const preparedQuery: Query = { + ...query, + builder: { + ...query.builder, + queryData: query.builder.queryData.map((item) => ({ + ...item, + filters: { + ...item.filters, + items: item.filters.items.filter((item) => item.key?.key !== 'id'), + }, + })), + }, + }; + + return preparedQuery; +}; diff --git a/frontend/src/container/LiveLogsTopNav/index.tsx b/frontend/src/container/LiveLogsTopNav/index.tsx new file mode 100644 index 0000000000..5f3718d85c --- /dev/null +++ b/frontend/src/container/LiveLogsTopNav/index.tsx @@ -0,0 +1,71 @@ +import { PauseCircleFilled, PlayCircleFilled } from '@ant-design/icons'; +import { Space } from 'antd'; +import BackButton from 'container/LiveLogs/BackButton'; +import { getQueryWithoutFilterId } from 'container/LiveLogs/utils'; +import LocalTopNav from 'container/LocalTopNav'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useEventSource } from 'providers/EventSource'; +import { memo, useCallback, useMemo } from 'react'; + +import { LiveButtonStyled } from './styles'; + +function LiveLogsTopNav(): JSX.Element { + const { + isConnectionOpen, + isConnectionLoading, + initialLoading, + handleCloseConnection, + handleSetInitialLoading, + } = useEventSource(); + + const { redirectWithQueryBuilderData, currentQuery } = useQueryBuilder(); + + const isPlaying = isConnectionOpen || isConnectionLoading || initialLoading; + + const onLiveButtonClick = useCallback(() => { + if (initialLoading) { + handleSetInitialLoading(false); + } + + if ((!isConnectionOpen && isConnectionLoading) || isConnectionOpen) { + handleCloseConnection(); + } else { + const preparedQuery = getQueryWithoutFilterId(currentQuery); + redirectWithQueryBuilderData(preparedQuery); + } + }, [ + initialLoading, + isConnectionOpen, + isConnectionLoading, + currentQuery, + handleSetInitialLoading, + handleCloseConnection, + redirectWithQueryBuilderData, + ]); + + const liveButton = useMemo( + () => ( + + : } + danger={isPlaying} + onClick={onLiveButtonClick} + type="primary" + > + {isPlaying ? 'Pause' : 'Resume'} + + + + ), + [isPlaying, onLiveButtonClick], + ); + + return ( + + ); +} + +export default memo(LiveLogsTopNav); diff --git a/frontend/src/container/LiveLogsTopNav/styles.ts b/frontend/src/container/LiveLogsTopNav/styles.ts new file mode 100644 index 0000000000..f6c58b9415 --- /dev/null +++ b/frontend/src/container/LiveLogsTopNav/styles.ts @@ -0,0 +1,20 @@ +import { Button, ButtonProps } from 'antd'; +import { themeColors } from 'constants/theme'; +import styled, { css, FlattenSimpleInterpolation } from 'styled-components'; + +export const LiveButtonStyled = styled(Button)` + background-color: rgba(${themeColors.buttonSuccessRgb}, 0.9); + + ${({ danger }): FlattenSimpleInterpolation => + !danger + ? css` + &:hover { + background-color: rgba(${themeColors.buttonSuccessRgb}, 1) !important; + } + + &:active { + background-color: rgba(${themeColors.buttonSuccessRgb}, 0.7) !important; + } + ` + : css``} +`; diff --git a/frontend/src/container/LiveLogsTopNav/types.ts b/frontend/src/container/LiveLogsTopNav/types.ts new file mode 100644 index 0000000000..61b7cad756 --- /dev/null +++ b/frontend/src/container/LiveLogsTopNav/types.ts @@ -0,0 +1,5 @@ +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +export type LiveLogsTopNavProps = { + getPreparedQuery: (query: Query) => Query; +}; diff --git a/frontend/src/container/LocalTopNav/index.tsx b/frontend/src/container/LocalTopNav/index.tsx new file mode 100644 index 0000000000..bd6b664e93 --- /dev/null +++ b/frontend/src/container/LocalTopNav/index.tsx @@ -0,0 +1,34 @@ +import { Col, Row, Space } from 'antd'; + +import ShowBreadcrumbs from '../TopNav/Breadcrumbs'; +import DateTimeSelector from '../TopNav/DateTimeSelection'; +import { Container } from './styles'; +import { LocalTopNavProps } from './types'; + +function LocalTopNav({ + actions, + renderPermissions, +}: LocalTopNavProps): JSX.Element | null { + return ( + + + + + + + + + {actions} + {renderPermissions?.isDateTimeEnabled && ( +
+ +
+ )} +
+
+ +
+ ); +} + +export default LocalTopNav; diff --git a/frontend/src/container/LocalTopNav/styles.ts b/frontend/src/container/LocalTopNav/styles.ts new file mode 100644 index 0000000000..feda027d24 --- /dev/null +++ b/frontend/src/container/LocalTopNav/styles.ts @@ -0,0 +1,9 @@ +import { Row } from 'antd'; +import styled from 'styled-components'; + +export const Container = styled(Row)` + &&& { + margin-top: 2rem; + min-height: 8vh; + } +`; diff --git a/frontend/src/container/LocalTopNav/types.ts b/frontend/src/container/LocalTopNav/types.ts new file mode 100644 index 0000000000..64d0ef9414 --- /dev/null +++ b/frontend/src/container/LocalTopNav/types.ts @@ -0,0 +1,6 @@ +import { ReactNode } from 'react'; + +export type LocalTopNavProps = { + actions?: ReactNode; + renderPermissions?: { isDateTimeEnabled: boolean }; +}; diff --git a/frontend/src/container/LogsExplorerChart/LogsExplorerChart.interfaces.ts b/frontend/src/container/LogsExplorerChart/LogsExplorerChart.interfaces.ts index 6df4bacee3..a19a41d778 100644 --- a/frontend/src/container/LogsExplorerChart/LogsExplorerChart.interfaces.ts +++ b/frontend/src/container/LogsExplorerChart/LogsExplorerChart.interfaces.ts @@ -3,4 +3,6 @@ import { QueryData } from 'types/api/widgets/getQuery'; export type LogsExplorerChartProps = { data: QueryData[]; isLoading: boolean; + isLabelEnabled?: boolean; + className?: string; }; diff --git a/frontend/src/container/LogsExplorerChart/index.tsx b/frontend/src/container/LogsExplorerChart/index.tsx index 2e85d9c5de..a64f8eb382 100644 --- a/frontend/src/container/LogsExplorerChart/index.tsx +++ b/frontend/src/container/LogsExplorerChart/index.tsx @@ -1,8 +1,9 @@ import Graph from 'components/Graph'; import Spinner from 'components/Spinner'; +import { themeColors } from 'constants/theme'; import getChartData, { GetChartDataProps } from 'lib/getChartData'; import { colors } from 'lib/getRandomColor'; -import { memo, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces'; import { CardStyled } from './LogsExplorerChart.styled'; @@ -10,17 +11,22 @@ import { CardStyled } from './LogsExplorerChart.styled'; function LogsExplorerChart({ data, isLoading, + isLabelEnabled = true, + className, }: LogsExplorerChartProps): JSX.Element { - const handleCreateDatasets: Required['createDataset'] = ( - element, - index, - allLabels, - ) => ({ - label: allLabels[index], - data: element, - backgroundColor: colors[index % colors.length] || 'red', - borderColor: colors[index % colors.length] || 'red', - }); + const handleCreateDatasets: Required['createDataset'] = useCallback( + (element, index, allLabels) => ({ + data: element, + backgroundColor: colors[index % colors.length] || themeColors.red, + borderColor: colors[index % colors.length] || themeColors.red, + ...(isLabelEnabled + ? { + label: allLabels[index], + } + : {}), + }), + [isLabelEnabled], + ); const graphData = useMemo( () => @@ -32,11 +38,11 @@ function LogsExplorerChart({ ], createDataset: handleCreateDatasets, }), - [data], + [data, handleCreateDatasets], ); return ( - + {isLoading ? ( ) : ( diff --git a/frontend/src/container/LogsExplorerList/InfinityTableView/index.tsx b/frontend/src/container/LogsExplorerList/InfinityTableView/index.tsx index b5a30e1568..433ce896c2 100644 --- a/frontend/src/container/LogsExplorerList/InfinityTableView/index.tsx +++ b/frontend/src/container/LogsExplorerList/InfinityTableView/index.tsx @@ -10,6 +10,7 @@ import { getDraggedColumns } from 'hooks/useDragColumns/utils'; import { cloneElement, forwardRef, + memo, ReactElement, ReactNode, useCallback, @@ -67,7 +68,6 @@ const InfinityTable = forwardRef( onAddToQuery, } = useActiveLog(); - const { onEndReached } = infitiyTableProps; const { dataSource, columns } = useTableView({ ...tableViewProps, onClickExpand: onSetActiveLog, @@ -158,8 +158,11 @@ const InfinityTable = forwardRef( }} itemContent={itemContent} fixedHeaderContent={tableHeader} - endReached={onEndReached} totalCount={dataSource.length} + // eslint-disable-next-line react/jsx-props-no-spreading + {...(infitiyTableProps?.onEndReached + ? { endReached: infitiyTableProps.onEndReached } + : {})} /> {activeContextLog && ( @@ -179,4 +182,4 @@ const InfinityTable = forwardRef( }, ); -export default InfinityTable; +export default memo(InfinityTable); diff --git a/frontend/src/container/LogsExplorerList/InfinityTableView/types.ts b/frontend/src/container/LogsExplorerList/InfinityTableView/types.ts index fb8eb23170..caf762263e 100644 --- a/frontend/src/container/LogsExplorerList/InfinityTableView/types.ts +++ b/frontend/src/container/LogsExplorerList/InfinityTableView/types.ts @@ -3,7 +3,7 @@ import { UseTableViewProps } from 'components/Logs/TableView/types'; export type InfinityTableProps = { isLoading?: boolean; tableViewProps: Omit; - infitiyTableProps: { + infitiyTableProps?: { onEndReached: (index: number) => void; }; }; diff --git a/frontend/src/container/LogsExplorerViews/index.tsx b/frontend/src/container/LogsExplorerViews/index.tsx index fac0441486..cab522c156 100644 --- a/frontend/src/container/LogsExplorerViews/index.tsx +++ b/frontend/src/container/LogsExplorerViews/index.tsx @@ -39,7 +39,7 @@ import { Query, TagFilter, } from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource, StringOperators } from 'types/common/queryBuilder'; +import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink'; @@ -120,7 +120,7 @@ function LogsExplorerViews(): JSX.Element { const modifiedQueryData: IBuilderQuery = { ...listQuery, - aggregateOperator: StringOperators.COUNT, + aggregateOperator: LogsAggregatorOperator.COUNT, }; const modifiedQuery: Query = { diff --git a/frontend/src/container/LogsTopNav/index.tsx b/frontend/src/container/LogsTopNav/index.tsx new file mode 100644 index 0000000000..8eaec2e1f0 --- /dev/null +++ b/frontend/src/container/LogsTopNav/index.tsx @@ -0,0 +1,43 @@ +import { PlayCircleFilled } from '@ant-design/icons'; +import ROUTES from 'constants/routes'; +import { liveLogsCompositeQuery } from 'container/LiveLogs/constants'; +import LocalTopNav from 'container/LocalTopNav'; +import { useCallback, useMemo } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { LiveButtonStyled } from './styles'; + +function LogsTopNav(): JSX.Element { + const history = useHistory(); + + const handleGoLive = useCallback(() => { + const JSONCompositeQuery = encodeURIComponent( + JSON.stringify(liveLogsCompositeQuery), + ); + + const path = `${ROUTES.LIVE_LOGS}?${JSONCompositeQuery}`; + + history.push(path); + }, [history]); + + const liveButton = useMemo( + () => ( + } + onClick={handleGoLive} + type="primary" + > + Go Live + + ), + [handleGoLive], + ); + return ( + + ); +} + +export default LogsTopNav; diff --git a/frontend/src/container/LogsTopNav/styles.ts b/frontend/src/container/LogsTopNav/styles.ts new file mode 100644 index 0000000000..f6c58b9415 --- /dev/null +++ b/frontend/src/container/LogsTopNav/styles.ts @@ -0,0 +1,20 @@ +import { Button, ButtonProps } from 'antd'; +import { themeColors } from 'constants/theme'; +import styled, { css, FlattenSimpleInterpolation } from 'styled-components'; + +export const LiveButtonStyled = styled(Button)` + background-color: rgba(${themeColors.buttonSuccessRgb}, 0.9); + + ${({ danger }): FlattenSimpleInterpolation => + !danger + ? css` + &:hover { + background-color: rgba(${themeColors.buttonSuccessRgb}, 1) !important; + } + + &:active { + background-color: rgba(${themeColors.buttonSuccessRgb}, 0.7) !important; + } + ` + : css``} +`; diff --git a/frontend/src/container/TopNav/NewExplorerCTA/config.ts b/frontend/src/container/NewExplorerCTA/config.ts similarity index 100% rename from frontend/src/container/TopNav/NewExplorerCTA/config.ts rename to frontend/src/container/NewExplorerCTA/config.ts diff --git a/frontend/src/container/TopNav/NewExplorerCTA/index.tsx b/frontend/src/container/NewExplorerCTA/index.tsx similarity index 100% rename from frontend/src/container/TopNav/NewExplorerCTA/index.tsx rename to frontend/src/container/NewExplorerCTA/index.tsx diff --git a/frontend/src/container/OptionsMenu/FormatField/index.tsx b/frontend/src/container/OptionsMenu/FormatField/index.tsx index 2f7dfa7ee4..b4a5ab5eb9 100644 --- a/frontend/src/container/OptionsMenu/FormatField/index.tsx +++ b/frontend/src/container/OptionsMenu/FormatField/index.tsx @@ -1,3 +1,5 @@ +import { RadioChangeEvent } from 'antd'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { FieldTitle } from '../styles'; @@ -7,6 +9,15 @@ import { FormatFieldWrapper, RadioButton, RadioGroup } from './styles'; function FormatField({ config }: FormatFieldProps): JSX.Element | null { const { t } = useTranslation(['trace']); + const onChange = useCallback( + (event: RadioChangeEvent) => { + if (!config) return; + + config.onChange(event.target.value); + }, + [config], + ); + if (!config) return null; return ( @@ -16,7 +27,7 @@ function FormatField({ config }: FormatFieldProps): JSX.Element | null { size="small" buttonStyle="solid" value={config.value} - onChange={config.onChange} + onChange={onChange} > {t('options_menu.raw')} {t('options_menu.default')} diff --git a/frontend/src/container/OptionsMenu/types.ts b/frontend/src/container/OptionsMenu/types.ts index a4bbdc8641..57b81364d6 100644 --- a/frontend/src/container/OptionsMenu/types.ts +++ b/frontend/src/container/OptionsMenu/types.ts @@ -14,7 +14,9 @@ export interface InitialOptions } export type OptionsMenuConfig = { - format?: Pick; + format?: Pick & { + onChange: (value: LogViewMode) => void; + }; maxLines?: Pick; addColumn?: Pick< SelectProps, diff --git a/frontend/src/container/OptionsMenu/useOptionsMenu.ts b/frontend/src/container/OptionsMenu/useOptionsMenu.ts index cd94cf1b80..be2ae00b37 100644 --- a/frontend/src/container/OptionsMenu/useOptionsMenu.ts +++ b/frontend/src/container/OptionsMenu/useOptionsMenu.ts @@ -1,8 +1,8 @@ -import { RadioChangeEvent } from 'antd'; import getFromLocalstorage from 'api/browser/localstorage/get'; import setToLocalstorage from 'api/browser/localstorage/set'; import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys'; import { LOCALSTORAGE } from 'constants/localStorage'; +import { LogViewMode } from 'container/LogsTable'; import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import useDebounce from 'hooks/useDebounce'; import { useNotifications } from 'hooks/useNotifications'; @@ -213,10 +213,10 @@ const useOptionsMenu = ({ ); const handleFormatChange = useCallback( - (event: RadioChangeEvent) => { + (value: LogViewMode) => { const optionsData: OptionsQuery = { ...optionsQueryData, - format: event.target.value, + format: value, }; handleRedirectWithOptionsData(optionsData); diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx index fb2770d0c4..243441fa9b 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx @@ -35,6 +35,7 @@ function QueryBuilderSearch({ query, onChange, whereClauseConfig, + className, }: QueryBuilderSearchProps): JSX.Element { const { updateTag, @@ -163,6 +164,7 @@ function QueryBuilderSearch({ placeholder={PLACEHOLDER} value={queryTags} searchValue={searchValue} + className={className} disabled={isMetricsDataSource && !query.aggregateAttribute.key} style={selectStyle} onSearch={handleSearch} @@ -186,10 +188,12 @@ interface QueryBuilderSearchProps { query: IBuilderQuery; onChange: (value: TagFilter) => void; whereClauseConfig?: WhereClauseConfig; + className?: string; } QueryBuilderSearch.defaultProps = { whereClauseConfig: undefined, + className: '', }; export interface CustomTagProps { diff --git a/frontend/src/container/TopNav/Breadcrumbs/index.tsx b/frontend/src/container/TopNav/Breadcrumbs/index.tsx index ca127b388f..6204c8e259 100644 --- a/frontend/src/container/TopNav/Breadcrumbs/index.tsx +++ b/frontend/src/container/TopNav/Breadcrumbs/index.tsx @@ -21,6 +21,7 @@ const breadcrumbNameMap = { [ROUTES.ALL_DASHBOARD]: 'Dashboard', [ROUTES.LOGS]: 'Logs', [ROUTES.LOGS_EXPLORER]: 'Logs Explorer', + [ROUTES.LIVE_LOGS]: 'Live View', [ROUTES.PIPELINES]: 'Pipelines', }; diff --git a/frontend/src/container/TopNav/DateTimeSelection/config.ts b/frontend/src/container/TopNav/DateTimeSelection/config.ts index ef31201da7..261f30cb8d 100644 --- a/frontend/src/container/TopNav/DateTimeSelection/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelection/config.ts @@ -84,3 +84,5 @@ export const routesToSkip = [ ROUTES.LIST_ALL_ALERT, ROUTES.PIPELINES, ]; + +export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS]; diff --git a/frontend/src/container/TopNav/index.tsx b/frontend/src/container/TopNav/index.tsx index a481ce77d4..e5f1d6ade3 100644 --- a/frontend/src/container/TopNav/index.tsx +++ b/frontend/src/container/TopNav/index.tsx @@ -3,10 +3,10 @@ import ROUTES from 'constants/routes'; import { useMemo } from 'react'; import { matchPath, useHistory } from 'react-router-dom'; +import NewExplorerCTA from '../NewExplorerCTA'; import ShowBreadcrumbs from './Breadcrumbs'; import DateTimeSelector from './DateTimeSelection'; -import { routesToSkip } from './DateTimeSelection/config'; -import NewExplorerCTA from './NewExplorerCTA'; +import { routesToDisable, routesToSkip } from './DateTimeSelection/config'; import { Container } from './styles'; function TopNav(): JSX.Element | null { @@ -20,12 +20,20 @@ function TopNav(): JSX.Element | null { [location.pathname], ); + const isDisabled = useMemo( + () => + routesToDisable.some((route) => + matchPath(location.pathname, { path: route, exact: true }), + ), + [location.pathname], + ); + const isSignUpPage = useMemo( () => matchPath(location.pathname, { path: ROUTES.SIGN_UP, exact: true }), [location.pathname], ); - if (isSignUpPage) { + if (isSignUpPage || isDisabled) { return null; } @@ -40,7 +48,6 @@ function TopNav(): JSX.Element | null { -
diff --git a/frontend/src/hooks/queryBuilder/useShareBuilderUrl.ts b/frontend/src/hooks/queryBuilder/useShareBuilderUrl.ts index b3ccc230e9..d79f6531ee 100644 --- a/frontend/src/hooks/queryBuilder/useShareBuilderUrl.ts +++ b/frontend/src/hooks/queryBuilder/useShareBuilderUrl.ts @@ -8,7 +8,7 @@ import { useQueryBuilder } from './useQueryBuilder'; export type UseShareBuilderUrlParams = { defaultValue: Query }; export const useShareBuilderUrl = (defaultQuery: Query): void => { - const { redirectWithQueryBuilderData, resetStagedQuery } = useQueryBuilder(); + const { redirectWithQueryBuilderData, resetQuery } = useQueryBuilder(); const urlQuery = useUrlQuery(); const compositeQuery = useGetCompositeQueryParam(); @@ -21,8 +21,8 @@ export const useShareBuilderUrl = (defaultQuery: Query): void => { useEffect( () => (): void => { - resetStagedQuery(); + resetQuery(); }, - [resetStagedQuery], + [resetQuery], ); }; diff --git a/frontend/src/hooks/useEventSourceEvent/index.ts b/frontend/src/hooks/useEventSourceEvent/index.ts index 2194d6255e..79e86a9b7e 100644 --- a/frontend/src/hooks/useEventSourceEvent/index.ts +++ b/frontend/src/hooks/useEventSourceEvent/index.ts @@ -2,20 +2,29 @@ import { EventListener, EventSourceEventMap } from 'event-source-polyfill'; import { useEventSource } from 'providers/EventSource'; import { useEffect } from 'react'; -export const useEventSourceEvent = ( - eventName: keyof EventSourceEventMap, - listener: EventListener, +type EventMap = { + message: MessageEvent; + open: Event; + error: Event; +}; + +export const useEventSourceEvent = ( + eventName: T, + listener: (event: EventMap[T]) => void, ): void => { const { eventSourceInstance } = useEventSource(); useEffect(() => { if (eventSourceInstance) { - eventSourceInstance.addEventListener(eventName, listener); + eventSourceInstance.addEventListener(eventName, listener as EventListener); } return (): void => { if (eventSourceInstance) { - eventSourceInstance.removeEventListener(eventName, listener); + eventSourceInstance.removeEventListener( + eventName, + listener as EventListener, + ); } }; }, [eventName, eventSourceInstance, listener]); diff --git a/frontend/src/pages/LiveLogs/index.tsx b/frontend/src/pages/LiveLogs/index.tsx new file mode 100644 index 0000000000..79a60a1222 --- /dev/null +++ b/frontend/src/pages/LiveLogs/index.tsx @@ -0,0 +1,25 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { liveLogsCompositeQuery } from 'container/LiveLogs/constants'; +import LiveLogsContainer from 'container/LiveLogs/LiveLogsContainer'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; +import { EventSourceProvider } from 'providers/EventSource'; +import { useEffect } from 'react'; +import { DataSource } from 'types/common/queryBuilder'; + +function LiveLogs(): JSX.Element { + useShareBuilderUrl(liveLogsCompositeQuery); + const { handleSetConfig } = useQueryBuilder(); + + useEffect(() => { + handleSetConfig(PANEL_TYPES.LIST, DataSource.LOGS); + }, [handleSetConfig]); + + return ( + + + + ); +} + +export default LiveLogs; diff --git a/frontend/src/pages/LogsExplorer/index.tsx b/frontend/src/pages/LogsExplorer/index.tsx index 229ab2f6a8..85514dfa5b 100644 --- a/frontend/src/pages/LogsExplorer/index.tsx +++ b/frontend/src/pages/LogsExplorer/index.tsx @@ -2,24 +2,28 @@ import { Col, Row } from 'antd'; import ExplorerCard from 'components/ExplorerCard'; import LogExplorerQuerySection from 'container/LogExplorerQuerySection'; import LogsExplorerViews from 'container/LogsExplorerViews'; +import LogsTopNav from 'container/LogsTopNav'; // ** Styles import { WrapperStyled } from './styles'; function LogsExplorer(): JSX.Element { return ( - - - - - - - - - - - - + <> + + + + + + + + + + + + + + ); } diff --git a/frontend/src/providers/EventSource.tsx b/frontend/src/providers/EventSource.tsx index 972438db77..89c962a778 100644 --- a/frontend/src/providers/EventSource.tsx +++ b/frontend/src/providers/EventSource.tsx @@ -1,11 +1,13 @@ import { apiV3 } from 'api/apiV1'; import { ENVIRONMENT } from 'constants/env'; +import { LIVE_TAIL_HEARTBEAT_TIMEOUT } from 'constants/liveTail'; import { EventListener, EventSourcePolyfill } from 'event-source-polyfill'; import { createContext, PropsWithChildren, useCallback, useContext, + useEffect, useMemo, useRef, useState, @@ -18,18 +20,25 @@ interface IEventSourceContext { eventSourceInstance: EventSourcePolyfill | null; isConnectionOpen: boolean; isConnectionLoading: boolean; - isConnectionError: string; - handleStartOpenConnection: (url?: string) => void; + isConnectionError: boolean; + initialLoading: boolean; + handleStartOpenConnection: (urlProps: { + url?: string; + queryString: string; + }) => void; handleCloseConnection: () => void; + handleSetInitialLoading: (value: boolean) => void; } const EventSourceContext = createContext({ eventSourceInstance: null, isConnectionOpen: false, isConnectionLoading: false, - isConnectionError: '', + initialLoading: true, + isConnectionError: false, handleStartOpenConnection: () => {}, handleCloseConnection: () => {}, + handleSetInitialLoading: () => {}, }); export function EventSourceProvider({ @@ -37,72 +46,101 @@ export function EventSourceProvider({ }: PropsWithChildren): JSX.Element { const [isConnectionOpen, setIsConnectionOpen] = useState(false); const [isConnectionLoading, setIsConnectionLoading] = useState(false); - const [isConnectionError, setIsConnectionError] = useState(''); + const [isConnectionError, setIsConnectionError] = useState(false); + + const [initialLoading, setInitialLoading] = useState(true); const { user } = useSelector((state) => state.app); const eventSourceRef = useRef(null); - const handleCloseConnection = useCallback(() => { - if (!eventSourceRef.current) return; - - eventSourceRef.current.close(); - setIsConnectionOpen(false); - setIsConnectionLoading(false); + const handleSetInitialLoading = useCallback((value: boolean) => { + setInitialLoading(value); }, []); const handleOpenConnection: EventListener = useCallback(() => { setIsConnectionLoading(false); setIsConnectionOpen(true); + setInitialLoading(false); }, []); const handleErrorConnection: EventListener = useCallback(() => { + setIsConnectionOpen(false); + setIsConnectionLoading(false); + setIsConnectionError(true); + setInitialLoading(false); + if (!eventSourceRef.current) return; - handleCloseConnection(); + eventSourceRef.current.close(); + }, []); + const destroyEventSourceSession = useCallback(() => { + if (!eventSourceRef.current) return; + + eventSourceRef.current.close(); eventSourceRef.current.removeEventListener('error', handleErrorConnection); eventSourceRef.current.removeEventListener('open', handleOpenConnection); - }, [handleCloseConnection, handleOpenConnection]); + }, [handleErrorConnection, handleOpenConnection]); + + const handleCloseConnection = useCallback(() => { + setIsConnectionOpen(false); + setIsConnectionLoading(false); + setIsConnectionError(false); + + destroyEventSourceSession(); + }, [destroyEventSourceSession]); const handleStartOpenConnection = useCallback( - (url?: string) => { - const eventSourceUrl = url || `${ENVIRONMENT.baseURL}${apiV3}logs/livetail`; + (urlProps: { url?: string; queryString: string }): void => { + const { url, queryString } = urlProps; - const TIMEOUT_IN_MS = 10 * 60 * 1000; + const eventSourceUrl = url + ? `${url}/?${queryString}` + : `${ENVIRONMENT.baseURL}${apiV3}logs/livetail?${queryString}`; eventSourceRef.current = new EventSourcePolyfill(eventSourceUrl, { headers: { Authorization: `Bearer ${user?.accessJwt}`, }, - heartbeatTimeout: TIMEOUT_IN_MS, + heartbeatTimeout: LIVE_TAIL_HEARTBEAT_TIMEOUT, }); setIsConnectionLoading(true); - setIsConnectionError(''); + setIsConnectionError(false); eventSourceRef.current.addEventListener('error', handleErrorConnection); - eventSourceRef.current.addEventListener('open', handleOpenConnection); }, - [handleErrorConnection, handleOpenConnection, user?.accessJwt], + [user, handleErrorConnection, handleOpenConnection], ); - const contextValue = useMemo( + useEffect( + () => (): void => { + handleCloseConnection(); + }, + [handleCloseConnection], + ); + + const contextValue: IEventSourceContext = useMemo( () => ({ eventSourceInstance: eventSourceRef.current, isConnectionError, isConnectionLoading, isConnectionOpen, + initialLoading, handleStartOpenConnection, handleCloseConnection, + handleSetInitialLoading, }), [ isConnectionError, isConnectionLoading, isConnectionOpen, + initialLoading, handleStartOpenConnection, handleCloseConnection, + handleSetInitialLoading, ], ); diff --git a/frontend/src/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index 7c1f1d9792..41d9d8eec4 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -67,7 +67,7 @@ export const QueryBuilderContext = createContext({ addNewQueryItem: () => {}, redirectWithQueryBuilderData: () => {}, handleRunQuery: () => {}, - resetStagedQuery: () => {}, + resetQuery: () => {}, updateAllQueriesOperators: () => initialQueriesMap.metrics, updateQueriesData: () => initialQueriesMap.metrics, initQueryBuilderData: () => {}, @@ -526,8 +526,12 @@ export function QueryBuilderProvider({ }); }, [currentQuery, queryType, maxTime, minTime, redirectWithQueryBuilderData]); - const resetStagedQuery = useCallback(() => { + const resetQuery = useCallback((newCurrentQuery?: QueryState) => { setStagedQuery(null); + + if (newCurrentQuery) { + setCurrentQuery(newCurrentQuery); + } }, []); useEffect(() => { @@ -595,7 +599,7 @@ export function QueryBuilderProvider({ addNewQueryItem, redirectWithQueryBuilderData, handleRunQuery, - resetStagedQuery, + resetQuery, updateAllQueriesOperators, updateQueriesData, initQueryBuilderData, @@ -618,7 +622,7 @@ export function QueryBuilderProvider({ addNewQueryItem, redirectWithQueryBuilderData, handleRunQuery, - resetStagedQuery, + resetQuery, updateAllQueriesOperators, updateQueriesData, initQueryBuilderData, diff --git a/frontend/src/store/actions/dashboard/getQueryResults.ts b/frontend/src/store/actions/dashboard/getQueryResults.ts index a132fc8e78..2e6beae483 100644 --- a/frontend/src/store/actions/dashboard/getQueryResults.ts +++ b/frontend/src/store/actions/dashboard/getQueryResults.ts @@ -3,106 +3,24 @@ // @ts-nocheck import { getMetricsQueryRange } from 'api/metrics/getQueryRange'; -import { GRAPH_TYPES } from 'container/NewDashboard/ComponentsSlider'; import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems'; import { Time } from 'container/TopNav/DateTimeSelection/config'; -import getStartEndRangeTime from 'lib/getStartEndRangeTime'; -import getStep from 'lib/getStep'; import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld'; -import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi'; import { isEmpty } from 'lodash-es'; -import store from 'store'; import { SuccessResponse } from 'types/api'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; -import { EQueryType } from 'types/common/dashboard'; import { Pagination } from 'hooks/queryPagination'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { prepareQueryRangePayload } from './prepareQueryRangePayload'; -export async function GetMetricQueryRange({ - query, - globalSelectedInterval, - graphType, - selectedTime, - tableParams, - variables = {}, - params = {}, -}: GetQueryResultsProps): Promise> { - const queryData = query[query.queryType]; - let legendMap: Record = {}; +export async function GetMetricQueryRange( + props: GetQueryResultsProps, +): Promise> { + const { legendMap, queryPayload } = prepareQueryRangePayload(props); - const QueryPayload = { - compositeQuery: { - queryType: query.queryType, - panelType: graphType, - unit: query?.unit, - }, - }; + const response = await getMetricsQueryRange(queryPayload); - switch (query.queryType) { - case EQueryType.QUERY_BUILDER: { - const { queryData: data, queryFormulas } = query.builder; - const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams); - const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName'); - - const builderQueries = { - ...currentQueryData.data, - ...currentFormulas.data, - }; - legendMap = { - ...currentQueryData.newLegendMap, - ...currentFormulas.newLegendMap, - }; - - QueryPayload.compositeQuery.builderQueries = builderQueries; - break; - } - case EQueryType.CLICKHOUSE: { - const chQueries = {}; - queryData.map((query) => { - if (!query.query) return; - chQueries[query.name] = { - query: query.query, - disabled: query.disabled, - }; - legendMap[query.name] = query.legend; - }); - QueryPayload.compositeQuery.chQueries = chQueries; - break; - } - case EQueryType.PROM: { - const promQueries = {}; - queryData.map((query) => { - if (!query.query) return; - promQueries[query.name] = { - query: query.query, - disabled: query.disabled, - }; - legendMap[query.name] = query.legend; - }); - QueryPayload.compositeQuery.promQueries = promQueries; - break; - } - default: - return; - } - - const { start, end } = getStartEndRangeTime({ - type: selectedTime, - interval: globalSelectedInterval, - }); - - const response = await getMetricsQueryRange({ - start: parseInt(start, 10) * 1e3, - end: parseInt(end, 10) * 1e3, - step: getStep({ - start: store.getState().globalTime.minTime, - end: store.getState().globalTime.maxTime, - inputFormat: 'ns', - }), - variables, - ...QueryPayload, - ...params, - }); if (response.statusCode >= 400) { throw new Error( `API responded with ${response.statusCode} - ${response.error}`, @@ -139,7 +57,7 @@ export async function GetMetricQueryRange({ export interface GetQueryResultsProps { query: Query; - graphType: GRAPH_TYPES; + graphType: PANEL_TYPES; selectedTime: timePreferenceType; globalSelectedInterval: Time; variables?: Record; diff --git a/frontend/src/store/actions/dashboard/prepareQueryRangePayload.ts b/frontend/src/store/actions/dashboard/prepareQueryRangePayload.ts new file mode 100644 index 0000000000..f8a41dcd97 --- /dev/null +++ b/frontend/src/store/actions/dashboard/prepareQueryRangePayload.ts @@ -0,0 +1,103 @@ +import getStartEndRangeTime from 'lib/getStartEndRangeTime'; +import getStep from 'lib/getStep'; +import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi'; +import store from 'store'; +import { QueryRangePayload } from 'types/api/metrics/getQueryRange'; +import { EQueryType } from 'types/common/dashboard'; + +import { GetQueryResultsProps } from './getQueryResults'; + +type PrepareQueryRangePayload = { + queryPayload: QueryRangePayload; + legendMap: Record; +}; + +export const prepareQueryRangePayload = ({ + query, + globalSelectedInterval, + graphType, + selectedTime, + tableParams, + variables = {}, + params = {}, +}: GetQueryResultsProps): PrepareQueryRangePayload => { + let legendMap: Record = {}; + + const compositeQuery: QueryRangePayload['compositeQuery'] = { + queryType: query.queryType, + panelType: graphType, + }; + + switch (query.queryType) { + case EQueryType.QUERY_BUILDER: { + const { queryData: data, queryFormulas } = query.builder; + const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams); + const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName'); + + const builderQueries = { + ...currentQueryData.data, + ...currentFormulas.data, + }; + legendMap = { + ...currentQueryData.newLegendMap, + ...currentFormulas.newLegendMap, + }; + + compositeQuery.builderQueries = builderQueries; + break; + } + case EQueryType.CLICKHOUSE: { + const chQueries = query[query.queryType].reduce((acc, query) => { + if (!query.query) return acc; + + acc[query.name] = query; + + legendMap[query.name] = query.legend; + + return acc; + }, {} as NonNullable); + + compositeQuery.chQueries = chQueries; + + break; + } + case EQueryType.PROM: { + // eslint-disable-next-line sonarjs/no-identical-functions + const promQueries = query[query.queryType].reduce((acc, query) => { + if (!query.query) return acc; + + acc[query.name] = query; + + legendMap[query.name] = query.legend; + + return acc; + }, {} as NonNullable); + + compositeQuery.promQueries = promQueries; + break; + } + + default: + break; + } + + const { start, end } = getStartEndRangeTime({ + type: selectedTime, + interval: globalSelectedInterval, + }); + + const queryPayload: QueryRangePayload = { + start: parseInt(start, 10) * 1e3, + end: parseInt(end, 10) * 1e3, + step: getStep({ + start: store.getState().globalTime.minTime, + end: store.getState().globalTime.maxTime, + inputFormat: 'ns', + }), + variables, + compositeQuery, + ...params, + }; + + return { legendMap, queryPayload }; +}; diff --git a/frontend/src/types/api/metrics/getQueryRange.ts b/frontend/src/types/api/metrics/getQueryRange.ts index 5dd80a451f..b5dcba2d77 100644 --- a/frontend/src/types/api/metrics/getQueryRange.ts +++ b/frontend/src/types/api/metrics/getQueryRange.ts @@ -1,6 +1,30 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { EQueryType } from 'types/common/dashboard'; + +import { + IBuilderFormula, + IBuilderQuery, + IClickHouseQuery, + IPromQLQuery, +} from '../queryBuilder/queryBuilderData'; import { QueryData, QueryDataV3 } from '../widgets/getQuery'; -export type MetricsRangeProps = never; +export type QueryRangePayload = { + compositeQuery: { + builderQueries?: { + [x: string]: IBuilderQuery | IBuilderFormula; + }; + chQueries?: Record; + promQueries?: Record; + queryType: EQueryType; + panelType: PANEL_TYPES; + }; + end: number; + start: number; + step: number; + variables?: Record; + [param: string]: unknown; +}; export interface MetricRangePayloadProps { data: { result: QueryData[]; diff --git a/frontend/src/types/api/queryBuilder/queryAutocompleteResponse.ts b/frontend/src/types/api/queryBuilder/queryAutocompleteResponse.ts index 2ce23c8b9b..24e062d9ab 100644 --- a/frontend/src/types/api/queryBuilder/queryAutocompleteResponse.ts +++ b/frontend/src/types/api/queryBuilder/queryAutocompleteResponse.ts @@ -9,7 +9,7 @@ export interface BaseAutocompleteData { dataType: DataType; isColumn: boolean; key: string; - type: AutocompleteType; + type: AutocompleteType | string | null; } export interface IQueryAutocompleteResponse { diff --git a/frontend/src/types/common/queryBuilder.ts b/frontend/src/types/common/queryBuilder.ts index 3d74f30fdc..141f265967 100644 --- a/frontend/src/types/common/queryBuilder.ts +++ b/frontend/src/types/common/queryBuilder.ts @@ -6,6 +6,7 @@ import { IClickHouseQuery, IPromQLQuery, Query, + QueryState, } from 'types/api/queryBuilder/queryBuilderData'; import { EQueryType } from './dashboard'; @@ -187,8 +188,8 @@ export type QueryBuilderContextType = { searchParams?: Record, ) => void; handleRunQuery: () => void; - resetStagedQuery: () => void; handleOnUnitsChange: (units: Format['id']) => void; + resetQuery: (newCurrentQuery?: QueryState) => void; updateAllQueriesOperators: ( queryData: Query, panelType: PANEL_TYPES, diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index 7beb10c385..2e0f233805 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -73,6 +73,7 @@ export const routePermission: Record = { VERSION: ['ADMIN', 'EDITOR', 'VIEWER'], LOGS: ['ADMIN', 'EDITOR', 'VIEWER'], LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'], + LIVE_LOGS: ['ADMIN', 'EDITOR', 'VIEWER'], LIST_LICENSES: ['ADMIN'], LOGS_INDEX_FIELDS: ['ADMIN', 'EDITOR', 'VIEWER'], LOGS_PIPELINE: ['ADMIN', 'EDITOR', 'VIEWER'],