Compare commits

...

13 Commits

33 changed files with 4439 additions and 29 deletions

View File

@@ -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",
"overlayscrollbars": "^2.8.1",
"overlayscrollbars-react": "^0.5.6",

View File

@@ -273,3 +273,7 @@ export const MetricsExplorer = Loadable(
() =>
import(/* webpackChunkName: "MetricsExplorer" */ 'pages/MetricsExplorer'),
);
export const ApiMonitoring = Loadable(
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
);

View File

@@ -8,6 +8,7 @@ import {
AllAlertChannels,
AllErrors,
APIKeys,
ApiMonitoring,
BillingPage,
CreateAlertChannelAlerts,
CreateNewAlerts,
@@ -465,6 +466,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 = {

View File

@@ -63,30 +63,31 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<div className="quick-filters">
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">Filters for</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
</section>
{source !== QuickFiltersSource.INFRA_MONITORING &&
source !== QuickFiltersSource.API_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">Filters for</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
</section>
<section className="right-actions">
<Tooltip title="Reset All">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</Tooltip>
<div className="divider-filter" />
<Tooltip title="Collapse Filters">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</Tooltip>
<section className="right-actions">
<Tooltip title="Reset All">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</Tooltip>
<div className="divider-filter" />
<Tooltip title="Collapse Filters">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</Tooltip>
</section>
</section>
</section>
)}
)}
<section className="filters">
{config.map((filter) => {

View File

@@ -39,4 +39,5 @@ export enum QuickFiltersSource {
LOGS_EXPLORER = 'logs-explorer',
INFRA_MONITORING = 'infra-monitoring',
TRACES_EXPLORER = 'traces-explorer',
API_MONITORING = 'api-monitoring',
}

View File

@@ -69,6 +69,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',
} as const;
export default ROUTES;

View File

@@ -0,0 +1,218 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Select, Spin, Table, Typography } from 'antd';
import { ENTITY_VERSION_V4 } from 'constants/app';
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 { VIEW_TYPES, VIEWS } from './constants';
import ExpandedRow from './ExpandedRow';
function AllEndPoints({
domainName,
setSelectedEndPointName,
setSelectedView,
}: {
domainName: string;
setSelectedEndPointName: (name: string) => void;
setSelectedView: (tab: VIEWS) => void;
}): JSX.Element {
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys({
dataSource: DataSource.TRACES,
aggregateAttribute: '',
aggregateOperator: 'noop',
searchText: '',
tagType: '',
});
const [groupBy, setGroupBy] = useState<IBuilderQuery['groupBy']>([]);
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
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],
);
useEffect(() => {
if (groupByFiltersData?.payload) {
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
}
}, [groupByFiltersData]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const queryPayloads = useMemo(
() =>
getEndPointsQueryPayload(
groupBy,
domainName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
),
[groupBy, domainName, minTime, maxTime],
);
const endPointsDataQueries = useQueries(
queryPayloads.map((payload) => ({
queryKey: ['domain-all-endpoints', payload, ENTITY_VERSION_V4, groupBy],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
staleTime: 60 * 1000, // 1 minute stale time : optimize this part
})),
);
const endPointsDataQuery = endPointsDataQueries[0];
const endPointsColumnsConfig = useMemo(
() => getEndPointsColumnsConfig(groupBy.length > 0, expandedRowKeys),
[groupBy.length, expandedRowKeys],
);
const expandedRowRender = (record: EndPointsTableRowData): JSX.Element => (
<ExpandedRow
domainName={domainName}
selectedRowData={record}
setSelectedEndPointName={setSelectedEndPointName}
setSelectedView={setSelectedView}
/>
);
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(
endPointsDataQuery.data?.payload.data.result[0].table.rows,
groupBy,
),
[groupBy, endPointsDataQuery.data],
);
return (
<div className="all-endpoints-container">
<div className="group-by-container">
<div className="group-by-label"> Group by </div>
<Select
className="group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
style={{ width: '100%' }}
options={groupByOptions}
onChange={handleGroupByChange}
/>{' '}
</div>
<div className="endpoints-table-container">
<div className="endpoints-table-header">Endpoint overview</div>
<Table
columns={endPointsColumnsConfig}
loading={{
spinning: endPointsDataQuery.isFetching || endPointsDataQuery.isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
dataSource={
endPointsDataQuery.isFetching || endPointsDataQuery.isLoading
? []
: formattedEndPointsData
}
locale={{
emptyText:
endPointsDataQuery.isFetching || endPointsDataQuery.isLoading ? null : (
<div className="no-filtered-hosts-message-container">
<div className="no-filtered-hosts-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-hosts-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className: 'clickable-row',
})}
expandable={{
expandedRowRender: groupBy.length > 0 ? expandedRowRender : undefined,
expandedRowKeys,
expandIconColumnIndex: -1,
}}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
</div>
</div>
);
}
export default AllEndPoints;

View File

@@ -0,0 +1,175 @@
import './DomainDetails.styles.scss';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Divider, Drawer, Progress, Radio, Tooltip, Typography } from 'antd';
import { RadioChangeEvent } from 'antd/lib';
import { getLastUsedRelativeTime } from 'container/ApiMonitoring/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { X } from 'lucide-react';
import { useState } from 'react';
import AllEndPoints from './AllEndPoints';
import { VIEW_TYPES, VIEWS } from './constants';
import EndPointDetails from './EndPointDetails';
function DomainDetails({
domainData,
handleClose,
}: {
domainData: any;
handleClose: () => void;
}): JSX.Element {
const [selectedView, setSelectedView] = useState<VIEWS>(VIEWS.ALL_ENDPOINTS);
const [selectedEndPointName, setSelectedEndPointName] = useState<string>('');
const isDarkMode = useIsDarkMode();
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
};
return (
<Drawer
width="60%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{domainData.domainName}
</Typography.Text>
{/* add the navigation buttons for domain */}
</>
}
placement="right"
onClose={handleClose}
open={!!domainData}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="entity-detail-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{domainData && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
EXTERNAL API
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
AVERAGE LATENCY
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
ERROR RATE
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
LAST USED
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={domainData.endpointCount}>
<span className="round-metric-tag">{domainData.endpointCount}</span>
</Tooltip>
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={domainData.latency}>
<span className="round-metric-tag">
{(domainData.latency / 1000).toFixed(3)}s
</span>
</Tooltip>
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="entity-details-metadata-value error-rate">
<Tooltip title={domainData.errorRate}>
<Progress
percent={Number((domainData.errorRate * 100).toFixed(1))}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const errorRatePercent = Number(
(domainData.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"
/>
</Tooltip>
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={domainData.lastUsed}>
{getLastUsedRelativeTime(domainData.lastUsed)}
</Tooltip>
</Typography.Text>
</div>
</div>
</div>
<div className="views-tabs-container">
<Radio.Group
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.ALL_ENDPOINTS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.ALL_ENDPOINTS}
>
<div className="view-title">All Endpoints</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.ENDPOINT_DETAILS
? 'tab selected_view'
: 'tab'
}
value={VIEW_TYPES.ENDPOINT_DETAILS}
>
<div className="view-title">Endpoint Details</div>
</Radio.Button>
</Radio.Group>
</div>
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={setSelectedEndPointName}
setSelectedView={setSelectedView}
/>
)}
{selectedView === VIEW_TYPES.ENDPOINT_DETAILS && (
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={setSelectedEndPointName}
/>
)}
</>
)}
</Drawer>
);
}
export default DomainDetails;

