diff --git a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml index 8dd8a5e1da..cf39bf49c4 100644 --- a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml @@ -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 diff --git a/deploy/docker-swarm/common/nginx-config.conf b/deploy/docker-swarm/common/nginx-config.conf index 158effc8bf..f7943e21aa 100644 --- a/deploy/docker-swarm/common/nginx-config.conf +++ b/deploy/docker-swarm/common/nginx-config.conf @@ -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; diff --git a/deploy/docker/clickhouse-setup/docker-compose-core.yaml b/deploy/docker/clickhouse-setup/docker-compose-core.yaml index 88c8d555e5..f18356c372 100644 --- a/deploy/docker/clickhouse-setup/docker-compose-core.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose-core.yaml @@ -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: diff --git a/deploy/docker/clickhouse-setup/docker-compose.yaml b/deploy/docker/clickhouse-setup/docker-compose.yaml index 1660bc4e83..0f96583036 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.yaml @@ -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: diff --git a/deploy/docker/common/nginx-config.conf b/deploy/docker/common/nginx-config.conf index 158effc8bf..f7943e21aa 100644 --- a/deploy/docker/common/nginx-config.conf +++ b/deploy/docker/common/nginx-config.conf @@ -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; diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go index 9f56f50655..e3a8b44cc6 100644 --- a/ee/query-service/model/plans.go +++ b/ee/query-service/model/plans.go @@ -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, diff --git a/frontend/package.json b/frontend/package.json index 9ea7ddd00a..3a5e7035a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/public/locales/en-GB/logs.json b/frontend/public/locales/en-GB/logs.json new file mode 100644 index 0000000000..804f66f494 --- /dev/null +++ b/frontend/public/locales/en-GB/logs.json @@ -0,0 +1 @@ +{ "fetching_log_lines": "Fetching log lines" } diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 53ac325f11..f6ba0b816c 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -29,6 +29,7 @@ "NOT_FOUND": "SigNoz | Page Not Found", "LOGS": "SigNoz | Logs", "LOGS_EXPLORER": "SigNoz | Logs Explorer", + "LIVE_LOGS": "SigNoz | Live Logs", "HOME_PAGE": "Open source Observability Platform | SigNoz", "PASSWORD_RESET": "SigNoz | Password Reset", "LIST_LICENSES": "SigNoz | List of Licenses", diff --git a/frontend/public/locales/en/channels.json b/frontend/public/locales/en/channels.json index 027501f69d..63094aa911 100644 --- a/frontend/public/locales/en/channels.json +++ b/frontend/public/locales/en/channels.json @@ -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", diff --git a/frontend/public/locales/en/logs.json b/frontend/public/locales/en/logs.json new file mode 100644 index 0000000000..804f66f494 --- /dev/null +++ b/frontend/public/locales/en/logs.json @@ -0,0 +1 @@ +{ "fetching_log_lines": "Fetching log lines" } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 53ac325f11..f6ba0b816c 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -29,6 +29,7 @@ "NOT_FOUND": "SigNoz | Page Not Found", "LOGS": "SigNoz | Logs", "LOGS_EXPLORER": "SigNoz | Logs Explorer", + "LIVE_LOGS": "SigNoz | Live Logs", "HOME_PAGE": "Open source Observability Platform | SigNoz", "PASSWORD_RESET": "SigNoz | Password Reset", "LIST_LICENSES": "SigNoz | List of Licenses", diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index ba9e4eb617..9b72092342 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -110,6 +110,10 @@ export const LogsExplorer = Loadable( () => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsExplorer'), ); +export const LiveLogs = Loadable( + () => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'), +); + export const Login = Loadable( () => import(/* webpackChunkName: "Login" */ 'pages/Login'), ); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index ecf74b5253..ed19a96d6d 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -14,6 +14,7 @@ import { GettingStarted, LicensePage, ListAllALertsPage, + LiveLogs, Login, Logs, LogsExplorer, @@ -234,6 +235,13 @@ const routes: AppRoutes[] = [ key: 'LOGS_EXPLORER', isPrivate: true, }, + { + path: ROUTES.LIVE_LOGS, + exact: true, + component: LiveLogs, + key: 'LIVE_LOGS', + isPrivate: true, + }, { path: ROUTES.LOGIN, exact: true, diff --git a/frontend/src/api/alerts/getTriggered.ts b/frontend/src/api/alerts/getTriggered.ts index 160b9a3b93..6955cc315c 100644 --- a/frontend/src/api/alerts/getTriggered.ts +++ b/frontend/src/api/alerts/getTriggered.ts @@ -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); diff --git a/frontend/src/api/channels/createOpsgenie.ts b/frontend/src/api/channels/createOpsgenie.ts new file mode 100644 index 0000000000..4cf60f9e94 --- /dev/null +++ b/frontend/src/api/channels/createOpsgenie.ts @@ -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 | 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; diff --git a/frontend/src/api/channels/editOpsgenie.ts b/frontend/src/api/channels/editOpsgenie.ts new file mode 100644 index 0000000000..71f830f9f8 --- /dev/null +++ b/frontend/src/api/channels/editOpsgenie.ts @@ -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 | 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; diff --git a/frontend/src/api/channels/testOpsgenie.ts b/frontend/src/api/channels/testOpsgenie.ts new file mode 100644 index 0000000000..780a4432ae --- /dev/null +++ b/frontend/src/api/channels/testOpsgenie.ts @@ -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 | 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; diff --git a/frontend/src/api/metrics/getQueryRange.ts b/frontend/src/api/metrics/getQueryRange.ts index bc70d19832..bee657d904 100644 --- a/frontend/src/api/metrics/getQueryRange.ts +++ b/frontend/src/api/metrics/getQueryRange.ts @@ -4,11 +4,11 @@ import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { MetricRangePayloadV3, - MetricsRangeProps, + QueryRangePayload, } from 'types/api/metrics/getQueryRange'; export const getMetricsQueryRange = async ( - props: MetricsRangeProps, + props: QueryRangePayload, ): Promise | ErrorResponse> => { try { const response = await axios.post('/query_range', props); diff --git a/frontend/src/api/saveView/deleteView.ts b/frontend/src/api/saveView/deleteView.ts new file mode 100644 index 0000000000..e58e731d10 --- /dev/null +++ b/frontend/src/api/saveView/deleteView.ts @@ -0,0 +1,5 @@ +import axios from 'api'; +import { DeleteViewPayloadProps } from 'types/api/saveViews/types'; + +export const deleteView = (uuid: string): Promise => + axios.delete(`explorer/views/${uuid}`); diff --git a/frontend/src/api/saveView/getAllViews.ts b/frontend/src/api/saveView/getAllViews.ts new file mode 100644 index 0000000000..bdafb96b61 --- /dev/null +++ b/frontend/src/api/saveView/getAllViews.ts @@ -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> => + axios.get(`explorer/views?sourcePage=${sourcepage}`); diff --git a/frontend/src/api/saveView/saveView.ts b/frontend/src/api/saveView/saveView.ts new file mode 100644 index 0000000000..a0c7ba5bf4 --- /dev/null +++ b/frontend/src/api/saveView/saveView.ts @@ -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> => + axios.post('explorer/views', { + name: viewName, + sourcePage, + compositeQuery, + extraData, + }); diff --git a/frontend/src/api/saveView/updateView.ts b/frontend/src/api/saveView/updateView.ts new file mode 100644 index 0000000000..6ee745ffc2 --- /dev/null +++ b/frontend/src/api/saveView/updateView.ts @@ -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 => + axios.put(`explorer/views/${viewKey}`, { + name: viewName, + compositeQuery, + extraData, + sourcePage, + }); diff --git a/frontend/src/components/ExplorerCard/ExplorerCard.tsx b/frontend/src/components/ExplorerCard/ExplorerCard.tsx new file mode 100644 index 0000000000..a7dda9ce95 --- /dev/null +++ b/frontend/src/components/ExplorerCard/ExplorerCard.tsx @@ -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(false); + const [, setCopyUrl] = useCopyToClipboard(); + const [isQueryUpdated, setIsQueryUpdated] = useState(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: ( + + ), + })), + }), + [refetchAllView, viewKey, viewsData?.data?.data], + ); + + const moreOptionMenu = useMemo( + (): MenuProps => ({ + items: [ + { + key: 'delete', + label: Delete, + onClick: onDeleteHandler, + icon: , + }, + ], + }), + [onDeleteHandler], + ); + + const saveButtonType = isQueryUpdated ? 'default' : 'primary'; + const saveButtonIcon = isQueryUpdated ? null : ; + + return ( + <> + + + + + {viewName} + + + + + + {viewsData?.data.data && viewsData?.data.data.length && ( + + {/* Saved Views */} + } + trigger={['click']} + overlayStyle={DropDownOverlay} + > + Select View + + + )} + {isQueryUpdated && ( + + )} + + } + showArrow={false} + open={isOpen} + onOpenChange={handleOpenChange} + > + + + + {viewKey && ( + + + + )} + + + + + {children} + + ); +} + +export default ExplorerCard; diff --git a/frontend/src/components/ExplorerCard/MenuItemGenerator.tsx b/frontend/src/components/ExplorerCard/MenuItemGenerator.tsx new file mode 100644 index 0000000000..2b101385e9 --- /dev/null +++ b/frontend/src/components/ExplorerCard/MenuItemGenerator.tsx @@ -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): 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 ( + + + + + {viewName} + + + Created by {createdBy} + + + + + + + + + + ); +} + +export default MenuItemGenerator; diff --git a/frontend/src/components/ExplorerCard/SaveViewWithName.tsx b/frontend/src/components/ExplorerCard/SaveViewWithName.tsx new file mode 100644 index 0000000000..3f77a769c8 --- /dev/null +++ b/frontend/src/components/ExplorerCard/SaveViewWithName.tsx @@ -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): 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 ( + + Name of the View + + + Save + + + ); +} + +export default SaveViewWithName; diff --git a/frontend/src/components/ExplorerCard/__mock__/viewData.ts b/frontend/src/components/ExplorerCard/__mock__/viewData.ts new file mode 100644 index 0000000000..5423cc98dc --- /dev/null +++ b/frontend/src/components/ExplorerCard/__mock__/viewData.ts @@ -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', + }, +]; diff --git a/frontend/src/components/ExplorerCard/constants.ts b/frontend/src/components/ExplorerCard/constants.ts new file mode 100644 index 0000000000..8caffb366c --- /dev/null +++ b/frontend/src/components/ExplorerCard/constants.ts @@ -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', +}; diff --git a/frontend/src/components/ExplorerCard/index.tsx b/frontend/src/components/ExplorerCard/index.tsx deleted file mode 100644 index fd8f3ceb81..0000000000 --- a/frontend/src/components/ExplorerCard/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Card, Space, Typography } from 'antd'; -import TextToolTip from 'components/TextToolTip'; - -function ExplorerCard({ children }: Props): JSX.Element { - return ( - - Query Builder - - - } - > - {children} - - ); -} - -interface Props { - children: React.ReactNode; -} - -export default ExplorerCard; diff --git a/frontend/src/components/ExplorerCard/styles.ts b/frontend/src/components/ExplorerCard/styles.ts new file mode 100644 index 0000000000..63ed068c53 --- /dev/null +++ b/frontend/src/components/ExplorerCard/styles.ts @@ -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; +`; diff --git a/frontend/src/components/ExplorerCard/test/ExplorerCard.test.tsx b/frontend/src/components/ExplorerCard/test/ExplorerCard.test.tsx new file mode 100644 index 0000000000..c7289756d7 --- /dev/null +++ b/frontend/src/components/ExplorerCard/test/ExplorerCard.test.tsx @@ -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( + + child + , + ); + expect(screen.getByText('Query Builder')).toBeInTheDocument(); + }); + + it('renders a save view button', () => { + render( + + child + , + ); + expect(screen.getByText('Save view')).toBeInTheDocument(); + }); + + it('should see all the view listed in dropdown', async () => { + const screen = render( + Mock Children, + ); + 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(); + }); +}); diff --git a/frontend/src/components/ExplorerCard/test/MenuItemGenerator.test.tsx b/frontend/src/components/ExplorerCard/test/MenuItemGenerator.test.tsx new file mode 100644 index 0000000000..de4f9c06a7 --- /dev/null +++ b/frontend/src/components/ExplorerCard/test/MenuItemGenerator.test.tsx @@ -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( + + + , + ); + + expect(screen.getByText(viewMockData[0].name)).toBeInTheDocument(); + }); + + it('should call onMenuItemSelectHandler on click of MenuItemGenerator', () => { + render( + + + , + ); + + const spanElement = screen.getByRole('img', { + name: 'delete', + }); + + expect(spanElement).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ExplorerCard/test/SaveViewWithName.test.tsx b/frontend/src/components/ExplorerCard/test/SaveViewWithName.test.tsx new file mode 100644 index 0000000000..8e6664ac8c --- /dev/null +++ b/frontend/src/components/ExplorerCard/test/SaveViewWithName.test.tsx @@ -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( + + + , + ); + + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('should call saveViewAsync on click of Save button', () => { + const screen = render( + + + , + ); + + fireEvent.click(screen.getByText('Save')); + + expect(screen.getByText('Save')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ExplorerCard/types.ts b/frontend/src/components/ExplorerCard/types.ts new file mode 100644 index 0000000000..bda7f702b9 --- /dev/null +++ b/frontend/src/components/ExplorerCard/types.ts @@ -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, + Error, + SaveViewProps, + SaveViewPayloadProps + >; + handlePopOverClose: SaveViewWithNameProps['handlePopOverClose']; + redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData']; + setName: (value: SetStateAction) => void; +} + +export interface DeleteViewHandlerProps { + deleteViewAsync: UseMutateAsyncFunction; + refetchAllView: MenuItemLabelGeneratorProps['refetchAllView']; + redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData']; + notifications: NotificationInstance; + panelType: PANEL_TYPES | null; + viewKey: string; + viewId: string; +} diff --git a/frontend/src/components/ExplorerCard/utils.ts b/frontend/src/components/ExplorerCard/utils.ts new file mode 100644 index 0000000000..e846eb4c6a --- /dev/null +++ b/frontend/src/components/ExplorerCard/utils.ts @@ -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); + }, + }); +}; diff --git a/frontend/src/components/Logs/RawLogView/index.tsx b/frontend/src/components/Logs/RawLogView/index.tsx index e926e73643..7c630a2939 100644 --- a/frontend/src/components/Logs/RawLogView/index.tsx +++ b/frontend/src/components/Logs/RawLogView/index.tsx @@ -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 { @@ -181,6 +178,7 @@ function RawLogView(props: RawLogViewProps): JSX.Element { RawLogView.defaultProps = { isActiveLog: false, isReadOnly: false, + isTextOverflowEllipsisDisabled: false, }; export default RawLogView; diff --git a/frontend/src/components/Logs/RawLogView/styles.ts b/frontend/src/components/Logs/RawLogView/styles.ts index b4be783a2a..4944d05f74 100644 --- a/frontend/src/components/Logs/RawLogView/styles.ts +++ b/frontend/src/components/Logs/RawLogView/styles.ts @@ -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` 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)` diff --git a/frontend/src/components/Logs/RawLogView/types.ts b/frontend/src/components/Logs/RawLogView/types.ts new file mode 100644 index 0000000000..2c70c0ddb2 --- /dev/null +++ b/frontend/src/components/Logs/RawLogView/types.ts @@ -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; +} diff --git a/frontend/src/components/TextToolTip/index.tsx b/frontend/src/components/TextToolTip/index.tsx index 39ce9072eb..70e5c04685 100644 --- a/frontend/src/components/TextToolTip/index.tsx +++ b/frontend/src/components/TextToolTip/index.tsx @@ -18,12 +18,24 @@ function TextToolTip({ }: TextToolTipProps): JSX.Element { const isDarkMode = useIsDarkMode(); + const onClickHandler = ( + event: React.MouseEvent, + ): void => { + event.stopPropagation(); + }; + const overlay = useMemo( () => (
{`${text} `} {url && ( - + {urlText || 'here'} )} diff --git a/frontend/src/constants/features.ts b/frontend/src/constants/features.ts index 0d444ff3f0..fc267c0e7c 100644 --- a/frontend/src/constants/features.ts +++ b/frontend/src/constants/features.ts @@ -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', diff --git a/frontend/src/constants/liveTail.ts b/frontend/src/constants/liveTail.ts new file mode 100644 index 0000000000..07813a248b --- /dev/null +++ b/frontend/src/constants/liveTail.ts @@ -0,0 +1,5 @@ +export const LIVE_TAIL_HEARTBEAT_TIMEOUT = 600000; + +export const LIVE_TAIL_GRAPH_INTERVAL = 60000; + +export const MAX_LOGS_LIST_SIZE = 1000; diff --git a/frontend/src/constants/panelTypes.ts b/frontend/src/constants/panelTypes.ts index 811e06f0d0..93d66e5019 100644 --- a/frontend/src/constants/panelTypes.ts +++ b/frontend/src/constants/panelTypes.ts @@ -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, +]; diff --git a/frontend/src/constants/queryBuilderQueryNames.ts b/frontend/src/constants/queryBuilderQueryNames.ts index b3ee34cf89..fd7311364b 100644 --- a/frontend/src/constants/queryBuilderQueryNames.ts +++ b/frontend/src/constants/queryBuilderQueryNames.ts @@ -6,6 +6,8 @@ type QueryParamNames = | 'selectedFields' | 'linesPerRow'; +export type QuerySearchParamNames = 'viewName' | 'viewKey'; + export const queryParamNamesMap: Record = { compositeQuery: 'compositeQuery', panelTypes: 'panelTypes', @@ -14,3 +16,11 @@ export const queryParamNamesMap: Record = { selectedFields: 'selectedFields', linesPerRow: 'linesPerRow', }; + +export const querySearchParams: Record< + QuerySearchParamNames, + QuerySearchParamNames +> = { + viewName: 'viewName', + viewKey: 'viewKey', +}; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 65b9fd6477..ada1875c0c 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -29,6 +29,7 @@ const ROUTES = { NOT_FOUND: '/not-found', LOGS: '/logs', LOGS_EXPLORER: '/logs-explorer', + LIVE_LOGS: '/logs-explorer/live', HOME_PAGE: '/', PASSWORD_RESET: '/password-reset', LIST_LICENSES: '/licenses', diff --git a/frontend/src/constants/theme.ts b/frontend/src/constants/theme.ts index 18b7db2b18..be46d0d342 100644 --- a/frontend/src/constants/theme.ts +++ b/frontend/src/constants/theme.ts @@ -52,6 +52,8 @@ const themeColors = { gamboge: '#D89614', bckgGrey: '#1d1d1d', lightBlue: '#177ddc', + buttonSuccessRgb: '73, 170, 25', + red: '#E84749', }; export { themeColors }; diff --git a/frontend/src/container/CreateAlertChannels/config.ts b/frontend/src/container/CreateAlertChannels/config.ts index 634dc95c41..e15c1d7e08 100644 --- a/frontend/src/container/CreateAlertChannels/config.ts +++ b/frontend/src/container/CreateAlertChannels/config.ts @@ -40,6 +40,30 @@ export interface PagerChannel extends Channel { details?: string; detailsArray?: Record; } + +// 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; + + // 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 { diff --git a/frontend/src/container/CreateAlertChannels/defaults.ts b/frontend/src/container/CreateAlertChannels/defaults.ts index e37ad6be03..3068d8dd0c 100644 --- a/frontend/src/container/CreateAlertChannels/defaults.ts +++ b/frontend/src/container/CreateAlertChannels/defaults.ts @@ -1,4 +1,4 @@ -import { PagerChannel } from './config'; +import { OpsgenieChannel, PagerChannel } from './config'; export const PagerInitialConfig: Partial = { description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} @@ -22,3 +22,31 @@ export const PagerInitialConfig: Partial = { num_resolved: '{{ .Alerts.Resolved | len }}', }), }; + +export const OpsgenieInitialConfig: Partial = { + 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 }}', +}; diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index 70fe3fb7c6..cbe0d39e55 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -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 + 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, }, }} /> diff --git a/frontend/src/container/CreateAlertChannels/utils.ts b/frontend/src/container/CreateAlertChannels/utils.ts new file mode 100644 index 0000000000..ce7520a9f1 --- /dev/null +++ b/frontend/src/container/CreateAlertChannels/utils.ts @@ -0,0 +1,4 @@ +import { ChannelType } from './config'; + +export const isChannelType = (type: string): type is ChannelType => + Object.values(ChannelType).includes(type as ChannelType); diff --git a/frontend/src/container/EditAlertChannels/index.tsx b/frontend/src/container/EditAlertChannels/index.tsx index d6cba08381..ca8bc96f7f 100644 --- a/frontend/src/container/EditAlertChannels/index.tsx +++ b/frontend/src/container/EditAlertChannels/index.tsx @@ -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 + Partial< + SlackChannel & + WebhookChannel & + PagerChannel & + MsTeamsChannel & + OpsgenieChannel + > >({ ...initialValue, }); @@ -45,7 +50,7 @@ function EditAlertChannels({ const { id } = useParams<{ id: string }>(); const [type, setType] = useState( - 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, ], ); diff --git a/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx b/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx new file mode 100644 index 0000000000..009dd01882 --- /dev/null +++ b/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx @@ -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, + ): void => { + setSelectedConfig((value) => ({ + ...value, + [field]: event.target.value, + })); + }; + + return ( + <> + + + + + +