Merge pull request #3463 from SigNoz/release/v0.28.0

Release/v0.28.0
This commit is contained in:
Srikanth Chekuri 2023-09-04 11:51:19 +05:30 committed by GitHub
commit aeee8b4cb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
135 changed files with 3950 additions and 790 deletions

View File

@ -131,7 +131,7 @@ services:
# - ./data/clickhouse-3/:/var/lib/clickhouse/
alertmanager:
image: signoz/alertmanager:0.23.2
image: signoz/alertmanager:0.23.3
volumes:
- ./data/alertmanager:/data
command:
@ -144,7 +144,7 @@ services:
condition: on-failure
query-service:
image: signoz/query-service:0.27.0
image: signoz/query-service:0.28.0
command: [ "-config=/root/config/prometheus.yml" ]
# ports:
# - "6060:6060" # pprof port
@ -180,7 +180,7 @@ services:
<<: *clickhouse-depend
frontend:
image: signoz/frontend:0.27.0
image: signoz/frontend:0.28.0
deploy:
restart_policy:
condition: on-failure

View File

@ -24,10 +24,6 @@ server {
try_files $uri $uri/ /index.html;
}
location /api/alertmanager {
proxy_pass http://alertmanager:9093/api/v2;
}
location ~ ^/api/(v1|v3)/logs/(tail|livetail){
proxy_pass http://query-service:8080;
proxy_http_version 1.1;

View File

@ -34,7 +34,7 @@ services:
alertmanager:
container_name: signoz-alertmanager
image: signoz/alertmanager:0.23.2
image: signoz/alertmanager:0.23.3
volumes:
- ./data/alertmanager:/data
depends_on:

View File

@ -147,7 +147,7 @@ services:
# - ./user_scripts:/var/lib/clickhouse/user_scripts/
alertmanager:
image: signoz/alertmanager:${ALERTMANAGER_TAG:-0.23.2}
image: signoz/alertmanager:${ALERTMANAGER_TAG:-0.23.3}
container_name: signoz-alertmanager
volumes:
- ./data/alertmanager:/data
@ -162,7 +162,7 @@ services:
# Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md`
query-service:
image: signoz/query-service:${DOCKER_TAG:-0.27.0}
image: signoz/query-service:${DOCKER_TAG:-0.28.0}
container_name: signoz-query-service
command: [ "-config=/root/config/prometheus.yml" ]
# ports:
@ -197,7 +197,7 @@ services:
<<: *clickhouse-depend
frontend:
image: signoz/frontend:${DOCKER_TAG:-0.27.0}
image: signoz/frontend:${DOCKER_TAG:-0.28.0}
container_name: signoz-frontend
restart: on-failure
depends_on:

View File

@ -24,10 +24,6 @@ server {
try_files $uri $uri/ /index.html;
}
location /api/alertmanager {
proxy_pass http://alertmanager:9093/api/v2;
}
location ~ ^/api/(v1|v3)/logs/(tail|livetail){
proxy_pass http://query-service:8080;
proxy_http_version 1.1;

View File

@ -81,6 +81,13 @@ var BasicPlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.AlertChannelOpsgenie,
Active: true,
Usage: 0,
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.AlertChannelMsTeams,
Active: false,
@ -161,6 +168,13 @@ var ProPlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.AlertChannelOpsgenie,
Active: true,
Usage: 0,
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.AlertChannelMsTeams,
Active: true,
@ -241,6 +255,13 @@ var EnterprisePlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.AlertChannelOpsgenie,
Active: true,
Usage: 0,
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.AlertChannelMsTeams,
Active: true,

View File

@ -185,7 +185,7 @@
"react-resizable": "3.0.4",
"ts-jest": "^27.1.4",
"ts-node": "^10.2.1",
"typescript-plugin-css-modules": "^3.4.0",
"typescript-plugin-css-modules": "5.0.1",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2"
},

View File

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

View File

@ -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",

View File

@ -20,6 +20,9 @@
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
"placeholder_opsgenie_description": "Description",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_pager_routing_key": "Routing Key",
@ -31,8 +34,12 @@
"field_pager_class": "Class",
"field_pager_client": "Client",
"field_pager_client_url": "Client URL",
"field_opsgenie_message": "Message",
"field_opsgenie_priority": "Priority",
"placeholder_slack_description": "Description",
"placeholder_pager_description": "Description",
"placeholder_opsgenie_message": "Message",
"placeholder_opsgenie_priority": "Priority",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_class": "The class/type of the event",
@ -43,6 +50,9 @@
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"help_pager_description": "Shows up as description in pagerduty",
"help_opsgenie_message": "Shows up as message in opsgenie",
"help_opsgenie_priority": "Priority of the incident",
"help_opsgenie_description": "Shows up as description in opsgenie",
"channel_creation_done": "Successfully created the channel",
"channel_creation_failed": "An unexpected error occurred while creating this channel",
"channel_edit_done": "Channels Edited Successfully",

View File

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

View File

@ -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",

View File

@ -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'),
);

View File

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

View File