View File

@@ -0,0 +1,126 @@
import { ENTITY_VERSION_V4 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { 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 DependentServices from './components/DependentServices';
import EndPointDetailsZeroState from './components/EndPointDetailsZeroState';
import EndPointMetrics from './components/EndPointMetrics';
import EndPointsDropDown from './components/EndPointsDropDown';
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
import StatusCodeTable from './components/StatusCodeTable';
function EndPointDetails({
domainName,
endPointName,
setSelectedEndPointName,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const currentQuery = initialQueriesMap[DataSource.TRACES];
const query = currentQuery?.builder?.queryData[0] || null;
const [filters, setFilters] = useState<IBuilderQuery['filters']>({
op: 'AND',
items: [],
});
const endPointDetailsQueryPayload = useMemo(
() =>
getEndPointDetailsQueryPayload(
domainName,
endPointName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
filters,
),
[domainName, endPointName, filters, minTime, maxTime],
);
const endPointDetailsDataQueries = useQueries(
endPointDetailsQueryPayload.map((payload, index) => ({
queryKey: [`domain-endpoints-details-${index}`, payload, ENTITY_VERSION_V4],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
})),
);
const [
endPointMetricsDataQuery,
endPointStatusCodeDataQuery,
endPointRateOverTimeDataQuery,
endPointLatencyOverTimeDataQuery,
endPointDropDownDataQuery,
endPointDependentServicesDataQuery,
] = useMemo(
() => [
endPointDetailsDataQueries[0],
endPointDetailsDataQueries[1],
endPointDetailsDataQueries[2],
endPointDetailsDataQueries[3],
endPointDetailsDataQueries[4],
endPointDetailsDataQueries[5],
],
[endPointDetailsDataQueries],
);
if (endPointName === '') {
return (
<EndPointDetailsZeroState
endPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
/>
);
}
return (
<div className="endpoint-details-container">
<div className="endpoint-details-filters-container">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
/>
<div style={{ flex: 1 }}>
<QueryBuilderSearchV2
query={query}
onChange={(searchFilters): void => setFilters(searchFilters)}
placeholder="Search for filters..."
/>
</div>
</div>
<EndPointMetrics endPointMetricsDataQuery={endPointMetricsDataQuery} />
<DependentServices
dependentServicesQuery={endPointDependentServicesDataQuery}
/>
<StatusCodeTable endPointStatusCodeDataQuery={endPointStatusCodeDataQuery} />
<MetricOverTimeGraph
metricOverTimeDataQuery={endPointRateOverTimeDataQuery}
widgetInfoIndex={0}
/>
<MetricOverTimeGraph
metricOverTimeDataQuery={endPointLatencyOverTimeDataQuery}
widgetInfoIndex={1}
/>
</div>
);
}
export default EndPointDetails;

View File

@@ -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 {
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<AppState, GlobalReducer>(
(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: [
`domain-endpoints-nested-expanded-row-${domainName}-${selectedRowData?.key}`,
payload,
ENTITY_VERSION_V4,
'DOMAIN',
selectedRowData?.key,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload && !!selectedRowData,
}))
: [],
);
const groupedByRowQuery = groupedByRowQueries[0];
return (
<div className="expanded-table-container">
{groupedByRowQuery?.isFetching || groupedByRowQuery?.isLoading ? (
<LoadingContainer />
) : (
<div className="expanded-table">
<Table
columns={nestedColumns as ColumnType<EndPointsTableRowData>[]}
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: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
setSelectedEndPointName(record.endpointName);
setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS);
},
className: 'expanded-clickable-row',
})}
/>
</div>
)}
</div>
);
}
export default ExpandedRow;

