feat: create live logs page and custom top nav (#3315)

* feat: create live logs page and custom top nav

* fix: success button color

* fix: turn back color

* feat: add live logs where clause (#3325)

* feat: add live logs where clause

* fix: undefined scenario

* feat: get live data (#3337)

* feat: get live data

* fix: change color, change number format

* chore: useMemo is updated

* feat: add live logs list (#3341)

* feat: add live logs list

* feat: hide view if error, clear logs

* feat: add condition for disable initial loading

* fix: double request

* fix: render id in the where clause

* fix: render where clause and live list

* fix: last log padding

* fix: list data loading

* fix: no logs text

* fix: logs list size

* fix: small issues

* fix: render view with memo

---------

Co-authored-by: Palash Gupta <palashgdev@gmail.com>

---------

Co-authored-by: Palash Gupta <palashgdev@gmail.com>

---------

Co-authored-by: Palash Gupta <palashgdev@gmail.com>

* fix: build is fixed

---------

Co-authored-by: Palash Gupta <palashgdev@gmail.com>
Co-authored-by: Yunus M <myounis.ar@live.com>
This commit is contained in:
Yevhen Shevchenko 2023-08-29 15:23:22 +03:00 committed by GitHub
parent 337e33eb8a
commit d184486978
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 1254 additions and 167 deletions

View File

@ -0,0 +1 @@
{ "fetching_log_lines": "Fetching log lines" }

View File

@ -29,6 +29,7 @@
"NOT_FOUND": "SigNoz | Page Not Found", "NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs", "LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer", "LOGS_EXPLORER": "SigNoz | Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"HOME_PAGE": "Open source Observability Platform | SigNoz", "HOME_PAGE": "Open source Observability Platform | SigNoz",
"PASSWORD_RESET": "SigNoz | Password Reset", "PASSWORD_RESET": "SigNoz | Password Reset",
"LIST_LICENSES": "SigNoz | List of Licenses", "LIST_LICENSES": "SigNoz | List of Licenses",

View File

@ -0,0 +1 @@
{ "fetching_log_lines": "Fetching log lines" }

View File

@ -29,6 +29,7 @@
"NOT_FOUND": "SigNoz | Page Not Found", "NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs", "LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer", "LOGS_EXPLORER": "SigNoz | Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"HOME_PAGE": "Open source Observability Platform | SigNoz", "HOME_PAGE": "Open source Observability Platform | SigNoz",
"PASSWORD_RESET": "SigNoz | Password Reset", "PASSWORD_RESET": "SigNoz | Password Reset",
"LIST_LICENSES": "SigNoz | List of Licenses", "LIST_LICENSES": "SigNoz | List of Licenses",

View File

@ -110,6 +110,10 @@ export const LogsExplorer = Loadable(
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsExplorer'), () => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsExplorer'),
); );
export const LiveLogs = Loadable(
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
);
export const Login = Loadable( export const Login = Loadable(
() => import(/* webpackChunkName: "Login" */ 'pages/Login'), () => import(/* webpackChunkName: "Login" */ 'pages/Login'),
); );

View File

@ -14,6 +14,7 @@ import {
GettingStarted, GettingStarted,
LicensePage, LicensePage,
ListAllALertsPage, ListAllALertsPage,
LiveLogs,
Login, Login,
Logs, Logs,
LogsExplorer, LogsExplorer,
@ -234,6 +235,13 @@ const routes: AppRoutes[] = [
key: 'LOGS_EXPLORER', key: 'LOGS_EXPLORER',
isPrivate: true, isPrivate: true,
}, },
{
path: ROUTES.LIVE_LOGS,
exact: true,
component: LiveLogs,
key: 'LIVE_LOGS',
isPrivate: true,
},
{ {
path: ROUTES.LOGIN, path: ROUTES.LOGIN,
exact: true, exact: true,

View File

@ -4,11 +4,11 @@ import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api'; import { ErrorResponse, SuccessResponse } from 'types/api';
import { import {
MetricRangePayloadV3, MetricRangePayloadV3,
MetricsRangeProps, QueryRangePayload,
} from 'types/api/metrics/getQueryRange'; } from 'types/api/metrics/getQueryRange';
export const getMetricsQueryRange = async ( export const getMetricsQueryRange = async (
props: MetricsRangeProps, props: QueryRangePayload,
): Promise<SuccessResponse<MetricRangePayloadV3> | ErrorResponse> => { ): Promise<SuccessResponse<MetricRangePayloadV3> | ErrorResponse> => {
try { try {
const response = await axios.post('/query_range', props); const response = await axios.post('/query_range', props);

View File

@ -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;

View File

@ -29,6 +29,7 @@ const ROUTES = {
NOT_FOUND: '/not-found', NOT_FOUND: '/not-found',
LOGS: '/logs', LOGS: '/logs',
LOGS_EXPLORER: '/logs-explorer', LOGS_EXPLORER: '/logs-explorer',
LIVE_LOGS: '/logs-explorer/live',
HOME_PAGE: '/', HOME_PAGE: '/',
PASSWORD_RESET: '/password-reset', PASSWORD_RESET: '/password-reset',
LIST_LICENSES: '/licenses', LIST_LICENSES: '/licenses',

View File

@ -52,6 +52,8 @@ const themeColors = {
gamboge: '#D89614', gamboge: '#D89614',
bckgGrey: '#1d1d1d', bckgGrey: '#1d1d1d',
lightBlue: '#177ddc', lightBlue: '#177ddc',
buttonSuccessRgb: '73, 170, 25',
red: '#E84749',
}; };
export { themeColors }; export { themeColors };

View File

@ -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 (
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
Exit live view
</Button>
);
}
export default BackButton;

View File

@ -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 (
<ContainerStyled>
<Col flex={1}>
<FilterSearchInputStyled query={query} onChange={handleChange} />
</Col>
<SearchButtonStyled onSearch={handleSearch} />
</ContainerStyled>
);
}
export default FiltersInput;

View File

@ -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;
}
`;

View File

@ -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 (
<PopoverContent
linesPerRow={linedPerRow}
handleLinesPerRowChange={handleLinesPerRowChange}
/>
);
}, [config]);
return (
<Space align="baseline" direction="horizontal">
<Select
style={defaultSelectStyle}
value={config.format?.value}
onChange={config.format?.onChange}
>
{viewModeOptionList.map((option) => (
<Select.Option key={option.value}>{option.label}</Select.Option>
))}
</Select>
{isFormatButtonVisible && (
<Popover placement="right" content={renderPopoverContent}>
<Button>Format</Button>
</Popover>
)}
</Space>
);
}
export default ListViewPanel;

View File

@ -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<ILog[]>([]);
const { stagedQuery } = useQueryBuilder();
const batchedEventsRef = useRef<ILog[]>([]);
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<string>) => {
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 (
<Wrapper>
<LiveLogsTopNav />
<ContentWrapper gutter={[0, 20]} style={{ color: themeColors.lightWhite }}>
<Col span={24}>
<FiltersInput />
</Col>
{initialLoading ? (
<Col span={24}>
<Spinner style={{ height: 'auto' }} tip="Fetching Logs" />
</Col>
) : (
<>
<Col span={24}>
<LiveLogsChart />
</Col>
<Col span={24}>
<ListViewPanel />
</Col>
<Col span={24}>
<LiveLogsList logs={logs} />
</Col>
</>
)}
<GoToTop />
</ContentWrapper>
</Wrapper>
);
}
export default LiveLogsContainer;

View File

@ -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;
`;

View File

@ -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<VirtuosoHandle>(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 (
<RawLogView key={log.id} data={log} linesPerRow={options.maxLines} />
);
}
return (
<ListLogView key={log.id} logData={log} selectedFields={selectedFields} />
);
},
[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 <Spinner style={{ height: 'auto' }} tip="Fetching Logs" />;
}
return (
<>
{options.format !== OptionFormatTypes.TABLE && (
<Heading>
<Typography.Text>Event</Typography.Text>
</Heading>
)}
{logs.length === 0 && <Typography>{t('fetching_log_lines')}</Typography>}
{!isConnectionError && logs.length !== 0 && (
<InfinityWrapperStyled>
{options.format === 'table' ? (
<InfinityTableView
ref={ref}
isLoading={false}
tableViewProps={{
logs,
fields: selectedFields,
linesPerRow: options.maxLines,
appendTo: 'end',
}}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={{ ...contentStyle }}>
<Virtuoso
ref={ref}
useWindowScroll
data={logs}
totalCount={logs.length}
itemContent={getItemContent}
/>
</Card>
)}
</InfinityWrapperStyled>
)}
</>
);
}
export default memo(LiveLogsList);

View File

@ -0,0 +1,5 @@
import { ILog } from 'types/api/logs/log';
export type LiveLogsListProps = {
logs: ILog[];
};

View File

@ -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 (
<LogsExplorerChart
isLoading={isLoading}
data={chartData}
isLabelEnabled={false}
className={className}
/>
);
}
export default LiveLogsListChart;

View File

@ -0,0 +1,3 @@
export type LiveLogsListChartProps = {
className?: string;
};

View File

@ -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,
};

View File

@ -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;
};