@ -1,4 +1,4 @@
import { AxiosAlertManagerInstance } from 'api';
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import convertObjectIntoParams from 'lib/query/convertObjectIntoParams';
@ -11,15 +11,15 @@ const getTriggered = async (
try {
const queryParams = convertObjectIntoParams(props);
const response = await AxiosAlertManagerInstance.get(
`/alerts?${queryParams}`,
);
const response = await axios.get(`/alerts?${queryParams}`);
const amData = JSON.parse(response.data.data);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
payload: amData.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);

View File

@ -0,0 +1,37 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
const create = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/channels', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default create;

View File

@ -0,0 +1,38 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editOpsgenie';
const editOpsgenie = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/channels/${props.id}`, {
name: props.name,
opsgenie_configs: [
{
send_resolved: true,
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default editOpsgenie;

View File

@ -0,0 +1,37 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
const testOpsgenie = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/testChannel', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default testOpsgenie;

View File

@ -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<SuccessResponse<MetricRangePayloadV3> | ErrorResponse> => {
try {
const response = await axios.post('/query_range', props);

View File

@ -0,0 +1,5 @@
import axios from 'api';
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
export const deleteView = (uuid: string): Promise<DeleteViewPayloadProps> =>
axios.delete(`explorer/views/${uuid}`);

View File

@ -0,0 +1,9 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { AllViewsProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
export const getAllViews = (
sourcepage: DataSource,
): Promise<AxiosResponse<AllViewsProps>> =>
axios.get(`explorer/views?sourcePage=${sourcepage}`);

View File

@ -0,0 +1,16 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
export const saveView = ({
compositeQuery,
sourcePage,
viewName,
extraData,
}: SaveViewProps): Promise<AxiosResponse<SaveViewPayloadProps>> =>
axios.post('explorer/views', {
name: viewName,
sourcePage,
compositeQuery,
extraData,
});

View File

@ -0,0 +1,19 @@
import axios from 'api';
import {
UpdateViewPayloadProps,
UpdateViewProps,
} from 'types/api/saveViews/types';
export const updateView = ({
compositeQuery,
viewName,
extraData,
sourcePage,
viewKey,
}: UpdateViewProps): Promise<UpdateViewPayloadProps> =>
axios.put(`explorer/views/${viewKey}`, {
name: viewName,
compositeQuery,
extraData,
sourcePage,
});

View File

@ -0,0 +1,278 @@
import {
DeleteOutlined,
DownOutlined,
MoreOutlined,
SaveOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import {
Button,
Card,
Col,
Dropdown,
MenuProps,
Popover,
Row,
Space,
Typography,
} from 'antd';
import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { querySearchParams } from 'constants/queryBuilderQueryNames';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
import { useUpdateView } from 'hooks/saveViews/useUpdateView';
import useErrorNotification from 'hooks/useErrorNotification';
import { useNotifications } from 'hooks/useNotifications';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { ExploreHeaderToolTip, SaveButtonText } from './constants';
import MenuItemGenerator from './MenuItemGenerator';
import SaveViewWithName from './SaveViewWithName';
import {
DropDownOverlay,
ExplorerCardHeadContainer,
OffSetCol,
} from './styles';
import { ExplorerCardProps } from './types';
import { deleteViewHandler, isQueryUpdatedInView } from './utils';
function ExplorerCard({
sourcepage,
children,
}: ExplorerCardProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [, setCopyUrl] = useCopyToClipboard();
const [isQueryUpdated, setIsQueryUpdated] = useState<boolean>(false);
const { notifications } = useNotifications();
const onCopyUrlHandler = (): void => {
setCopyUrl(window.location.href);
notifications.success({
message: 'Copied to clipboard',
});
};
const {
stagedQuery,
currentQuery,
panelType,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const {
data: viewsData,
isLoading,
error,
isRefetching,
refetch: refetchAllView,
} = useGetAllViews(sourcepage);
useErrorNotification(error);
const handlePopOverClose = (): void => {
setIsOpen(false);
};
const handleOpenChange = (newOpen: boolean): void => {
setIsOpen(newOpen);
};
const viewName =
useGetSearchQueryParam(querySearchParams.viewName) || 'Query Builder';
const viewKey = useGetSearchQueryParam(querySearchParams.viewKey) || '';
const { mutateAsync: updateViewAsync } = useUpdateView({
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
viewKey,
extraData: '',
sourcePage: sourcepage,
viewName,
});
const { mutateAsync: deleteViewAsync } = useDeleteView(viewKey);
const showErrorNotification = (err: Error): void => {
notifications.error({
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
});
};
const onDeleteHandler = useCallback(() => {
deleteViewHandler({
deleteViewAsync,
notifications,
panelType,
redirectWithQueryBuilderData,
refetchAllView,
viewId: viewKey,
viewKey,
});
}, [
deleteViewAsync,
notifications,
panelType,
redirectWithQueryBuilderData,
refetchAllView,
viewKey,
]);
const onUpdateQueryHandler = (): void => {
updateViewAsync(
{
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
viewKey,
extraData: '',
sourcePage: sourcepage,
viewName,
},
{
onSuccess: () => {
setIsQueryUpdated(false);
notifications.success({
message: 'View Updated Successfully',
});
refetchAllView();
},
onError: (err) => {
showErrorNotification(err);
},
},
);
};
useEffect(() => {
setIsQueryUpdated(
isQueryUpdatedInView({
data: viewsData?.data?.data,
stagedQuery,
viewKey,
currentPanelType: panelType,
}),
);
}, [
currentQuery,
viewsData?.data?.data,
stagedQuery,
stagedQuery?.builder.queryData,
viewKey,
panelType,
]);
const menu = useMemo(
(): MenuProps => ({
items: viewsData?.data?.data?.map((view) => ({
key: view.uuid,
label: (
<MenuItemGenerator
viewName={view.name}
viewKey={viewKey}
createdBy={view.createdBy}
uuid={view.uuid}
refetchAllView={refetchAllView}
viewData={viewsData.data.data}
/>
),
})),
}),
[refetchAllView, viewKey, viewsData?.data?.data],
);
const moreOptionMenu = useMemo(
(): MenuProps => ({
items: [
{
key: 'delete',
label: <Typography.Text strong>Delete</Typography.Text>,
onClick: onDeleteHandler,
icon: <DeleteOutlined />,
},
],
}),
[onDeleteHandler],
);
const saveButtonType = isQueryUpdated ? 'default' : 'primary';
const saveButtonIcon = isQueryUpdated ? null : <SaveOutlined />;
return (
<>
<ExplorerCardHeadContainer size="small">
<Row align="middle">
<Col span={6}>
<Space>
<Typography>{viewName}</Typography>
<TextToolTip
url={ExploreHeaderToolTip.url}
text={ExploreHeaderToolTip.text}
useFilledIcon={false}
/>
</Space>
</Col>
<OffSetCol span={10} offset={8}>
<Space size="large">
{viewsData?.data.data && viewsData?.data.data.length && (
<Space>
{/* <Typography.Text>Saved Views</Typography.Text> */}
<Dropdown.Button
menu={menu}
loading={isLoading || isRefetching}
icon={<DownOutlined />}
trigger={['click']}
overlayStyle={DropDownOverlay}
>
Select View
</Dropdown.Button>
</Space>
)}
{isQueryUpdated && (
<Button
type="primary"
icon={<SaveOutlined />}
onClick={onUpdateQueryHandler}
>
Save changes
</Button>
)}
<Popover
placement="bottomLeft"
trigger="click"
content={
<SaveViewWithName
sourcePage={sourcepage}
handlePopOverClose={handlePopOverClose}
refetchAllView={refetchAllView}
/>
}
showArrow={false}
open={isOpen}
onOpenChange={handleOpenChange}
>
<Button type={saveButtonType} icon={saveButtonIcon}>
{isQueryUpdated
? SaveButtonText.SAVE_AS_NEW_VIEW
: SaveButtonText.SAVE_VIEW}
</Button>
</Popover>
<ShareAltOutlined onClick={onCopyUrlHandler} />
{viewKey && (
<Dropdown trigger={['click']} menu={moreOptionMenu}>
<MoreOutlined />
</Dropdown>
)}
</Space>
</OffSetCol>
</Row>
</ExplorerCardHeadContainer>
<Card>{children}</Card>
</>
);
}
export default ExplorerCard;

View File

@ -0,0 +1,87 @@
import { DeleteOutlined } from '@ant-design/icons';
import { Col, Row, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { MouseEvent, useCallback } from 'react';
import { MenuItemContainer } from './styles';
import { MenuItemLabelGeneratorProps } from './types';
import { deleteViewHandler, getViewDetailsUsingViewKey } from './utils';
function MenuItemGenerator({
viewName,
viewKey,
createdBy,
uuid,
viewData,
refetchAllView,
}: MenuItemLabelGeneratorProps): JSX.Element {
const { panelType, redirectWithQueryBuilderData } = useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { notifications } = useNotifications();
const { mutateAsync: deleteViewAsync } = useDeleteView(uuid);
const onDeleteHandler = (event: MouseEvent<HTMLElement>): void => {
event.stopPropagation();
deleteViewHandler({
deleteViewAsync,
notifications,
panelType,
redirectWithQueryBuilderData,
refetchAllView,
viewId: uuid,
viewKey,
});
};
const onMenuItemSelectHandler = useCallback(
({ key }: { key: string }): void => {
const currentViewDetails = getViewDetailsUsingViewKey(key, viewData);
if (!currentViewDetails) return;
const {
query,
name,
uuid,
panelType: currentPanelType,
} = currentViewDetails;
handleExplorerTabChange(currentPanelType, {
query,
name,
uuid,
});
},
[viewData, handleExplorerTabChange],
);
const onLabelClickHandler = (): void => {
onMenuItemSelectHandler({
key: uuid,
});
};
return (
<MenuItemContainer onClick={onLabelClickHandler}>
<Row justify="space-between">
<Col span={22}>
<Row>
<Typography.Text strong>{viewName}</Typography.Text>
</Row>
<Row>
<Typography.Text type="secondary">Created by {createdBy}</Typography.Text>
</Row>
</Col>
<Col span={2}>
<Typography.Link>
<DeleteOutlined onClick={onDeleteHandler} />
</Typography.Link>
</Col>
</Row>
</MenuItemContainer>
);
}
export default MenuItemGenerator;

View File

@ -0,0 +1,68 @@
import { Card, Input, Typography } from 'antd';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveView } from 'hooks/saveViews/useSaveView';
import { useNotifications } from 'hooks/useNotifications';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { ChangeEvent, useCallback, useState } from 'react';
import { SaveButton } from './styles';
import { SaveViewWithNameProps } from './types';
import { saveViewHandler } from './utils';
function SaveViewWithName({
sourcePage,
handlePopOverClose,
refetchAllView,
}: SaveViewWithNameProps): JSX.Element {
const [name, setName] = useState('');
const {
currentQuery,
panelType,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { notifications } = useNotifications();
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
const { isLoading, mutateAsync: saveViewAsync } = useSaveView({
viewName: name,
compositeQuery,
sourcePage,
extraData: '',
});
const onChangeHandler = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
setName(e.target.value);
},
[],
);
const onSaveHandler = (): void => {
saveViewHandler({
compositeQuery,
handlePopOverClose,
extraData: '',
notifications,
panelType: panelType || PANEL_TYPES.LIST,
redirectWithQueryBuilderData,
refetchAllView,
saveViewAsync,
sourcePage,
viewName: name,
setName,
});
};
return (
<Card>
<Typography>Name of the View</Typography>
<Input placeholder="Enter Name" onChange={onChangeHandler} />
<SaveButton onClick={onSaveHandler} type="primary" loading={isLoading}>
Save
</SaveButton>
</Card>
);
}
export default SaveViewWithName;

View File

@ -0,0 +1,32 @@
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
export const viewMockData: ViewProps[] = [
{
uuid: 'view1',
name: 'View 1',
createdBy: 'User 1',
category: 'category 1',
compositeQuery: {} as ICompositeMetricQuery,
createdAt: '2021-07-07T06:31:00.000Z',
updatedAt: '2021-07-07T06:33:00.000Z',
extraData: '',
sourcePage: DataSource.TRACES,
tags: [],
updatedBy: 'User 1',
},
{
uuid: 'view2',
name: 'View 2',
createdBy: 'User 2',
category: 'category 2',
compositeQuery: {} as ICompositeMetricQuery,
createdAt: '2021-07-07T06:30:00.000Z',
updatedAt: '2021-07-07T06:30:00.000Z',
extraData: '',
sourcePage: DataSource.TRACES,
tags: [],
updatedBy: 'User 2',
},
];

View File

@ -0,0 +1,10 @@
export const ExploreHeaderToolTip = {
url:
'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
text: 'More details on how to use query builder',
};
export const SaveButtonText = {
SAVE_AS_NEW_VIEW: 'Save as new view',
SAVE_VIEW: 'Save view',
};

View File

@ -1,27 +0,0 @@
import { Card, Space, Typography } from 'antd';
import TextToolTip from 'components/TextToolTip';
function ExplorerCard({ children }: Props): JSX.Element {
return (
<Card
size="small"
title={
<Space>
<Typography>Query Builder</Typography>
<TextToolTip
url="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder"
text="More details on how to use query builder"
/>
</Space>
}
>
{children}
</Card>
);
}
interface Props {
children: React.ReactNode;
}
export default ExplorerCard;

View File

@ -0,0 +1,28 @@
import { Button, Card, Col } from 'antd';
import styled, { CSSProperties } from 'styled-components';
export const ExplorerCardHeadContainer = styled(Card)`
margin: 1rem 0;
`;
export const OffSetCol = styled(Col)`
text-align: right;
`;
export const SaveButton = styled(Button)`
&&& {
margin: 1rem 0;
width: 5rem;
}
`;
export const DropDownOverlay: CSSProperties = {
maxHeight: '20rem',
overflowY: 'auto',
width: '20rem',
padding: 0,
};
export const MenuItemContainer = styled(Card)`
padding: 0;
`;

View File

@ -0,0 +1,76 @@
import { fireEvent, render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { DataSource } from 'types/common/queryBuilder';
import { viewMockData } from '../__mock__/viewData';
import ExplorerCard from '../ExplorerCard';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
}),
}));
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
}));
jest.mock('hooks/saveViews/useGetAllViews', () => ({
useGetAllViews: jest.fn(() => ({
data: { data: { data: viewMockData } },
isLoading: false,
error: null,
isRefetching: false,
refetch: jest.fn(),
})),
}));
jest.mock('hooks/saveViews/useUpdateView', () => ({
useUpdateView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
jest.mock('hooks/saveViews/useDeleteView', () => ({
useDeleteView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
describe('ExplorerCard', () => {
it('renders a card with a title and a description', () => {
render(
<MockQueryClientProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
</MockQueryClientProvider>,
);
expect(screen.getByText('Query Builder')).toBeInTheDocument();
});
it('renders a save view button', () => {
render(
<MockQueryClientProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
</MockQueryClientProvider>,
);
expect(screen.getByText('Save view')).toBeInTheDocument();
});
it('should see all the view listed in dropdown', async () => {
const screen = render(
<ExplorerCard sourcepage={DataSource.TRACES}>Mock Children</ExplorerCard>,
);
const selectButton = screen.getByText('Select View');
fireEvent.click(selectButton);
const spanElement = screen.getByRole('img', {
name: 'down',
});
fireEvent.click(spanElement);
const viewNameText = await screen.findByText('View 2');
expect(viewNameText).toBeInTheDocument();
});
});

View File

@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { viewMockData } from '../__mock__/viewData';
import MenuItemGenerator from '../MenuItemGenerator';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
}),
}));
describe('MenuItemGenerator', () => {
it('should render MenuItemGenerator component', () => {
const screen = render(
<MockQueryClientProvider>
<MenuItemGenerator
viewName={viewMockData[0].name}
viewKey={viewMockData[0].uuid}
createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].uuid}
refetchAllView={jest.fn()}
viewData={viewMockData}
/>
</MockQueryClientProvider>,
);
expect(screen.getByText(viewMockData[0].name)).toBeInTheDocument();
});
it('should call onMenuItemSelectHandler on click of MenuItemGenerator', () => {
render(
<MockQueryClientProvider>
<MenuItemGenerator
viewName={viewMockData[0].name}
viewKey={viewMockData[0].uuid}
createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].uuid}
refetchAllView={jest.fn()}
viewData={viewMockData}
/>
</MockQueryClientProvider>,
);
const spanElement = screen.getByRole('img', {
name: 'delete',
});
expect(spanElement).toBeInTheDocument();
});
});

View File

@ -0,0 +1,63 @@
import { fireEvent, render } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { QueryClient, QueryClientProvider } from 'react-query';
import { DataSource } from 'types/common/queryBuilder';
import SaveViewWithName from '../SaveViewWithName';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
}),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
}));
jest.mock('hooks/saveViews/useSaveView', () => ({
useSaveView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
describe('SaveViewWithName', () => {
it('should render SaveViewWithName component', () => {
const screen = render(
<QueryClientProvider client={queryClient}>
<SaveViewWithName
sourcePage={DataSource.TRACES}
handlePopOverClose={jest.fn()}
refetchAllView={jest.fn()}
/>
</QueryClientProvider>,
);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('should call saveViewAsync on click of Save button', () => {
const screen = render(
<QueryClientProvider client={queryClient}>
<SaveViewWithName
sourcePage={DataSource.TRACES}
handlePopOverClose={jest.fn()}
refetchAllView={jest.fn()}
/>
</QueryClientProvider>,
);
fireEvent.click(screen.getByText('Save'));
expect(screen.getByText('Save')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,77 @@
import { NotificationInstance } from 'antd/es/notification/interface';
import { AxiosResponse } from 'axios';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { SetStateAction } from 'react';
import { UseMutateAsyncFunction } from 'react-query';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
DeleteViewPayloadProps,
SaveViewPayloadProps,
SaveViewProps,
ViewProps,
} from 'types/api/saveViews/types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
export interface ExplorerCardProps {
sourcepage: DataSource;
children: React.ReactNode;
}
export type GetViewDetailsUsingViewKey = (
viewKey: string,
data: ViewProps[] | undefined,
) =>
| { query: Query; name: string; uuid: string; panelType: PANEL_TYPES }
| undefined;
export interface IsQueryUpdatedInViewProps {
viewKey: string;
data: ViewProps[] | undefined;
stagedQuery: Query | null;
currentPanelType: PANEL_TYPES | null;
}
export interface SaveViewWithNameProps {
sourcePage: ExplorerCardProps['sourcepage'];
handlePopOverClose: VoidFunction;
refetchAllView: VoidFunction;
}
export interface MenuItemLabelGeneratorProps {
viewName: string;
viewKey: string;
createdBy: string;
uuid: string;
viewData: ViewProps[];
refetchAllView: VoidFunction;
}
export interface SaveViewHandlerProps {
viewName: string;
compositeQuery: ICompositeMetricQuery;
sourcePage: ExplorerCardProps['sourcepage'];
extraData: string;
panelType: PANEL_TYPES | null;
notifications: NotificationInstance;
refetchAllView: SaveViewWithNameProps['refetchAllView'];
saveViewAsync: UseMutateAsyncFunction<
AxiosResponse<SaveViewPayloadProps>,
Error,
SaveViewProps,
SaveViewPayloadProps
>;
handlePopOverClose: SaveViewWithNameProps['handlePopOverClose'];
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
setName: (value: SetStateAction<string>) => void;
}
export interface DeleteViewHandlerProps {
deleteViewAsync: UseMutateAsyncFunction<DeleteViewPayloadProps, Error, string>;
refetchAllView: MenuItemLabelGeneratorProps['refetchAllView'];
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
notifications: NotificationInstance;
panelType: PANEL_TYPES | null;
viewKey: string;
viewId: string;
}

View File

@ -0,0 +1,170 @@
import { NotificationInstance } from 'antd/es/notification/interface';
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
queryParamNamesMap,
querySearchParams,
} from 'constants/queryBuilderQueryNames';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import isEqual from 'lodash-es/isEqual';
import {
DeleteViewHandlerProps,
GetViewDetailsUsingViewKey,
IsQueryUpdatedInViewProps,
SaveViewHandlerProps,
} from './types';
const showErrorNotification = (
notifications: NotificationInstance,
err: Error,
): void => {
notifications.error({
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
});
};
export const getViewDetailsUsingViewKey: GetViewDetailsUsingViewKey = (
viewKey,
data,
) => {
const selectedView = data?.find((view) => view.uuid === viewKey);
if (selectedView) {
const { compositeQuery, name, uuid } = selectedView;
const query = mapQueryDataFromApi(compositeQuery);
return { query, name, uuid, panelType: compositeQuery.panelType };
}
return undefined;
};
export const isQueryUpdatedInView = ({
viewKey,
data,
stagedQuery,
currentPanelType,
}: IsQueryUpdatedInViewProps): boolean => {
const currentViewDetails = getViewDetailsUsingViewKey(viewKey, data);
if (!currentViewDetails) {
return false;
}
const { query, panelType } = currentViewDetails;
// Omitting id from aggregateAttribute and groupBy
const updatedCurrentQuery = {
...stagedQuery,
builder: {
...stagedQuery?.builder,
queryData: stagedQuery?.builder.queryData.map((queryData) => {
const { id, ...rest } = queryData.aggregateAttribute;
const newAggregateAttribute = rest;
const newGroupByAttributes = queryData.groupBy.map((groupByAttribute) => {
const { id, ...rest } = groupByAttribute;
return rest;
});
const newItems = queryData.filters.items.map((item) => {
const { id, ...newItem } = item;
if (item.key) {
const { id, ...rest } = item.key;
return {
...newItem,
key: rest,
};
}
return newItem;
});
return {
...queryData,
aggregateAttribute: newAggregateAttribute,
groupBy: newGroupByAttributes,
filters: {
...queryData.filters,
items: newItems,
},
limit: queryData.limit ? queryData.limit : 0,
offset: queryData.offset ? queryData.offset : 0,
pageSize: queryData.pageSize ? queryData.pageSize : 0,
};
}),
},
};
return (
panelType !== currentPanelType ||
!isEqual(query.builder, updatedCurrentQuery?.builder) ||
!isEqual(query.clickhouse_sql, updatedCurrentQuery?.clickhouse_sql) ||
!isEqual(query.promql, updatedCurrentQuery?.promql)
);
};
export const saveViewHandler = ({
saveViewAsync,
refetchAllView,
notifications,
handlePopOverClose,
viewName,
compositeQuery,
sourcePage,
extraData,
redirectWithQueryBuilderData,
panelType,
setName,
}: SaveViewHandlerProps): void => {
saveViewAsync(
{
viewName,
compositeQuery,
sourcePage,
extraData,
},
{
onSuccess: (data) => {
refetchAllView();
redirectWithQueryBuilderData(mapQueryDataFromApi(compositeQuery), {
[queryParamNamesMap.panelTypes]: panelType,
[querySearchParams.viewName]: viewName,
[querySearchParams.viewKey]: data.data.data,
});
notifications.success({
message: 'View Saved Successfully',
});
},
onError: (err) => {
showErrorNotification(notifications, err);
},
onSettled: () => {
handlePopOverClose();
setName('');
},
},
);
};
export const deleteViewHandler = ({
deleteViewAsync,
refetchAllView,
redirectWithQueryBuilderData,
notifications,
panelType,
viewKey,
viewId,
}: DeleteViewHandlerProps): void => {
deleteViewAsync(viewKey, {
onSuccess: () => {
if (viewId === viewKey) {
redirectWithQueryBuilderData(initialQueriesMap.traces, {
[querySearchParams.viewName]: 'Query Builder',
[queryParamNamesMap.panelTypes]: panelType,
[querySearchParams.viewKey]: '',
});
}
notifications.success({
message: 'View Deleted Successfully',
});
refetchAllView();
},
onError: (err) => {
showErrorNotification(notifications, err);
},
});
};

View File

@ -21,8 +21,6 @@ import {
useMemo,
useState,
} from 'react';
// interfaces
import { ILog } from 'types/api/logs/log';
// styles
import {
@ -31,19 +29,17 @@ import {
RawLogContent,
RawLogViewContainer,
} from './styles';
import { RawLogViewProps } from './types';
const convert = new Convert();
interface RawLogViewProps {
isActiveLog?: boolean;
isReadOnly?: boolean;
data: ILog;
linesPerRow: number;
}
function RawLogView(props: RawLogViewProps): JSX.Element {
const { isActiveLog = false, isReadOnly = false, data, linesPerRow } = props;
function RawLogView({
isActiveLog,
isReadOnly,
data,
linesPerRow,
isTextOverflowEllipsisDisabled,
}: RawLogViewProps): JSX.Element {
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
data.id,
);
@ -143,6 +139,7 @@ function RawLogView(props: RawLogViewProps): JSX.Element {
<RawLogContent
$isReadOnly={isReadOnly}
$isActiveLog={isActiveLog}
$isTextOverflowEllipsisDisabled={isTextOverflowEllipsisDisabled}
linesPerRow={linesPerRow}
dangerouslySetInnerHTML={html}
/>
@ -181,6 +178,7 @@ function RawLogView(props: RawLogViewProps): JSX.Element {
RawLogView.defaultProps = {
isActiveLog: false,
isReadOnly: false,
isTextOverflowEllipsisDisabled: false,
};
export default RawLogView;

View File

@ -3,10 +3,12 @@ import { Col, Row, Space } from 'antd';
import styled from 'styled-components';
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
import { RawLogContentProps } from './types';
export const RawLogViewContainer = styled(Row)<{
$isDarkMode: boolean;
$isReadOnly: boolean;
$isActiveLog: boolean;
$isReadOnly?: boolean;
$isActiveLog?: boolean;
}>`
position: relative;
width: 100%;
@ -31,32 +33,29 @@ export const ExpandIconWrapper = styled(Col)`
font-size: 12px;
`;
interface RawLogContentProps {
linesPerRow: number;
$isReadOnly: boolean;
$isActiveLog: boolean;
}
export const RawLogContent = styled.div<RawLogContentProps>`
margin-bottom: 0;
font-family: Fira Code, monospace;
font-weight: 300;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: ${(props): number => props.linesPerRow};
line-clamp: ${(props): number => props.linesPerRow};
-webkit-box-orient: vertical;
${({ $isTextOverflowEllipsisDisabled, linesPerRow }): string =>
$isTextOverflowEllipsisDisabled
? 'white-space: nowrap'
: `overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: ${linesPerRow};
line-clamp: ${linesPerRow};
-webkit-box-orient: vertical;`};
font-size: 1rem;
line-height: 2rem;
cursor: ${(props): string =>
props.$isActiveLog || props.$isReadOnly ? 'initial' : 'pointer'};
cursor: ${({ $isActiveLog, $isReadOnly }): string =>
$isActiveLog || $isReadOnly ? 'initial' : 'pointer'};
${(props): string =>
props.$isReadOnly && !props.$isActiveLog ? 'padding: 0 1.5rem;' : ''}
${({ $isActiveLog, $isReadOnly }): string =>
$isReadOnly && $isActiveLog ? 'padding: 0 1.5rem;' : ''}
`;
export const ActionButtonsWrapper = styled(Space)`

View File

@ -0,0 +1,16 @@
import { ILog } from 'types/api/logs/log';
export interface RawLogViewProps {
isActiveLog?: boolean;
isReadOnly?: boolean;
isTextOverflowEllipsisDisabled?: boolean;
data: ILog;
linesPerRow: number;
}
export interface RawLogContentProps {
linesPerRow: number;
$isReadOnly?: boolean;
$isActiveLog?: boolean;
$isTextOverflowEllipsisDisabled?: boolean;
}

View File

@ -18,12 +18,24 @@ function TextToolTip({
}: TextToolTipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const onClickHandler = (
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
): void => {
event.stopPropagation();
};
const overlay = useMemo(
() => (
<div>
{`${text} `}
{url && (
<a href={url} rel="noopener noreferrer" target="_blank">
<a
// Stopping event propagation on click so that parent click listener are not triggered
onClick={onClickHandler}
href={url}
rel="noopener noreferrer"
target="_blank"
>
{urlText || 'here'}
</a>
)}

View File

@ -6,6 +6,7 @@ export enum FeatureKeys {
ALERT_CHANNEL_SLACK = 'ALERT_CHANNEL_SLACK',
ALERT_CHANNEL_WEBHOOK = 'ALERT_CHANNEL_WEBHOOK',
ALERT_CHANNEL_PAGERDUTY = 'ALERT_CHANNEL_PAGERDUTY',
ALERT_CHANNEL_OPSGENIE = 'ALERT_CHANNEL_OPSGENIE',
ALERT_CHANNEL_MSTEAMS = 'ALERT_CHANNEL_MSTEAMS',
DurationSort = 'DurationSort',
TimestampSort = 'TimestampSort',

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

@ -12,3 +12,8 @@ export const PANEL_TYPES_COMPONENT_MAP = {
[PANEL_TYPES.LIST]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
} as const;
export const AVAILABLE_EXPORT_PANEL_TYPES = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.TABLE,
];

View File

@ -6,6 +6,8 @@ type QueryParamNames =
| 'selectedFields'
| 'linesPerRow';
export type QuerySearchParamNames = 'viewName' | 'viewKey';
export const queryParamNamesMap: Record<QueryParamNames, QueryParamNames> = {
compositeQuery: 'compositeQuery',
panelTypes: 'panelTypes',
@ -14,3 +16,11 @@ export const queryParamNamesMap: Record<QueryParamNames, QueryParamNames> = {
selectedFields: 'selectedFields',
linesPerRow: 'linesPerRow',
};
export const querySearchParams: Record<
QuerySearchParamNames,
QuerySearchParamNames
> = {
viewName: 'viewName',
viewKey: 'viewKey',
};

View File

@ -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',

View File

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

View File

@ -40,6 +40,30 @@ export interface PagerChannel extends Channel {
details?: string;
detailsArray?: Record<string, string>;
}
// OpsgenieChannel configures alert manager to send
// events to opsgenie
export interface OpsgenieChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#opsgenie_config
api_key: string;
message?: string;
// A description of the incident
description?: string;
// A backlink to the sender of the notification.
source?: string;
// A set of arbitrary key/value pairs that provide further detail
// about the alert.
details?: string;
detailsArray?: Record<string, string>;
// Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
priority?: string;
}
export const ValidatePagerChannel = (p: PagerChannel): string => {
if (!p) {
return 'Received unexpected input for this channel, please contact your administrator ';
@ -63,16 +87,14 @@ export const ValidatePagerChannel = (p: PagerChannel): string => {
return '';
};
export type ChannelType =
| 'slack'
| 'email'
| 'webhook'
| 'pagerduty'
| 'msteams';
export const SlackType: ChannelType = 'slack';
export const WebhookType: ChannelType = 'webhook';
export const PagerType: ChannelType = 'pagerduty';
export const MsTeamsType: ChannelType = 'msteams';
export enum ChannelType {
Slack = 'slack',
Email = 'email',
Webhook = 'webhook',
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
export interface LabelFilterStatement {

View File

@ -1,4 +1,4 @@
import { PagerChannel } from './config';
import { OpsgenieChannel, PagerChannel } from './config';
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
@ -22,3 +22,31 @@ export const PagerInitialConfig: Partial<PagerChannel> = {
num_resolved: '{{ .Alerts.Resolved | len }}',
}),
};
export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
message: '{{ .CommonLabels.alertname }}',
description: `{{ if gt (len .Alerts.Firing) 0 -}}
Alerts Firing:
{{ range .Alerts.Firing }}
- Message: {{ .Annotations.description }}
Labels:
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }} Annotations:
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }} Source: {{ .GeneratorURL }}
{{ end }}
{{- end }}
{{ if gt (len .Alerts.Resolved) 0 -}}
Alerts Resolved:
{{ range .Alerts.Resolved }}
- Message: {{ .Annotations.description }}
Labels:
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }} Annotations:
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }} Source: {{ .GeneratorURL }}
{{ end }}
{{- end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};

View File

@ -1,9 +1,11 @@
import { Form } from 'antd';
import createMsTeamsApi from 'api/channels/createMsTeams';
import createOpsgenie from 'api/channels/createOpsgenie';
import createPagerApi from 'api/channels/createPager';
import createSlackApi from 'api/channels/createSlack';
import createWebhookApi from 'api/channels/createWebhook';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsGenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
@ -17,19 +19,17 @@ import { useTranslation } from 'react-i18next';
import {
ChannelType,
MsTeamsChannel,
MsTeamsType,
OpsgenieChannel,
PagerChannel,
PagerType,
SlackChannel,
SlackType,
ValidatePagerChannel,
WebhookChannel,
WebhookType,
} from './config';
import { PagerInitialConfig } from './defaults';
import { OpsgenieInitialConfig, PagerInitialConfig } from './defaults';
import { isChannelType } from './utils';
function CreateAlertChannels({
preType = 'slack',
preType = ChannelType.Slack,
}: CreateAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
@ -37,7 +37,13 @@ function CreateAlertChannels({
const [formInstance] = Form.useForm();
const [selectedConfig, setSelectedConfig] = useState<
Partial<SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel>
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel
>
>({
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
@ -71,7 +77,7 @@ function CreateAlertChannels({
const currentType = type;
setType(value as ChannelType);
if (value === PagerType && currentType !== value) {
if (value === ChannelType.Pagerduty && currentType !== value) {
// reset config to pager defaults
setSelectedConfig({
name: selectedConfig?.name,
@ -79,6 +85,13 @@ function CreateAlertChannels({
...PagerInitialConfig,
});
}
if (value === ChannelType.Opsgenie && currentType !== value) {
setSelectedConfig((selectedConfig) => ({
...selectedConfig,
...OpsgenieInitialConfig,
}));
}
},
[type, selectedConfig],
);
@ -239,6 +252,45 @@ function CreateAlertChannels({
setSavingState(false);
}, [t, notifications, preparePagerRequest]);
const prepareOpsgenieRequest = useCallback(
() => ({
api_key: selectedConfig?.api_key || '',
name: selectedConfig?.name || '',
send_resolved: true,
description: selectedConfig?.description || '',
message: selectedConfig?.message || '',
priority: selectedConfig?.priority || '',
}),
[selectedConfig],
);
const onOpsgenieHandler = useCallback(async () => {
setSavingState(true);
try {
const response = await createOpsgenie(prepareOpsgenieRequest());
if (response.statusCode === 200) {
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
} else {
notifications.error({
message: 'Error',
description: response.error || t('channel_creation_failed'),
});
}
} catch (error) {
notifications.error({
message: 'Error',
description: t('channel_creation_failed'),
});
}
setSavingState(false);
}, [prepareOpsgenieRequest, t, notifications]);
const prepareMsTeamsRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
@ -280,26 +332,31 @@ function CreateAlertChannels({
const onSaveHandler = useCallback(
async (value: ChannelType) => {
const functionMapper = {
[SlackType]: onSlackHandler,
[WebhookType]: onWebhookHandler,
[PagerType]: onPagerHandler,
[MsTeamsType]: onMsTeamsHandler,
[ChannelType.Slack]: onSlackHandler,
[ChannelType.Webhook]: onWebhookHandler,
[ChannelType.Pagerduty]: onPagerHandler,
[ChannelType.Opsgenie]: onOpsgenieHandler,
[ChannelType.MsTeams]: onMsTeamsHandler,
};
const functionToCall = functionMapper[value];
if (functionToCall) {
functionToCall();
} else {
notifications.error({
message: 'Error',
description: t('selected_channel_invalid'),
});
if (isChannelType(value)) {
const functionToCall = functionMapper[value as keyof typeof functionMapper];
if (functionToCall) {
functionToCall();
} else {
notifications.error({
message: 'Error',
description: t('selected_channel_invalid'),
});
}
}
},
[
onSlackHandler,
onWebhookHandler,
onPagerHandler,
onOpsgenieHandler,
onMsTeamsHandler,
notifications,
t,
@ -313,22 +370,26 @@ function CreateAlertChannels({
let request;
let response;
switch (channelType) {
case WebhookType:
case ChannelType.Webhook:
request = prepareWebhookRequest();
response = await testWebhookApi(request);
break;
case SlackType:
case ChannelType.Slack:
request = prepareSlackRequest();
response = await testSlackApi(request);
break;
case PagerType:
case ChannelType.Pagerduty:
request = preparePagerRequest();
if (request) response = await testPagerApi(request);
break;
case MsTeamsType:
case ChannelType.MsTeams:
request = prepareMsTeamsRequest();
response = await testMsTeamsApi(request);
break;
case ChannelType.Opsgenie:
request = prepareOpsgenieRequest();
response = await testOpsGenie(request);
break;
default:
notifications.error({
message: 'Error',
@ -361,6 +422,7 @@ function CreateAlertChannels({
prepareWebhookRequest,
t,
preparePagerRequest,
prepareOpsgenieRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
notifications,
@ -390,6 +452,7 @@ function CreateAlertChannels({
type,
...selectedConfig,
...PagerInitialConfig,
...OpsgenieInitialConfig,
},
}}
/>

View File

@ -0,0 +1,4 @@
import { ChannelType } from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);

View File

@ -1,9 +1,11 @@
import { Form } from 'antd';
import editMsTeamsApi from 'api/channels/editMsTeams';
import editOpsgenie from 'api/channels/editOpsgenie';
import editPagerApi from 'api/channels/editPager';
import editSlackApi from 'api/channels/editSlack';
import editWebhookApi from 'api/channels/editWebhook';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsgenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
@ -11,14 +13,11 @@ import ROUTES from 'constants/routes';
import {
ChannelType,
MsTeamsChannel,
MsTeamsType,
OpsgenieChannel,
PagerChannel,
PagerType,
SlackChannel,
SlackType,
ValidatePagerChannel,
WebhookChannel,
WebhookType,
} from 'container/CreateAlertChannels/config';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
@ -35,7 +34,13 @@ function EditAlertChannels({
const [formInstance] = Form.useForm();
const [selectedConfig, setSelectedConfig] = useState<
Partial<SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel>
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel
>
>({
...initialValue,
});
@ -45,7 +50,7 @@ function EditAlertChannels({
const { id } = useParams<{ id: string }>();
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : SlackType,
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
const onTypeChangeHandler = useCallback((value: string) => {
@ -193,6 +198,48 @@ function EditAlertChannels({
setSavingState(false);
}, [preparePagerRequest, notifications, selectedConfig, t]);
const prepareOpsgenieRequest = useCallback(
() => ({
name: selectedConfig.name || '',
api_key: selectedConfig.api_key || '',
message: selectedConfig.message || '',
description: selectedConfig.description || '',
priority: selectedConfig.priority || '',
id,
}),
[id, selectedConfig],
);
const onOpsgenieEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.api_key === '') {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
setSavingState(false);
return;
}
const response = await editOpsgenie(prepareOpsgenieRequest());
if (response.statusCode === 200) {
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
} else {
notifications.error({
message: 'Error',
description: response.error || t('channel_edit_failed'),
});
}
setSavingState(false);
}, [prepareOpsgenieRequest, t, notifications, selectedConfig]);
const prepareMsTeamsRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
@ -237,14 +284,16 @@ function EditAlertChannels({
const onSaveHandler = useCallback(
(value: ChannelType) => {
if (value === SlackType) {
if (value === ChannelType.Slack) {
onSlackEditHandler();
} else if (value === WebhookType) {
} else if (value === ChannelType.Webhook) {
onWebhookEditHandler();
} else if (value === PagerType) {
} else if (value === ChannelType.Pagerduty) {
onPagerEditHandler();
} else if (value === MsTeamsType) {
} else if (value === ChannelType.MsTeams) {
onMsTeamsEditHandler();
} else if (value === ChannelType.Opsgenie) {
onOpsgenieEditHandler();
}
},
[
@ -252,6 +301,7 @@ function EditAlertChannels({
onWebhookEditHandler,
onPagerEditHandler,
onMsTeamsEditHandler,
onOpsgenieEditHandler,
],
);
@ -262,22 +312,26 @@ function EditAlertChannels({
let request;
let response;
switch (channelType) {
case WebhookType:
case ChannelType.Webhook:
request = prepareWebhookRequest();
response = await testWebhookApi(request);
break;
case SlackType:
case ChannelType.Slack:
request = prepareSlackRequest();
response = await testSlackApi(request);
break;
case PagerType:
case ChannelType.Pagerduty:
request = preparePagerRequest();
if (request) response = await testPagerApi(request);
break;
case MsTeamsType:
case ChannelType.MsTeams:
request = prepareMsTeamsRequest();
if (request) response = await testMsTeamsApi(request);
break;
case ChannelType.Opsgenie:
request = prepareOpsgenieRequest();
if (request) response = await testOpsgenie(request);
break;
default:
notifications.error({
message: 'Error',
@ -312,6 +366,7 @@ function EditAlertChannels({
preparePagerRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareOpsgenieRequest,
notifications,
],
);

View File

@ -0,0 +1,74 @@
import { Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
const { TextArea } = Input;
function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
const { t } = useTranslation('channels');
const handleInputChange = (field: string) => (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
): void => {
setSelectedConfig((value) => ({
...value,
[field]: event.target.value,
}));
};
return (
<>
<Form.Item name="api_key" label={t('field_opsgenie_api_key')} required>
<Input onChange={handleInputChange('api_key')} />
</Form.Item>
<Form.Item
name="message"
help={t('help_opsgenie_message')}
label={t('field_opsgenie_message')}
required
>
<TextArea
rows={4}
onChange={handleInputChange('message')}
placeholder={t('placeholder_opsgenie_message')}
/>
</Form.Item>
<Form.Item
name="description"
help={t('help_opsgenie_description')}
label={t('field_opsgenie_description')}
required
>
<TextArea
rows={4}
onChange={handleInputChange('description')}
placeholder={t('placeholder_opsgenie_description')}
/>
</Form.Item>
<Form.Item
name="priority"
help={t('help_opsgenie_priority')}
label={t('field_opsgenie_priority')}
required
>
<TextArea
rows={4}
onChange={handleInputChange('priority')}
placeholder={t('placeholder_opsgenie_priority')}
/>
</Form.Item>
</>
);
}
interface OpsgenieFormProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<OpsgenieChannel>>
>;
}
export default OpsgenieForm;

View File

@ -5,13 +5,10 @@ import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import {
ChannelType,
MsTeamsType,
OpsgenieChannel,
PagerChannel,
PagerType,
SlackChannel,
SlackType,
WebhookChannel,
WebhookType,
} from 'container/CreateAlertChannels/config';
import useFeatureFlags from 'hooks/useFeatureFlag';
import { isFeatureKeys } from 'hooks/useFeatureFlag/utils';
@ -20,6 +17,7 @@ import { Dispatch, ReactElement, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
import SlackSettings from './Settings/Slack';
import WebhookSettings from './Settings/Webhook';
@ -61,14 +59,16 @@ function FormAlertChannels({
}
switch (type) {
case SlackType:
case ChannelType.Slack:
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
case WebhookType:
case ChannelType.Webhook:
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
case PagerType:
case ChannelType.Pagerduty:
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
case MsTeamsType:
case ChannelType.MsTeams:
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
default:
return null;
}
@ -102,6 +102,9 @@ function FormAlertChannels({
<Select.Option value="pagerduty" key="pagerduty">
Pagerduty
</Select.Option>
<Select.Option value="opsgenie" key="opsgenie">
Opsgenie
</Select.Option>
{!isOssFeature?.active && (
<Select.Option value="msteams" key="msteams">
<div>
@ -147,7 +150,9 @@ interface FormAlertChannelsProps {
formInstance: FormInstance;
type: ChannelType;
setSelectedConfig: Dispatch<
SetStateAction<Partial<SlackChannel & WebhookChannel & PagerChannel>>
SetStateAction<
Partial<SlackChannel & WebhookChannel & PagerChannel & OpsgenieChannel>
>
>;
onTypeChangeHandler: (value: ChannelType) => void;
onSaveHandler: (props: ChannelType) => void;

View File

@ -134,7 +134,7 @@ function GridCardGraph({
return (
<span ref={graphRef}>
<WidgetGraphComponent
enableModel={false}
enableModel
enableWidgetHeader
widget={widget}
queryResponse={queryResponse}

View File

@ -0,0 +1,55 @@
import { ArrowLeftOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { DataSource } from 'types/common/queryBuilder';
import { constructCompositeQuery } from '../constants';
function BackButton(): JSX.Element {
const history = useHistory();
const { updateAllQueriesOperators, resetQuery } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
const handleBack = useCallback(() => {
if (!compositeQuery) return;
const nextCompositeQuery = constructCompositeQuery({
query: compositeQuery,
initialQueryData: initialQueryBuilderFormValuesMap.logs,
customQueryData: { disabled: false },
});
const updatedQuery = updateAllQueriesOperators(
nextCompositeQuery,
PANEL_TYPES.LIST,
DataSource.LOGS,
);
resetQuery(updatedQuery);
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(updatedQuery));
const path = `${ROUTES.LOGS_EXPLORER}?${queryParamNamesMap.compositeQuery}=${JSONCompositeQuery}`;
history.push(path);
}, [history, compositeQuery, resetQuery, updateAllQueriesOperators]);
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,69 @@
import { Button, Popover, Select } from 'antd';
import Spinner from 'components/Spinner';
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 { useEventSource } from 'providers/EventSource';
import { useCallback } from 'react';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { SpinnerWrapper, Wrapper } from './styles';
function ListViewPanel(): JSX.Element {
const { config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: StringOperators.NOOP,
});
const { isConnectionLoading } = useEventSource();
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 (
<Wrapper>
<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>
)}
{isConnectionLoading && (
<SpinnerWrapper>
<Spinner style={{ height: 'auto' }} />
</SpinnerWrapper>
)}
</Wrapper>
);
}
export default ListViewPanel;

View File

@ -0,0 +1,11 @@
import styled from 'styled-components';
export const Wrapper = styled.div`
display: flex;
align-items: center;
gap: 1.5rem;
`;
export const SpinnerWrapper = styled.div`
margin-left: auto;
`;

View File

@ -0,0 +1,203 @@
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 { useLocation } from 'react-router-dom';
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 { QueryHistoryState } from '../types';
import { prepareQueryByFilter } from '../utils';
import { ContentWrapper, LiveLogsChart, Wrapper } from './styles';
function LiveLogsContainer(): JSX.Element {
const location = useLocation();
const [logs, setLogs] = useState<ILog[]>([]);
const { stagedQuery } = useQueryBuilder();
const queryLocationState = location.state as QueryHistoryState;
const batchedEventsRef = useRef<ILog[]>([]);
const { notifications } = useNotifications();
const { selectedTime: globalSelectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
handleStartOpenConnection,
handleCloseConnection,
initialLoading,
isConnectionLoading,
} = useEventSource();
const compositeQuery = useGetCompositeQueryParam();
const updateLogs = useCallback((newLogs: ILog[]) => {
setLogs((prevState) =>
[...newLogs, ...prevState].slice(0, MAX_LOGS_LIST_SIZE),
);
batchedEventsRef.current = [];
}, []);
const debouncedUpdateLogs = useDebouncedFn(() => {
const reversedData = batchedEventsRef.current.reverse();
updateLogs(reversedData);
}, 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 openConnection = useCallback(
(query: Query) => {
const { queryPayload } = prepareQueryRangePayload({
query,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
});
const encodedQueryPayload = encodeURIComponent(JSON.stringify(queryPayload));
const queryString = `q=${encodedQueryPayload}`;
handleStartOpenConnection({ queryString });
},
[globalSelectedTime, handleStartOpenConnection],
);
const handleStartNewConnection = useCallback(
(query: Query) => {
handleCloseConnection();
const preparedQuery = getPreparedQuery(query);
openConnection(preparedQuery);
},
[getPreparedQuery, handleCloseConnection, openConnection],
);
useEffect(() => {
if (!compositeQuery) return;
if (
(initialLoading && !isConnectionLoading) ||
compositeQuery.id !== stagedQuery?.id
) {
handleStartNewConnection(compositeQuery);
}
}, [
compositeQuery,
initialLoading,
stagedQuery,
isConnectionLoading,
openConnection,
handleStartNewConnection,
]);
useEffect(() => {
const prefetchedList = queryLocationState?.listQueryPayload[0]?.list;
if (prefetchedList) {
const prefetchedLogs: ILog[] = prefetchedList
.map((item) => ({
...item.data,
timestamp: item.timestamp,
}))
.reverse();
updateLogs(prefetchedLogs);
}
}, [queryLocationState, updateLogs]);
return (
<Wrapper>
<LiveLogsTopNav />
<ContentWrapper gutter={[0, 20]} style={{ color: themeColors.lightWhite }}>
<Col span={24}>
<FiltersInput />
</Col>
{initialLoading && logs.length === 0 ? (
<Col span={24}>
<Spinner style={{ height: 'auto' }} tip="Fetching Logs" />
</Col>
) : (
<>
<Col span={24}>
<LiveLogsChart
initialData={queryLocationState?.graphQueryPayload || null}
/>
</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 { 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>}
{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,
initialData,
}: LiveLogsListChartProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
const { isConnectionOpen } = 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 (initialData) return initialData;
if (!data) return [];
return data.payload.data.result;
}, [data, initialData]);
return (
<LogsExplorerChart
isLoading={initialData ? false : isFetching}
data={chartData}
isLabelEnabled={false}
className={className}
/>
);
}
export default LiveLogsListChart;

View File

@ -0,0 +1,6 @@
import { QueryData } from 'types/api/widgets/getQuery';
export type LiveLogsListChartProps = {
className?: string;
initialData: QueryData[] | null;
};

View File

@ -0,0 +1,50 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
} from 'constants/queryBuilder';
import { FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
export const defaultLiveQueryDataConfig: Partial<IBuilderQuery> = {
aggregateOperator: LogsAggregatorOperator.NOOP,
disabled: true,
pageSize: 10,
orderBy: [{ columnName: 'timestamp', order: FILTERS.DESC }],
};
type GetDefaultCompositeQueryParams = {
query: Query;
initialQueryData: IBuilderQuery;
customQueryData?: Partial<IBuilderQuery>;
};
export const constructCompositeQuery = ({
query,
initialQueryData,
customQueryData,
}: GetDefaultCompositeQueryParams): Query => ({
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...initialQueryData,
...item,
...customQueryData,
})),
},
});
export const liveLogsCompositeQuery = constructCompositeQuery({
query: initialQueriesMap.logs,
initialQueryData: initialQueryBuilderFormValuesMap.logs,
customQueryData: defaultLiveQueryDataConfig,
});
export const idObject: BaseAutocompleteData = {
key: 'id',
type: '',
dataType: 'string',
isColumn: true,
};

View File

@ -0,0 +1,6 @@
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
export type QueryHistoryState = {
graphQueryPayload: QueryData[];
listQueryPayload: QueryDataV3[];
};

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,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="start" 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

@ -91,7 +91,7 @@ function TableView({
const columns: ColumnsType<DataType> = [
{
title: 'Action',
width: 15,
width: 30,
render: (fieldData: Record<string, string>): JSX.Element | null => {
const fieldKey = fieldData.field.split('.').slice(-1);
if (!RESTRICTED_FIELDS.includes(fieldKey[0])) {

View File

@ -154,7 +154,13 @@ function LogsContextList({
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => (
<RawLogView isReadOnly key={log.id} data={log} linesPerRow={1} />
<RawLogView
isReadOnly
isTextOverflowEllipsisDisabled
key={log.id}
data={log}
linesPerRow={1}
/>
),
[],
);

View File

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

View File

@ -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<GetChartDataProps>['createDataset'] = (
element,
index,
allLabels,
) => ({
label: allLabels[index],
data: element,
backgroundColor: colors[index % colors.length] || 'red',
borderColor: colors[index % colors.length] || 'red',
});
const handleCreateDatasets: Required<GetChartDataProps>['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 (
<CardStyled>
<CardStyled className={className}>
{isLoading ? (
<Spinner size="default" height="100%" />
) : (

View File

@ -9,7 +9,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { memo, useCallback, useMemo, useState } from 'react';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EditButton, TitleWrapper } from './styles';
import { EditButton, LogContainer, TitleWrapper } from './styles';
import { LogsExplorerContextProps } from './types';
import useInitialQuery from './useInitialQuery';
@ -96,7 +96,15 @@ function LogsExplorerContext({
// eslint-disable-next-line react/jsx-props-no-spreading
{...contextListParams}
/>
<RawLogView isActiveLog isReadOnly data={log} linesPerRow={1} />
<LogContainer>
<RawLogView
isActiveLog
isReadOnly
isTextOverflowEllipsisDisabled
data={log}
linesPerRow={1}
/>
</LogContainer>
<LogsContextList
order={FILTERS.DESC}
// eslint-disable-next-line react/jsx-props-no-spreading

View File

@ -28,3 +28,7 @@ export const EditButton = styled(Button)<{ $isDarkMode: boolean }>`
? getAlphaColor(themeColors.white)[45]
: getAlphaColor(themeColors.black)[45]};
`;
export const LogContainer = styled.div`
overflow-x: auto;
`;

View File

@ -10,6 +10,7 @@ import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import {
cloneElement,
forwardRef,
memo,
ReactElement,
ReactNode,
useCallback,
@ -67,7 +68,6 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
onAddToQuery,
} = useActiveLog();
const { onEndReached } = infitiyTableProps;
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: onSetActiveLog,
@ -158,8 +158,11 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
}}
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<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 = {
isLoading?: boolean;
tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>;
infitiyTableProps: {
infitiyTableProps?: {
onEndReached: (index: number) => void;
};
};

View File

@ -1,22 +1,19 @@
import { Tabs, TabsProps } from 'antd';
import TabLabel from 'components/TabLabel';
import { QueryParams } from 'constants/query';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import {
initialAutocompleteData,
initialFilters,
initialQueriesMap,
initialQueryBuilderFormValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
import ROUTES from 'constants/routes';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import ExportPanel from 'container/ExportPanel';
import GoToTop from 'container/GoToTop';
import LogsExplorerChart from 'container/LogsExplorerChart';
import LogsExplorerList from 'container/LogsExplorerList';
import LogsExplorerTable from 'container/LogsExplorerTable';
import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/constants';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { addEmptyWidgetInDashboardJSONWithQuery } from 'hooks/dashboard/utils';
@ -25,12 +22,13 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useAxiosError from 'hooks/useAxiosError';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { getPaginationQueryData } from 'lib/newQueryBuilder/getPaginationQueryData';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { generatePath, useHistory } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { Dashboard } from 'types/api/dashboard/getAll';
import { ILog } from 'types/api/logs/log';
@ -40,8 +38,9 @@ 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';
import { ActionsWrapper } from './LogsExplorerViews.styled';
@ -67,10 +66,10 @@ function LogsExplorerViews(): JSX.Element {
stagedQuery,
panelType,
updateAllQueriesOperators,
updateQueriesData,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
// State
const [page, setPage] = useState<number>(1);
const [logs, setLogs] = useState<ILog[]>([]);
@ -120,7 +119,7 @@ function LogsExplorerViews(): JSX.Element {
const modifiedQueryData: IBuilderQuery = {
...listQuery,
aggregateOperator: StringOperators.COUNT,
aggregateOperator: LogsAggregatorOperator.COUNT,
};
const modifiedQuery: Query = {
@ -172,42 +171,6 @@ function LogsExplorerViews(): JSX.Element {
},
);
const getUpdateQuery = useCallback(
(newPanelType: PANEL_TYPES): Query => {
let query = updateAllQueriesOperators(
currentQuery,
newPanelType,
DataSource.TRACES,
);
if (newPanelType === PANEL_TYPES.LIST) {
query = updateQueriesData(query, 'queryData', (item) => ({
...item,
orderBy: item.orderBy.filter((item) => item.columnName !== SIGNOZ_VALUE),
aggregateAttribute: initialAutocompleteData,
}));
}
return query;
},
[currentQuery, updateAllQueriesOperators, updateQueriesData],
);
const handleChangeView = useCallback(
(type: string) => {
const newPanelType = type as PANEL_TYPES;
if (newPanelType === panelType) return;
const query = getUpdateQuery(newPanelType);
redirectWithQueryBuilderData(query, {
[queryParamNamesMap.panelTypes]: newPanelType,
});
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData],
);
const getRequestData = useCallback(
(
query: Query | null,
@ -299,11 +262,16 @@ function LogsExplorerViews(): JSX.Element {
const handleExport = useCallback(
(dashboard: Dashboard | null): void => {
if (!dashboard) return;
if (!dashboard || !panelType) return;
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
const updatedDashboard = addEmptyWidgetInDashboardJSONWithQuery(
dashboard,
exportDefaultQuery,
panelTypeParam,
);
updateDashboard(updatedDashboard, {
@ -332,11 +300,11 @@ function LogsExplorerViews(): JSX.Element {
return;
}
const dashboardEditView = `${generatePath(ROUTES.DASHBOARD, {
dashboardId: data?.payload?.uuid,
})}/new?${QueryParams.graphType}=graph&${QueryParams.widgetId}=empty&${
queryParamNamesMap.compositeQuery
}=${encodeURIComponent(JSON.stringify(exportDefaultQuery))}`;
const dashboardEditView = generateExportToDashboardLink({
query: exportDefaultQuery,
panelType: panelTypeParam,
dashboardId: data.payload?.uuid || '',
});
history.push(dashboardEditView);
},
@ -347,6 +315,7 @@ function LogsExplorerViews(): JSX.Element {
exportDefaultQuery,
history,
notifications,
panelType,
updateDashboard,
handleAxisError,
],
@ -356,9 +325,9 @@ function LogsExplorerViews(): JSX.Element {
const shouldChangeView = isMultipleQueries || isGroupByExist;
if (panelType === PANEL_TYPES.LIST && shouldChangeView) {
handleChangeView(PANEL_TYPES.TIME_SERIES);
handleExplorerTabChange(PANEL_TYPES.TIME_SERIES);
}
}, [panelType, isMultipleQueries, isGroupByExist, handleChangeView]);
}, [panelType, isMultipleQueries, isGroupByExist, handleExplorerTabChange]);
useEffect(() => {
const currentParams = data?.params as Omit<LogTimeRange, 'pageSize'>;
@ -512,7 +481,7 @@ function LogsExplorerViews(): JSX.Element {
items={tabsItems}
defaultActiveKey={panelType || PANEL_TYPES.LIST}
activeKey={panelType || PANEL_TYPES.LIST}
onChange={handleChangeView}
onChange={handleExplorerTabChange}
destroyInactiveTabPane
/>

View File

@ -0,0 +1,91 @@
import { PlayCircleFilled } from '@ant-design/icons';
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import {
constructCompositeQuery,
defaultLiveQueryDataConfig,
} from 'container/LiveLogs/constants';
import { QueryHistoryState } from 'container/LiveLogs/types';
import LocalTopNav from 'container/LocalTopNav';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { useHistory } from 'react-router-dom';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { LiveButtonStyled } from './styles';
function LogsTopNav(): JSX.Element {
const history = useHistory();
const queryClient = useQueryClient();
const { stagedQuery, panelType } = useQueryBuilder();
const handleGoLive = useCallback(() => {
if (!stagedQuery) return;
let queryHistoryState: QueryHistoryState | null = null;
const compositeQuery = constructCompositeQuery({
query: stagedQuery,
initialQueryData: initialQueryBuilderFormValuesMap.logs,
customQueryData: defaultLiveQueryDataConfig,
});
const isListView =
panelType === PANEL_TYPES.LIST && stagedQuery.builder.queryData[0];
if (isListView) {
const [graphQuery, listQuery] = queryClient.getQueriesData<
SuccessResponse<MetricRangePayloadProps> | ErrorResponse
>({
queryKey: REACT_QUERY_KEY.GET_QUERY_RANGE,
active: true,
});
queryHistoryState = {
graphQueryPayload:
graphQuery && graphQuery[1]
? graphQuery[1].payload?.data.result || []
: [],
listQueryPayload:
listQuery && listQuery[1]
? listQuery[1].payload?.data.newResult.data.result || []
: [],
};
}
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(compositeQuery));
const path = `${ROUTES.LIVE_LOGS}?${queryParamNamesMap.compositeQuery}=${JSONCompositeQuery}`;
history.push(path, queryHistoryState);
}, [history, panelType, queryClient, stagedQuery]);
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 { 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}
>
<RadioButton value="raw">{t('options_menu.raw')}</RadioButton>
<RadioButton value="list">{t('options_menu.default')}</RadioButton>

View File

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

View File

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

View File

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

View File

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

View File

@ -84,3 +84,5 @@ export const routesToSkip = [
ROUTES.LIST_ALL_ALERT,
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 { 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 {
<Row justify="end">
<Space align="start" size={60} direction="horizontal">
<NewExplorerCTA />
<div>
<DateTimeSelector />
</div>

View File

@ -5,6 +5,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const addEmptyWidgetInDashboardJSONWithQuery = (
dashboard: Dashboard,
query: Query,
panelTypes?: PANEL_TYPES,
): Dashboard => ({
...dashboard,
data: {
@ -30,7 +31,7 @@ export const addEmptyWidgetInDashboardJSONWithQuery = (
opacity: '',
title: '',
timePreferance: 'GLOBAL_TIME',
panelTypes: PANEL_TYPES.TIME_SERIES,
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
},
],
},

View File

@ -0,0 +1,15 @@
import { QuerySearchParamNames } from 'constants/queryBuilderQueryNames';
import useUrlQuery from 'hooks/useUrlQuery';
import { useMemo } from 'react';
export const useGetSearchQueryParam = (
searchParams: QuerySearchParamNames,
): string | null => {
const urlQuery = useUrlQuery();
return useMemo(() => {
const searchQuery = urlQuery.get(searchParams);
return searchQuery ? JSON.parse(searchQuery) : null;
}, [urlQuery, searchParams]);
};

View File

@ -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 } = useQueryBuilder();
const urlQuery = useUrlQuery();
const compositeQuery = useGetCompositeQueryParam();
@ -18,11 +18,4 @@ export const useShareBuilderUrl = (defaultQuery: Query): void => {
redirectWithQueryBuilderData(defaultQuery);
}
}, [defaultQuery, urlQuery, redirectWithQueryBuilderData, compositeQuery]);
useEffect(
() => (): void => {
resetStagedQuery();
},
[resetStagedQuery],
);
};

View File

@ -0,0 +1,11 @@
import { deleteView } from 'api/saveView/deleteView';
import { useMutation, UseMutationResult } from 'react-query';
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
export const useDeleteView = (
uuid: string,
): UseMutationResult<DeleteViewPayloadProps, Error, string> =>
useMutation({
mutationKey: [uuid],
mutationFn: () => deleteView(uuid),
});

View File

@ -0,0 +1,13 @@
import { getAllViews } from 'api/saveView/getAllViews';
import { AxiosError, AxiosResponse } from 'axios';
import { useQuery, UseQueryResult } from 'react-query';
import { AllViewsProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
export const useGetAllViews = (
sourcepage: DataSource,
): UseQueryResult<AxiosResponse<AllViewsProps>, AxiosError> =>
useQuery<AxiosResponse<AllViewsProps>, AxiosError>({
queryKey: [{ sourcepage }],
queryFn: () => getAllViews(sourcepage),
});

View File

@ -0,0 +1,26 @@
import { saveView } from 'api/saveView/saveView';
import { AxiosResponse } from 'axios';
import { useMutation, UseMutationResult } from 'react-query';
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
export const useSaveView = ({
compositeQuery,
sourcePage,
viewName,
extraData,
}: SaveViewProps): UseMutationResult<
AxiosResponse<SaveViewPayloadProps>,
Error,
SaveViewProps,
SaveViewPayloadProps
> =>
useMutation({
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
mutationFn: () =>
saveView({
compositeQuery,
sourcePage,
viewName,
extraData,
}),
});

View File

@ -0,0 +1,30 @@
import { updateView } from 'api/saveView/updateView';
import { useMutation, UseMutationResult } from 'react-query';
import {
UpdateViewPayloadProps,
UpdateViewProps,
} from 'types/api/saveViews/types';
export const useUpdateView = ({
compositeQuery,
viewName,
extraData,
sourcePage,
viewKey,
}: UpdateViewProps): UseMutationResult<
UpdateViewPayloadProps,
Error,
UpdateViewProps,
UpdateViewPayloadProps
> =>
useMutation({
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
mutationFn: () =>
updateView({
compositeQuery,
viewName,
extraData,
sourcePage,
viewKey,
}),
});

View File

@ -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 = <T extends keyof EventSourceEventMap>(
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]);

Some files were not shown because too many files have changed in this diff Show More