View File

@@ -0,0 +1,77 @@
import { getFormattedDependentServicesData } from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
interface DependentServicesProps {
dependentServicesQuery: UseQueryResult<SuccessResponse<any>, unknown>;
}
// need to add a loading state
// discuss slice vs index based rendering
function DependentServices({
dependentServicesQuery,
}: DependentServicesProps): JSX.Element {
const { data } = dependentServicesQuery;
const [currentRenderCount, setCurrentRenderCount] = useState(0);
const dependentServicesData = useMemo(() => {
const formattedDependentServicesData = getFormattedDependentServicesData(
data?.payload?.data?.result[0].table.rows,
);
setCurrentRenderCount(Math.min(formattedDependentServicesData.length, 5));
return formattedDependentServicesData;
}, [data]);
const renderItems = useMemo(
() => dependentServicesData.slice(0, currentRenderCount),
[currentRenderCount, dependentServicesData],
);
return (
<div className="top-attributes-content">
<div className="top-attributes-title">
<span className="title-wrapper">Dependent Services</span>
</div>
<div className="dependent-services-container">
{renderItems.map((item) => (
<div className="top-attributes-item" key={item.key}>
<div className="top-attributes-item-progress">
<div className="top-attributes-item-key">{item.serviceName}</div>
<div className="top-attributes-item-count">{item.count}</div>
<div
className="top-attributes-item-progress-bar"
style={{ width: `${item.percentage}%` }}
/>
</div>
<div className="top-attributes-item-percentage">
{item.percentage.toFixed(2)}%
</div>
</div>
))}
{currentRenderCount < dependentServicesData.length && (
<div
className="top-attributes-load-more"
onClick={(): void => setCurrentRenderCount(dependentServicesData.length)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
setCurrentRenderCount(dependentServicesData.length);
}
}}
role="button"
tabIndex={0}
>
<UnfoldVertical size={14} />
Show more...
</div>
)}
</div>
</div>
);
}
export default DependentServices;