View File

@ -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(
() => (
<Space size={16}>
<LiveButtonStyled
icon={isPlaying ? <PauseCircleFilled /> : <PlayCircleFilled />}
danger={isPlaying}
onClick={onLiveButtonClick}
type="primary"
>
{isPlaying ? 'Pause' : 'Resume'}
</LiveButtonStyled>
<BackButton />
</Space>
),
[isPlaying, onLiveButtonClick],
);
return (
<LocalTopNav
actions={liveButton}
renderPermissions={{ isDateTimeEnabled: false }}
/>
);
}
export default memo(LiveLogsTopNav);

View File

@ -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)<ButtonProps>`
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``}
`;

View File

@ -0,0 +1,5 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export type LiveLogsTopNavProps = {
getPreparedQuery: (query: Query) => Query;
};

View File

@ -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 (
<Container>
<Col span={16}>
<ShowBreadcrumbs />
</Col>
<Col span={8}>
<Row justify="end">
<Space align="center" size={30} direction="horizontal">
{actions}
{renderPermissions?.isDateTimeEnabled && (
<div>
<DateTimeSelector />
</div>
)}
</Space>
</Row>
</Col>
</Container>
);
}
export default LocalTopNav;

View File

@ -0,0 +1,9 @@
import { Row } from 'antd';
import styled from 'styled-components';
export const Container = styled(Row)`
&&& {
margin-top: 2rem;
min-height: 8vh;
}
`;

