diff --git a/frontend/package.json b/frontend/package.json index 79a79c9f94..b9dcc6f74f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -90,7 +90,7 @@ "less": "^4.1.2", "less-loader": "^10.2.0", "lodash-es": "^4.17.21", - "lucide-react": "0.379.0", + "lucide-react": "0.427.0", "mini-css-extract-plugin": "2.4.5", "motion": "12.4.13", "overlayscrollbars": "^2.8.1", diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 6e3668b1fa..cb650e772f 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -295,3 +295,7 @@ export const MetricsExplorer = Loadable( () => import(/* webpackChunkName: "MetricsExplorer" */ 'pages/MetricsExplorer'), ); + +export const ApiMonitoring = Loadable( + () => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'), +); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 4182e063e4..b0492a8a18 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -8,6 +8,7 @@ import { AllAlertChannels, AllErrors, APIKeys, + ApiMonitoring, BillingPage, CreateAlertChannelAlerts, CreateNewAlerts, @@ -497,6 +498,13 @@ const routes: AppRoutes[] = [ key: 'METRICS_EXPLORER_VIEWS', isPrivate: true, }, + { + path: ROUTES.API_MONITORING, + exact: true, + component: ApiMonitoring, + key: 'API_MONITORING', + isPrivate: true, + }, ]; export const SUPPORT_ROUTE: AppRoutes = { diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx index ffd7ee3a25..fc50363ec1 100644 --- a/frontend/src/components/QuickFilters/QuickFilters.tsx +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -63,30 +63,31 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { return (
- {source !== QuickFiltersSource.INFRA_MONITORING && ( -
-
- - Filters for - - {lastQueryName} - -
+ {source !== QuickFiltersSource.INFRA_MONITORING && + source !== QuickFiltersSource.API_MONITORING && ( +
+
+ + Filters for + + {lastQueryName} + +
-
- - - -
- - - +
+ + + +
+ + + +
-
- )} + )}
{config.map((filter) => { diff --git a/frontend/src/components/QuickFilters/types.ts b/frontend/src/components/QuickFilters/types.ts index 0d5766f862..601bfee72a 100644 --- a/frontend/src/components/QuickFilters/types.ts +++ b/frontend/src/components/QuickFilters/types.ts @@ -39,4 +39,5 @@ export enum QuickFiltersSource { LOGS_EXPLORER = 'logs-explorer', INFRA_MONITORING = 'infra-monitoring', TRACES_EXPLORER = 'traces-explorer', + API_MONITORING = 'api-monitoring', } diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index d6f2ee3693..f96778df2b 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -51,6 +51,21 @@ export const REACT_QUERY_KEY = { GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES', GET_METRIC_DETAILS: 'GET_METRIC_DETAILS', GET_RELATED_METRICS: 'GET_RELATED_METRICS', + + // API Monitoring Query Keys + GET_DOMAINS_LIST: 'GET_DOMAINS_LIST', + GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN', + GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST', + GET_ENDPOINT_METRICS_DATA: 'GET_ENDPOINT_METRICS_DATA', + GET_ENDPOINT_STATUS_CODE_DATA: 'GET_ENDPOINT_STATUS_CODE_DATA', + GET_ENDPOINT_RATE_OVER_TIME_DATA: 'GET_ENDPOINT_RATE_OVER_TIME_DATA', + GET_ENDPOINT_LATENCY_OVER_TIME_DATA: 'GET_ENDPOINT_LATENCY_OVER_TIME_DATA', + GET_ENDPOINT_DROPDOWN_DATA: 'GET_ENDPOINT_DROPDOWN_DATA', + GET_ENDPOINT_DEPENDENT_SERVICES_DATA: 'GET_ENDPOINT_DEPENDENT_SERVICES_DATA', + GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA: + 'GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA', + GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA: + 'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA', GET_FUNNELS_LIST: 'GET_FUNNELS_LIST', GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS', } as const; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 226953c045..4ec3c4e3eb 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -71,6 +71,7 @@ const ROUTES = { METRICS_EXPLORER: '/metrics-explorer/summary', METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer', METRICS_EXPLORER_VIEWS: '/metrics-explorer/views', + API_MONITORING: '/api-monitoring/explorer', METRICS_EXPLORER_BASE: '/metrics-explorer', WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted', HOME_PAGE: '/', diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/AllEndPoints.tsx b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/AllEndPoints.tsx new file mode 100644 index 0000000000..be638aa795 --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/AllEndPoints.tsx @@ -0,0 +1,239 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { Select, Spin, Table, Typography } from 'antd'; +import { ENTITY_VERSION_V4 } from 'constants/app'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import { + EndPointsTableRowData, + formatEndPointsDataForTable, + getEndPointsColumnsConfig, + getEndPointsQueryPayload, +} from 'container/ApiMonitoring/utils'; +import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; +import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useQueries } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { SuccessResponse } from 'types/api'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +import ErrorState from './components/ErrorState'; +import ExpandedRow from './components/ExpandedRow'; +import { VIEW_TYPES, VIEWS } from './constants'; + +function AllEndPoints({ + domainName, + setSelectedEndPointName, + setSelectedView, + groupBy, + setGroupBy, +}: { + domainName: string; + setSelectedEndPointName: (name: string) => void; + setSelectedView: (tab: VIEWS) => void; + groupBy: IBuilderQuery['groupBy']; + setGroupBy: (groupBy: IBuilderQuery['groupBy']) => void; +}): JSX.Element { + const { + data: groupByFiltersData, + isLoading: isLoadingGroupByFilters, + } = useGetAggregateKeys({ + dataSource: DataSource.TRACES, + aggregateAttribute: '', + aggregateOperator: 'noop', + searchText: '', + tagType: '', + }); + + const [groupByOptions, setGroupByOptions] = useState< + { value: string; label: string }[] + >([]); + + const [expandedRowKeys, setExpandedRowKeys] = useState([]); + + const handleGroupByChange = useCallback( + (value: IBuilderQuery['groupBy']) => { + const groupBy = []; + + for (let index = 0; index < value.length; index++) { + const element = (value[index] as unknown) as string; + + const key = groupByFiltersData?.payload?.attributeKeys?.find( + (key) => key.key === element, + ); + + if (key) { + groupBy.push(key); + } + } + setGroupBy(groupBy); + }, + [groupByFiltersData, setGroupBy], + ); + + useEffect(() => { + if (groupByFiltersData?.payload) { + setGroupByOptions( + groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({ + value: filter.key, + label: filter.key, + })) || [], + ); + } + }, [groupByFiltersData]); + + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + + const queryPayloads = useMemo( + () => + getEndPointsQueryPayload( + groupBy, + domainName, + Math.floor(minTime / 1e9), + Math.floor(maxTime / 1e9), + ), + [groupBy, domainName, minTime, maxTime], + ); + + // Since only one query here + const endPointsDataQueries = useQueries( + queryPayloads.map((payload) => ({ + queryKey: [ + REACT_QUERY_KEY.GET_ENDPOINTS_LIST_BY_DOMAIN, + payload, + ENTITY_VERSION_V4, + groupBy, + ], + queryFn: (): Promise> => + GetMetricQueryRange(payload, ENTITY_VERSION_V4), + enabled: !!payload, + staleTime: 60 * 1000, // 1 minute stale time : optimize this part + })), + ); + + const endPointsDataQuery = endPointsDataQueries[0]; + const { + data: allEndPointsData, + isLoading, + isRefetching, + isError, + refetch, + } = endPointsDataQuery; + + const endPointsColumnsConfig = useMemo( + () => getEndPointsColumnsConfig(groupBy.length > 0, expandedRowKeys), + [groupBy.length, expandedRowKeys], + ); + + const expandedRowRender = (record: EndPointsTableRowData): JSX.Element => ( + + ); + + const handleGroupByRowClick = (record: EndPointsTableRowData): void => { + if (expandedRowKeys.includes(record.key)) { + setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key)); + } else { + setExpandedRowKeys((expandedRowKeys) => [...expandedRowKeys, record.key]); + } + }; + + const handleRowClick = (record: EndPointsTableRowData): void => { + if (groupBy.length === 0) { + setSelectedEndPointName(record.endpointName); // this will open up the endpoint details tab + setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS); + } else { + handleGroupByRowClick(record); // this will prepare the nested query payload + } + }; + + const formattedEndPointsData = useMemo( + () => + formatEndPointsDataForTable( + allEndPointsData?.payload?.data?.result[0]?.table?.rows, + groupBy, + ), + [groupBy, allEndPointsData], + ); + + if (isError) { + return ( +
+ +
+ ); + } + + return ( +
+
+
Group by
+ + ); +} + +EndPointsDropDown.defaultProps = defaultProps; + +export default EndPointsDropDown; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/ErrorState.tsx b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/ErrorState.tsx new file mode 100644 index 0000000000..929102216c --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/ErrorState.tsx @@ -0,0 +1,31 @@ +import { Button, Typography } from 'antd'; +import { RotateCw } from 'lucide-react'; + +function ErrorState({ refetch }: { refetch: () => void }): JSX.Element { + return ( +
+
+
+
+ awwSnap +
+
+ Uh-oh :/ We ran into an error. + + Please refresh this panel. + +
+
+ +
+
+ ); +} + +export default ErrorState; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/ExpandedRow.tsx b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/ExpandedRow.tsx new file mode 100644 index 0000000000..e8cf25c93d --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/ExpandedRow.tsx @@ -0,0 +1,127 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { Spin, Table } from 'antd'; +import { ColumnType } from 'antd/lib/table'; +import { ENTITY_VERSION_V4 } from 'constants/app'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import { + createFiltersForSelectedRowData, + EndPointsTableRowData, + formatEndPointsDataForTable, + getEndPointsColumnsConfig, + getEndPointsQueryPayload, +} from 'container/ApiMonitoring/utils'; +import LoadingContainer from 'container/InfraMonitoringK8s/LoadingContainer'; +import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; +import { useMemo } from 'react'; +import { useQueries } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { SuccessResponse } from 'types/api'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +import { VIEW_TYPES, VIEWS } from '../constants'; + +function ExpandedRow({ + domainName, + selectedRowData, + setSelectedEndPointName, + setSelectedView, +}: { + domainName: string; + selectedRowData: EndPointsTableRowData; + setSelectedEndPointName: (name: string) => void; + setSelectedView: (view: VIEWS) => void; +}): JSX.Element { + const nestedColumns = useMemo(() => getEndPointsColumnsConfig(false, []), []); + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + const groupedByRowDataQueryPayload = useMemo(() => { + if (!selectedRowData) return null; + + const filters = createFiltersForSelectedRowData(selectedRowData); + + const baseQueryPayload = getEndPointsQueryPayload( + [], + domainName, + Math.floor(minTime / 1e9), + Math.floor(maxTime / 1e9), + ); + + return baseQueryPayload.map((currentQueryPayload) => ({ + ...currentQueryPayload, + query: { + ...currentQueryPayload.query, + builder: { + ...currentQueryPayload.query.builder, + queryData: currentQueryPayload.query.builder.queryData.map( + (queryData) => ({ + ...queryData, + filters: { + items: [...(queryData.filters?.items || []), ...filters.items], + op: 'AND', + }, + }), + ), + }, + }, + })); + }, [domainName, minTime, maxTime, selectedRowData]); + + const groupedByRowQueries = useQueries( + groupedByRowDataQueryPayload + ? groupedByRowDataQueryPayload.map((payload) => ({ + queryKey: [ + `${REACT_QUERY_KEY.GET_NESTED_ENDPOINTS_LIST}-${domainName}-${selectedRowData?.key}`, + payload, + ENTITY_VERSION_V4, + selectedRowData?.key, + ], + queryFn: (): Promise> => + GetMetricQueryRange(payload, ENTITY_VERSION_V4), + enabled: !!payload && !!selectedRowData, + })) + : [], + ); + + const groupedByRowQuery = groupedByRowQueries[0]; + return ( +
+ {groupedByRowQuery?.isFetching || groupedByRowQuery?.isLoading ? ( + + ) : ( +
+ []} + dataSource={ + groupedByRowQuery?.data + ? formatEndPointsDataForTable( + groupedByRowQuery.data?.payload.data.result[0].table?.rows, + [], + ) + : [] + } + pagination={false} + scroll={{ x: true }} + tableLayout="fixed" + showHeader={false} + loading={{ + spinning: groupedByRowQuery?.isFetching || groupedByRowQuery?.isLoading, + indicator: } />, + }} + onRow={(record): { onClick: () => void; className: string } => ({ + onClick: (): void => { + setSelectedEndPointName(record.endpointName); + setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS); + }, + className: 'expanded-clickable-row', + })} + /> + + )} + + ); +} + +export default ExpandedRow; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/MetricOverTimeGraph.tsx b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/MetricOverTimeGraph.tsx new file mode 100644 index 0000000000..fd4b3bb262 --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/MetricOverTimeGraph.tsx @@ -0,0 +1,114 @@ +import { Card, Skeleton, Typography } from 'antd'; +import cx from 'classnames'; +import Uplot from 'components/Uplot'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { + apiWidgetInfo, + extractPortAndEndpoint, + getFormattedChartData, +} from 'container/ApiMonitoring/utils'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions'; +import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; +import { useCallback, useMemo, useRef } from 'react'; +import { UseQueryResult } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { SuccessResponse } from 'types/api'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { Options } from 'uplot'; + +import ErrorState from './ErrorState'; + +function MetricOverTimeGraph({ + metricOverTimeDataQuery, + widgetInfoIndex, + endPointName, +}: { + metricOverTimeDataQuery: UseQueryResult, unknown>; + widgetInfoIndex: number; + endPointName: string; +}): JSX.Element { + const { data } = metricOverTimeDataQuery; + + const { minTime, maxTime } = useSelector( + (state) => state.globalTime, + ); + + const graphRef = useRef(null); + const dimensions = useResizeObserver(graphRef); + + const { endpoint } = extractPortAndEndpoint(endPointName); + + const formattedChartData = useMemo( + () => getFormattedChartData(data?.payload, [endpoint]), + [data?.payload, endpoint], + ); + + const chartData = useMemo(() => getUPlotChartData(formattedChartData), [ + formattedChartData, + ]); + + const isDarkMode = useIsDarkMode(); + + const options = useMemo( + () => + getUPlotChartOptions({ + apiResponse: formattedChartData, + isDarkMode, + dimensions, + yAxisUnit: apiWidgetInfo[widgetInfoIndex].yAxisUnit, + softMax: null, + softMin: null, + minTimeScale: Math.floor(minTime / 1e9), + maxTimeScale: Math.floor(maxTime / 1e9), + panelType: PANEL_TYPES.TIME_SERIES, + }), + [ + formattedChartData, + minTime, + maxTime, + widgetInfoIndex, + dimensions, + isDarkMode, + ], + ); + + const renderCardContent = useCallback( + (query: UseQueryResult, unknown>): JSX.Element => { + if (query.isLoading) { + return ; + } + + if (query.error) { + return ; + } + + return ( +
+ +
+ ); + }, + [options, chartData], + ); + + return ( +
+ + {apiWidgetInfo[widgetInfoIndex].title} +
+ {renderCardContent(metricOverTimeDataQuery)} +
+
+
+ ); +} + +export default MetricOverTimeGraph; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/StatusCodeBarCharts.tsx b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/StatusCodeBarCharts.tsx new file mode 100644 index 0000000000..bba17d1f21 --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/StatusCodeBarCharts.tsx @@ -0,0 +1,168 @@ +import { Button, Card, Skeleton, Typography } from 'antd'; +import cx from 'classnames'; +import Uplot from 'components/Uplot'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { + getFormattedEndPointStatusCodeChartData, + statusCodeWidgetInfo, +} from 'container/ApiMonitoring/utils'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions'; +import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { UseQueryResult } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { SuccessResponse } from 'types/api'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { Options } from 'uplot'; + +import ErrorState from './ErrorState'; + +function StatusCodeBarCharts({ + endPointStatusCodeBarChartsDataQuery, + endPointStatusCodeLatencyBarChartsDataQuery, +}: { + endPointStatusCodeBarChartsDataQuery: UseQueryResult< + SuccessResponse, + unknown + >; + endPointStatusCodeLatencyBarChartsDataQuery: UseQueryResult< + SuccessResponse, + unknown + >; +}): JSX.Element { + // 0 : Status Code Count + // 1 : Status Code Latency + const [currentWidgetInfoIndex, setCurrentWidgetInfoIndex] = useState(0); + + const { + data: endPointStatusCodeBarChartsData, + } = endPointStatusCodeBarChartsDataQuery; + + const { + data: endPointStatusCodeLatencyBarChartsData, + } = endPointStatusCodeLatencyBarChartsDataQuery; + + const { minTime, maxTime } = useSelector( + (state) => state.globalTime, + ); + + const graphRef = useRef(null); + const dimensions = useResizeObserver(graphRef); + const formattedEndPointStatusCodeBarChartsDataPayload = useMemo( + () => + getFormattedEndPointStatusCodeChartData( + endPointStatusCodeBarChartsData?.payload, + 'sum', + ), + [endPointStatusCodeBarChartsData?.payload], + ); + + const formattedEndPointStatusCodeLatencyBarChartsDataPayload = useMemo( + () => + getFormattedEndPointStatusCodeChartData( + endPointStatusCodeLatencyBarChartsData?.payload, + 'average', + ), + [endPointStatusCodeLatencyBarChartsData?.payload], + ); + + const chartData = useMemo( + () => + getUPlotChartData( + currentWidgetInfoIndex === 0 + ? formattedEndPointStatusCodeBarChartsDataPayload + : formattedEndPointStatusCodeLatencyBarChartsDataPayload, + ), + [ + currentWidgetInfoIndex, + formattedEndPointStatusCodeBarChartsDataPayload, + formattedEndPointStatusCodeLatencyBarChartsDataPayload, + ], + ); + + const isDarkMode = useIsDarkMode(); + + const options = useMemo( + () => + getUPlotChartOptions({ + apiResponse: + currentWidgetInfoIndex === 0 + ? formattedEndPointStatusCodeBarChartsDataPayload + : formattedEndPointStatusCodeLatencyBarChartsDataPayload, + isDarkMode, + dimensions, + yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit, + softMax: null, + softMin: null, + minTimeScale: Math.floor(minTime / 1e9), + maxTimeScale: Math.floor(maxTime / 1e9), + panelType: PANEL_TYPES.BAR, + }), + [ + minTime, + maxTime, + currentWidgetInfoIndex, + dimensions, + formattedEndPointStatusCodeBarChartsDataPayload, + formattedEndPointStatusCodeLatencyBarChartsDataPayload, + isDarkMode, + ], + ); + + const renderCardContent = useCallback( + (query: UseQueryResult, unknown>): JSX.Element => { + if (query.isLoading) { + return ; + } + + if (query.error) { + return ; + } + return ( +
+ +
+ ); + }, + [options, chartData], + ); + + return ( +
+ +
+ Call response status + + + + +
+
+ {renderCardContent(endPointStatusCodeBarChartsDataQuery)} +
+
+
+ ); +} +export default StatusCodeBarCharts; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/StatusCodeTable.tsx b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/StatusCodeTable.tsx new file mode 100644 index 0000000000..02a31ce27e --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/components/StatusCodeTable.tsx @@ -0,0 +1,72 @@ +import { Table, Typography } from 'antd'; +import { + endPointStatusCodeColumns, + getFormattedEndPointStatusCodeData, +} from 'container/ApiMonitoring/utils'; +import { useMemo } from 'react'; +import { UseQueryResult } from 'react-query'; +import { SuccessResponse } from 'types/api'; + +import ErrorState from './ErrorState'; + +function StatusCodeTable({ + endPointStatusCodeDataQuery, +}: { + endPointStatusCodeDataQuery: UseQueryResult, unknown>; +}): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + refetch, + } = endPointStatusCodeDataQuery; + + const statusCodeData = useMemo(() => { + if (isLoading || isRefetching || isError) { + return []; + } + + return getFormattedEndPointStatusCodeData( + data?.payload?.data?.result[0].table.rows, + ); + }, [data?.payload?.data?.result, isLoading, isRefetching, isError]); + + if (isError) { + return ; + } + + return ( +
+
+ index % 2 === 0 ? 'table-row-dark' : 'table-row-light' + } + locale={{ + emptyText: + isLoading || isRefetching ? null : ( +
+
+ thinking-emoji + + + This query had no results. Edit your query and try again! + +
+
+ ), + }} + /> + + ); +} + +export default StatusCodeTable; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/constants.ts b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/constants.ts new file mode 100644 index 0000000000..9b3314d855 --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/constants.ts @@ -0,0 +1,9 @@ +export enum VIEWS { + ALL_ENDPOINTS = 'all_endpoints', + ENDPOINT_DETAILS = 'endpoint_details', +} + +export const VIEW_TYPES = { + ALL_ENDPOINTS: VIEWS.ALL_ENDPOINTS, + ENDPOINT_DETAILS: VIEWS.ENDPOINT_DETAILS, +}; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainList.tsx b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainList.tsx new file mode 100644 index 0000000000..b326feb519 --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainList.tsx @@ -0,0 +1,156 @@ +import '../Explorer.styles.scss'; + +import { LoadingOutlined } from '@ant-design/icons'; +import { Spin, Table, Typography } from 'antd'; +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import cx from 'classnames'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2'; +import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; +import { useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { HandleChangeQueryData } from 'types/common/operations.types'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +import { + columnsConfig, + formatDataForTable, + hardcodedAttributeKeys, +} from '../../utils'; +import DomainDetails from './DomainDetails/DomainDetails'; + +function DomainList({ + query, + showIP, + handleChangeQueryData, +}: { + query: IBuilderQuery; + showIP: boolean; + handleChangeQueryData: HandleChangeQueryData; +}): JSX.Element { + const [selectedDomainIndex, setSelectedDomainIndex] = useState(-1); + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + + const fetchApiOverview = async (): Promise< + SuccessResponse | ErrorResponse + > => { + const requestBody = { + start: minTime, + end: maxTime, + show_ip: showIP, + filters: { + op: 'AND', + items: query?.filters.items, + }, + }; + + try { + const response = await axios.post( + '/third-party-apis/overview/list', + requestBody, + ); + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } + }; + + const { data, isLoading, isFetching } = useQuery( + [REACT_QUERY_KEY.GET_DOMAINS_LIST, minTime, maxTime, query, showIP], + fetchApiOverview, + ); + + const formattedDataForTable = useMemo( + () => formatDataForTable(data?.payload?.data?.result[0]?.table?.rows), + [data], + ); + + return ( +
+
+ + handleChangeQueryData('filters', searchFilters) + } + placeholder="Search filters..." + hardcodedAttributeKeys={hardcodedAttributeKeys} + /> + +
+
} />, + }} + locale={{ + emptyText: + isFetching || isLoading ? null : ( +
+
+ thinking-emoji + + + This query had no results. Edit your query and try again! + +
+
+ ), + }} + scroll={{ x: true }} + tableLayout="fixed" + onRow={(record, index): { onClick: () => void; className: string } => ({ + onClick: (): void => { + if (index !== undefined) { + const dataIndex = formattedDataForTable.findIndex( + (item) => item.key === record.key, + ); + setSelectedDomainIndex(dataIndex); + } + }, + className: 'expanded-clickable-row', + })} + rowClassName={(_, index): string => + index % 2 === 0 ? 'table-row-dark' : 'table-row-light' + } + /> + {selectedDomainIndex !== -1 && ( + { + setSelectedDomainIndex(-1); + }} + /> + )} + + ); +} + +export default DomainList; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Explorer.styles.scss b/frontend/src/container/ApiMonitoring/Explorer/Explorer.styles.scss new file mode 100644 index 0000000000..f05767113e --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Explorer.styles.scss @@ -0,0 +1,219 @@ +.api-monitoring-page { + display: flex; + height: 100%; + + .api-quick-filter-left-section { + width: 0%; + flex-shrink: 0; + + .api-quick-filters-header { + padding: 12px; + border-bottom: 1px solid var(--bg-slate-400); + + display: flex; + align-items: center; + gap: 6px; + + font-size: 14px; + line-height: 18px; + } + } + + .api-module-right-section { + display: flex; + flex-direction: column; + width: 100%; + + .api-monitoring-list-header { + width: 100%; + padding: 8px; + display: flex; + + justify-content: space-between; + align-items: center; + gap: 12px; + } + + .query-builder-search-v2 { + min-width: 80%; + flex: 1; + } + } + + .api-monitoring-domain-list-table { + .ant-table { + .ant-table-thead > tr > th { + padding: 12px; + font-weight: 500; + font-size: 12px; + line-height: 18px; + border-bottom: none; + + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 18px; + /* 163.636% */ + letter-spacing: 0.44px; + text-transform: uppercase; + background: none; + + &::before { + background-color: transparent; + } + } + + .ant-table-thead > tr > th:has(.domain-list-name-col-header) { + background: var(--bg-ink-300); + opacity: 0.6; + } + + .ant-table-cell { + padding: 12px; + font-size: 13px; + line-height: 20px; + color: var(--bg-vanilla-100); + border-bottom: none; + } + + .ant-table-cell:has(.domain-list-name-col-value) { + background: var(--bg-ink-300); + opacity: 0.6; + } + + .round-metric-tag { + display: inline-flex; + padding: 2px 8px; + align-items: center; + gap: 6px; + width: fit-content; + + border-radius: 50px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-slate-500); + text-transform: lowercase; + } + + .ant-table-tbody > tr:hover > td { + background: rgba(255, 255, 255, 0.04); + } + + .ant-table-cell:first-child { + text-align: justify; + } + + .ant-table-cell:nth-child(2) { + padding-left: 16px; + padding-right: 16px; + } + + .ant-table-cell:nth-child(n + 3) { + padding-right: 24px; + } + + .column-header-right { + text-align: right; + } + + .ant-table-tbody > tr > td { + border-bottom: none; + } + + .ant-table-thead + > tr + > th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before { + background-color: transparent; + } + + .ant-empty-normal { + visibility: hidden; + } + + .table-row-light { + background: none; + } + + .table-row-dark { + background: var(--bg-ink-300); + } + + .error-rate { + width: 120px; + } + } + } + + &.filter-visible { + .api-quick-filter-left-section { + width: 260px; + } + + .api-module-right-section { + width: calc(100% - 260px); + } + } +} + +.no-filtered-domains-message-container { + height: 30vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .no-filtered-domains-message-content { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + + width: fit-content; + padding: 24px; + } + + .no-filtered-domains-message { + margin-top: 8px; + } +} + +.lightMode { + .api-monitoring-domain-list-table { + .ant-table { + .ant-table-thead > tr > th { + background: var(--bg-vanilla-100); + color: var(--text-ink-300); + } + + .ant-table-thead > tr > th:has(.domain-list-name-col-header) { + background: var(--bg-vanilla-100); + } + + .ant-table-cell { + background: var(--bg-vanilla-100); + color: var(--bg-ink-500); + } + + .ant-table-cell:has(.domain-list-name-col-value) { + background: var(--bg-vanilla-100); + } + + .ant-table-tbody > tr:hover > td { + background: rgba(0, 0, 0, 0.04); + } + + .table-row-light { + background: none; + } + + .table-row-dark { + background: none; + } + + .round-metric-tag { + color: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/container/ApiMonitoring/Explorer/Explorer.tsx b/frontend/src/container/ApiMonitoring/Explorer/Explorer.tsx new file mode 100644 index 0000000000..5a1de332c1 --- /dev/null +++ b/frontend/src/container/ApiMonitoring/Explorer/Explorer.tsx @@ -0,0 +1,91 @@ +import './Explorer.styles.scss'; + +import { FilterOutlined } from '@ant-design/icons'; +import * as Sentry from '@sentry/react'; +import { Switch, Typography } from 'antd'; +import cx from 'classnames'; +import QuickFilters from 'components/QuickFilters/QuickFilters'; +import { QuickFiltersSource } from 'components/QuickFilters/types'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; +import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; +import { useMemo, useState } from 'react'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; + +import { ApiMonitoringQuickFiltersConfig } from '../utils'; +import DomainList from './Domains/DomainList'; + +function Explorer(): JSX.Element { + const [showIP, setShowIP] = useState(true); + + const { currentQuery } = useQueryBuilder(); + + const { handleChangeQueryData } = useQueryOperations({ + index: 0, + query: currentQuery.builder.queryData[0], + entityVersion: '', + }); + + const updatedCurrentQuery = useMemo( + () => ({ + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: [ + { + ...currentQuery.builder.queryData[0], + dataSource: DataSource.TRACES, + aggregateOperator: 'noop', + aggregateAttribute: { + ...currentQuery.builder.queryData[0].aggregateAttribute, + }, + }, + ], + }, + }), + [currentQuery], + ); + const query = updatedCurrentQuery?.builder?.queryData[0] || null; + + return ( + }> +
+
+
+ + Filters +
+ +
+ Show IP addresses + { + setShowIP((showIP) => !showIP); + }} + /> +
+ + {}} + onFilterChange={(query: Query): void => + handleChangeQueryData('filters', query.builder.queryData[0].filters) + } + /> +
+ +
+
+ ); +} + +export default Explorer; diff --git a/frontend/src/container/ApiMonitoring/utils.tsx b/frontend/src/container/ApiMonitoring/utils.tsx new file mode 100644 index 0000000000..1706934708 --- /dev/null +++ b/frontend/src/container/ApiMonitoring/utils.tsx @@ -0,0 +1,2087 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Color } from '@signozhq/design-tokens'; +import { Progress, Tag } from 'antd'; +import { ColumnType } from 'antd/es/table'; +import { + FiltersType, + IQuickFiltersConfig, +} from 'components/QuickFilters/types'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import dayjs from 'dayjs'; +import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; +import { cloneDeep } from 'lodash-es'; +import { ArrowUpDown, ChevronDown, ChevronRight } from 'lucide-react'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import { + BaseAutocompleteData, + DataTypes, +} from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { QueryData } from 'types/api/widgets/getQuery'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; +import { v4 } from 'uuid'; + +export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [ + { + type: FiltersType.CHECKBOX, + title: 'Environment', + + attributeKey: { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + dataSource: DataSource.TRACES, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Service Name', + attributeKey: { + key: 'service.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + }, + dataSource: DataSource.TRACES, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'RPC Method', + attributeKey: { + key: 'rpc.method', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + }, + dataSource: DataSource.TRACES, + defaultOpen: true, + }, +]; + +export const getLastUsedRelativeTime = (lastRefresh: number): string => { + const currentTime = dayjs(); + + const secondsDiff = currentTime.diff(lastRefresh, 'seconds'); + + const minutedDiff = currentTime.diff(lastRefresh, 'minutes'); + const hoursDiff = currentTime.diff(lastRefresh, 'hours'); + const daysDiff = currentTime.diff(lastRefresh, 'days'); + const monthsDiff = currentTime.diff(lastRefresh, 'months'); + + if (monthsDiff > 0) { + return `${monthsDiff} ${monthsDiff === 1 ? 'month' : 'months'} ago`; + } + + if (daysDiff > 0) { + return `${daysDiff} ${daysDiff === 1 ? 'day' : 'days'} ago`; + } + + if (hoursDiff > 0) { + return `${hoursDiff}h ago`; + } + + if (minutedDiff > 0) { + return `${minutedDiff}m ago`; + } + + return `${secondsDiff}s ago`; +}; + +// Rename this to a proper name +export const columnsConfig: ColumnType[] = [ + { + title:
Domain
, + dataIndex: 'domainName', + key: 'domainName', + width: '23.7%', + ellipsis: true, + sorter: false, + className: 'column column-domain-name', + align: 'left', + render: (domainName: string): React.ReactNode => ( +
{domainName}
+ ), + }, + { + title:
Endpoints in use
, + dataIndex: 'endpointCount', + key: 'endpointCount', + width: '14.2%', + ellipsis: true, + sorter: false, + align: 'right', + className: `column`, + }, + { + title:
Last used
, + dataIndex: 'lastUsed', + key: 'lastUsed', + width: '14.2%', + sorter: false, + align: 'right', + className: `column`, + render: (lastUsed: number): string => getLastUsedRelativeTime(lastUsed), + }, + { + title: ( +
+ Rate /s +
+ ), + dataIndex: 'rate', + key: 'rate', + width: '14.2%', + sorter: false, + align: 'right', + className: `column`, + }, + { + title: ( +
+ Error rate % +
+ ), + dataIndex: 'errorRate', + key: 'errorRate', + width: '14.2%', + sorter: false, + align: 'right', + className: `column`, + render: (errorRate: number): React.ReactNode => ( + { + const errorRatePercent = Number((errorRate * 100).toFixed(1)); + if (errorRatePercent >= 90) return Color.BG_SAKURA_500; + if (errorRatePercent >= 60) return Color.BG_AMBER_500; + return Color.BG_FOREST_500; + })()} + className="progress-bar error-rate" + /> + ), + }, + { + title: ( +
+ Avg. Latency ms +
+ ), + dataIndex: 'latency', + key: 'latency', + width: '14.2%', + sorter: false, + align: 'right', + className: `column`, + }, +]; + +// Rename this to a proper name +export const hardcodedAttributeKeys: BaseAutocompleteData[] = [ + { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + { + key: 'service.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + }, + { + key: 'rpc.method', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + }, +]; + +const domainNameKey = 'net.peer.name'; + +interface APIMonitoringResponseRow { + data: { + endpoints: number; + error_rate: number; + lastseen: number; + [domainNameKey]: string; + p99: number; + rps: number; + }; +} + +interface EndPointsResponseRow { + data: { + [key: string]: string | number | undefined; + }; +} + +export interface APIDomainsRowData { + key: string; + domainName: React.ReactNode; + endpointCount: React.ReactNode; + rate: React.ReactNode; + errorRate: React.ReactNode; + latency: React.ReactNode; + lastUsed: React.ReactNode; +} + +// Rename this to a proper name +export const formatDataForTable = ( + data: APIMonitoringResponseRow[], +): APIDomainsRowData[] => + data?.map((domain) => ({ + key: v4(), + domainName: domain.data[domainNameKey] || '', + endpointCount: domain.data.endpoints, + rate: domain.data.rps, + errorRate: domain.data.error_rate, + latency: Math.round(domain.data.p99 / 1000000), // Convert from nanoseconds to milliseconds + lastUsed: new Date(Math.floor(domain.data.lastseen / 1000000)).toISOString(), // Convert from nanoseconds to milliseconds + })); + +// Rename this to a proper name +const defaultGroupBy = [ + { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, +]; + +export const getEndPointsQueryPayload = ( + groupBy: BaseAutocompleteData[], + domainName: string, + start: number, + end: number, +): GetQueryResultsProps[] => { + const isGroupedByAttribute = groupBy.length > 0; + return [ + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TABLE, + query: { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: DataTypes.String, + id: 'span_id--string----true', + isColumn: true, + isJSON: false, + key: 'span_id', + type: '', + }, + aggregateOperator: 'count', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'A', + filters: { + items: [ + { + id: '82bdab49', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + ], + op: 'AND', + }, + functions: [], + groupBy: isGroupedByAttribute ? groupBy : defaultGroupBy, + having: [], + legend: '', + limit: null, + orderBy: [], + queryName: 'A', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'rate', + }, + { + aggregateAttribute: { + dataType: DataTypes.Float64, + id: 'duration_nano--float64----true', + isColumn: true, + isJSON: false, + key: 'duration_nano', + type: '', + }, + aggregateOperator: 'p99', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'B', + filters: { + items: [ + { + id: '3f46231e', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + ], + op: 'AND', + }, + functions: [], + groupBy: isGroupedByAttribute ? groupBy : defaultGroupBy, + having: [], + legend: '', + limit: null, + orderBy: [], + queryName: 'B', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'p99', + }, + { + aggregateAttribute: { + dataType: DataTypes.String, + id: 'timestamp------false', + isColumn: false, + key: 'timestamp', + type: '', + }, + aggregateOperator: 'max', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'C', + filters: { + items: [ + { + id: '55ad75cd', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + ], + op: 'AND', + }, + functions: [], + groupBy: isGroupedByAttribute ? groupBy : defaultGroupBy, + having: [], + legend: '', + limit: null, + orderBy: [], + queryName: 'C', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'max', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: true, + start, + end, + step: 60, + }, + ]; +}; + +export interface EndPointsTableRowData { + key: string; + endpointName: string; + callCount: number | string; + latency: number | string; + lastUsed: string; + groupedByMeta?: Record; +} + +export const extractPortAndEndpoint = ( + url: string, +): { port: string; endpoint: string } => { + try { + // Create a URL object to parse the URL + const parsedUrl = new URL(url); + + // Extract the port (will be empty string if not specified) + const port = parsedUrl.port || '-'; + + // Extract the pathname (endpoint) + query params + const endpoint = parsedUrl.pathname + parsedUrl.search; + + return { port, endpoint }; + } catch (error) { + // If URL parsing fails, return default values + return { port: '-', endpoint: url }; + } +}; + +// Add icons in the below column headers +export const getEndPointsColumnsConfig = ( + isGroupedByAttribute: boolean, + expandedRowKeys: React.Key[], +): ColumnType[] => [ + { + title: ( +
+ {isGroupedByAttribute ? 'Endpoint group' : 'Endpoint'} +
+ ), + dataIndex: 'endpointName', + key: 'endpointName', + width: 180, + ellipsis: true, + sorter: false, + className: 'column', + render: (text: string, record: EndPointsTableRowData): React.ReactNode => { + const endPointName = isGroupedByAttribute + ? text + : extractPortAndEndpoint(record.endpointName).endpoint; + return ( +
+ {((): React.ReactNode => { + if (!isGroupedByAttribute) return null; + return expandedRowKeys.includes(record.key) ? ( + + ) : ( + + ); + })()} + {isGroupedByAttribute + ? text.split(',').map((value) => ( + + {value === '' ? '' : value} + + )) + : endPointName} +
+ ); + }, + }, + { + title:
Port
, + dataIndex: 'port', + key: 'port', + width: 180, + ellipsis: true, + sorter: false, + align: 'right', + className: `column`, + }, + { + title: ( +
+ Num of calls +
+ ), + dataIndex: 'callCount', + key: 'callCount', + width: 180, + ellipsis: true, + sorter: false, + align: 'right', + className: `column`, + }, + { + title: ( +
+ Latency ms +
+ ), + dataIndex: 'latency', + key: 'latency', + width: 120, + sorter: false, + align: 'right', + className: `column`, + }, + { + title:
Last used
, + dataIndex: 'lastUsed', + key: 'lastUsed', + width: 120, + sorter: false, + align: 'right', + className: `column`, + }, +]; + +export const formatEndPointsDataForTable = ( + data: EndPointsResponseRow[] | undefined, + groupBy: BaseAutocompleteData[], + // eslint-disable-next-line sonarjs/cognitive-complexity +): EndPointsTableRowData[] => { + if (!data) return []; + const isGroupedByAttribute = groupBy.length > 0; + if (!isGroupedByAttribute) { + return data?.map((endpoint) => { + const { port } = extractPortAndEndpoint( + (endpoint.data['http.url'] as string) || '', + ); + return { + key: v4(), + endpointName: (endpoint.data['http.url'] as string) || '', + port, + callCount: endpoint.data.A || '-', + latency: + endpoint.data.B === 'n/a' + ? '-' + : Math.round(Number(endpoint.data.B) / 1000000), // Convert from nanoseconds to milliseconds + lastUsed: + endpoint.data.C === 'n/a' + ? '-' + : getLastUsedRelativeTime(Math.floor(Number(endpoint.data.C) / 1000000)), // Convert from nanoseconds to milliseconds + }; + }); + } + + const groupedByAttributeData = groupBy.map((attribute) => attribute.key); + + // TODO: Use tags to show the concatenated attribute values + return data?.map((endpoint) => { + const newEndpointName = groupedByAttributeData + .map((attribute) => endpoint.data[attribute]) + .join(','); + return { + key: v4(), + endpointName: newEndpointName, + callCount: endpoint.data.A || '-', + latency: + endpoint.data.B === 'n/a' + ? '-' + : Math.round(Number(endpoint.data.B) / 1000000), // Convert from nanoseconds to milliseconds + lastUsed: + endpoint.data.C === 'n/a' + ? '-' + : getLastUsedRelativeTime(Math.floor(Number(endpoint.data.C) / 1000000)), // Convert from nanoseconds to milliseconds + groupedByMeta: groupedByAttributeData.reduce((acc, attribute) => { + acc[attribute] = endpoint.data[attribute] || ''; + return acc; + }, {} as Record), + }; + }); +}; + +export const createFiltersForSelectedRowData = ( + selectedRowData: EndPointsTableRowData, + currentFilters?: IBuilderQuery['filters'], +): IBuilderQuery['filters'] => { + const baseFilters: IBuilderQuery['filters'] = { + items: [...(currentFilters?.items || [])], + op: 'and', + }; + + if (!selectedRowData) return baseFilters; + + const { groupedByMeta = {} } = selectedRowData; + + // Replace for...of with Object.keys().map() + baseFilters.items.push( + ...Object.keys(groupedByMeta).map((key) => ({ + key: { + key, + type: null, + }, + op: '=', + value: groupedByMeta[key], + id: key, + })), + ); + + return baseFilters; +}; + +// First query payload for endpoint metrics +// Second query payload for endpoint status code +// Third query payload for endpoint rate over time graph +// Fourth query payload for endpoint latency over time graph +// Fifth query payload for endpoint dropdown selection +// Sixth query payload for endpoint dependant services +// Seventh query payload for endpoint response status count bar chart +// Eighth query payload for endpoint response status code latency bar chart +export const getEndPointDetailsQueryPayload = ( + domainName: string, + endPointName: string, + start: number, + end: number, + filters: IBuilderQuery['filters'], +): GetQueryResultsProps[] => [ + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TABLE, + query: { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: DataTypes.String, + id: '------false', + isColumn: false, + isJSON: false, + key: '', + type: '', + }, + aggregateOperator: 'rate', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'A', + filters: { + items: [ + { + id: '3db61dd6', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + { + id: '2d0f6061', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [], + having: [], + legend: 'Rate', + limit: null, + orderBy: [], + queryName: 'A', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'rate', + }, + { + aggregateAttribute: { + dataType: DataTypes.Float64, + id: 'duration_nano--float64----true', + isColumn: true, + isJSON: false, + key: 'duration_nano', + type: '', + }, + aggregateOperator: 'p99', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'B', + filters: { + items: [ + { + id: 'bd65eb28', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + { + id: '14b30e30', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [], + having: [], + legend: 'P99', + limit: null, + orderBy: [], + queryName: 'B', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'p99', + }, + { + aggregateAttribute: { + dataType: DataTypes.String, + id: '------false', + isColumn: false, + key: '', + type: '', + }, + aggregateOperator: 'rate', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'C', + filters: { + items: [ + { + id: 'd9d55d83', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + { + id: '6087aebe', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + { + id: 'c575cf6e', + key: { + dataType: DataTypes.bool, + id: 'has_error--bool----true', + isColumn: true, + isJSON: false, + key: 'has_error', + type: '', + }, + op: '=', + value: 'true', + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [], + having: [], + legend: 'Error', + limit: null, + orderBy: [], + queryName: 'C', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'rate', + }, + { + aggregateAttribute: { + dataType: DataTypes.String, + id: 'timestamp------false', + isColumn: false, + key: 'timestamp', + type: '', + }, + aggregateOperator: 'max', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'D', + filters: { + items: [ + { + id: '8899ca87', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + { + id: '5c4646fd', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [], + having: [], + legend: 'Last seen', + limit: null, + orderBy: [], + queryName: 'D', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'max', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: true, + start, + end, + step: 60, + }, + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TABLE, + query: { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: DataTypes.String, + id: 'span_id--string----true', + isColumn: true, + isJSON: false, + key: 'span_id', + type: '', + }, + aggregateOperator: 'count_distinct', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'A', + filters: { + items: [ + { + id: '23450eb8', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + { + id: 'e1b24204', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [ + { + dataType: DataTypes.String, + id: 'response_status_code--string----true', + isColumn: true, + isJSON: false, + key: 'response_status_code', + type: '', + }, + ], + having: [], + legend: 'number of calls', + limit: null, + orderBy: [], + queryName: 'A', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'count_distinct', + }, + { + aggregateAttribute: { + dataType: DataTypes.Float64, + id: 'duration_nano--float64----true', + isColumn: true, + isJSON: false, + key: 'duration_nano', + type: '', + }, + aggregateOperator: 'p99', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'B', + filters: { + items: [ + { + id: '2687dc18', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + { + id: '5dbe3518', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [ + { + dataType: DataTypes.String, + id: 'response_status_code--string----true', + isColumn: true, + isJSON: false, + key: 'response_status_code', + type: '', + }, + ], + having: [], + legend: 'p99 latency', + limit: null, + orderBy: [], + queryName: 'B', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'p99', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: true, + start, + end, + step: 60, + }, + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TIME_SERIES, + query: { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: DataTypes.String, + id: '------false', + isColumn: false, + key: '', + type: '', + }, + aggregateOperator: 'rate', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'B', + filters: { + items: [ + { + id: '3c76fe0b', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + { + id: '30710f04', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [ + { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + ], + having: [], + legend: '', + limit: null, + orderBy: [], + queryName: 'B', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'rate', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: false, + start, + end, + step: 60, + }, + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TIME_SERIES, + query: { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: DataTypes.Float64, + id: 'duration_nano--float64----true', + isColumn: true, + isJSON: false, + key: 'duration_nano', + type: '', + }, + aggregateOperator: 'p99', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'B', + filters: { + items: [ + { + id: '63adb3ff', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + { + id: '50142500', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [ + { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + ], + having: [], + legend: '', + limit: null, + orderBy: [], + queryName: 'B', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'p99', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: false, + start, + end, + step: 60, + }, + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TABLE, + query: { + builder: { + queryData: [ + { + dataSource: DataSource.TRACES, + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + dataType: DataTypes.String, + key: '', + isColumn: false, + type: '', + }, + timeAggregation: 'count', + spaceAggregation: 'sum', + functions: [], + filters: { + items: [ + { + id: '3db61dd6', + key: { + key: 'net.peer.name', + dataType: DataTypes.String, + type: 'tag', + isColumn: false, + isJSON: false, + }, + op: '=', + value: domainName, + }, + ...filters.items, + ], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + legend: '', + limit: null, + orderBy: [], + groupBy: [ + { + key: 'http.url', + dataType: DataTypes.String, + type: 'tag', + isColumn: false, + isJSON: false, + }, + ], + reduceTo: 'avg', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: true, + start, + end, + step: 60, + }, + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TABLE, + query: { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: DataTypes.String, + id: 'span_id--string----true', + isColumn: true, + isJSON: false, + key: 'span_id', + type: '', + }, + aggregateOperator: 'count', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'A', + filters: { + items: [ + { + id: 'a32988a4', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + { + id: '5a15032f', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [ + { + dataType: DataTypes.String, + id: 'service.name--string--resource--true', + isColumn: true, + isJSON: false, + key: 'service.name', + type: 'resource', + }, + ], + having: [], + legend: 'count', + limit: null, + orderBy: [], + queryName: 'A', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'count', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: true, + start, + end, + step: 60, + }, + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TIME_SERIES, + query: { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: DataTypes.String, + id: '------false', + isColumn: false, + key: '', + type: '', + }, + aggregateOperator: 'count', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'A', + filters: { + items: [ + { + id: 'c6724407', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + { + id: '8b1be6f0', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [ + { + dataType: DataTypes.String, + id: 'response_status_code--string----true', + isColumn: true, + isJSON: false, + key: 'response_status_code', + type: '', + }, + ], + having: [], + legend: '', + limit: null, + orderBy: [], + queryName: 'A', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'rate', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: false, + start, + end, + step: 60, + }, + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TIME_SERIES, + query: { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: DataTypes.Float64, + id: 'duration_nano--float64----true', + isColumn: true, + isJSON: false, + key: 'duration_nano', + type: '', + }, + aggregateOperator: 'p99', + dataSource: DataSource.TRACES, + disabled: false, + expression: 'A', + filters: { + items: [ + { + id: '52aca159', + key: { + dataType: DataTypes.String, + id: 'http.url--string--tag--false', + isColumn: false, + isJSON: false, + key: 'http.url', + type: 'tag', + }, + op: '=', + value: endPointName, + }, + { + id: 'aae93366', + key: { + dataType: DataTypes.String, + id: 'net.peer.name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'net.peer.name', + type: 'tag', + }, + op: '=', + value: domainName, + }, + ...filters.items, + ], + op: 'AND', + }, + functions: [], + groupBy: [ + { + dataType: DataTypes.String, + id: 'response_status_code--string----true', + isColumn: true, + isJSON: false, + key: 'response_status_code', + type: '', + }, + ], + having: [], + legend: '', + limit: null, + orderBy: [], + queryName: 'A', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'p99', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: false, + start, + end, + step: 60, + }, +]; + +export const getEndPointZeroStateQueryPayload = ( + domainName: string, + start: number, + end: number, +): GetQueryResultsProps[] => [ + { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TABLE, + query: { + builder: { + queryData: [ + { + dataSource: DataSource.TRACES, + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + dataType: DataTypes.String, + key: '', + isColumn: false, + type: '', + }, + timeAggregation: 'count', + spaceAggregation: 'sum', + functions: [], + filters: { + items: [ + { + id: '3db61dd6', + key: { + key: 'net.peer.name', + dataType: DataTypes.String, + type: 'tag', + isColumn: false, + isJSON: false, + }, + op: '=', + value: domainName, + }, + ], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + legend: '', + limit: null, + orderBy: [], + groupBy: [ + { + key: 'http.url', + dataType: DataTypes.String, + type: 'tag', + isColumn: false, + isJSON: false, + }, + ], + reduceTo: 'avg', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: true, + start, + end, + step: 60, + }, +]; + +interface EndPointMetricsResponseRow { + data: { + A: number; + B: number | string; + C: number; + D: number | string; + }; +} + +interface EndPointStatusCodeResponseRow { + data: { + response_status_code: string; + A: number; + B: number | string; + }; +} + +interface EndPointMetricsData { + key: string; + rate: number | string; + latency: number | string; + errorRate: number; + lastUsed: string; +} + +interface EndPointStatusCodeData { + key: string; + statusCode: string; + count: number; + p99Latency: number | string; +} + +export const getFormattedEndPointMetricsData = ( + data: EndPointMetricsResponseRow[], +): EndPointMetricsData => ({ + key: v4(), + rate: data[0].data.D === 'n/a' || !data[0].data.D ? '-' : data[0].data.A, + latency: + data[0].data.B === 'n/a' ? '-' : Math.round(Number(data[0].data.B) / 1000000), + errorRate: data[0].data.C, + lastUsed: + data[0].data.D === 'n/a' || !data[0].data.D + ? '-' + : getLastUsedRelativeTime(Math.floor(Number(data[0].data.D) / 1000000)), +}); + +export const getFormattedEndPointStatusCodeData = ( + data: EndPointStatusCodeResponseRow[], +): EndPointStatusCodeData[] => + data?.map((row) => ({ + key: v4(), + statusCode: row.data.response_status_code, + count: row.data.A, + p99Latency: + row.data.B === 'n/a' ? '-' : Math.round(Number(row.data.B) / 1000000), // Convert from nanoseconds to milliseconds, + })); + +export const endPointStatusCodeColumns: ColumnType[] = [ + { + title:
STATUS CODE
, + dataIndex: 'statusCode', + key: 'statusCode', + render: (text): JSX.Element => ( +
{text}
+ ), + }, + { + title: ( +
+ NUMBER OF CALLS +
+ ), + dataIndex: 'count', + key: 'count', + align: 'right', + }, + { + title: 'P99', + dataIndex: 'p99Latency', + key: 'p99Latency', + align: 'right', + }, +]; + +export const apiWidgetInfo = [ + { title: 'Rate over time', yAxisUnit: 'ops/s' }, + { title: 'Latency over time', yAxisUnit: 'ns' }, +]; + +export const statusCodeWidgetInfo = [ + { yAxisUnit: 'calls' }, + { yAxisUnit: 'ns' }, +]; + +interface EndPointDropDownResponseRow { + data: { + ['http.url']: string; + A: number; + }; +} + +interface EndPointDropDownData { + key: string; + label: string; + value: string; +} + +export const getFormattedEndPointDropDownData = ( + data: EndPointDropDownResponseRow[], +): EndPointDropDownData[] => + data?.map((row) => ({ + key: v4(), + label: row.data['http.url'], + value: row.data['http.url'], + })); + +interface DependentServicesResponseRow { + data: { + ['service.name']: string; + A: number; + }; +} + +interface DependentServicesData { + key: string; + serviceName: string; + count: number; + percentage: number; +} + +export const getFormattedDependentServicesData = ( + data: DependentServicesResponseRow[], +): DependentServicesData[] => { + if (!data) return []; + const totalCount = data?.reduce((acc, row) => acc + row.data.A, 0); + return data?.map((row) => ({ + key: v4(), + serviceName: row.data['service.name'], + count: row.data.A, + percentage: Number(((row.data.A / totalCount) * 100).toFixed(2)), + })); +}; + +export const getFormattedChartData = ( + data: MetricRangePayloadProps, + newLegendArray: string[], +): MetricRangePayloadProps => { + const result = cloneDeep(data); + if (result?.data?.result) { + result.data.result = result?.data?.result?.map((series, index) => ({ + ...series, + legend: newLegendArray[index], + })); + } + + return result; +}; + +const getStatusCodeClass = (statusCode: string): string => { + const code = parseInt(statusCode, 10); + + if (Number.isNaN(code)) { + return 'Other'; + } + + if (code >= 200 && code < 300) { + return '200-299'; + } + + if (code >= 300 && code < 400) { + return '300-399'; + } + + if (code >= 400 && code < 500) { + return '400-499'; + } + + if (code >= 500 && code < 600) { + return '500-599'; + } + + return 'Other'; +}; + +export const groupStatusCodes = ( + seriesList: QueryData[], + aggregationType: 'sum' | 'average' = 'sum', + // eslint-disable-next-line sonarjs/cognitive-complexity +): QueryData[] => { + if (!seriesList?.length) { + return seriesList; + } + + const result = cloneDeep(seriesList); + + // Group series by status code class + const groupedSeries: Record = {}; + + // Initialize timestamp map to track all timestamps across all series + const allTimestamps = new Set(); + + // First pass: collect all series and timestamps + result.forEach((series) => { + const statusCode = series.metric?.response_status_code; + if (!statusCode) return; + + const statusClass = getStatusCodeClass(statusCode); + + // Track all timestamps + series.values.forEach((value) => { + allTimestamps.add(value[0]); + }); + + // Initialize or update the grouped series + if (!groupedSeries[statusClass]) { + groupedSeries[statusClass] = { + metric: { + response_status_code: statusClass, + }, + values: [], + queryName: series.queryName, + legend: series.legend || statusClass, + }; + } + }); + + // Create a sorted array of all timestamps + const sortedTimestamps = Array.from(allTimestamps).sort((a, b) => a - b); + // Initialize values and counters for each timestamp with zeros for each group + const timestampValues: Record> = {}; + const timestampCounts: Record> = {}; + + Object.keys(groupedSeries).forEach((group) => { + timestampValues[group] = {}; + timestampCounts[group] = {}; + sortedTimestamps.forEach((timestamp) => { + timestampValues[group][timestamp] = 0; + timestampCounts[group][timestamp] = 0; + }); + }); + + // Second pass: aggregate values by status class and timestamp + result.forEach((series) => { + const statusCode = series.metric?.response_status_code; + if (!statusCode) return; + + const statusClass = getStatusCodeClass(statusCode); + + series.values.forEach((value) => { + const timestamp = value[0]; + const numValue = parseFloat(value[1]); + if (!Number.isNaN(numValue)) { + timestampValues[statusClass][timestamp] += numValue; + timestampCounts[statusClass][timestamp] += 1; + } + }); + }); + + // Convert aggregated values back to series format + Object.keys(groupedSeries).forEach((group) => { + groupedSeries[group].values = sortedTimestamps.map((timestamp) => { + let finalValue: number; + + if (aggregationType === 'average' && timestampCounts[group][timestamp] > 0) { + // Calculate average if aggregationType is average + finalValue = + timestampValues[group][timestamp] / timestampCounts[group][timestamp]; + } else { + // Otherwise, use the sum + finalValue = timestampValues[group][timestamp]; + } + + return [timestamp, finalValue.toString()]; + }); + }); + + return Object.values(groupedSeries); +}; +interface EndPointStatusCodePayloadData { + data: { + result: QueryData[]; + newResult: any; + resultType: string; + }; +} + +export const getFormattedEndPointStatusCodeChartData = ( + data: EndPointStatusCodePayloadData, + aggregationType: 'sum' | 'average' = 'sum', +): EndPointStatusCodePayloadData => { + if (!data) { + return data; + } + return { + data: { + result: groupStatusCodes(data?.data?.result, aggregationType), + newResult: data?.data?.newResult, + resultType: data?.data?.resultType, + }, + }; +}; + +export const END_POINT_DETAILS_QUERY_KEYS_ARRAY = [ + REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA, + REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA, + REACT_QUERY_KEY.GET_ENDPOINT_RATE_OVER_TIME_DATA, + REACT_QUERY_KEY.GET_ENDPOINT_LATENCY_OVER_TIME_DATA, + REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA, + REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA, + REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA, + REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA, +]; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index f28c714b47..d7065fcc8f 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -337,6 +337,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { routeKey === 'LOGS_PIPELINES' || routeKey === 'LOGS_SAVE_VIEWS'; + const isApiMonitoringView = (): boolean => routeKey === 'API_MONITORING'; + const isTracesView = (): boolean => routeKey === 'TRACES_EXPLORER' || routeKey === 'TRACES_SAVE_VIEWS'; @@ -658,7 +660,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { isAlertOverview() || isMessagingQueues() || isCloudIntegrationPage() || - isInfraMonitoring() + isInfraMonitoring() || + isApiMonitoringView() ? 0 : '0 1rem', diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx index 59fcb085d5..f85b41b209 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -87,6 +87,7 @@ interface QueryBuilderSearchV2Props { placeholder?: string; className?: string; suffixIcon?: React.ReactNode; + hardcodedAttributeKeys?: BaseAutocompleteData[]; } export interface Option { @@ -119,6 +120,7 @@ function QueryBuilderSearchV2( className, suffixIcon, whereClauseConfig, + hardcodedAttributeKeys, } = props; const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); @@ -233,7 +235,7 @@ function QueryBuilderSearchV2( }, { queryKey: [searchParams], - enabled: isQueryEnabled && !isLogsDataSource, + enabled: isQueryEnabled && !isLogsDataSource && !hardcodedAttributeKeys, }, ); @@ -674,6 +676,18 @@ function QueryBuilderSearchV2( value: key, })) || []), ]); + } else if (hardcodedAttributeKeys) { + const filteredKeys = hardcodedAttributeKeys.filter((key) => + key.key + .toLowerCase() + .includes((searchValue?.split(' ')[0] || '').toLowerCase()), + ); + setDropdownOptions( + filteredKeys.map((key) => ({ + label: key.key, + value: key, + })), + ); } else { setDropdownOptions( data?.payload?.attributeKeys?.map((key) => ({ @@ -752,6 +766,7 @@ function QueryBuilderSearchV2( ); } }, [ + hardcodedAttributeKeys, attributeValues?.payload, currentFilterItem?.key?.dataType, currentState, @@ -984,6 +999,7 @@ QueryBuilderSearchV2.defaultProps = { className: '', suffixIcon: null, whereClauseConfig: {}, + hardcodedAttributeKeys: undefined, }; export default QueryBuilderSearchV2; diff --git a/frontend/src/container/SideNav/menuItems.tsx b/frontend/src/container/SideNav/menuItems.tsx index 2c0d3bccba..f713b0a294 100644 --- a/frontend/src/container/SideNav/menuItems.tsx +++ b/frontend/src/container/SideNav/menuItems.tsx @@ -3,6 +3,7 @@ import ROUTES from 'constants/routes'; import { BarChart2, BellDot, + Binoculars, Boxes, BugIcon, Cloudy, @@ -123,6 +124,11 @@ const menuItems: SidebarItem[] = [ label: 'Messaging Queues', icon: , }, + { + key: ROUTES.API_MONITORING, + label: 'API Monitoring', + icon: , + }, { key: ROUTES.LIST_ALL_ALERT, label: 'Alerts', diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts index 15b4c70a81..9445840cbc 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts @@ -226,6 +226,7 @@ export const routesToSkip = [ ROUTES.METRICS_EXPLORER, ROUTES.METRICS_EXPLORER_EXPLORER, ROUTES.METRICS_EXPLORER_VIEWS, + ROUTES.API_MONITORING, ROUTES.CHANNELS_NEW, ROUTES.CHANNELS_EDIT, ROUTES.WORKSPACE_ACCESS_RESTRICTED, diff --git a/frontend/src/lib/dashboard/getQueryResults.ts b/frontend/src/lib/dashboard/getQueryResults.ts index 24792245dd..fb0324ba57 100644 --- a/frontend/src/lib/dashboard/getQueryResults.ts +++ b/frontend/src/lib/dashboard/getQueryResults.ts @@ -102,4 +102,5 @@ export interface GetQueryResultsProps { }; start?: number; end?: number; + step?: number; } diff --git a/frontend/src/pages/ApiMonitoring/ApiMonitoringPage.styles.scss b/frontend/src/pages/ApiMonitoring/ApiMonitoringPage.styles.scss new file mode 100644 index 0000000000..d07e6e436b --- /dev/null +++ b/frontend/src/pages/ApiMonitoring/ApiMonitoringPage.styles.scss @@ -0,0 +1,50 @@ +.api-monitoring-page { + flex: 1; + display: flex; + + .ant-tabs { + flex: 1; + } + + .ant-tabs-nav { + padding: 0 16px; + margin-bottom: 0px; + + &::before { + border-bottom: 1px solid var(--bg-slate-400) !important; + } + } + + .ant-tabs-content-holder { + display: flex; + + .ant-tabs-content { + flex: 1; + display: flex; + flex-direction: column; + + .ant-tabs-tabpane { + flex: 1; + display: flex; + flex-direction: column; + } + } + } + + .tab-item { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + } +} + +.lightMode { + .api-monitoring-page { + .ant-tabs-nav { + &::before { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } +} diff --git a/frontend/src/pages/ApiMonitoring/ApiMonitoringPage.tsx b/frontend/src/pages/ApiMonitoring/ApiMonitoringPage.tsx new file mode 100644 index 0000000000..0066b1e742 --- /dev/null +++ b/frontend/src/pages/ApiMonitoring/ApiMonitoringPage.tsx @@ -0,0 +1,22 @@ +import './ApiMonitoringPage.styles.scss'; + +import RouteTab from 'components/RouteTab'; +import { TabRoutes } from 'components/RouteTab/types'; +import history from 'lib/history'; +import { useLocation } from 'react-use'; + +import { Explorer } from './constants'; + +function ApiMonitoringPage(): JSX.Element { + const { pathname } = useLocation(); + + const routes: TabRoutes[] = [Explorer]; + + return ( +
+ +
+ ); +} + +export default ApiMonitoringPage; diff --git a/frontend/src/pages/ApiMonitoring/constants.tsx b/frontend/src/pages/ApiMonitoring/constants.tsx new file mode 100644 index 0000000000..0113217a25 --- /dev/null +++ b/frontend/src/pages/ApiMonitoring/constants.tsx @@ -0,0 +1,15 @@ +import { TabRoutes } from 'components/RouteTab/types'; +import ROUTES from 'constants/routes'; +import ExplorerPage from 'container/ApiMonitoring/Explorer/Explorer'; +import { Compass } from 'lucide-react'; + +export const Explorer: TabRoutes = { + Component: ExplorerPage, + name: ( +
+ Explorer +
+ ), + route: ROUTES.API_MONITORING, + key: ROUTES.API_MONITORING, +}; diff --git a/frontend/src/pages/ApiMonitoring/index.ts b/frontend/src/pages/ApiMonitoring/index.ts new file mode 100644 index 0000000000..bc1e40fc8a --- /dev/null +++ b/frontend/src/pages/ApiMonitoring/index.ts @@ -0,0 +1,3 @@ +import ApiMonitoring from './ApiMonitoringPage'; + +export default ApiMonitoring; diff --git a/frontend/src/types/api/widgets/getQuery.ts b/frontend/src/types/api/widgets/getQuery.ts index b7387d5c98..c7e4b43d23 100644 --- a/frontend/src/types/api/widgets/getQuery.ts +++ b/frontend/src/types/api/widgets/getQuery.ts @@ -20,6 +20,16 @@ export interface QueryData { values: [number, string][]; quantity?: number[]; unit?: string; + table?: { + rows: { + data: { + [key: string]: any; + }; + }[]; + columns: { + [key: string]: string; + }[]; + }; } export interface SeriesItem { diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index c6b289163f..46d6df9eba 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -117,6 +117,7 @@ export const routePermission: Record = { METRICS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'], METRICS_EXPLORER_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'], METRICS_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'], + API_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'], WORKSPACE_ACCESS_RESTRICTED: ['ADMIN', 'EDITOR', 'VIEWER'], METRICS_EXPLORER_BASE: ['ADMIN', 'EDITOR', 'VIEWER'], }; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index cfd14b8df3..faecc15b6d 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -11856,10 +11856,10 @@ lru-cache@^6.0.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== -lucide-react@0.379.0: - version "0.379.0" - resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.379.0.tgz#29e34eeffae7fb241b64b09868cbe3ab888ef7cc" - integrity sha512-KcdeVPqmhRldldAAgptb8FjIunM2x2Zy26ZBh1RsEUcdLIvsEmbcw7KpzFYUy5BbpGeWhPu9Z9J5YXfStiXwhg== +lucide-react@0.427.0: + version "0.427.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.427.0.tgz#e06974514bbd591049f9d736b3d3ae99d4ede8c9" + integrity sha512-lv9s6c5BDF/ccuA0EgTdskTxIe11qpwBDmzRZHJAKtp8LTewAvDvOM+pTES9IpbBuTqkjiMhOmGpJ/CB+mKjFw== lz-string@^1.4.4: version "1.5.0"