View File

@@ -0,0 +1,33 @@
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import EndPointsDropDown from './EndPointsDropDown';
function EndPointDetailsZeroState({
endPointName,
setSelectedEndPointName,
endPointDropDownDataQuery,
}: {
endPointName: string;
setSelectedEndPointName: (endPointName: string) => void;
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>>;
}): JSX.Element {
return (
<div className="end-point-details-zero-state-wrapper">
<div className="end-point-details-zero-state-icon">Icon</div>
<div className="end-point-details-zero-state-content-wrapper">
<div className="end-point-details-zero-state-content">
<div className="title">No endpoint selected yet</div>
<div className="description">Select an endpoint to see the details</div>
</div>
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
/>
</div>
</div>
);
}
export default EndPointDetailsZeroState;

View File

@@ -0,0 +1,109 @@
import { Color } from '@signozhq/design-tokens';
import { Progress, Skeleton, Tooltip, Typography } from 'antd';
import { getFormattedEndPointMetricsData } from 'container/ApiMonitoring/utils';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
function EndPointMetrics({
endPointMetricsDataQuery,
}: {
endPointMetricsDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
}): JSX.Element {
const { isLoading, isRefetching, isError, data } = endPointMetricsDataQuery;
const metricsData = useMemo(() => {
if (isLoading || isRefetching || isError) {
return null;
}
return getFormattedEndPointMetricsData(
data?.payload?.data?.result[0].table.rows,
);
}, [data?.payload?.data?.result, isLoading, isRefetching, isError]);
return (
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Rate
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
AVERAGE LATENCY
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
ERROR RATE
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
LAST USED
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="entity-details-metadata-value">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={metricsData?.rate}>
<span className="round-metric-tag">{metricsData?.rate}/sec</span>
</Tooltip>
)}
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={metricsData?.latency}>
<span className="round-metric-tag">{metricsData?.latency}ms</span>
</Tooltip>
)}
</Typography.Text>
<Typography.Text className="entity-details-metadata-value error-rate">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={metricsData?.errorRate}>
<Progress
percent={Number((metricsData?.errorRate ?? 0 * 100).toFixed(1))}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const errorRatePercent = Number(
(metricsData?.errorRate ?? 0 * 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"
/>
</Tooltip>
)}
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={metricsData?.lastUsed}>{metricsData?.lastUsed}</Tooltip>
)}
</Typography.Text>
</div>
</div>
</div>
);
}
export default EndPointMetrics;

View File

@@ -0,0 +1,40 @@
import { Select } from 'antd';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
function EndPointsDropDown({
selectedEndPointName,
setSelectedEndPointName,
endPointDropDownDataQuery,
}: {
selectedEndPointName: string;
setSelectedEndPointName: (value: string) => void;
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
}): JSX.Element {
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
const handleChange = (value: string): void => {
setSelectedEndPointName(value);
};
const formattedData = useMemo(
() =>
getFormattedEndPointDropDownData(data?.payload.data.result[0].table.rows),
[data?.payload.data.result],
);
return (
<Select
placeholder="Select endpoint"
loading={isLoading || isFetching}
defaultValue={selectedEndPointName || ''}
style={{ width: 120 }}
onChange={handleChange}
options={formattedData}
/>
);
}
export default EndPointsDropDown;

View File