View File

@ -0,0 +1,6 @@
import { ReactNode } from 'react';
export type LocalTopNavProps = {
actions?: ReactNode;
renderPermissions?: { isDateTimeEnabled: boolean };
};

View File

@ -3,4 +3,6 @@ import { QueryData } from 'types/api/widgets/getQuery';
export type LogsExplorerChartProps = { export type LogsExplorerChartProps = {
data: QueryData[]; data: QueryData[];
isLoading: boolean; isLoading: boolean;
isLabelEnabled?: boolean;
className?: string;
}; };

View File

@ -1,8 +1,9 @@
import Graph from 'components/Graph'; import Graph from 'components/Graph';
import Spinner from 'components/Spinner'; import Spinner from 'components/Spinner';
import { themeColors } from 'constants/theme';
import getChartData, { GetChartDataProps } from 'lib/getChartData'; import getChartData, { GetChartDataProps } from 'lib/getChartData';
import { colors } from 'lib/getRandomColor'; import { colors } from 'lib/getRandomColor';
import { memo, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces'; import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces';
import { CardStyled } from './LogsExplorerChart.styled'; import { CardStyled } from './LogsExplorerChart.styled';
@ -10,17 +11,22 @@ import { CardStyled } from './LogsExplorerChart.styled';
function LogsExplorerChart({ function LogsExplorerChart({
data, data,
isLoading, isLoading,
isLabelEnabled = true,
className,
}: LogsExplorerChartProps): JSX.Element { }: LogsExplorerChartProps): JSX.Element {
const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] = ( const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] = useCallback(
element, (element, index, allLabels) => ({
index,
allLabels,
) => ({
label: allLabels[index],
data: element, data: element,
backgroundColor: colors[index % colors.length] || 'red', backgroundColor: colors[index % colors.length] || themeColors.red,
borderColor: colors[index % colors.length] || 'red', borderColor: colors[index % colors.length] || themeColors.red,
}); ...(isLabelEnabled
? {
label: allLabels[index],
}
: {}),
}),
[isLabelEnabled],
);
const graphData = useMemo( const graphData = useMemo(
() => () =>
@ -32,11 +38,11 @@ function LogsExplorerChart({
], ],
createDataset: handleCreateDatasets, createDataset: handleCreateDatasets,
}), }),
[data], [data, handleCreateDatasets],
); );
return ( return (
<CardStyled> <CardStyled className={className}>
{isLoading ? ( {isLoading ? (
<Spinner size="default" height="100%" /> <Spinner size="default" height="100%" />
) : ( ) : (

View File

@ -10,6 +10,7 @@ import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import { import {
cloneElement, cloneElement,
forwardRef, forwardRef,
memo,
ReactElement, ReactElement,
ReactNode, ReactNode,
useCallback, useCallback,
@ -67,7 +68,6 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
onAddToQuery, onAddToQuery,
} = useActiveLog(); } = useActiveLog();
const { onEndReached } = infitiyTableProps;
const { dataSource, columns } = useTableView({ const { dataSource, columns } = useTableView({
...tableViewProps, ...tableViewProps,
onClickExpand: onSetActiveLog, onClickExpand: onSetActiveLog,
@ -158,8 +158,11 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
}} }}
itemContent={itemContent} itemContent={itemContent}
fixedHeaderContent={tableHeader} fixedHeaderContent={tableHeader}
endReached={onEndReached}
totalCount={dataSource.length} totalCount={dataSource.length}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(infitiyTableProps?.onEndReached
? { endReached: infitiyTableProps.onEndReached }
: {})}
/> />
{activeContextLog && ( {activeContextLog && (
@ -179,4 +182,4 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
}, },
); );
export default InfinityTable; export default memo(InfinityTable);

View File

@ -3,7 +3,7 @@ import { UseTableViewProps } from 'components/Logs/TableView/types';
export type InfinityTableProps = { export type InfinityTableProps = {
isLoading?: boolean; isLoading?: boolean;
tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>; tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>;
infitiyTableProps: { infitiyTableProps?: {
onEndReached: (index: number) => void; onEndReached: (index: number) => void;
}; };
}; };

View File

@ -39,7 +39,7 @@ import {
Query, Query,
TagFilter, TagFilter,
} from 'types/api/queryBuilder/queryBuilderData'; } 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 { GlobalReducer } from 'types/reducer/globalTime';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink'; import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
@ -120,7 +120,7 @@ function LogsExplorerViews(): JSX.Element {
const modifiedQueryData: IBuilderQuery = { const modifiedQueryData: IBuilderQuery = {
...listQuery, ...listQuery,
aggregateOperator: StringOperators.COUNT, aggregateOperator: LogsAggregatorOperator.COUNT,
}; };
const modifiedQuery: Query = { const modifiedQuery: Query = {

View File

@ -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(
() => (
<LiveButtonStyled
icon={<PlayCircleFilled />}
onClick={handleGoLive}
type="primary"
>
Go Live
</LiveButtonStyled>
),
[handleGoLive],
);
return (
<LocalTopNav
actions={liveButton}
renderPermissions={{ isDateTimeEnabled: true }}
/>
);
}
export default LogsTopNav;

View File

@ -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)<ButtonProps>`
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``}
`;

View File

@ -1,3 +1,5 @@
import { RadioChangeEvent } from 'antd';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FieldTitle } from '../styles'; import { FieldTitle } from '../styles';
@ -7,6 +9,15 @@ import { FormatFieldWrapper, RadioButton, RadioGroup } from './styles';
function FormatField({ config }: FormatFieldProps): JSX.Element | null { function FormatField({ config }: FormatFieldProps): JSX.Element | null {
const { t } = useTranslation(['trace']); const { t } = useTranslation(['trace']);
const onChange = useCallback(
(event: RadioChangeEvent) => {
if (!config) return;
config.onChange(event.target.value);
},
[config],
);
if (!config) return null; if (!config) return null;
return ( return (
@ -16,7 +27,7 @@ function FormatField({ config }: FormatFieldProps): JSX.Element | null {
size="small" size="small"
buttonStyle="solid" buttonStyle="solid"
value={config.value} value={config.value}
onChange={config.onChange} onChange={onChange}
> >
<RadioButton value="raw">{t('options_menu.raw')}</RadioButton> <RadioButton value="raw">{t('options_menu.raw')}</RadioButton>
<RadioButton value="list">{t('options_menu.default')}</RadioButton> <RadioButton value="list">{t('options_menu.default')}</RadioButton>

View File

@ -14,7 +14,9 @@ export interface InitialOptions
} }
export type OptionsMenuConfig = { export type OptionsMenuConfig = {
format?: Pick<RadioProps, 'value' | 'onChange'>; format?: Pick<RadioProps, 'value'> & {
onChange: (value: LogViewMode) => void;
};
maxLines?: Pick<InputNumberProps, 'value' | 'onChange'>; maxLines?: Pick<InputNumberProps, 'value' | 'onChange'>;
addColumn?: Pick< addColumn?: Pick<
SelectProps, SelectProps,

View File

@ -1,8 +1,8 @@
import { RadioChangeEvent } from 'antd';
import getFromLocalstorage from 'api/browser/localstorage/get'; import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set'; import setToLocalstorage from 'api/browser/localstorage/set';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys'; import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { LOCALSTORAGE } from 'constants/localStorage'; import { LOCALSTORAGE } from 'constants/localStorage';
import { LogViewMode } from 'container/LogsTable';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import useDebounce from 'hooks/useDebounce'; import useDebounce from 'hooks/useDebounce';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
@ -213,10 +213,10 @@ const useOptionsMenu = ({
); );
const handleFormatChange = useCallback( const handleFormatChange = useCallback(
(event: RadioChangeEvent) => { (value: LogViewMode) => {
const optionsData: OptionsQuery = { const optionsData: OptionsQuery = {
...optionsQueryData, ...optionsQueryData,
format: event.target.value, format: value,
}; };
handleRedirectWithOptionsData(optionsData); handleRedirectWithOptionsData(optionsData);

View File

@ -35,6 +35,7 @@ function QueryBuilderSearch({
query, query,
onChange, onChange,
whereClauseConfig, whereClauseConfig,
className,
}: QueryBuilderSearchProps): JSX.Element { }: QueryBuilderSearchProps): JSX.Element {
const { const {
updateTag, updateTag,
@ -163,6 +164,7 @@ function QueryBuilderSearch({
placeholder={PLACEHOLDER} placeholder={PLACEHOLDER}
value={queryTags} value={queryTags}
searchValue={searchValue} searchValue={searchValue}
className={className}
disabled={isMetricsDataSource && !query.aggregateAttribute.key} disabled={isMetricsDataSource && !query.aggregateAttribute.key}
style={selectStyle} style={selectStyle}
onSearch={handleSearch} onSearch={handleSearch}
@ -186,10 +188,12 @@ interface QueryBuilderSearchProps {
query: IBuilderQuery; query: IBuilderQuery;
onChange: (value: TagFilter) => void; onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig; whereClauseConfig?: WhereClauseConfig;
className?: string;
} }
QueryBuilderSearch.defaultProps = { QueryBuilderSearch.defaultProps = {
whereClauseConfig: undefined, whereClauseConfig: undefined,
className: '',
}; };
export interface CustomTagProps { export interface CustomTagProps {

View File

@ -21,6 +21,7 @@ const breadcrumbNameMap = {
[ROUTES.ALL_DASHBOARD]: 'Dashboard', [ROUTES.ALL_DASHBOARD]: 'Dashboard',
[ROUTES.LOGS]: 'Logs', [ROUTES.LOGS]: 'Logs',
[ROUTES.LOGS_EXPLORER]: 'Logs Explorer', [ROUTES.LOGS_EXPLORER]: 'Logs Explorer',
[ROUTES.LIVE_LOGS]: 'Live View',
[ROUTES.PIPELINES]: 'Pipelines', [ROUTES.PIPELINES]: 'Pipelines',
}; };

View File

@ -84,3 +84,5 @@ export const routesToSkip = [
ROUTES.LIST_ALL_ALERT, ROUTES.LIST_ALL_ALERT,
ROUTES.PIPELINES, ROUTES.PIPELINES,
]; ];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@ -3,10 +3,10 @@ import ROUTES from 'constants/routes';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { matchPath, useHistory } from 'react-router-dom'; import { matchPath, useHistory } from 'react-router-dom';
import NewExplorerCTA from '../NewExplorerCTA';
import ShowBreadcrumbs from './Breadcrumbs'; import ShowBreadcrumbs from './Breadcrumbs';
import DateTimeSelector from './DateTimeSelection'; import DateTimeSelector from './DateTimeSelection';
import { routesToSkip } from './DateTimeSelection/config'; import { routesToDisable, routesToSkip } from './DateTimeSelection/config';
import NewExplorerCTA from './NewExplorerCTA';
import { Container } from './styles'; import { Container } from './styles';
function TopNav(): JSX.Element | null { function TopNav(): JSX.Element | null {
@ -20,12 +20,20 @@ function TopNav(): JSX.Element | null {
[location.pathname], [location.pathname],
); );
const isDisabled = useMemo(
() =>
routesToDisable.some((route) =>
matchPath(location.pathname, { path: route, exact: true }),
),
[location.pathname],
);
const isSignUpPage = useMemo( const isSignUpPage = useMemo(
() => matchPath(location.pathname, { path: ROUTES.SIGN_UP, exact: true }), () => matchPath(location.pathname, { path: ROUTES.SIGN_UP, exact: true }),
[location.pathname], [location.pathname],
); );
if (isSignUpPage) { if (isSignUpPage || isDisabled) {
return null; return null;
} }
@ -40,7 +48,6 @@ function TopNav(): JSX.Element | null {
<Row justify="end"> <Row justify="end">
<Space align="start" size={60} direction="horizontal"> <Space align="start" size={60} direction="horizontal">
<NewExplorerCTA /> <NewExplorerCTA />
<div> <div>
<DateTimeSelector /> <DateTimeSelector />
</div> </div>

View File

@ -8,7 +8,7 @@ import { useQueryBuilder } from './useQueryBuilder';
export type UseShareBuilderUrlParams = { defaultValue: Query }; export type UseShareBuilderUrlParams = { defaultValue: Query };
export const useShareBuilderUrl = (defaultQuery: Query): void => { export const useShareBuilderUrl = (defaultQuery: Query): void => {
const { redirectWithQueryBuilderData, resetStagedQuery } = useQueryBuilder(); const { redirectWithQueryBuilderData, resetQuery } = useQueryBuilder();
const urlQuery = useUrlQuery(); const urlQuery = useUrlQuery();
const compositeQuery = useGetCompositeQueryParam(); const compositeQuery = useGetCompositeQueryParam();
@ -21,8 +21,8 @@ export const useShareBuilderUrl = (defaultQuery: Query): void => {
useEffect( useEffect(
() => (): void => { () => (): void => {
resetStagedQuery(); resetQuery();
}, },
[resetStagedQuery], [resetQuery],
); );
}; };

View File

@ -2,20 +2,29 @@ import { EventListener, EventSourceEventMap } from 'event-source-polyfill';
import { useEventSource } from 'providers/EventSource'; import { useEventSource } from 'providers/EventSource';
import { useEffect } from 'react'; import { useEffect } from 'react';
export const useEventSourceEvent = ( type EventMap = {
eventName: keyof EventSourceEventMap, message: MessageEvent;
listener: EventListener, open: Event;
error: Event;
};
export const useEventSourceEvent = <T extends keyof EventSourceEventMap>(
eventName: T,
listener: (event: EventMap[T]) => void,
): void => { ): void => {
const { eventSourceInstance } = useEventSource(); const { eventSourceInstance } = useEventSource();
useEffect(() => { useEffect(() => {
if (eventSourceInstance) { if (eventSourceInstance) {
eventSourceInstance.addEventListener(eventName, listener); eventSourceInstance.addEventListener(eventName, listener as EventListener);
} }
return (): void => { return (): void => {
if (eventSourceInstance) { if (eventSourceInstance) {
eventSourceInstance.removeEventListener(eventName, listener); eventSourceInstance.removeEventListener(
eventName,
listener as EventListener,
);
} }
}; };
}, [eventName, eventSourceInstance, listener]); }, [eventName, eventSourceInstance, listener]);

View File

@ -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 (
<EventSourceProvider>
<LiveLogsContainer />
</EventSourceProvider>
);
}
export default LiveLogs;

View File

@ -2,12 +2,15 @@ import { Col, Row } from 'antd';
import ExplorerCard from 'components/ExplorerCard'; import ExplorerCard from 'components/ExplorerCard';
import LogExplorerQuerySection from 'container/LogExplorerQuerySection'; import LogExplorerQuerySection from 'container/LogExplorerQuerySection';
import LogsExplorerViews from 'container/LogsExplorerViews'; import LogsExplorerViews from 'container/LogsExplorerViews';
import LogsTopNav from 'container/LogsTopNav';
// ** Styles // ** Styles
import { WrapperStyled } from './styles'; import { WrapperStyled } from './styles';
function LogsExplorer(): JSX.Element { function LogsExplorer(): JSX.Element {
return ( return (
<>
<LogsTopNav />
<WrapperStyled> <WrapperStyled>
<Row gutter={[0, 16]}> <Row gutter={[0, 16]}>
<Col xs={24}> <Col xs={24}>
@ -20,6 +23,7 @@ function LogsExplorer(): JSX.Element {
</Col> </Col>
</Row> </Row>
</WrapperStyled> </WrapperStyled>
</>
); );
} }

View File

@ -1,11 +1,13 @@
import { apiV3 } from 'api/apiV1'; import { apiV3 } from 'api/apiV1';
import { ENVIRONMENT } from 'constants/env'; import { ENVIRONMENT } from 'constants/env';
import { LIVE_TAIL_HEARTBEAT_TIMEOUT } from 'constants/liveTail';
import { EventListener, EventSourcePolyfill } from 'event-source-polyfill'; import { EventListener, EventSourcePolyfill } from 'event-source-polyfill';
import { import {
createContext, createContext,
PropsWithChildren, PropsWithChildren,
useCallback, useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@ -18,18 +20,25 @@ interface IEventSourceContext {
eventSourceInstance: EventSourcePolyfill | null; eventSourceInstance: EventSourcePolyfill | null;
isConnectionOpen: boolean; isConnectionOpen: boolean;
isConnectionLoading: boolean; isConnectionLoading: boolean;
isConnectionError: string; isConnectionError: boolean;
handleStartOpenConnection: (url?: string) => void; initialLoading: boolean;
handleStartOpenConnection: (urlProps: {
url?: string;
queryString: string;
}) => void;
handleCloseConnection: () => void; handleCloseConnection: () => void;
handleSetInitialLoading: (value: boolean) => void;
} }
const EventSourceContext = createContext<IEventSourceContext>({ const EventSourceContext = createContext<IEventSourceContext>({
eventSourceInstance: null, eventSourceInstance: null,
isConnectionOpen: false, isConnectionOpen: false,
isConnectionLoading: false, isConnectionLoading: false,
isConnectionError: '', initialLoading: true,
isConnectionError: false,
handleStartOpenConnection: () => {}, handleStartOpenConnection: () => {},
handleCloseConnection: () => {}, handleCloseConnection: () => {},
handleSetInitialLoading: () => {},
}); });
export function EventSourceProvider({ export function EventSourceProvider({
@ -37,72 +46,101 @@ export function EventSourceProvider({
}: PropsWithChildren): JSX.Element { }: PropsWithChildren): JSX.Element {
const [isConnectionOpen, setIsConnectionOpen] = useState<boolean>(false); const [isConnectionOpen, setIsConnectionOpen] = useState<boolean>(false);
const [isConnectionLoading, setIsConnectionLoading] = useState<boolean>(false); const [isConnectionLoading, setIsConnectionLoading] = useState<boolean>(false);
const [isConnectionError, setIsConnectionError] = useState<string>(''); const [isConnectionError, setIsConnectionError] = useState<boolean>(false);
const [initialLoading, setInitialLoading] = useState<boolean>(true);
const { user } = useSelector<AppState, AppReducer>((state) => state.app); const { user } = useSelector<AppState, AppReducer>((state) => state.app);
const eventSourceRef = useRef<EventSourcePolyfill | null>(null); const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
const handleCloseConnection = useCallback(() => { const handleSetInitialLoading = useCallback((value: boolean) => {
if (!eventSourceRef.current) return; setInitialLoading(value);
eventSourceRef.current.close();
setIsConnectionOpen(false);
setIsConnectionLoading(false);
}, []); }, []);
const handleOpenConnection: EventListener = useCallback(() => { const handleOpenConnection: EventListener = useCallback(() => {
setIsConnectionLoading(false); setIsConnectionLoading(false);
setIsConnectionOpen(true); setIsConnectionOpen(true);
setInitialLoading(false);
}, []); }, []);
const handleErrorConnection: EventListener = useCallback(() => { const handleErrorConnection: EventListener = useCallback(() => {
setIsConnectionOpen(false);
setIsConnectionLoading(false);
setIsConnectionError(true);
setInitialLoading(false);
if (!eventSourceRef.current) return; 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('error', handleErrorConnection);
eventSourceRef.current.removeEventListener('open', handleOpenConnection); eventSourceRef.current.removeEventListener('open', handleOpenConnection);
}, [handleCloseConnection, handleOpenConnection]); }, [handleErrorConnection, handleOpenConnection]);
const handleCloseConnection = useCallback(() => {
setIsConnectionOpen(false);
setIsConnectionLoading(false);
setIsConnectionError(false);
destroyEventSourceSession();
}, [destroyEventSourceSession]);
const handleStartOpenConnection = useCallback( const handleStartOpenConnection = useCallback(
(url?: string) => { (urlProps: { url?: string; queryString: string }): void => {
const eventSourceUrl = url || `${ENVIRONMENT.baseURL}${apiV3}logs/livetail`; 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, { eventSourceRef.current = new EventSourcePolyfill(eventSourceUrl, {
headers: { headers: {
Authorization: `Bearer ${user?.accessJwt}`, Authorization: `Bearer ${user?.accessJwt}`,
}, },
heartbeatTimeout: TIMEOUT_IN_MS, heartbeatTimeout: LIVE_TAIL_HEARTBEAT_TIMEOUT,
}); });
setIsConnectionLoading(true); setIsConnectionLoading(true);
setIsConnectionError(''); setIsConnectionError(false);
eventSourceRef.current.addEventListener('error', handleErrorConnection); eventSourceRef.current.addEventListener('error', handleErrorConnection);
eventSourceRef.current.addEventListener('open', handleOpenConnection); 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, eventSourceInstance: eventSourceRef.current,
isConnectionError, isConnectionError,
isConnectionLoading, isConnectionLoading,
isConnectionOpen, isConnectionOpen,
initialLoading,
handleStartOpenConnection, handleStartOpenConnection,
handleCloseConnection, handleCloseConnection,
handleSetInitialLoading,
}), }),
[ [
isConnectionError, isConnectionError,
isConnectionLoading, isConnectionLoading,
isConnectionOpen, isConnectionOpen,
initialLoading,
handleStartOpenConnection, handleStartOpenConnection,
handleCloseConnection, handleCloseConnection,
handleSetInitialLoading,
], ],
); );

View File

@ -67,7 +67,7 @@ export const QueryBuilderContext = createContext<QueryBuilderContextType>({
addNewQueryItem: () => {}, addNewQueryItem: () => {},
redirectWithQueryBuilderData: () => {}, redirectWithQueryBuilderData: () => {},
handleRunQuery: () => {}, handleRunQuery: () => {},
resetStagedQuery: () => {}, resetQuery: () => {},
updateAllQueriesOperators: () => initialQueriesMap.metrics, updateAllQueriesOperators: () => initialQueriesMap.metrics,
updateQueriesData: () => initialQueriesMap.metrics, updateQueriesData: () => initialQueriesMap.metrics,
initQueryBuilderData: () => {}, initQueryBuilderData: () => {},
@ -526,8 +526,12 @@ export function QueryBuilderProvider({
}); });
}, [currentQuery, queryType, maxTime, minTime, redirectWithQueryBuilderData]); }, [currentQuery, queryType, maxTime, minTime, redirectWithQueryBuilderData]);
const resetStagedQuery = useCallback(() => { const resetQuery = useCallback((newCurrentQuery?: QueryState) => {
setStagedQuery(null); setStagedQuery(null);
if (newCurrentQuery) {
setCurrentQuery(newCurrentQuery);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -595,7 +599,7 @@ export function QueryBuilderProvider({
addNewQueryItem, addNewQueryItem,
redirectWithQueryBuilderData, redirectWithQueryBuilderData,
handleRunQuery, handleRunQuery,
resetStagedQuery, resetQuery,
updateAllQueriesOperators, updateAllQueriesOperators,
updateQueriesData, updateQueriesData,
initQueryBuilderData, initQueryBuilderData,
@ -618,7 +622,7 @@ export function QueryBuilderProvider({
addNewQueryItem, addNewQueryItem,
redirectWithQueryBuilderData, redirectWithQueryBuilderData,
handleRunQuery, handleRunQuery,
resetStagedQuery, resetQuery,
updateAllQueriesOperators, updateAllQueriesOperators,
updateQueriesData, updateQueriesData,
initQueryBuilderData, initQueryBuilderData,

View File

@ -3,106 +3,24 @@
// @ts-nocheck // @ts-nocheck
import { getMetricsQueryRange } from 'api/metrics/getQueryRange'; import { getMetricsQueryRange } from 'api/metrics/getQueryRange';
import { GRAPH_TYPES } from 'container/NewDashboard/ComponentsSlider';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems'; import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { Time } from 'container/TopNav/DateTimeSelection/config'; import { Time } from 'container/TopNav/DateTimeSelection/config';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import getStep from 'lib/getStep';
import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld'; import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty } from 'lodash-es'; import { isEmpty } from 'lodash-es';
import store from 'store';
import { SuccessResponse } from 'types/api'; import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { Pagination } from 'hooks/queryPagination'; import { Pagination } from 'hooks/queryPagination';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { prepareQueryRangePayload } from './prepareQueryRangePayload';
export async function GetMetricQueryRange({ export async function GetMetricQueryRange(
query, props: GetQueryResultsProps,
globalSelectedInterval, ): Promise<SuccessResponse<MetricRangePayloadProps>> {
graphType, const { legendMap, queryPayload } = prepareQueryRangePayload(props);
selectedTime,
tableParams,
variables = {},
params = {},
}: GetQueryResultsProps): Promise<SuccessResponse<MetricRangePayloadProps>> {
const queryData = query[query.queryType];
let legendMap: Record<string, string> = {};
const QueryPayload = { const response = await getMetricsQueryRange(queryPayload);
compositeQuery: {
queryType: query.queryType,
panelType: graphType,
unit: query?.unit,
},
};
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) { if (response.statusCode >= 400) {
throw new Error( throw new Error(
`API responded with ${response.statusCode} - ${response.error}`, `API responded with ${response.statusCode} - ${response.error}`,
@ -139,7 +57,7 @@ export async function GetMetricQueryRange({
export interface GetQueryResultsProps { export interface GetQueryResultsProps {
query: Query; query: Query;
graphType: GRAPH_TYPES; graphType: PANEL_TYPES;
selectedTime: timePreferenceType; selectedTime: timePreferenceType;
globalSelectedInterval: Time; globalSelectedInterval: Time;
variables?: Record<string, unknown>; variables?: Record<string, unknown>;

View File

@ -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<string, string>;
};
export const prepareQueryRangePayload = ({
query,
globalSelectedInterval,
graphType,
selectedTime,
tableParams,
variables = {},
params = {},
}: GetQueryResultsProps): PrepareQueryRangePayload => {
let legendMap: Record<string, string> = {};
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<QueryRangePayload['compositeQuery']['chQueries']>);
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<QueryRangePayload['compositeQuery']['promQueries']>);
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 };
};

View File

@ -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'; import { QueryData, QueryDataV3 } from '../widgets/getQuery';
export type MetricsRangeProps = never; export type QueryRangePayload = {
compositeQuery: {
builderQueries?: {
[x: string]: IBuilderQuery | IBuilderFormula;
};
chQueries?: Record<string, IClickHouseQuery>;
promQueries?: Record<string, IPromQLQuery>;
queryType: EQueryType;
panelType: PANEL_TYPES;
};
end: number;
start: number;
step: number;
variables?: Record<string, unknown>;
[param: string]: unknown;
};
export interface MetricRangePayloadProps { export interface MetricRangePayloadProps {
data: { data: {
result: QueryData[]; result: QueryData[];

View File

@ -9,7 +9,7 @@ export interface BaseAutocompleteData {
dataType: DataType; dataType: DataType;
isColumn: boolean; isColumn: boolean;
key: string; key: string;
type: AutocompleteType; type: AutocompleteType | string | null;
} }
export interface IQueryAutocompleteResponse { export interface IQueryAutocompleteResponse {

View File

@ -6,6 +6,7 @@ import {
IClickHouseQuery, IClickHouseQuery,
IPromQLQuery, IPromQLQuery,
Query, Query,
QueryState,
} from 'types/api/queryBuilder/queryBuilderData'; } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from './dashboard'; import { EQueryType } from './dashboard';
@ -187,8 +188,8 @@ export type QueryBuilderContextType = {
searchParams?: Record<string, unknown>, searchParams?: Record<string, unknown>,
) => void; ) => void;
handleRunQuery: () => void; handleRunQuery: () => void;
resetStagedQuery: () => void;
handleOnUnitsChange: (units: Format['id']) => void; handleOnUnitsChange: (units: Format['id']) => void;
resetQuery: (newCurrentQuery?: QueryState) => void;
updateAllQueriesOperators: ( updateAllQueriesOperators: (
queryData: Query, queryData: Query,
panelType: PANEL_TYPES, panelType: PANEL_TYPES,

View File

@ -73,6 +73,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'], VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'], LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'], LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
LIVE_LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
LIST_LICENSES: ['ADMIN'], LIST_LICENSES: ['ADMIN'],
LOGS_INDEX_FIELDS: ['ADMIN', 'EDITOR', 'VIEWER'], LOGS_INDEX_FIELDS: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_PIPELINE: ['ADMIN', 'EDITOR', 'VIEWER'], LOGS_PIPELINE: ['ADMIN', 'EDITOR', 'VIEWER'],