@@ -0,0 +1,88 @@
import { Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { apiWidgetInfo } from 'container/ApiMonitoring/utils';
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';
function MetricOverTimeGraph({
metricOverTimeDataQuery,
widgetInfoIndex,
}: {
metricOverTimeDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
widgetInfoIndex: number;
}): JSX.Element {
const { data } = metricOverTimeDataQuery;
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const chartData = useMemo(() => getUPlotChartData(data?.payload), [
data?.payload,
]);
const options = useMemo(
() =>
getUPlotChartOptions({
apiResponse: data?.payload,
isDarkMode: true,
dimensions,
yAxisUnit: apiWidgetInfo[widgetInfoIndex].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: Math.floor(minTime / 1e9),
maxTimeScale: Math.floor(maxTime / 1e9),
panelType: PANEL_TYPES.TIME_SERIES,
}),
[data?.payload, minTime, maxTime, widgetInfoIndex, dimensions],
);
const renderCardContent = useCallback(
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
if (query.isLoading) {
return <Skeleton />;
}
if (query.error) {
const errorMessage =
(query.error as Error)?.message || 'Something went wrong';
return <div>{errorMessage}</div>;
}
return (
<div
className={cx('chart-container', {
'no-data-container':
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
<Uplot options={options as Options} data={chartData} />
</div>
);
},
[options, chartData],
);
return (
<div>
<Typography.Text>{apiWidgetInfo[widgetInfoIndex].title}</Typography.Text>
<Card bordered className="endpoint-details-card" ref={graphRef}>
{renderCardContent(metricOverTimeDataQuery)}
</Card>
</div>
);
}
export default MetricOverTimeGraph;

View File

@@ -0,0 +1,42 @@
import { Table } from 'antd';
import {
endPointStatusCodeColumns,
getFormattedEndPointStatusCodeData,
} from 'container/ApiMonitoring/utils';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
function StatusCodeTable({
endPointStatusCodeDataQuery,
}: {
endPointStatusCodeDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
}): JSX.Element {
const { isLoading, isRefetching, isError, data } = 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]);
return (
<div className="status-code-table-container">
<Table
loading={isLoading || isRefetching}
dataSource={statusCodeData || []}
columns={endPointStatusCodeColumns}
pagination={false}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
</div>
);
}
export default StatusCodeTable;

View File

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

View File

@@ -0,0 +1,148 @@
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 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 [selectedDomainData, setSelectedDomainData] = useState<any>(null);
// const [selectedDomainIndex, setSelectedDomainIndex] = useState<
// number | undefined
// >(undefined);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const fetchApiOverview = async (): Promise<
SuccessResponse<any> | 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(
['apiOverview', minTime, maxTime, query, showIP],
fetchApiOverview,
);
const formattedDataForTable = useMemo(
() => formatDataForTable(data?.payload?.data?.result[0]?.table?.rows),
[data],
);
return (
<section className={cx('api-module-right-section')}>
<div className={cx('api-monitoring-list-header')}>
<QueryBuilderSearchV2
query={query}
onChange={(searchFilters): void =>
handleChangeQueryData('filters', searchFilters)
}
placeholder="Search filters..."
hardcodedAttributeKeys={hardcodedAttributeKeys}
/>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
<Table
className={cx('api-monitoring-domain-list-table')}
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText:
isFetching || isLoading ? null : (
<div className="no-filtered-domains-message-container">
<div className="no-filtered-domains-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-domains-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
// setSelectedDomainIndex(index);
setSelectedDomainData(record); // TODO: update the selected domain data based on index later to support navigation
},
className: 'expanded-clickable-row',
})}
/>
{selectedDomainData && (
<DomainDetails
domainData={selectedDomainData}
handleClose={(): void => {
setSelectedDomainData(null);
// setSelectedDomainIndex(undefined);
}}
/>
)}
</section>
);
}
export default DomainList;

View File

@@ -0,0 +1,199 @@
.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;
background: var(--bg-ink-500);
border-bottom: none;
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
/* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
background-color: transparent;
}
}
.ant-table-thead > tr > th:has(.domain-list-name-col-header) {
background: var(--bg-ink-400);
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--bg-vanilla-100);
background: var(--bg-ink-500);
border-bottom: none;
}
.ant-table-cell:has(.domain-list-name-col-value) {
background: var(--bg-ink-400);
}
.hostname-column-value {
color: var(--bg-vanilla-100);
font-family: 'Geist Mono';
font-style: normal;
font-weight: 600;
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
.status-cell {
.active-tag {
color: var(--bg-forest-500);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
}
.progress-container {
.ant-progress-bg {
height: 8px !important;
border-radius: 4px;
}
}
.progress-bar {
flex: 1;
margin-right: 8px;
}
.round-metric-tag {
display: inline-flex;
padding: 2px 8px;
align-items: center;
gap: 6px;
width: fit-content;
border-radius: 50px;
border: 1px solid var(--Slate-400, #1d212d);
background: var(--Slate-500, #161922);
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;
}
}
}
&.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;
}
}

View File

@@ -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<boolean>(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 (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<section className={cx('api-quick-filter-left-section')}>
<div className={cx('api-quick-filters-header')}>
<FilterOutlined />
<Typography.Text>Filters</Typography.Text>
</div>
<div className={cx('api-quick-filters-header')}>
<Typography.Text>Show IP addresses</Typography.Text>
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={showIP}
onClick={(): void => {
setShowIP((showIP) => !showIP);
}}
/>
</div>
<QuickFilters
source={QuickFiltersSource.API_MONITORING}
config={ApiMonitoringQuickFiltersConfig}
handleFilterVisibilityChange={(): void => {}}
onFilterChange={(query: Query): void =>
handleChangeQueryData('filters', query.builder.queryData[0].filters)
}
/>
</section>
<DomainList
query={query}
showIP={showIP}
handleChangeQueryData={handleChangeQueryData}
/>
</div>
</Sentry.ErrorBoundary>
);
}
export default Explorer;

File diff suppressed because it is too large Load Diff

View File

@@ -307,6 +307,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';
@@ -556,7 +558,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isAlertOverview() ||
isMessagingQueues() ||
isCloudIntegrationPage() ||
isInfraMonitoring()
isInfraMonitoring() ||
isApiMonitoringView()
? 0
: '0 1rem',

View File

@@ -86,6 +86,7 @@ interface QueryBuilderSearchV2Props {
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
hardcodedAttributeKeys?: BaseAutocompleteData[];
}
export interface Option {
@@ -118,6 +119,7 @@ function QueryBuilderSearchV2(
className,
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
} = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -232,7 +234,7 @@ function QueryBuilderSearchV2(
},
{
queryKey: [searchParams],
enabled: isQueryEnabled && !isLogsDataSource,
enabled: isQueryEnabled && !isLogsDataSource && !hardcodedAttributeKeys,
},
);
@@ -673,6 +675,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) => ({
@@ -749,6 +763,7 @@ function QueryBuilderSearchV2(
);
}
}, [
hardcodedAttributeKeys,
attributeValues?.payload,
currentFilterItem?.key?.dataType,
currentState,
@@ -981,6 +996,7 @@ QueryBuilderSearchV2.defaultProps = {
className: '',
suffixIcon: null,
whereClauseConfig: {},
hardcodedAttributeKeys: undefined,
};
export default QueryBuilderSearchV2;

View File

@@ -3,6 +3,7 @@ import ROUTES from 'constants/routes';
import {
BarChart2,
BellDot,
Binoculars,
Boxes,
BugIcon,
Cloudy,
@@ -111,6 +112,11 @@ const menuItems: SidebarItem[] = [
label: 'Messaging Queues',
icon: <ListMinus size={16} />,
},
{
key: ROUTES.API_MONITORING,
label: 'API Monitoring',
icon: <Binoculars size={16} />,
},
{
key: ROUTES.LIST_ALL_ALERT,
label: 'Alerts',

View File

@@ -223,6 +223,7 @@ export const routesToSkip = [
ROUTES.METRICS_EXPLORER,
ROUTES.METRICS_EXPLORER_EXPLORER,
ROUTES.METRICS_EXPLORER_VIEWS,
ROUTES.API_MONITORING,
ROUTES.CHANNELS_NEW,
ROUTES.CHANNELS_EDIT,
];

View File

@@ -102,4 +102,5 @@ export interface GetQueryResultsProps {
};
start?: number;
end?: number;
step?: number;
}

View File

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

View File

@@ -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 (
<div className="api-monitoring-page">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
);
}
export default ApiMonitoringPage;

View File

@@ -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: (
<div className="tab-item">
<Compass size={16} /> Explorer
</div>
),
route: ROUTES.API_MONITORING,
key: ROUTES.API_MONITORING,
};

View File

@@ -0,0 +1,3 @@
import ApiMonitoring from './ApiMonitoringPage';
export default ApiMonitoring;

View File

@@ -114,4 +114,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
METRICS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
API_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -11111,10 +11111,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"