mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-14 04:11:19 +00:00
Compare commits
31 Commits
feat/input
...
feat/celer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc6a230c0 | ||
|
|
9f0e0b7df4 | ||
|
|
877cc87b40 | ||
|
|
be60e30446 | ||
|
|
e6d1eea990 | ||
|
|
72c66ba84b | ||
|
|
75b6be358a | ||
|
|
16eb36aeed | ||
|
|
4edc3d37ca | ||
|
|
f041a2e0a5 | ||
|
|
9c31b3c90f | ||
|
|
d8b91c12ae | ||
|
|
a34be3f63f | ||
|
|
97e06f658e | ||
|
|
4ab56c4f2a | ||
|
|
86856c6714 | ||
|
|
dd880f695f | ||
|
|
5cb1f33525 | ||
|
|
3851339e6c | ||
|
|
a4dde27546 | ||
|
|
0402254209 | ||
|
|
4225452554 | ||
|
|
50dfec7247 | ||
|
|
ce1117eaa0 | ||
|
|
2eb46f19fb | ||
|
|
e37e94af53 | ||
|
|
f060e262d2 | ||
|
|
27a11ada95 | ||
|
|
a4d83ea2fd | ||
|
|
2e4d47a124 | ||
|
|
5a62bcd85c |
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"metricGraphCategory": {
|
||||
"brokerMetrics": {
|
||||
"title": "Broker Metrics",
|
||||
"description": "The Kafka Broker metrics here inform you of data loss/delay through unclean leader elections and network throughputs, as well as request fails through request purgatories and timeouts metrics"
|
||||
},
|
||||
"consumerMetrics": {
|
||||
"title": "Consumer Metrics",
|
||||
"description": "Kafka Consumer metrics provide insights into lag between message production and consumption, success rates and latency of message delivery, and the volume of data consumed."
|
||||
},
|
||||
"producerMetrics": {
|
||||
"title": "Producer Metrics",
|
||||
"description": "Kafka Producers send messages to brokers for storage and distribution by topic. These metrics inform you of the volume and rate of data sent, and the success rate of message delivery."
|
||||
},
|
||||
"brokerJVMMetrics": {
|
||||
"title": "Broker JVM Metrics",
|
||||
"description": "Kafka brokers are Java applications that expose JVM metrics to inform on the broker's system health. Garbage collection metrics like those below provide key insights into free memory, broker performance, and heap size. You need to enable new_gc_metrics for this section to populate."
|
||||
},
|
||||
"partitionMetrics": {
|
||||
"title": "Partition Metrics",
|
||||
"description": "Kafka partitions are the unit of parallelism in Kafka. These metrics inform you of the number of partitions per topic, the current offset of each partition, the oldest offset, and the number of in-sync replicas."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"metricGraphCategory": {
|
||||
"brokerMetrics": {
|
||||
"title": "Broker Metrics",
|
||||
"description": "The Kafka Broker metrics here inform you of data loss/delay through unclean leader elections and network throughputs, as well as request fails through request purgatories and timeouts metrics"
|
||||
},
|
||||
"consumerMetrics": {
|
||||
"title": "Consumer Metrics",
|
||||
"description": "Kafka Consumer metrics provide insights into lag between message production and consumption, success rates and latency of message delivery, and the volume of data consumed."
|
||||
},
|
||||
"producerMetrics": {
|
||||
"title": "Producer Metrics",
|
||||
"description": "Kafka Producers send messages to brokers for storage and distribution by topic. These metrics inform you of the volume and rate of data sent, and the success rate of message delivery."
|
||||
},
|
||||
"brokerJVMMetrics": {
|
||||
"title": "Broker JVM Metrics",
|
||||
"description": "Kafka brokers are Java applications that expose JVM metrics to inform on the broker's system health. Garbage collection metrics like those below provide key insights into free memory, broker performance, and heap size. You need to enable new_gc_metrics for this section to populate."
|
||||
},
|
||||
"partitionMetrics": {
|
||||
"title": "Partition Metrics",
|
||||
"description": "Kafka partitions are the unit of parallelism in Kafka. These metrics inform you of the number of partitions per topic, the current offset of each partition, the oldest offset, and the number of in-sync replicas."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const ServicesTablePage = Loadable(
|
||||
export const ServiceMetricsPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "ServiceMetricsPage" */ 'pages/MetricsApplication/MetricsApplication'
|
||||
/* webpackChunkName: "ServiceMetricsPage" */ 'pages/ServiceDetails/ServiceDetails'
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ApiV4Instance } from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { MetricMetaProps } from 'types/api/metrics/getApDex';
|
||||
|
||||
export const getMetricMeta = (
|
||||
metricName: string,
|
||||
servicename: string,
|
||||
): Promise<AxiosResponse<MetricMetaProps>> =>
|
||||
ApiV4Instance.get(
|
||||
`/metric/metric_metadata?metricName=${metricName}&serviceName=${servicename}`,
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ApiV2Instance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/metrics/getTopOperations';
|
||||
|
||||
const getTopOperations = async (props: Props): Promise<PayloadProps> => {
|
||||
try {
|
||||
const endpoint = props.isEntryPoint
|
||||
? '/service/entry_point_operations'
|
||||
: '/service/top_operations';
|
||||
|
||||
const response = await ApiV2Instance.post(endpoint, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
service: props.service,
|
||||
tags: props.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getTopOperations;
|
||||
@@ -1,26 +0,0 @@
|
||||
.celery-task-filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
.celery-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.config-select-option {
|
||||
width: 100%;
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 164px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Select, Spin } from 'antd';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SelectMaxTagPlaceholder } from 'components/MessagingQueues/MQCommon/MQCommon';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import {
|
||||
getValuesFromQueryParams,
|
||||
setQueryParamsFromOptions,
|
||||
} from '../CeleryUtils';
|
||||
import { useCeleryFilterOptions } from '../useCeleryFilterOptions';
|
||||
|
||||
import './CeleryTaskConfigOptions.styles.scss';
|
||||
|
||||
function CeleryTaskConfigOptions(): JSX.Element {
|
||||
const { handleSearch, isFetching, options } =
|
||||
useCeleryFilterOptions('celery.task_name');
|
||||
const location = useAppLocation();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return (
|
||||
<div className="celery-task-filters">
|
||||
<div className="celery-filters">
|
||||
<Typography.Text style={{ whiteSpace: 'nowrap' }}>
|
||||
Task Name
|
||||
</Typography.Text>
|
||||
<Select
|
||||
data-testid="celery-task-name-select"
|
||||
placeholder="Task Name"
|
||||
showSearch
|
||||
mode="multiple"
|
||||
options={options}
|
||||
loading={isFetching}
|
||||
className="config-select-option"
|
||||
onSearch={handleSearch}
|
||||
maxTagCount={4}
|
||||
maxTagPlaceholder={SelectMaxTagPlaceholder}
|
||||
value={getValuesFromQueryParams(QueryParams.taskName, urlQuery) || []}
|
||||
notFoundContent={
|
||||
isFetching ? (
|
||||
<span>
|
||||
<Spin size="small" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>No Task Name found</span>
|
||||
)
|
||||
}
|
||||
onChange={(value): void => {
|
||||
handleSearch('');
|
||||
setQueryParamsFromOptions(
|
||||
value,
|
||||
urlQuery,
|
||||
navigate,
|
||||
location,
|
||||
QueryParams.taskName,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CeleryTaskConfigOptions;
|
||||
@@ -1,74 +0,0 @@
|
||||
.celery-task-detail-drawer {
|
||||
.ant-drawer-wrapper-body {
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 0px;
|
||||
|
||||
.ant-card {
|
||||
border: none;
|
||||
.ant-card-body {
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
.ant-table {
|
||||
background: var(--l1-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
.ant-drawer-header-title {
|
||||
.ant-drawer-close {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
button > svg {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.45px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-footer {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
|
||||
.footer-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Drawer } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import CeleryTaskGraph from '../CeleryTaskGraph/CeleryTaskGraph';
|
||||
import { createFiltersFromData } from '../CeleryUtils';
|
||||
import { useNavigateToExplorer } from '../useNavigateToExplorer';
|
||||
|
||||
import './CeleryTaskDetail.style.scss';
|
||||
|
||||
export type CeleryTaskData = {
|
||||
entity: string;
|
||||
value: string | number;
|
||||
timeRange: [number, number];
|
||||
};
|
||||
|
||||
export interface CaptureDataProps extends CeleryTaskData {
|
||||
widgetData: Widgets;
|
||||
}
|
||||
|
||||
export type CeleryTaskDetailProps = {
|
||||
onClose: () => void;
|
||||
widgetData: Widgets;
|
||||
taskData: CeleryTaskData;
|
||||
drawerOpen: boolean;
|
||||
};
|
||||
|
||||
export default function CeleryTaskDetail({
|
||||
widgetData,
|
||||
taskData,
|
||||
onClose,
|
||||
drawerOpen,
|
||||
}: CeleryTaskDetailProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const shouldShowDrawer =
|
||||
!!taskData.entity && !!taskData.timeRange[0] && drawerOpen;
|
||||
|
||||
const formatTimestamp = (timestamp: number): string =>
|
||||
dayjs(timestamp).format('DD-MM-YYYY hh:mm A');
|
||||
|
||||
const [totalTask, setTotalTask] = useState(0);
|
||||
|
||||
const getGraphData = (graphData?: MetricRangePayloadProps['data']): void => {
|
||||
setTotalTask((graphData?.result?.[0] as any)?.table?.rows.length);
|
||||
};
|
||||
|
||||
const startTime = taskData.timeRange[0];
|
||||
const endTime = taskData.timeRange[1];
|
||||
|
||||
const navigateToExplorer = useNavigateToExplorer();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="45%"
|
||||
title={
|
||||
<div>
|
||||
<Typography.Text className="title">{`Details - ${taskData.entity}`}</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text className="subtitle">
|
||||
{`${formatTimestamp(startTime)} ${
|
||||
endTime ? `- ${formatTimestamp(endTime)}` : ''
|
||||
}`}
|
||||
</Typography.Text>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text className="subtitle">{taskData.value}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={shouldShowDrawer}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
|
||||
}}
|
||||
className="celery-task-detail-drawer"
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
footer={
|
||||
<Typography.Text className="footer-text">{`Total Task: ${totalTask}`}</Typography.Text>
|
||||
}
|
||||
>
|
||||
<CeleryTaskGraph
|
||||
widgetData={widgetData}
|
||||
getGraphData={getGraphData}
|
||||
panelType={PANEL_TYPES.TABLE}
|
||||
queryEnabled
|
||||
openTracesButton
|
||||
onOpenTraceBtnClick={(rowData): void => {
|
||||
const filters = createFiltersFromData({
|
||||
...rowData,
|
||||
[taskData.entity]: taskData.value,
|
||||
});
|
||||
logEvent('MQ Celery: navigation to trace page', {
|
||||
filters,
|
||||
startTime,
|
||||
endTime,
|
||||
source: widgetData.title,
|
||||
});
|
||||
navigateToExplorer({
|
||||
filters,
|
||||
dataSource: DataSource.TRACES,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
}}
|
||||
start={startTime}
|
||||
end={endTime}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ViewMenuAction } from 'container/WidgetCard/config';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import { Card } from 'container/WidgetCard/styles';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { CaptureDataProps } from '../CeleryTaskDetail/CeleryTaskDetail';
|
||||
import {
|
||||
applyCeleryFilterOnWidgetData,
|
||||
getFiltersFromQueryParams,
|
||||
} from '../CeleryUtils';
|
||||
import { useGetGraphCustomSeries } from '../useGetGraphCustomSeries';
|
||||
import {
|
||||
celeryAllStateWidgetData,
|
||||
celeryFailedStateWidgetData,
|
||||
celeryFailedTasksTableWidgetData,
|
||||
celeryRetryStateWidgetData,
|
||||
celeryRetryTasksTableWidgetData,
|
||||
celerySlowestTasksTableWidgetData,
|
||||
celerySuccessStateWidgetData,
|
||||
celerySuccessTasksTableWidgetData,
|
||||
} from './CeleryTaskGraphUtils';
|
||||
import {
|
||||
CeleryTaskState,
|
||||
CeleryTaskStateGraphConfig,
|
||||
} from './CeleryTaskStateGraphConfig';
|
||||
|
||||
import './CeleryTaskGraph.style.scss';
|
||||
|
||||
function CeleryTaskBar({
|
||||
onClick,
|
||||
queryEnabled,
|
||||
checkIfDataExists,
|
||||
}: {
|
||||
onClick?: (task: CaptureDataProps) => void;
|
||||
|
||||
queryEnabled: boolean;
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const [barState, setBarState] = useState<CeleryTaskState>(CeleryTaskState.All);
|
||||
|
||||
const selectedFilters = useMemo(
|
||||
() =>
|
||||
getFiltersFromQueryParams(
|
||||
QueryParams.taskName,
|
||||
urlQuery,
|
||||
'celery.task_name',
|
||||
),
|
||||
[urlQuery],
|
||||
);
|
||||
|
||||
const celeryAllStateData = useMemo(
|
||||
() => celeryAllStateWidgetData(minTime, maxTime),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const celeryAllStateFilteredData = useMemo(
|
||||
() =>
|
||||
applyCeleryFilterOnWidgetData(selectedFilters || [], celeryAllStateData),
|
||||
[selectedFilters, celeryAllStateData],
|
||||
);
|
||||
|
||||
const celeryFailedStateData = useMemo(
|
||||
() => celeryFailedStateWidgetData(minTime, maxTime),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const celeryFailedStateFilteredData = useMemo(
|
||||
() =>
|
||||
applyCeleryFilterOnWidgetData(selectedFilters || [], celeryFailedStateData),
|
||||
[selectedFilters, celeryFailedStateData],
|
||||
);
|
||||
|
||||
const celeryRetryStateData = useMemo(
|
||||
() => celeryRetryStateWidgetData(minTime, maxTime),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const celeryRetryStateFilteredData = useMemo(
|
||||
() =>
|
||||
applyCeleryFilterOnWidgetData(selectedFilters || [], celeryRetryStateData),
|
||||
[selectedFilters, celeryRetryStateData],
|
||||
);
|
||||
|
||||
const celerySuccessStateData = useMemo(
|
||||
() => celerySuccessStateWidgetData(minTime, maxTime),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const celerySuccessStateFilteredData = useMemo(
|
||||
() =>
|
||||
applyCeleryFilterOnWidgetData(selectedFilters || [], celerySuccessStateData),
|
||||
[selectedFilters, celerySuccessStateData],
|
||||
);
|
||||
|
||||
const onGraphClick = (
|
||||
widgetData: Widgets,
|
||||
xValue: number,
|
||||
_yValue: number,
|
||||
_mouseX: number,
|
||||
_mouseY: number,
|
||||
data?: {
|
||||
[key: string]: string;
|
||||
},
|
||||
): void => {
|
||||
const { start, end } = getStartAndEndTimesInMilliseconds(xValue);
|
||||
|
||||
// Extract entity and value from data
|
||||
const [firstDataPoint] = Object.entries(data || {});
|
||||
const [entity, value] = (firstDataPoint || ([] as unknown)) as [
|
||||
string,
|
||||
string,
|
||||
];
|
||||
|
||||
if (!isEmpty(entity) || !isEmpty(value)) {
|
||||
onClick?.({
|
||||
entity,
|
||||
value,
|
||||
timeRange: [start, end],
|
||||
widgetData,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { getCustomSeries } = useGetGraphCustomSeries({
|
||||
isDarkMode,
|
||||
drawStyle: 'bars',
|
||||
colorMapping: {
|
||||
SUCCESS: Color.BG_FOREST_500,
|
||||
FAILURE: Color.BG_CHERRY_500,
|
||||
RETRY: Color.BG_AMBER_400,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={PANEL_TYPES.BAR}
|
||||
className="celery-task-graph-bar"
|
||||
>
|
||||
<CeleryTaskStateGraphConfig barState={barState} setBarState={setBarState} />
|
||||
<div className="celery-task-graph-grid-content">
|
||||
{barState === CeleryTaskState.All && (
|
||||
<GridCard
|
||||
widget={celeryAllStateFilteredData}
|
||||
headerMenuList={[...ViewMenuAction]}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={queryEnabled}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
|
||||
onGraphClick(
|
||||
celerySlowestTasksTableWidgetData,
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
data,
|
||||
)
|
||||
}
|
||||
customSeries={getCustomSeries}
|
||||
dataAvailable={checkIfDataExists}
|
||||
/>
|
||||
)}
|
||||
{barState === CeleryTaskState.Failed && (
|
||||
<GridCard
|
||||
widget={celeryFailedStateFilteredData}
|
||||
headerMenuList={[...ViewMenuAction]}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={queryEnabled}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
|
||||
onGraphClick(
|
||||
celeryFailedTasksTableWidgetData,
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
data,
|
||||
)
|
||||
}
|
||||
customSeries={getCustomSeries}
|
||||
/>
|
||||
)}
|
||||
{barState === CeleryTaskState.Retry && (
|
||||
<GridCard
|
||||
widget={celeryRetryStateFilteredData}
|
||||
headerMenuList={[...ViewMenuAction]}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={queryEnabled}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
|
||||
onGraphClick(
|
||||
celeryRetryTasksTableWidgetData,
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
data,
|
||||
)
|
||||
}
|
||||
customSeries={getCustomSeries}
|
||||
/>
|
||||
)}
|
||||
{barState === CeleryTaskState.Successful && (
|
||||
<GridCard
|
||||
widget={celerySuccessStateFilteredData}
|
||||
headerMenuList={[...ViewMenuAction]}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={queryEnabled}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void =>
|
||||
onGraphClick(
|
||||
celerySuccessTasksTableWidgetData,
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
data,
|
||||
)
|
||||
}
|
||||
customSeries={getCustomSeries}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
CeleryTaskBar.defaultProps = {
|
||||
onClick: (): void => {},
|
||||
checkIfDataExists: undefined,
|
||||
};
|
||||
|
||||
export default CeleryTaskBar;
|
||||
@@ -1,243 +0,0 @@
|
||||
.celery-task-graph-grid-container {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 10px;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.celery-task-graph-bar,
|
||||
.celery-task-graph-task-latency {
|
||||
height: 380px !important;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.celery-task-graph-grid-content {
|
||||
padding: 6px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: calc(100% - 18px);
|
||||
|
||||
.widget-graph-component-container {
|
||||
.widget-graph-container {
|
||||
&.bar-panel-container {
|
||||
height: calc(100% - 110px);
|
||||
}
|
||||
|
||||
&.graph-panel-container {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.celery-task-graph-worker-count {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.ant-card-body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.worker-count-text-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.celery-task-graph-worker-count-text {
|
||||
font-size: 2.5vw;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.worker-count-header {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.celery-task-graph {
|
||||
height: 380px !important;
|
||||
padding: 6px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.ant-card-body {
|
||||
height: calc(100% - 18px);
|
||||
|
||||
.widget-graph-component-container {
|
||||
.widget-graph-container {
|
||||
&.bar-panel-container {
|
||||
height: calc(100% - 110px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.celery-task-graph-grid-bottom {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
.celery-task-graph {
|
||||
height: 380px !important;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.ant-card-body {
|
||||
height: calc(100% - 18px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric-based-graphs,
|
||||
.trace-based-graphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.metric-page-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
.celery-task-graph {
|
||||
height: 280px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trace-based-graphs {
|
||||
.trace-based-graphs-header {
|
||||
display: grid;
|
||||
grid-template-columns: 50% 50%;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.configure-option-Info {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
border: 1px dashed var(--l1-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 24px 6px 12px;
|
||||
width: max-content;
|
||||
|
||||
.configure-option-Info-text {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.row-panel {
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--card) 40%, transparent);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
height: 48px !important;
|
||||
width: 100%;
|
||||
|
||||
.ant-typography {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row-panel-section {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
.row-icon {
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.celery-task-states {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
&__tab {
|
||||
min-width: 140px;
|
||||
padding: 12px 13px 12px 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:not([data-last-tab='true']) {
|
||||
border-right: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__label-wrapper {
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
color: var(--l2-foreground);
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 24px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ViewMenuAction } from 'container/WidgetCard/config';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import { Card } from 'container/WidgetCard/styles';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { CaptureDataProps } from '../CeleryTaskDetail/CeleryTaskDetail';
|
||||
import {
|
||||
applyCeleryFilterOnWidgetData,
|
||||
getFiltersFromQueryParams,
|
||||
} from '../CeleryUtils';
|
||||
import { celeryTimeSeriesTablesWidgetData } from './CeleryTaskGraphUtils';
|
||||
|
||||
function CeleryTaskGraph({
|
||||
widgetData,
|
||||
onClick,
|
||||
getGraphData,
|
||||
queryEnabled,
|
||||
rightPanelTitle,
|
||||
panelType,
|
||||
openTracesButton,
|
||||
onOpenTraceBtnClick,
|
||||
applyCeleryTaskFilter,
|
||||
customErrorMessage,
|
||||
start,
|
||||
end,
|
||||
checkIfDataExists,
|
||||
analyticsEvent,
|
||||
}: {
|
||||
widgetData: Widgets;
|
||||
onClick?: (task: CaptureDataProps) => void;
|
||||
getGraphData?: (graphData?: MetricRangePayloadProps['data']) => void;
|
||||
queryEnabled: boolean;
|
||||
rightPanelTitle?: string;
|
||||
panelType?: PANEL_TYPES;
|
||||
openTracesButton?: boolean;
|
||||
onOpenTraceBtnClick?: (record: RowData) => void;
|
||||
applyCeleryTaskFilter?: boolean;
|
||||
customErrorMessage?: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
analyticsEvent?: string;
|
||||
}): JSX.Element {
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const selectedFilters = useMemo(
|
||||
() =>
|
||||
getFiltersFromQueryParams(
|
||||
QueryParams.taskName,
|
||||
urlQuery,
|
||||
'celery.task_name',
|
||||
),
|
||||
[urlQuery],
|
||||
);
|
||||
|
||||
const updatedWidgetData = useMemo(
|
||||
() => applyCeleryFilterOnWidgetData(selectedFilters || [], widgetData),
|
||||
[selectedFilters, widgetData],
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={PANEL_TYPES.TIME_SERIES || panelType}
|
||||
className="celery-task-graph"
|
||||
>
|
||||
<GridCard
|
||||
widget={applyCeleryTaskFilter ? updatedWidgetData : widgetData}
|
||||
headerMenuList={[...ViewMenuAction]}
|
||||
onDragSelect={onDragSelect}
|
||||
onClickHandler={(xValue, _yValue, _mouseX, _mouseY, data): void => {
|
||||
const { start, end } = getStartAndEndTimesInMilliseconds(xValue);
|
||||
|
||||
// Extract entity and value from data
|
||||
const [firstDataPoint] = Object.entries(data || {});
|
||||
const [entity, value] = firstDataPoint || [];
|
||||
|
||||
const widgetData = celeryTimeSeriesTablesWidgetData(
|
||||
entity,
|
||||
value,
|
||||
rightPanelTitle || '',
|
||||
);
|
||||
|
||||
onClick?.({
|
||||
entity,
|
||||
value,
|
||||
timeRange: [start, end],
|
||||
widgetData,
|
||||
});
|
||||
}}
|
||||
getGraphData={getGraphData}
|
||||
isQueryEnabled={queryEnabled}
|
||||
openTracesButton={openTracesButton}
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
version={ENTITY_VERSION_V4}
|
||||
customErrorMessage={customErrorMessage}
|
||||
start={start}
|
||||
end={end}
|
||||
dataAvailable={checkIfDataExists}
|
||||
analyticsEvent={analyticsEvent}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
CeleryTaskGraph.defaultProps = {
|
||||
getGraphData: undefined,
|
||||
onClick: undefined,
|
||||
rightPanelTitle: undefined,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
openTracesButton: false,
|
||||
onOpenTraceBtnClick: undefined,
|
||||
applyCeleryTaskFilter: false,
|
||||
customErrorMessage: undefined,
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
checkIfDataExists: undefined,
|
||||
analyticsEvent: undefined,
|
||||
};
|
||||
|
||||
export default CeleryTaskGraph;
|
||||
@@ -1,232 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Card } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { CardContainer } from 'container/WidgetCard/styles';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { CaptureDataProps } from '../CeleryTaskDetail/CeleryTaskDetail';
|
||||
import { useCeleryFilterOptions } from '../useCeleryFilterOptions';
|
||||
import CeleryTaskBar from './CeleryTaskBar';
|
||||
import CeleryTaskGraph from './CeleryTaskGraph';
|
||||
import {
|
||||
celeryActiveTasksWidgetData,
|
||||
celeryErrorByWorkerWidgetData,
|
||||
celeryLatencyByWorkerWidgetData,
|
||||
celeryTasksByWorkerWidgetData,
|
||||
} from './CeleryTaskGraphUtils';
|
||||
import CeleryTaskLatencyGraph from './CeleryTaskLatencyGraph';
|
||||
|
||||
import './CeleryTaskGraph.style.scss';
|
||||
|
||||
export default function CeleryTaskGraphGrid({
|
||||
onClick,
|
||||
queryEnabled,
|
||||
configureOptionComponent,
|
||||
}: {
|
||||
onClick: (task: CaptureDataProps) => void;
|
||||
queryEnabled: boolean;
|
||||
configureOptionComponent?: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const celeryActiveTasksData = useMemo(
|
||||
() => celeryActiveTasksWidgetData(minTime, maxTime),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const celeryErrorByWorkerData = useMemo(
|
||||
() => celeryErrorByWorkerWidgetData(minTime, maxTime),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const celeryLatencyByWorkerData = useMemo(
|
||||
() => celeryLatencyByWorkerWidgetData(minTime, maxTime),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const celeryTasksByWorkerData = useMemo(
|
||||
() => celeryTasksByWorkerWidgetData(minTime, maxTime),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const bottomWidgetData = [
|
||||
celeryTasksByWorkerData,
|
||||
celeryErrorByWorkerData,
|
||||
celeryLatencyByWorkerData,
|
||||
];
|
||||
|
||||
const rightPanelTitle = [
|
||||
'Tasks/s by worker',
|
||||
'Error% by worker',
|
||||
'Latency by worker',
|
||||
];
|
||||
|
||||
const { options } = useCeleryFilterOptions(
|
||||
'worker',
|
||||
'rate',
|
||||
DataSource.METRICS,
|
||||
'flower_task_runtime_seconds_sum',
|
||||
DataTypes.String,
|
||||
'tag',
|
||||
);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [collapsedSections, setCollapsedSections] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({
|
||||
metricBasedGraphs: false,
|
||||
traceBasedGraphs: false,
|
||||
});
|
||||
|
||||
const toggleCollapse = (key: string): void => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const checkIfDataExists = (isDataAvailable: boolean, title: string): void => {
|
||||
if (isDataAvailable) {
|
||||
logEvent(`MQ Celery: ${title} data exists`, {
|
||||
graph: title,
|
||||
isDataAvailable,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="celery-task-graph-grid-container"
|
||||
data-testid="celery-graph-grid"
|
||||
>
|
||||
<div className="metric-based-graphs">
|
||||
<CardContainer className="row-card" isDarkMode={isDarkMode}>
|
||||
<div className="row-panel">
|
||||
<div className="row-panel-section">
|
||||
<Typography.Text className="section-title">
|
||||
Flower Metrics
|
||||
</Typography.Text>
|
||||
{collapsedSections.metricBasedGraphs ? (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
onClick={(): void => toggleCollapse('metricBasedGraphs')}
|
||||
className="row-icon"
|
||||
/>
|
||||
) : (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
onClick={(): void => toggleCollapse('metricBasedGraphs')}
|
||||
className="row-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContainer>
|
||||
{!collapsedSections.metricBasedGraphs && (
|
||||
<div className="metric-page-grid">
|
||||
<CeleryTaskGraph
|
||||
key={celeryActiveTasksData.id}
|
||||
widgetData={celeryActiveTasksData}
|
||||
queryEnabled={queryEnabled}
|
||||
customErrorMessage="Enable Flower metrics to view this graph"
|
||||
checkIfDataExists={(isDataAvailable): void =>
|
||||
checkIfDataExists(isDataAvailable, 'Active Tasks by worker')
|
||||
}
|
||||
analyticsEvent="MQ Celery: Flower metric not enabled"
|
||||
/>
|
||||
<Card className="celery-task-graph-worker-count">
|
||||
<div className="worker-count-header">
|
||||
<Typography.Text className="worker-count-header-text">
|
||||
Worker Online
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="worker-count-text-container">
|
||||
<Typography.Text className="celery-task-graph-worker-count-text">
|
||||
{options.filter((option) => option.value).length}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="trace-based-graphs">
|
||||
<div className="trace-based-graphs-header">
|
||||
<CardContainer className="row-card" isDarkMode={isDarkMode}>
|
||||
<div className="row-panel">
|
||||
<div className="row-panel-section">
|
||||
<Typography.Text className="section-title">
|
||||
Span Based Stats
|
||||
</Typography.Text>
|
||||
{collapsedSections.traceBasedGraphs ? (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
onClick={(): void => toggleCollapse('traceBasedGraphs')}
|
||||
className="row-icon"
|
||||
/>
|
||||
) : (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
onClick={(): void => toggleCollapse('traceBasedGraphs')}
|
||||
className="row-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContainer>
|
||||
<div className="configure-option-Info">
|
||||
{configureOptionComponent}
|
||||
<Typography.Text className="configure-option-Info-text">
|
||||
Click on a graph co-ordinate to see more details
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{!collapsedSections.traceBasedGraphs && (
|
||||
<>
|
||||
<CeleryTaskBar
|
||||
queryEnabled={queryEnabled}
|
||||
onClick={onClick}
|
||||
checkIfDataExists={(isDataAvailable): void =>
|
||||
checkIfDataExists(isDataAvailable, 'State Graph')
|
||||
}
|
||||
/>
|
||||
<CeleryTaskLatencyGraph
|
||||
queryEnabled={queryEnabled}
|
||||
checkIfDataExists={(isDataAvailable): void =>
|
||||
checkIfDataExists(isDataAvailable, 'Task Latency')
|
||||
}
|
||||
/>
|
||||
<div className="celery-task-graph-grid-bottom">
|
||||
{bottomWidgetData.map((widgetData, index) => (
|
||||
<CeleryTaskGraph
|
||||
key={widgetData.id}
|
||||
widgetData={widgetData}
|
||||
onClick={onClick}
|
||||
queryEnabled={queryEnabled}
|
||||
rightPanelTitle={rightPanelTitle[index]}
|
||||
applyCeleryTaskFilter
|
||||
checkIfDataExists={(isDataAvailable): void =>
|
||||
checkIfDataExists(isDataAvailable, rightPanelTitle[index])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CeleryTaskGraphGrid.defaultProps = {
|
||||
configureOptionComponent: null,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,259 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Col, Row } from 'antd';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ViewMenuAction } from 'container/WidgetCard/config';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import { Card } from 'container/WidgetCard/styles';
|
||||
import { Button } from 'container/MetricsApplication/Tabs/styles';
|
||||
import { useGraphClickHandler } from 'container/MetricsApplication/Tabs/util';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
applyCeleryFilterOnWidgetData,
|
||||
createFiltersFromData,
|
||||
getFiltersFromQueryParams,
|
||||
} from '../CeleryUtils';
|
||||
import { useNavigateToExplorer } from '../useNavigateToExplorer';
|
||||
import { celeryTaskLatencyWidgetData } from './CeleryTaskGraphUtils';
|
||||
|
||||
import './CeleryTaskGraph.style.scss';
|
||||
|
||||
interface TabData {
|
||||
label: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export enum CeleryTaskGraphState {
|
||||
P99 = 'p99',
|
||||
P95 = 'p95',
|
||||
P90 = 'p90',
|
||||
}
|
||||
|
||||
function CeleryTaskLatencyGraph({
|
||||
queryEnabled,
|
||||
checkIfDataExists,
|
||||
}: {
|
||||
queryEnabled: boolean;
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const tabs: TabData[] = [
|
||||
{ label: CeleryTaskGraphState.P99, key: CeleryTaskGraphState.P99 },
|
||||
{ label: CeleryTaskGraphState.P95, key: CeleryTaskGraphState.P95 },
|
||||
{ label: CeleryTaskGraphState.P90, key: CeleryTaskGraphState.P90 },
|
||||
];
|
||||
|
||||
const [graphState, setGraphState] = useState<CeleryTaskGraphState>(
|
||||
CeleryTaskGraphState.P99,
|
||||
);
|
||||
|
||||
const handleTabClick = (key: CeleryTaskGraphState): void => {
|
||||
setGraphState(key as CeleryTaskGraphState);
|
||||
logEvent('MQ Celery: Task latency graph tab clicked', {
|
||||
taskName: urlQuery.get(QueryParams.taskName),
|
||||
graphState: key,
|
||||
});
|
||||
};
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const selectedFilters = useMemo(
|
||||
() =>
|
||||
getFiltersFromQueryParams(
|
||||
QueryParams.taskName,
|
||||
urlQuery,
|
||||
'celery.task_name',
|
||||
),
|
||||
[urlQuery],
|
||||
);
|
||||
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const celeryTaskLatencyData = useMemo(
|
||||
() => celeryTaskLatencyWidgetData(graphState, minTime, maxTime),
|
||||
[graphState, minTime, maxTime],
|
||||
);
|
||||
|
||||
const updatedWidgetData = useMemo(
|
||||
() =>
|
||||
applyCeleryFilterOnWidgetData(selectedFilters || [], celeryTaskLatencyData),
|
||||
[celeryTaskLatencyData, selectedFilters],
|
||||
);
|
||||
|
||||
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
|
||||
const [entityData, setEntityData] = useState<{
|
||||
entity: string;
|
||||
value: string;
|
||||
}>();
|
||||
|
||||
const handleSetTimeStamp = useCallback((selectTime: number) => {
|
||||
setSelectedTimeStamp(selectTime);
|
||||
}, []);
|
||||
|
||||
const onGraphClickHandler = useGraphClickHandler(handleSetTimeStamp);
|
||||
|
||||
const onGraphClick = useCallback(
|
||||
(type: string): OnClickPluginOpts['onClick'] =>
|
||||
(xValue, yValue, mouseX, mouseY, data): Promise<void> => {
|
||||
const [firstDataPoint] = Object.entries(data || {});
|
||||
const [entity, value] = firstDataPoint;
|
||||
setEntityData({
|
||||
entity,
|
||||
value,
|
||||
});
|
||||
|
||||
return onGraphClickHandler(xValue, yValue, mouseX, mouseY, type);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[handleSetTimeStamp],
|
||||
);
|
||||
|
||||
const navigateToExplorer = useNavigateToExplorer();
|
||||
|
||||
const goToTraces = useCallback(() => {
|
||||
const { start, end } = getStartAndEndTimesInMilliseconds(selectedTimeStamp);
|
||||
const filters = createFiltersFromData({
|
||||
[entityData?.entity as string]: entityData?.value,
|
||||
});
|
||||
navigateToExplorer({
|
||||
filters,
|
||||
dataSource: DataSource.TRACES,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
sameTab: true,
|
||||
});
|
||||
}, [entityData, navigateToExplorer, selectedTimeStamp]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={PANEL_TYPES.TIME_SERIES}
|
||||
className="celery-task-graph-task-latency"
|
||||
>
|
||||
<Row className="celery-task-states">
|
||||
{tabs.map((tab, index) => (
|
||||
<Col
|
||||
key={tab.key}
|
||||
onClick={(): void => handleTabClick(tab.key as CeleryTaskGraphState)}
|
||||
className={`celery-task-states__tab ${
|
||||
tab.key === graphState ? 'celery-task-states__tab--selected' : ''
|
||||
}`}
|
||||
data-last-tab={index === tabs.length - 1}
|
||||
>
|
||||
<div className="celery-task-states__label-wrapper">
|
||||
<div className="celery-task-states__label">
|
||||
{tab.label.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
{tab.key === graphState && (
|
||||
<div className="celery-task-states__indicator" />
|
||||
)}
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<div className="celery-task-graph-grid-content">
|
||||
{graphState === CeleryTaskGraphState.P99 && (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="Celery_p99_latency_button"
|
||||
onClick={goToTraces}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<GridCard
|
||||
widget={updatedWidgetData}
|
||||
headerMenuList={[...ViewMenuAction]}
|
||||
onDragSelect={onDragSelect}
|
||||
onClickHandler={onGraphClick('Celery_p99_latency')}
|
||||
isQueryEnabled={queryEnabled}
|
||||
dataAvailable={checkIfDataExists}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{graphState === CeleryTaskGraphState.P95 && (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="Celery_p95_latency_button"
|
||||
onClick={goToTraces}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<GridCard
|
||||
widget={updatedWidgetData}
|
||||
headerMenuList={[...ViewMenuAction]}
|
||||
onDragSelect={onDragSelect}
|
||||
onClickHandler={onGraphClick('Celery_p95_latency')}
|
||||
isQueryEnabled={queryEnabled}
|
||||
dataAvailable={checkIfDataExists}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{graphState === CeleryTaskGraphState.P90 && (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="Celery_p90_latency_button"
|
||||
onClick={goToTraces}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<GridCard
|
||||
widget={updatedWidgetData}
|
||||
headerMenuList={[...ViewMenuAction]}
|
||||
onDragSelect={onDragSelect}
|
||||
onClickHandler={onGraphClick('Celery_p90_latency')}
|
||||
isQueryEnabled={queryEnabled}
|
||||
dataAvailable={checkIfDataExists}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CeleryTaskLatencyGraph;
|
||||
|
||||
CeleryTaskLatencyGraph.defaultProps = {
|
||||
checkIfDataExists: undefined,
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { Col, Row } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import {
|
||||
applyCeleryFilterOnWidgetData,
|
||||
getFiltersFromQueryParams,
|
||||
} from '../CeleryUtils';
|
||||
import {
|
||||
celeryAllStateCountWidgetData,
|
||||
celeryFailedStateCountWidgetData,
|
||||
celeryRetryStateCountWidgetData,
|
||||
celerySuccessStateCountWidgetData,
|
||||
} from './CeleryTaskGraphUtils';
|
||||
import { useGetValueFromWidget } from './useGetValueFromWidget';
|
||||
|
||||
import './CeleryTaskGraph.style.scss';
|
||||
|
||||
interface TabData {
|
||||
label: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export enum CeleryTaskState {
|
||||
All = 'all',
|
||||
Failed = 'failed',
|
||||
Retry = 'retry',
|
||||
Successful = 'successful',
|
||||
}
|
||||
|
||||
function CeleryTaskStateGraphConfig({
|
||||
barState,
|
||||
setBarState,
|
||||
}: {
|
||||
setBarState: Dispatch<SetStateAction<CeleryTaskState>>;
|
||||
barState: CeleryTaskState;
|
||||
}): JSX.Element {
|
||||
const tabs: TabData[] = [
|
||||
{ label: 'All Tasks', key: CeleryTaskState.All },
|
||||
{ label: 'Failed', key: CeleryTaskState.Failed },
|
||||
{ label: 'Retry', key: CeleryTaskState.Retry },
|
||||
{ label: 'Successful', key: CeleryTaskState.Successful },
|
||||
];
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const handleTabClick = (key: CeleryTaskState): void => {
|
||||
setBarState(key as CeleryTaskState);
|
||||
logEvent('MQ Celery: State graph tab clicked', {
|
||||
taskName: urlQuery.get(QueryParams.taskName),
|
||||
graphState: key,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedFilters = useMemo(
|
||||
() =>
|
||||
getFiltersFromQueryParams(
|
||||
QueryParams.taskName,
|
||||
urlQuery,
|
||||
'celery.task_name',
|
||||
),
|
||||
[urlQuery],
|
||||
);
|
||||
|
||||
const widgetData = [
|
||||
celeryAllStateCountWidgetData,
|
||||
celeryFailedStateCountWidgetData,
|
||||
celeryRetryStateCountWidgetData,
|
||||
celerySuccessStateCountWidgetData,
|
||||
].map((data) => applyCeleryFilterOnWidgetData(selectedFilters || [], data));
|
||||
|
||||
const { values, isLoading, isError } = useGetValueFromWidget(widgetData, [
|
||||
'celery-task-states',
|
||||
]);
|
||||
|
||||
return (
|
||||
<Row className="celery-task-states">
|
||||
{tabs.map((tab, index) => (
|
||||
<Col
|
||||
key={tab.key}
|
||||
data-testid={`celery-state-tab-${tab.key}`}
|
||||
onClick={(): void => handleTabClick(tab.key as CeleryTaskState)}
|
||||
className={`celery-task-states__tab ${
|
||||
tab.key === barState ? 'celery-task-states__tab--selected' : ''
|
||||
}`}
|
||||
data-last-tab={index === tabs.length - 1}
|
||||
>
|
||||
<div className="celery-task-states__label-wrapper">
|
||||
<div className="celery-task-states__label">{tab.label}</div>
|
||||
<div className="celery-task-states__value">
|
||||
{isLoading
|
||||
? '-'
|
||||
: isError
|
||||
? '-'
|
||||
: Number.isNaN(values[index])
|
||||
? '-'
|
||||
: Math.round(Number(values[index]))}
|
||||
</div>
|
||||
</div>
|
||||
{tab.key === barState && <div className="celery-task-states__indicator" />}
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export { CeleryTaskStateGraphConfig };
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { getQueryPayloadFromWidgetsData } from 'pages/Celery/CeleryOverview/CeleryOverviewUtils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
interface UseGetValueResult {
|
||||
values: string[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export const useGetValueFromWidget = (
|
||||
widgetsData: Widgets | Widgets[],
|
||||
queryKey: string[],
|
||||
): UseGetValueResult => {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const queryPayloads = useCallback(
|
||||
() =>
|
||||
getQueryPayloadFromWidgetsData({
|
||||
start: Math.floor(minTime / 1000000000),
|
||||
end: Math.floor(maxTime / 1000000000),
|
||||
widgetsData: Array.isArray(widgetsData) ? widgetsData : [widgetsData],
|
||||
panelType: PANEL_TYPES.VALUE,
|
||||
}),
|
||||
[minTime, maxTime, widgetsData],
|
||||
);
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads().map((payload) => ({
|
||||
queryKey: [...queryKey, payload, ENTITY_VERSION_V4],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
|
||||
enabled: !!payload,
|
||||
})),
|
||||
);
|
||||
|
||||
const isLoading = queries.some((query) => query.isLoading);
|
||||
const isError = queries.some((query) => query.isError);
|
||||
|
||||
const values = queries.map((query) => {
|
||||
if (query.isLoading) {
|
||||
return 'Loading...';
|
||||
}
|
||||
if (query.isError) {
|
||||
return 'Error';
|
||||
}
|
||||
|
||||
const value = parseFloat(
|
||||
query.data?.payload?.data?.newResult?.data?.result?.[0]?.series?.[0]
|
||||
?.values?.[0]?.value || 'NaN',
|
||||
);
|
||||
return Number.isNaN(value) ? 'NaN' : value.toFixed(2);
|
||||
});
|
||||
|
||||
return { values, isLoading, isError };
|
||||
};
|
||||
@@ -1,10 +1,6 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { AppLocation, NavigateFn } from 'lib/router/types';
|
||||
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export function getValuesFromQueryParams(
|
||||
queryParams: QueryParams,
|
||||
@@ -26,53 +22,6 @@ export function setQueryParamsFromOptions(
|
||||
navigate(generatedUrl, { replace: true });
|
||||
}
|
||||
|
||||
export function getFiltersFromQueryParams(
|
||||
queryParams: QueryParams,
|
||||
urlQuery: URLSearchParams,
|
||||
key: string,
|
||||
): TagFilterItem[] {
|
||||
const value = urlQuery.get(queryParams);
|
||||
const filters = value ? value.split(',') : [];
|
||||
return filters.map((value) => ({
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
key,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: `${key}--string--tag--false`,
|
||||
},
|
||||
op: '=',
|
||||
value: value.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyCeleryFilterOnWidgetData(
|
||||
filters: TagFilterItem[],
|
||||
widgetData: Widgets,
|
||||
): Widgets {
|
||||
return {
|
||||
...widgetData,
|
||||
query: {
|
||||
...widgetData.query,
|
||||
builder: {
|
||||
...widgetData.query.builder,
|
||||
queryData: widgetData.query.builder.queryData.map((queryItem, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...queryItem,
|
||||
filters: {
|
||||
...queryItem.filters,
|
||||
items: [...(queryItem.filters?.items || []), ...filters],
|
||||
op: queryItem.filters?.op || 'AND',
|
||||
},
|
||||
}
|
||||
: queryItem,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const paths = (
|
||||
u: any,
|
||||
seriesIdx: number,
|
||||
@@ -89,37 +38,3 @@ export const paths = (
|
||||
|
||||
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
|
||||
};
|
||||
|
||||
export const createFiltersFromData = (
|
||||
data: Record<string, any>,
|
||||
): Array<{
|
||||
id: string;
|
||||
key: {
|
||||
key: string;
|
||||
dataType: DataTypes;
|
||||
type: string;
|
||||
|
||||
id: string;
|
||||
};
|
||||
op: string;
|
||||
value: string;
|
||||
}> => {
|
||||
const excludeKeys = ['A', 'A_without_unit'];
|
||||
|
||||
return (
|
||||
Object.entries(data)
|
||||
.filter(([key]) => !excludeKeys.includes(key))
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
.map(([key, value]) => ({
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
key,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: `${key}--string--tag--false`,
|
||||
},
|
||||
op: '=',
|
||||
value: value.toString(),
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,7 +41,6 @@ export enum QueryParams {
|
||||
getStartedSource = 'getStartedSource',
|
||||
getStartedSourceService = 'getStartedSourceService',
|
||||
mqServiceView = 'mqServiceView',
|
||||
taskName = 'taskName',
|
||||
spanName = 'spanName',
|
||||
msgSystem = 'msgSystem',
|
||||
destination = 'destination',
|
||||
|
||||
@@ -3,24 +3,4 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-0) var(--spacing-0);
|
||||
//TODO: remove this once we have a proper dashboard page.
|
||||
// The embedded V2 DashboardContainer renders its own page header (dashboard
|
||||
// title + share/feedback chrome) removed it for now
|
||||
:global([class*='dashboardPageHeader']),
|
||||
:global([class*='dashboardInfoWithActions']) {
|
||||
display: none;
|
||||
}
|
||||
// Embedded read-only: the add-variable and panel-action triggers are dead here.
|
||||
:global([class*='addSlot']),
|
||||
:global([data-testid^='panel-actions-']) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Spinner from 'components/Spinner';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import { CHARTS_ONLY } from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
|
||||
import { useSystemDashboard } from './hooks/useSystemDashboard';
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
function Overview(): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
|
||||
const AI_OVERVIEW = createSystemDashboardHost(
|
||||
'ai-o11y-overview',
|
||||
'llm-observability-overview',
|
||||
);
|
||||
|
||||
function Overview(): JSX.Element {
|
||||
useEffect(() => {
|
||||
void logEvent('AI Observability Overview: Page visited', {});
|
||||
}, []);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState}>
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>
|
||||
{error?.response?.data?.error?.message ?? error?.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
canEditDashboardOverride={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
{renderContent()}
|
||||
<SystemDashboard host={AI_OVERVIEW} controls={CHARTS_ONLY} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
useGetSystemDashboard,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const SYSTEM_DASHBOARD_NAME = 'ai-o11y-overview';
|
||||
|
||||
const DASHBOARD_ID = 'llm-observability-overview';
|
||||
|
||||
export interface UseSystemDashboardResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: GetSystemDashboardQueryError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSystemDashboard(): UseSystemDashboardResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, error, refetch } = useGetSystemDashboard(
|
||||
{ name: SYSTEM_DASHBOARD_NAME },
|
||||
{ query: { staleTime: Infinity, refetchOnMount: false } },
|
||||
);
|
||||
|
||||
const dashboard = useMemo(
|
||||
() =>
|
||||
data
|
||||
? ({
|
||||
...data.data,
|
||||
id: DASHBOARD_ID,
|
||||
} as DashboardtypesGettableDashboardV2DTO)
|
||||
: undefined,
|
||||
[data],
|
||||
);
|
||||
|
||||
if (dashboard) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(
|
||||
getGetDashboardV2QueryKey({ id: DASHBOARD_ID }),
|
||||
{ data: dashboard, status: 'success' },
|
||||
);
|
||||
}
|
||||
|
||||
const refetchDashboard = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
|
||||
}
|
||||
@@ -2,77 +2,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.meter-explorer-date-time {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
justify-content: end;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.meter-explorer-graphs {
|
||||
.meter-explorer-notices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 36px;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 0;
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meter-column-graph {
|
||||
.row-card {
|
||||
background-color: var(--l2-background);
|
||||
padding-left: 10px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.section-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-description {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meter-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
.meter-graph {
|
||||
height: 400px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
.meter-column-graph {
|
||||
.meter-page-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
.meter-graph {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Alert } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import { Card, CardContainer } from 'container/WidgetCard/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
getLogCountWidgetData,
|
||||
getLogSizeWidgetData,
|
||||
getMetricCountWidgetData,
|
||||
getSpanCountWidgetData,
|
||||
getSpanSizeWidgetData,
|
||||
getTotalLogSizeWidgetData,
|
||||
getTotalMetricDatapointCountWidgetData,
|
||||
getTotalTraceSizeWidgetData,
|
||||
} from './graphs';
|
||||
CHARTS_ONLY,
|
||||
withControls,
|
||||
} from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './BreakDown.styles.scss';
|
||||
|
||||
type MetricSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
graphs: Widgets[];
|
||||
};
|
||||
const COST_METER = createSystemDashboardHost(
|
||||
'cost-meter-breakdown',
|
||||
'cost-meter-breakdown',
|
||||
);
|
||||
|
||||
const sections: MetricSection[] = [
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Total',
|
||||
graphs: [
|
||||
getTotalLogSizeWidgetData(),
|
||||
getTotalTraceSizeWidgetData(),
|
||||
getTotalMetricDatapointCountWidgetData(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Logs',
|
||||
graphs: [getLogCountWidgetData(), getLogSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Traces',
|
||||
graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Metrics',
|
||||
graphs: [getMetricCountWidgetData()],
|
||||
},
|
||||
];
|
||||
|
||||
function Section(section: MetricSection): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { title, graphs } = section;
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="meter-column-graph"
|
||||
data-testid={`meter-breakdown-section-${title.toLowerCase()}`}
|
||||
>
|
||||
<CardContainer className="row-card" isDarkMode={isDarkMode}>
|
||||
<Typography.Text className="section-title">{title}</Typography.Text>
|
||||
</CardContainer>
|
||||
<div className="meter-page-grid">
|
||||
{graphs.map((widget) => (
|
||||
<Card
|
||||
key={widget?.id}
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={PANEL_TYPES.BAR}
|
||||
className="meter-graph"
|
||||
>
|
||||
<GridCard widget={widget} onDragSelect={onDragSelect} version="v5" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CONTROLS = withControls(CHARTS_ONLY, {
|
||||
toolbar: { autoRefresh: false },
|
||||
});
|
||||
|
||||
function BreakDown(): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
@@ -133,12 +42,10 @@ function BreakDown(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="meter-explorer-breakdown">
|
||||
<section className="meter-explorer-date-time">
|
||||
<DateTimeSelectionV2 showAutoRefresh={false} />
|
||||
</section>
|
||||
<section className="meter-explorer-graphs">
|
||||
<section className="meter-explorer-notices">
|
||||
{showInfo && (
|
||||
<Alert
|
||||
data-testid="cost-meter-utc-notice"
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
@@ -179,28 +86,8 @@ function BreakDown(): JSX.Element {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<section className="total">
|
||||
<Section
|
||||
id={sections[0].id}
|
||||
title={sections[0].title}
|
||||
graphs={sections[0].graphs}
|
||||
/>
|
||||
</section>
|
||||
{sections.map((section, idx) => {
|
||||
if (idx === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
title={section.title}
|
||||
graphs={section.graphs}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<SystemDashboard host={COST_METER} controls={CONTROLS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
|
||||
import { resolveControls } from 'pages/DashboardPage/DashboardContainer/controls/dashboardControls';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import type { SystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
|
||||
import BreakDown from '../BreakDown';
|
||||
|
||||
jest.mock('container/SystemDashboard/SystemDashboard', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
host,
|
||||
controls,
|
||||
}: {
|
||||
host: SystemDashboardHost;
|
||||
controls: DashboardControlsInput;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="system-dashboard"
|
||||
data-name={host.name}
|
||||
data-controls={JSON.stringify(controls)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('Cost meter breakdown', () => {
|
||||
it('mounts the cost meter system dashboard as charts only, without auto-refresh', () => {
|
||||
render(<BreakDown />);
|
||||
|
||||
const dashboard = screen.getByTestId('system-dashboard');
|
||||
expect(dashboard).toHaveAttribute('data-name', 'cost-meter-breakdown');
|
||||
const controls = resolveControls(
|
||||
JSON.parse(dashboard.getAttribute('data-controls') ?? ''),
|
||||
true,
|
||||
);
|
||||
expect(controls.header.breadcrumbs).toBe(false);
|
||||
expect(controls.panelActions.createAlert).toBe(false);
|
||||
expect(controls.panelActions.drilldown).toBe(false);
|
||||
expect(controls.panelActions.zoom).toBe(true);
|
||||
expect(controls.toolbar.autoRefresh).toBe(false);
|
||||
expect(controls.toolbar.timeRange).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the UTC billing notice above the dashboard', () => {
|
||||
render(<BreakDown />);
|
||||
|
||||
expect(screen.getByTestId('cost-meter-utc-notice')).toHaveTextContent(
|
||||
'Billing is calculated in UTC',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,374 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetWidgetQueryBuilderProps } from 'container/MetricsApplication/types';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface GetWidgetQueryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
queryData: IBuilderQuery[];
|
||||
queryFormulas?: IBuilderFormula[];
|
||||
panelTypes?: PANEL_TYPES;
|
||||
yAxisUnit?: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GetWidgetQueryPropsReturn extends GetWidgetQueryBuilderProps {
|
||||
description?: string;
|
||||
nullZeroValues: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const getWidgetQueryBuilder = ({
|
||||
query,
|
||||
title = '',
|
||||
panelTypes,
|
||||
yAxisUnit = '',
|
||||
fillSpans = false,
|
||||
id,
|
||||
nullZeroValues,
|
||||
description,
|
||||
}: GetWidgetQueryPropsReturn): Widgets => ({
|
||||
description: description || '',
|
||||
id: id || uuid(),
|
||||
nullZeroValues: nullZeroValues || '',
|
||||
opacity: '1',
|
||||
panelTypes,
|
||||
query,
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
title,
|
||||
yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
selectedLogFields: [],
|
||||
selectedTracesFields: [],
|
||||
fillSpans,
|
||||
});
|
||||
|
||||
export function getWidgetQuery(
|
||||
props: GetWidgetQueryProps,
|
||||
): GetWidgetQueryPropsReturn {
|
||||
const { title, description, panelTypes, yAxisUnit, columnUnits } = props;
|
||||
return {
|
||||
title,
|
||||
yAxisUnit: yAxisUnit || 'none',
|
||||
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
|
||||
fillSpans: false,
|
||||
description,
|
||||
nullZeroValues: 'zero',
|
||||
columnUnits,
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: props.queryData,
|
||||
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
};
|
||||
}
|
||||
export const getTotalLogSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.size',
|
||||
id: 'signoz.meter.log.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total size of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getTotalTraceSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.size',
|
||||
id: 'signoz.meter.span.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total size of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.metric.datapoint.count',
|
||||
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total metric datapoints ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getLogCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.count',
|
||||
id: 'signoz.meter.log.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getLogSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.size',
|
||||
id: 'signoz.meter.log.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'size',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Size of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getSpanCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.count',
|
||||
id: 'signoz.meter.span.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getSpanSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.size',
|
||||
id: 'signoz.meter.span.size--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'size',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Size of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getMetricCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.metric.datapoint.count',
|
||||
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of metric datapoints ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
@@ -1,136 +0,0 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import { FORMULA, MetricsType, WidgetKeys } from '../constant';
|
||||
import { DatabaseCallProps, DatabaseCallsRPSProps } from '../types';
|
||||
import {
|
||||
getQueryBuilderQueries,
|
||||
getQueryBuilderQuerieswithFormula,
|
||||
} from './MetricsPageQueriesFactory';
|
||||
|
||||
export const databaseCallsRPS = ({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}: DatabaseCallsRPSProps): QueryBuilderData => {
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
{
|
||||
key: WidgetKeys.SignozDBLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
const groupBy: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.DbSystem,
|
||||
type: 'tag',
|
||||
},
|
||||
];
|
||||
const filterItems: TagFilterItem[][] = [
|
||||
[
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [`${servicename}`],
|
||||
},
|
||||
...tagFilterItems,
|
||||
],
|
||||
];
|
||||
|
||||
const legends = [legend];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
groupBy,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
export const databaseCallsAvgDuration = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
}: DatabaseCallProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozDbLatencySum,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozDBLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
autocompleteDataA,
|
||||
autocompleteDataB,
|
||||
];
|
||||
|
||||
const additionalItems: TagFilterItem[][] = [
|
||||
additionalItemsA,
|
||||
additionalItemsA,
|
||||
];
|
||||
|
||||
const legends = ['', ''];
|
||||
const disabled = [true, true];
|
||||
const legendFormulas = ['Average Duration'];
|
||||
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
@@ -1,276 +0,0 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import { FORMULA, MetricsType, WidgetKeys } from '../constant';
|
||||
import {
|
||||
ExternalCallDurationByAddressProps,
|
||||
ExternalCallProps,
|
||||
} from '../Tabs/types';
|
||||
import {
|
||||
getQueryBuilderQueries,
|
||||
getQueryBuilderQuerieswithFormula,
|
||||
} from './MetricsPageQueriesFactory';
|
||||
|
||||
const groupBy: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.Address,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
];
|
||||
|
||||
export const externalCallErrorPercent = ({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.StatusCode,
|
||||
dataType: DataTypes.Int64,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: ['STATUS_CODE_ERROR'],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
const additionalItemsB: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
const legendFormulas = [legend];
|
||||
const expressions = [FORMULA.ERROR_PERCENTAGE];
|
||||
const disabled = [true, true];
|
||||
const autocompleteData = [autocompleteDataA, autocompleteDataB];
|
||||
|
||||
const additionalItems = [additionalItemsA, additionalItemsB];
|
||||
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const legends = [legend, legend];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
groupBy,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
|
||||
export const externalCallDuration = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
}: ExternalCallProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencySum,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
type: '',
|
||||
};
|
||||
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
|
||||
const legendFormulas = ['Average Duration'];
|
||||
const legend = '';
|
||||
const disabled = [true, true];
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const autocompleteData = [autocompleteDataA, autocompleteDataB];
|
||||
|
||||
const additionalItems = [additionalItemsA, additionalItemsA];
|
||||
const legends = [legend, legend];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
|
||||
export const externalCallRpsByAddress = ({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
const filterItems: TagFilterItem[][] = [
|
||||
[
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
],
|
||||
];
|
||||
|
||||
const legends = [legend];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
groupBy,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
export const externalCallDurationByAddress = ({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencySum,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: WidgetKeys.SignozExternalCallLatencyCount,
|
||||
type: '',
|
||||
};
|
||||
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
|
||||
const legendFormulas = [legend];
|
||||
const disabled = [true, true];
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const autocompleteData = [autocompleteDataA, autocompleteDataB];
|
||||
const additionalItems = [additionalItemsA, additionalItemsA];
|
||||
const legends = [legend, legend];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
groupBy,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
@@ -5,68 +5,13 @@ import {
|
||||
} from 'constants/queryBuilder';
|
||||
import getStep from 'lib/getStep';
|
||||
import store from 'store';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
ReduceOperators,
|
||||
Temporality,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
BuilderQueriesProps,
|
||||
BuilderQuerieswithFormulaProps,
|
||||
} from '../Tabs/types';
|
||||
|
||||
export const getQueryBuilderQueries = ({
|
||||
autocompleteData,
|
||||
groupBy = [],
|
||||
legends,
|
||||
filterItems,
|
||||
aggregateOperator,
|
||||
dataSource,
|
||||
queryNameAndExpression,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
}: BuilderQueriesProps): QueryBuilderData => ({
|
||||
queryFormulas: [],
|
||||
queryData: autocompleteData.map((item, index) => {
|
||||
const newQueryData: IBuilderQuery = {
|
||||
...initialQueryBuilderFormValuesMap.metrics,
|
||||
aggregateOperator: ((): string => {
|
||||
if (aggregateOperator) {
|
||||
return aggregateOperator[index];
|
||||
}
|
||||
return MetricAggregateOperator.SUM_RATE;
|
||||
})(),
|
||||
disabled: false,
|
||||
groupBy,
|
||||
aggregateAttribute: item,
|
||||
legend: legends[index],
|
||||
stepInterval: getStep({
|
||||
end: store.getState().globalTime.maxTime,
|
||||
inputFormat: 'ns',
|
||||
start: store.getState().globalTime.minTime,
|
||||
}),
|
||||
filters: {
|
||||
items: filterItems[index],
|
||||
op: 'AND',
|
||||
},
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: spaceAggregateOperators[index],
|
||||
timeAggregation: timeAggregateOperators[index],
|
||||
dataSource,
|
||||
};
|
||||
|
||||
if (queryNameAndExpression) {
|
||||
newQueryData.queryName = queryNameAndExpression[index];
|
||||
newQueryData.expression = queryNameAndExpression[index];
|
||||
}
|
||||
|
||||
return newQueryData;
|
||||
}),
|
||||
queryTraceOperator: [],
|
||||
});
|
||||
import { BuilderQuerieswithFormulaProps } from '../Tabs/types';
|
||||
|
||||
export const getQueryBuilderQuerieswithFormula = ({
|
||||
autocompleteData,
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
FORMULA,
|
||||
GraphTitle,
|
||||
LATENCY_AGGREGATEOPERATOR,
|
||||
LATENCY_AGGREGATEOPERATOR_SPAN_METRICS,
|
||||
MetricsType,
|
||||
OPERATION_LEGENDS,
|
||||
QUERYNAME_AND_EXPRESSION,
|
||||
WidgetKeys,
|
||||
} from '../constant';
|
||||
import {
|
||||
ApDexMetricsQueryBuilderQueriesProps,
|
||||
ApDexProps,
|
||||
LatencyProps,
|
||||
OperationPerSecProps,
|
||||
} from '../Tabs/types';
|
||||
import { convertMilSecToNanoSec, getNearestHighestBucketValue } from '../utils';
|
||||
import {
|
||||
getQueryBuilderQueries,
|
||||
getQueryBuilderQuerieswithFormula,
|
||||
} from './MetricsPageQueriesFactory';
|
||||
|
||||
export const latency = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
isSpanMetricEnable = false,
|
||||
topLevelOperationsRoute,
|
||||
}: LatencyProps): QueryBuilderData => {
|
||||
const signozLatencyBucketMetrics = WidgetKeys.SignozLatencyBucket;
|
||||
|
||||
const signozMetricsServiceName = WidgetKeys.OTelServiceName;
|
||||
const newAutoCompleteData: BaseAutocompleteData = {
|
||||
key: isSpanMetricEnable
|
||||
? signozLatencyBucketMetrics
|
||||
: WidgetKeys.DurationNano,
|
||||
dataType: DataTypes.Float64,
|
||||
type: isSpanMetricEnable ? '' : MetricsType.Tag,
|
||||
};
|
||||
|
||||
const autocompleteData = Array(3).fill(newAutoCompleteData);
|
||||
|
||||
const filterItem: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: isSpanMetricEnable ? signozMetricsServiceName : WidgetKeys.ServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: isSpanMetricEnable ? MetricsType.Resource : MetricsType.Tag,
|
||||
},
|
||||
op: isSpanMetricEnable ? OPERATORS.IN : OPERATORS['='],
|
||||
value: isSpanMetricEnable ? [servicename] : servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: isSpanMetricEnable ? WidgetKeys.Operation : WidgetKeys.Name,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN.toLowerCase(), // TODO: need to remove toLowerCase() this once backend is changed
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const filterItems = Array(3).fill([...filterItem]);
|
||||
const legends = LATENCY_AGGREGATEOPERATOR;
|
||||
const aggregateOperator = isSpanMetricEnable
|
||||
? LATENCY_AGGREGATEOPERATOR_SPAN_METRICS
|
||||
: LATENCY_AGGREGATEOPERATOR;
|
||||
const dataSource = isSpanMetricEnable ? DataSource.METRICS : DataSource.TRACES;
|
||||
const queryNameAndExpression = QUERYNAME_AND_EXPRESSION;
|
||||
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P99,
|
||||
];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
legends,
|
||||
filterItems,
|
||||
aggregateOperator,
|
||||
dataSource,
|
||||
queryNameAndExpression,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
export const apDexTracesQueryBuilderQueries = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperationsRoute,
|
||||
threashold,
|
||||
}: ApDexProps): QueryBuilderData => {
|
||||
const autoCompleteDataA: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: '',
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autoCompleteDataB: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: '',
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autoCompleteDataC: BaseAutocompleteData = {
|
||||
dataType: DataTypes.Float64,
|
||||
key: '',
|
||||
type: '',
|
||||
};
|
||||
|
||||
const filterItemA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.ServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Name,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const filterItemB: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.HasError,
|
||||
dataType: DataTypes.bool,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.DurationNano,
|
||||
dataType: DataTypes.Float64,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['<='],
|
||||
value: convertMilSecToNanoSec(threashold),
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.ServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Name,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
];
|
||||
|
||||
const filterItemC: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.DurationNano,
|
||||
dataType: DataTypes.Float64,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['<='],
|
||||
value: convertMilSecToNanoSec(threashold * 4),
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.HasError,
|
||||
dataType: DataTypes.bool,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.ServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Name,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
];
|
||||
|
||||
const autocompleteData = [
|
||||
autoCompleteDataA,
|
||||
autoCompleteDataB,
|
||||
autoCompleteDataC,
|
||||
];
|
||||
const additionalItems = [filterItemA, filterItemB, filterItemC];
|
||||
const legends = [GraphTitle.APDEX];
|
||||
const disabled = Array(3).fill(true);
|
||||
const expressions = [FORMULA.APDEX_TRACES];
|
||||
const legendFormulas = [GraphTitle.APDEX];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.COUNT,
|
||||
MetricAggregateOperator.COUNT,
|
||||
MetricAggregateOperator.COUNT,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
];
|
||||
const dataSource = DataSource.TRACES;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
|
||||
export const apDexMetricsQueryBuilderQueries = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperationsRoute,
|
||||
threashold,
|
||||
delta,
|
||||
metricsBuckets,
|
||||
}: ApDexMetricsQueryBuilderQueriesProps): QueryBuilderData => {
|
||||
const autoCompleteDataA: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autoCompleteDataB: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyBucket,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autoCompleteDataC: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyBucket,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const filterItemA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const filterItemB: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.StatusCode,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['!='],
|
||||
value: 'STATUS_CODE_ERROR',
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Le,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: getNearestHighestBucketValue(threashold * 1000, metricsBuckets),
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const filterItemC: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Le,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: getNearestHighestBucketValue(threashold * 1000 * 4, metricsBuckets),
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.StatusCode,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['!='],
|
||||
value: 'STATUS_CODE_ERROR',
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: servicename,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [...topLevelOperationsRoute],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const autocompleteData = [
|
||||
autoCompleteDataA,
|
||||
autoCompleteDataB,
|
||||
autoCompleteDataC,
|
||||
];
|
||||
|
||||
const additionalItems = [filterItemA, filterItemB, filterItemC];
|
||||
const legends = [GraphTitle.APDEX];
|
||||
const disabled = Array(3).fill(true);
|
||||
const expressions = delta
|
||||
? [FORMULA.APDEX_DELTA_SPAN_METRICS]
|
||||
: [FORMULA.APDEX_CUMULATIVE_SPAN_METRICS];
|
||||
const legendFormulas = [GraphTitle.APDEX];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
|
||||
export const operationPerSec = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations,
|
||||
}: OperationPerSecProps): QueryBuilderData => {
|
||||
const autocompleteData: BaseAutocompleteData[] = [
|
||||
{
|
||||
key: WidgetKeys.SignozLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
|
||||
const filterItems: TagFilterItem[][] = [
|
||||
[
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [`${servicename}`],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: topLevelOperations,
|
||||
},
|
||||
...tagFilterItems,
|
||||
],
|
||||
];
|
||||
|
||||
const legends = OPERATION_LEGENDS;
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
export const errorPercentage = ({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations,
|
||||
}: OperationPerSecProps): QueryBuilderData => {
|
||||
const autocompleteDataA: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozCallsTotal,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
const autocompleteDataB: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozCallsTotal,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const autocompleteData = [autocompleteDataA, autocompleteDataB];
|
||||
|
||||
const additionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [`${servicename}`],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: topLevelOperations,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.StatusCode,
|
||||
dataType: DataTypes.Int64,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: ['STATUS_CODE_ERROR'],
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const additionalItemsB: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [`${servicename}`],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.Operation,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: topLevelOperations,
|
||||
},
|
||||
...tagFilterItems,
|
||||
];
|
||||
|
||||
const additionalItems = [additionalItemsA, additionalItemsB];
|
||||
const legends = [GraphTitle.ERROR_PERCENTAGE];
|
||||
const disabled = [true, true];
|
||||
const expressions = [FORMULA.ERROR_PERCENTAGE];
|
||||
const legendFormulas = [GraphTitle.ERROR_PERCENTAGE];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
legends,
|
||||
disabled,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
dataSource,
|
||||
});
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QueryBuilderData,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
GraphTitle,
|
||||
KeyOperationTableHeader,
|
||||
MetricsType,
|
||||
WidgetKeys,
|
||||
} from '../constant';
|
||||
import { TopOperationQueryFactoryProps } from '../Tabs/types';
|
||||
import { getQueryBuilderQuerieswithFormula } from './MetricsPageQueriesFactory';
|
||||
|
||||
export const topOperationQueries = ({
|
||||
servicename,
|
||||
}: TopOperationQueryFactoryProps): QueryBuilderData => {
|
||||
const latencyAutoCompleteData: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyBucket,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const errorRateAutoCompleteData: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozCallsTotal,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const numOfCallAutoCompleteData: BaseAutocompleteData = {
|
||||
key: WidgetKeys.SignozLatencyCount,
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
const latencyAndNumberOfCallAdditionalItems: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
dataType: DataTypes.String,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
value: [servicename],
|
||||
op: OPERATORS.IN,
|
||||
},
|
||||
];
|
||||
|
||||
const errorRateAdditionalItemsA: TagFilterItem[] = [
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.OTelServiceName,
|
||||
type: MetricsType.Resource,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: [servicename],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
key: {
|
||||
dataType: DataTypes.Int64,
|
||||
key: WidgetKeys.StatusCode,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
op: OPERATORS.IN,
|
||||
value: ['STATUS_CODE_ERROR'],
|
||||
},
|
||||
];
|
||||
|
||||
const errorRateAdditionalItemsB = latencyAndNumberOfCallAdditionalItems;
|
||||
|
||||
const groupBy: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: WidgetKeys.Operation,
|
||||
type: MetricsType.Tag,
|
||||
},
|
||||
];
|
||||
|
||||
const autocompleteData = [
|
||||
latencyAutoCompleteData,
|
||||
latencyAutoCompleteData,
|
||||
latencyAutoCompleteData,
|
||||
errorRateAutoCompleteData,
|
||||
errorRateAutoCompleteData,
|
||||
numOfCallAutoCompleteData,
|
||||
];
|
||||
const additionalItems = [
|
||||
latencyAndNumberOfCallAdditionalItems,
|
||||
latencyAndNumberOfCallAdditionalItems,
|
||||
latencyAndNumberOfCallAdditionalItems,
|
||||
errorRateAdditionalItemsA,
|
||||
errorRateAdditionalItemsB,
|
||||
latencyAndNumberOfCallAdditionalItems,
|
||||
];
|
||||
const disabled = [false, false, false, true, true, false];
|
||||
const legends = [
|
||||
KeyOperationTableHeader.P50,
|
||||
KeyOperationTableHeader.P90,
|
||||
KeyOperationTableHeader.P99,
|
||||
KeyOperationTableHeader.ERROR_RATE,
|
||||
KeyOperationTableHeader.ERROR_RATE,
|
||||
KeyOperationTableHeader.NUM_OF_CALLS,
|
||||
];
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
MetricAggregateOperator.RATE,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P99,
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
MetricAggregateOperator.SUM,
|
||||
];
|
||||
const expressions = ['D*100/E'];
|
||||
const legendFormulas = [GraphTitle.ERROR_PERCENTAGE];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
return getQueryBuilderQuerieswithFormula({
|
||||
autocompleteData,
|
||||
additionalItems,
|
||||
disabled,
|
||||
legends,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
expressions,
|
||||
legendFormulas,
|
||||
dataSource,
|
||||
groupBy,
|
||||
});
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { Col } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import {
|
||||
databaseCallsAvgDuration,
|
||||
databaseCallsRPS,
|
||||
} from 'container/MetricsApplication/MetricsPageQueries/DBCallQueries';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import {
|
||||
convertRawQueriesToTraceSelectedTags,
|
||||
getResourceDeploymentKeys,
|
||||
resourceAttributesToTagFilterItems,
|
||||
} from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getStep from 'lib/getStep';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import store from 'store';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
GraphTitle,
|
||||
MENU_ITEMS,
|
||||
SERVICE_CHART_ID,
|
||||
SERVICE_DETAIL_DRILLDOWN_ENABLED,
|
||||
} from '../constant';
|
||||
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
|
||||
import { Card, GraphContainer, Row } from '../styles';
|
||||
import { Button } from './styles';
|
||||
import {
|
||||
dbSystemTags,
|
||||
handleNonInQueryRange,
|
||||
onViewTracePopupClick,
|
||||
useGetAPMToTracesQueries,
|
||||
useGraphClickHandler,
|
||||
} from './util';
|
||||
|
||||
function DBCall(): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
|
||||
const { queries } = useResourceAttribute();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const tagFilterItems: TagFilterItem[] = useMemo(
|
||||
() =>
|
||||
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const selectedTraceTags: string = useMemo(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries).concat(...dbSystemTags) || [],
|
||||
),
|
||||
[queries],
|
||||
);
|
||||
|
||||
const legend = '{{db.system}}';
|
||||
|
||||
const databaseCallsRPSWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: databaseCallsRPS({
|
||||
servicename,
|
||||
legend,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.DATABASE_CALLS_RPS,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'reqps',
|
||||
id: SERVICE_CHART_ID.dbCallsRPS,
|
||||
fillSpans: false,
|
||||
}),
|
||||
[servicename, tagFilterItems, legend],
|
||||
);
|
||||
const databaseCallsAverageDurationWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: databaseCallsAvgDuration({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.DATABASE_CALLS_AVG_DURATION,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ms',
|
||||
id: GraphTitle.DATABASE_CALLS_AVG_DURATION,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const stepInterval = useMemo(
|
||||
() =>
|
||||
getStep({
|
||||
end: store.getState().globalTime.maxTime,
|
||||
inputFormat: 'ns',
|
||||
start: store.getState().globalTime.minTime,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
const selectedEnvironments = queries.find(
|
||||
(val) => val.tagKey === getResourceDeploymentKeys(),
|
||||
)?.tagValue;
|
||||
|
||||
logEvent('APM: Service detail page visited', {
|
||||
selectedEnvironments,
|
||||
resourceAttributeUsed: !!queries?.length,
|
||||
section: 'dbMetrics',
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
isDBCall: true,
|
||||
});
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const onGraphClickHandler = useGraphClickHandler(setSelectedTimeStamp);
|
||||
|
||||
return (
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="database_call_rps_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<Card data-testid="database_call_rps">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={databaseCallsRPSWidget}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'database_call_rps',
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="database_call_avg_duration_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
|
||||
<Card data-testid="database_call_avg_duration">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={databaseCallsAverageDurationWidget}
|
||||
headerMenuList={MENU_ITEMS}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'database_call_avg_duration',
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default DBCall;
|
||||
@@ -1,420 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { Col } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import {
|
||||
externalCallDuration,
|
||||
externalCallDurationByAddress,
|
||||
externalCallErrorPercent,
|
||||
externalCallRpsByAddress,
|
||||
} from 'container/MetricsApplication/MetricsPageQueries/ExternalQueries';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import {
|
||||
convertRawQueriesToTraceSelectedTags,
|
||||
getResourceDeploymentKeys,
|
||||
resourceAttributesToTagFilterItems,
|
||||
} from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getStep from 'lib/getStep';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import store from 'store';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
GraphTitle,
|
||||
legend,
|
||||
MENU_ITEMS,
|
||||
SERVICE_DETAIL_DRILLDOWN_ENABLED,
|
||||
} from '../constant';
|
||||
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
|
||||
import { Card, GraphContainer, Row } from '../styles';
|
||||
import GraphControlsPanel from './Overview/GraphControlsPanel/GraphControlsPanel';
|
||||
import {
|
||||
handleNonInQueryRange,
|
||||
onViewAPIMonitoringPopupClick,
|
||||
onViewTracePopupClick,
|
||||
useGetAPMToTracesQueries,
|
||||
useGraphClickHandler,
|
||||
} from './util';
|
||||
|
||||
function External(): JSX.Element {
|
||||
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
|
||||
const [selectedData, setSelectedData] = useState<any>(undefined);
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const tagFilterItems = useMemo(
|
||||
() =>
|
||||
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const externalCallErrorWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: externalCallErrorPercent({
|
||||
servicename,
|
||||
legend: legend.address,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: '%',
|
||||
id: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const selectedTraceTags = useMemo(
|
||||
() => JSON.stringify(convertRawQueriesToTraceSelectedTags(queries) || []),
|
||||
[queries],
|
||||
);
|
||||
|
||||
const externalCallDurationWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: externalCallDuration({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.EXTERNAL_CALL_DURATION,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ms',
|
||||
id: GraphTitle.EXTERNAL_CALL_DURATION,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const errorApmToTraceQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
isExternalCall: true,
|
||||
filters: [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'hasError',
|
||||
dataType: DataTypes.bool,
|
||||
type: 'tag',
|
||||
id: 'hasError--bool--tag--true',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['true'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const stepInterval = useMemo(
|
||||
() =>
|
||||
getStep({
|
||||
end: store.getState().globalTime.maxTime,
|
||||
inputFormat: 'ns',
|
||||
start: store.getState().globalTime.minTime,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
const selectedEnvironments = queries.find(
|
||||
(val) => val.tagKey === getResourceDeploymentKeys(),
|
||||
)?.tagValue;
|
||||
|
||||
logEvent('APM: Service detail page visited', {
|
||||
selectedEnvironments,
|
||||
resourceAttributeUsed: !!queries?.length,
|
||||
section: 'externalMetrics',
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const externalCallRPSWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: externalCallRpsByAddress({
|
||||
servicename,
|
||||
legend: legend.address,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'reqps',
|
||||
id: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const externalCallDurationAddressWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: externalCallDurationByAddress({
|
||||
servicename,
|
||||
legend: legend.address,
|
||||
tagFilterItems,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ms',
|
||||
id: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems],
|
||||
);
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
isExternalCall: true,
|
||||
});
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const onGraphClickHandler = useGraphClickHandler(
|
||||
setSelectedTimeStamp,
|
||||
setSelectedData,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<GraphControlsPanel
|
||||
id="external_call_error_percentage_button"
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery: errorApmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address || '',
|
||||
isError: true,
|
||||
stepInterval: 300,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
<Card data-testid="external_call_error_percentage">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
headerMenuList={MENU_ITEMS}
|
||||
widget={externalCallErrorWidget}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'external_call_error_percentage',
|
||||
data,
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<GraphControlsPanel
|
||||
id="external_call_duration_button"
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
|
||||
<Card data-testid="external_call_duration">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
headerMenuList={MENU_ITEMS}
|
||||
widget={externalCallDurationWidget}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'external_call_duration',
|
||||
data,
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<GraphControlsPanel
|
||||
id="external_call_rps_by_address_button"
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
<Card data-testid="external_call_rps_by_address">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={externalCallRPSWidget}
|
||||
headerMenuList={MENU_ITEMS}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): Promise<void> =>
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'external_call_rps_by_address',
|
||||
data,
|
||||
)
|
||||
}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<GraphControlsPanel
|
||||
id="external_call_duration_by_address_button"
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
|
||||
<Card data-testid="external_call_duration_by_address">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={externalCallDurationAddressWidget}
|
||||
headerMenuList={MENU_ITEMS}
|
||||
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
|
||||
onGraphClickHandler(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
'external_call_duration_by_address',
|
||||
data,
|
||||
);
|
||||
}}
|
||||
onDragSelect={onDragSelect}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default External;
|
||||
@@ -1,412 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import getTopLevelOperations, {
|
||||
ServiceDataProps,
|
||||
} from 'api/metrics/getTopLevelOperations';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { routeConfig } from 'container/SideNav/config';
|
||||
import { getQueryString } from 'container/SideNav/helper';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import {
|
||||
convertRawQueriesToTraceSelectedTags,
|
||||
getResourceDeploymentKeys,
|
||||
resourceAttributesToTagFilterItems,
|
||||
} from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getStep from 'lib/getStep';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { secondsToMilliseconds } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { GraphTitle, SERVICE_CHART_ID } from '../constant';
|
||||
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
|
||||
import {
|
||||
errorPercentage,
|
||||
operationPerSec,
|
||||
} from '../MetricsPageQueries/OverviewQueries';
|
||||
import { Col, ColApDexContainer, ColErrorContainer, Row } from '../styles';
|
||||
import ApDex from './Overview/ApDex';
|
||||
import GraphControlsPanel from './Overview/GraphControlsPanel/GraphControlsPanel';
|
||||
import ServiceOverview from './Overview/ServiceOverview';
|
||||
import TopLevelOperation from './Overview/TopLevelOperations';
|
||||
import TopOperation from './Overview/TopOperation';
|
||||
import TopOperationMetrics from './Overview/TopOperationMetrics';
|
||||
import { Button, Card } from './styles';
|
||||
import {
|
||||
generateExplorerPath,
|
||||
handleNonInQueryRange,
|
||||
onViewTracePopupClick,
|
||||
useGetAPMToLogsQueries,
|
||||
useGetAPMToTracesQueries,
|
||||
useGraphClickHandler,
|
||||
} from './util';
|
||||
|
||||
function Application(): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
|
||||
const { search, pathname } = useAppLocation();
|
||||
const { queries } = useResourceAttribute();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const isSpanMetricEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_SPAN_METRICS)
|
||||
?.active || false;
|
||||
|
||||
const handleSetTimeStamp = useCallback((selectTime: number) => {
|
||||
setSelectedTimeStamp(selectTime);
|
||||
}, []);
|
||||
|
||||
const onGraphClickHandler = useGraphClickHandler(handleSetTimeStamp);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const handleGraphClick = useCallback(
|
||||
(type: string): OnClickPluginOpts['onClick'] =>
|
||||
(xValue, yValue, mouseX, mouseY): Promise<void> =>
|
||||
onGraphClickHandler(xValue, yValue, mouseX, mouseY, type),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[handleSetTimeStamp],
|
||||
);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
const selectedEnvironments = queries.find(
|
||||
(val) => val.tagKey === getResourceDeploymentKeys(),
|
||||
)?.tagValue;
|
||||
|
||||
logEvent('APM: Service detail page visited', {
|
||||
selectedEnvironments,
|
||||
resourceAttributeUsed: !!queries?.length,
|
||||
section: 'overview',
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: topLevelOperations,
|
||||
error: topLevelOperationsError,
|
||||
isLoading: topLevelOperationsIsLoading,
|
||||
isError: topLevelOperationsIsError,
|
||||
} = useQuery<ServiceDataProps>({
|
||||
queryKey: [servicename, minTime, maxTime],
|
||||
queryFn: (): Promise<ServiceDataProps> =>
|
||||
getTopLevelOperations({
|
||||
service: servicename || '',
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
}),
|
||||
// the time range is part of the key, so without this every window change blanks the
|
||||
// operations list and the widgets below are rebuilt with an empty `operation in []`
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const selectedTraceTags: string = JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries) || [],
|
||||
);
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
|
||||
|
||||
const tagFilterItems = useMemo(
|
||||
() =>
|
||||
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const topLevelOperationsRoute = useMemo(
|
||||
() =>
|
||||
topLevelOperations
|
||||
? defaultTo(topLevelOperations[servicename || ''], [])
|
||||
: [],
|
||||
[servicename, topLevelOperations],
|
||||
);
|
||||
|
||||
const operationPerSecWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: operationPerSec({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations: topLevelOperationsRoute,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.RATE_PER_OPS,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ops',
|
||||
id: SERVICE_CHART_ID.rps,
|
||||
}),
|
||||
[servicename, tagFilterItems, topLevelOperationsRoute],
|
||||
);
|
||||
|
||||
const errorPercentageWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: errorPercentage({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperations: topLevelOperationsRoute,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.ERROR_PERCENTAGE,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: '%',
|
||||
id: SERVICE_CHART_ID.errorPercentage,
|
||||
fillSpans: true,
|
||||
}),
|
||||
[servicename, tagFilterItems, topLevelOperationsRoute],
|
||||
);
|
||||
|
||||
const stepInterval = useMemo(
|
||||
() =>
|
||||
getStep({
|
||||
end: maxTime,
|
||||
inputFormat: 'ns',
|
||||
start: minTime,
|
||||
}),
|
||||
[maxTime, minTime],
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param timestamp - The timestamp in seconds
|
||||
* @param apmToTraceQuery - query object
|
||||
* @param isViewLogsClicked - Whether this is for viewing logs vs traces
|
||||
* @returns A callback function that handles the navigation when executed
|
||||
*/
|
||||
const onErrorTrackHandler = useCallback(
|
||||
(
|
||||
timestamp: number,
|
||||
apmToTraceQuery: Query,
|
||||
isViewLogsClicked?: boolean,
|
||||
): ((e: React.MouseEvent) => void) =>
|
||||
(e: React.MouseEvent): void => {
|
||||
const endTime = secondsToMilliseconds(timestamp);
|
||||
const startTime = secondsToMilliseconds(timestamp - stepInterval);
|
||||
|
||||
const urlParams = new URLSearchParams(search);
|
||||
urlParams.set(QueryParams.startTime, startTime.toString());
|
||||
urlParams.set(QueryParams.endTime, endTime.toString());
|
||||
urlParams.delete(QueryParams.relativeTime);
|
||||
const avialableParams = routeConfig[ROUTES.TRACES_EXPLORER];
|
||||
const queryString = getQueryString(avialableParams, urlParams);
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(
|
||||
JSON.stringify(apmToTraceQuery),
|
||||
);
|
||||
|
||||
const newPath = generateExplorerPath(
|
||||
isViewLogsClicked,
|
||||
urlParams,
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
JSONCompositeQuery,
|
||||
queryString,
|
||||
);
|
||||
|
||||
if (isModifierKeyPressed(e)) {
|
||||
openInNewTab(newPath);
|
||||
} else {
|
||||
navigate(newPath);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stepInterval],
|
||||
);
|
||||
|
||||
const logErrorQuery = useGetAPMToLogsQueries({
|
||||
servicename,
|
||||
filters: [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'severity_text',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'severity_text--string----true',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['ERROR', 'FATAL', 'error', 'fatal'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const errorTrackQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
filters: [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'hasError',
|
||||
dataType: DataTypes.bool,
|
||||
type: 'tag',
|
||||
id: 'hasError--bool--tag--true',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['true'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<ServiceOverview
|
||||
onDragSelect={onDragSelect}
|
||||
handleGraphClick={handleGraphClick}
|
||||
selectedTimeStamp={selectedTimeStamp}
|
||||
selectedTraceTags={selectedTraceTags}
|
||||
topLevelOperationsRoute={topLevelOperationsRoute}
|
||||
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="Rate_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<TopLevelOperation
|
||||
handleGraphClick={handleGraphClick}
|
||||
onDragSelect={onDragSelect}
|
||||
topLevelOperationsError={topLevelOperationsError}
|
||||
topLevelOperationsIsError={topLevelOperationsIsError}
|
||||
name="operations_per_sec"
|
||||
widget={operationPerSecWidget}
|
||||
opName="Rate"
|
||||
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<ColApDexContainer>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
id="ApDex_button"
|
||||
onClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
>
|
||||
View Traces
|
||||
</Button>
|
||||
<ApDex
|
||||
handleGraphClick={handleGraphClick}
|
||||
onDragSelect={onDragSelect}
|
||||
topLevelOperationsRoute={topLevelOperationsRoute}
|
||||
tagFilterItems={tagFilterItems}
|
||||
/>
|
||||
</ColApDexContainer>
|
||||
<ColErrorContainer>
|
||||
<GraphControlsPanel
|
||||
id="Error_button"
|
||||
onViewLogsClick={onErrorTrackHandler(
|
||||
selectedTimeStamp,
|
||||
logErrorQuery,
|
||||
true,
|
||||
)}
|
||||
onViewTracesClick={onErrorTrackHandler(
|
||||
selectedTimeStamp,
|
||||
errorTrackQuery,
|
||||
)}
|
||||
/>
|
||||
|
||||
<TopLevelOperation
|
||||
handleGraphClick={handleGraphClick}
|
||||
onDragSelect={onDragSelect}
|
||||
topLevelOperationsError={topLevelOperationsError}
|
||||
topLevelOperationsIsError={topLevelOperationsIsError}
|
||||
name="error_percentage_%"
|
||||
widget={errorPercentageWidget}
|
||||
opName="Error"
|
||||
topLevelOperationsIsLoading={topLevelOperationsIsLoading}
|
||||
/>
|
||||
</ColErrorContainer>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card>
|
||||
{isSpanMetricEnabled ? <TopOperationMetrics /> : <TopOperation />}{' '}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type ClickHandlerType = () => void;
|
||||
|
||||
export default Application;
|
||||
@@ -1,109 +0,0 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import {
|
||||
apDexToolTipText,
|
||||
apDexToolTipUrl,
|
||||
apDexToolTipUrlText,
|
||||
} from 'constants/apDex';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import DisplayThreshold from 'container/WidgetCard/Header/DisplayThreshold';
|
||||
import {
|
||||
GraphTitle,
|
||||
SERVICE_CHART_ID,
|
||||
SERVICE_DETAIL_DRILLDOWN_ENABLED,
|
||||
} from 'container/MetricsApplication/constant';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { apDexMetricsQueryBuilderQueries } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ApDexMetricsProps } from './types';
|
||||
|
||||
function ApDexMetrics({
|
||||
delta,
|
||||
metricsBuckets,
|
||||
thresholdValue,
|
||||
onDragSelect,
|
||||
tagFilterItems,
|
||||
topLevelOperationsRoute,
|
||||
handleGraphClick,
|
||||
}: ApDexMetricsProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const apDexMetricsWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: apDexMetricsQueryBuilderQueries({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
topLevelOperationsRoute,
|
||||
threashold: thresholdValue || 0,
|
||||
delta: delta || false,
|
||||
metricsBuckets: metricsBuckets || [],
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: (
|
||||
<Space>
|
||||
<Typography>{GraphTitle.APDEX}</Typography>
|
||||
<TextToolTip
|
||||
text={apDexToolTipText}
|
||||
url={apDexToolTipUrl}
|
||||
useFilledIcon={false}
|
||||
urlText={apDexToolTipUrlText}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
id: SERVICE_CHART_ID.apdex,
|
||||
}),
|
||||
[
|
||||
delta,
|
||||
metricsBuckets,
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
thresholdValue,
|
||||
topLevelOperationsRoute,
|
||||
],
|
||||
);
|
||||
|
||||
const threshold: ReactNode = useMemo(() => {
|
||||
if (thresholdValue) {
|
||||
return <DisplayThreshold threshold={thresholdValue} />;
|
||||
}
|
||||
return null;
|
||||
}, [thresholdValue]);
|
||||
|
||||
const isQueryEnabled =
|
||||
topLevelOperationsRoute.length > 0 &&
|
||||
!!metricsBuckets &&
|
||||
metricsBuckets?.length > 0 &&
|
||||
delta !== undefined;
|
||||
|
||||
return (
|
||||
<Graph
|
||||
widget={apDexMetricsWidget}
|
||||
onDragSelect={onDragSelect}
|
||||
onClickHandler={handleGraphClick('ApDex')}
|
||||
threshold={threshold}
|
||||
isQueryEnabled={isQueryEnabled}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ApDexMetrics.defaultProps = {
|
||||
delta: undefined,
|
||||
le: undefined,
|
||||
};
|
||||
|
||||
export default ApDexMetrics;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { useGetMetricMeta } from 'hooks/apDex/useGetMetricMeta';
|
||||
import useErrorNotification from 'hooks/useErrorNotification';
|
||||
|
||||
import { WidgetKeys } from '../../../constant';
|
||||
import ApDexMetrics from './ApDexMetrics';
|
||||
import { ApDexDataSwitcherProps } from './types';
|
||||
|
||||
function ApDexMetricsApplication({
|
||||
handleGraphClick,
|
||||
onDragSelect,
|
||||
tagFilterItems,
|
||||
thresholdValue,
|
||||
topLevelOperationsRoute,
|
||||
}: ApDexDataSwitcherProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const { data, isLoading, error } = useGetMetricMeta(
|
||||
WidgetKeys.SignozLatencyBucket,
|
||||
servicename,
|
||||
);
|
||||
useErrorNotification(error);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner height="40vh" tip="Loading..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ApDexMetrics
|
||||
topLevelOperationsRoute={topLevelOperationsRoute}
|
||||
handleGraphClick={handleGraphClick}
|
||||
delta={data?.data.delta}
|
||||
metricsBuckets={data?.data.le || []}
|
||||
onDragSelect={onDragSelect}
|
||||
tagFilterItems={tagFilterItems}
|
||||
thresholdValue={thresholdValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApDexMetricsApplication;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
|
||||
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import ApDexMetricsApplication from './ApDexMetricsApplication';
|
||||
import { ApDexApplicationProps } from './types';
|
||||
|
||||
function ApDexApplication({
|
||||
handleGraphClick,
|
||||
onDragSelect,
|
||||
topLevelOperationsRoute,
|
||||
tagFilterItems,
|
||||
}: ApDexApplicationProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { data, isLoading, error, isRefetching } =
|
||||
useGetApDexSettings(servicename);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
}
|
||||
}, [error, notifications]);
|
||||
|
||||
if (isLoading || isRefetching) {
|
||||
return (
|
||||
<Card>
|
||||
<Spinner height="40vh" tip="Loading..." />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-testid="apdex">
|
||||
<GraphContainer>
|
||||
<ApDexMetricsApplication
|
||||
handleGraphClick={handleGraphClick}
|
||||
onDragSelect={onDragSelect}
|
||||
topLevelOperationsRoute={topLevelOperationsRoute}
|
||||
tagFilterItems={tagFilterItems}
|
||||
thresholdValue={data?.data[0].threshold}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ApDexApplication);
|
||||
@@ -1,18 +0,0 @@
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface ApDexApplicationProps {
|
||||
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
topLevelOperationsRoute: string[];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
}
|
||||
|
||||
export interface ApDexDataSwitcherProps extends ApDexApplicationProps {
|
||||
thresholdValue?: number;
|
||||
}
|
||||
|
||||
export interface ApDexMetricsProps extends ApDexDataSwitcherProps {
|
||||
delta?: boolean;
|
||||
metricsBuckets?: number[];
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.graph-controls-panel {
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
display: none;
|
||||
width: 150px;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--l2-background);
|
||||
|
||||
.ant-btn-link {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-btn-link:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import { DraftingCompass, ScrollText, Binoculars } from '@signozhq/icons';
|
||||
|
||||
import './GraphControlsPanel.styles.scss';
|
||||
|
||||
interface GraphControlsPanelProps {
|
||||
id: string;
|
||||
onViewLogsClick?: (e: React.MouseEvent) => void;
|
||||
onViewTracesClick: (e: React.MouseEvent) => void;
|
||||
onViewAPIMonitoringClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function GraphControlsPanel({
|
||||
id,
|
||||
onViewLogsClick,
|
||||
onViewTracesClick,
|
||||
onViewAPIMonitoringClick,
|
||||
}: GraphControlsPanelProps): JSX.Element {
|
||||
return (
|
||||
<div id={id} className="graph-controls-panel">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<DraftingCompass size={14} />}
|
||||
size="small"
|
||||
onClick={onViewTracesClick}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View traces
|
||||
</Button>
|
||||
{onViewLogsClick && (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ScrollText size={14} />}
|
||||
size="small"
|
||||
onClick={onViewLogsClick}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View logs
|
||||
</Button>
|
||||
)}
|
||||
{onViewAPIMonitoringClick && (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<Binoculars size={14} />}
|
||||
size="small"
|
||||
onClick={onViewAPIMonitoringClick}
|
||||
style={{ color: Color.BG_VANILLA_100 }}
|
||||
>
|
||||
View External APIs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GraphControlsPanel.defaultProps = {
|
||||
onViewLogsClick: undefined,
|
||||
onViewAPIMonitoringClick: undefined,
|
||||
};
|
||||
|
||||
export default GraphControlsPanel;
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import {
|
||||
GraphTitle,
|
||||
SERVICE_CHART_ID,
|
||||
SERVICE_DETAIL_DRILLDOWN_ENABLED,
|
||||
} from 'container/MetricsApplication/constant';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { latency } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
|
||||
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { resourceAttributesToTagFilterItems } from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
handleNonInQueryRange,
|
||||
onViewTracePopupClick,
|
||||
useGetAPMToLogsQueries,
|
||||
useGetAPMToTracesQueries,
|
||||
} from '../util';
|
||||
import GraphControlsPanel from './GraphControlsPanel/GraphControlsPanel';
|
||||
|
||||
function ServiceOverview({
|
||||
onDragSelect,
|
||||
handleGraphClick,
|
||||
selectedTraceTags,
|
||||
selectedTimeStamp,
|
||||
topLevelOperationsRoute,
|
||||
topLevelOperationsIsLoading,
|
||||
stepInterval,
|
||||
}: ServiceOverviewProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const isSpanMetricEnable =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_SPAN_METRICS)
|
||||
?.active || false;
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const tagFilterItems = useMemo(
|
||||
() =>
|
||||
handleNonInQueryRange(
|
||||
resourceAttributesToTagFilterItems(queries, !isSpanMetricEnable),
|
||||
) || [],
|
||||
[isSpanMetricEnable, queries],
|
||||
);
|
||||
|
||||
const latencyWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: latency({
|
||||
servicename,
|
||||
tagFilterItems,
|
||||
isSpanMetricEnable,
|
||||
topLevelOperationsRoute,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
title: GraphTitle.LATENCY,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
yAxisUnit: 'ns',
|
||||
id: SERVICE_CHART_ID.latency,
|
||||
}),
|
||||
[isSpanMetricEnable, servicename, tagFilterItems, topLevelOperationsRoute],
|
||||
);
|
||||
|
||||
const isQueryEnabled =
|
||||
!topLevelOperationsIsLoading && topLevelOperationsRoute.length > 0;
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
|
||||
|
||||
const apmToLogQuery = useGetAPMToLogsQueries({ servicename });
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GraphControlsPanel
|
||||
id="Service_button"
|
||||
onViewLogsClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery: apmToLogQuery,
|
||||
isViewLogsClicked: true,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
onViewTracesClick={onViewTracePopupClick({
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
timestamp: selectedTimeStamp,
|
||||
apmToTraceQuery,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
<Card data-testid="service_latency">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
onDragSelect={onDragSelect}
|
||||
widget={latencyWidget}
|
||||
onClickHandler={handleGraphClick('Service')}
|
||||
isQueryEnabled={isQueryEnabled}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
interface ServiceOverviewProps {
|
||||
stepInterval: number;
|
||||
selectedTimeStamp: number;
|
||||
selectedTraceTags: string;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
|
||||
topLevelOperationsRoute: string[];
|
||||
topLevelOperationsIsLoading: boolean;
|
||||
}
|
||||
|
||||
export default ServiceOverview;
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { navigateToTrace } from 'container/MetricsApplication/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useGetAPMToTracesQueries } from '../../util';
|
||||
|
||||
function ColumnWithLink({
|
||||
servicename,
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTraceTags,
|
||||
record,
|
||||
}: LinkColumnProps): JSX.Element {
|
||||
const text = record.toString();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
filters: [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
},
|
||||
op: 'in',
|
||||
value: [text],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handleOnClick = (operation: string, openInNewTab: boolean): void => {
|
||||
navigateToTrace({
|
||||
servicename,
|
||||
operation,
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTraceTags,
|
||||
apmToTraceQuery,
|
||||
safeNavigate,
|
||||
openInNewTab,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip placement="topLeft" title={text}>
|
||||
<Typography.Link
|
||||
onClick={(e): void => handleOnClick(text, e.metaKey || e.ctrlKey)}
|
||||
>
|
||||
{text}
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface LinkColumnProps {
|
||||
servicename: string;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
selectedTraceTags: string;
|
||||
record: RowData;
|
||||
}
|
||||
|
||||
export default ColumnWithLink;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
|
||||
import { TableRendererProps } from '../../types';
|
||||
|
||||
export const getTableColumnRenderer = ({
|
||||
columnName,
|
||||
renderFunction,
|
||||
}: TableRendererProps): Record<string, (record: RowData) => ReactNode> => ({
|
||||
[columnName]: renderFunction,
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import Graph from 'container/WidgetCard/Card';
|
||||
import { SERVICE_DETAIL_DRILLDOWN_ENABLED } from 'container/MetricsApplication/constant';
|
||||
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
|
||||
function TopLevelOperation({
|
||||
name,
|
||||
opName,
|
||||
topLevelOperationsIsError,
|
||||
topLevelOperationsError,
|
||||
onDragSelect,
|
||||
handleGraphClick,
|
||||
widget,
|
||||
topLevelOperationsIsLoading,
|
||||
}: TopLevelOperationProps): JSX.Element {
|
||||
return (
|
||||
<Card data-testid={name}>
|
||||
{topLevelOperationsIsError ? (
|
||||
<Typography>
|
||||
{axios.isAxiosError(topLevelOperationsError)
|
||||
? topLevelOperationsError.response?.data
|
||||
: SOMETHING_WENT_WRONG}
|
||||
</Typography>
|
||||
) : (
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={widget}
|
||||
onClickHandler={handleGraphClick(opName)}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={!topLevelOperationsIsLoading}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
</GraphContainer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface TopLevelOperationProps {
|
||||
name: string;
|
||||
opName: string;
|
||||
topLevelOperationsIsError: boolean;
|
||||
topLevelOperationsError: unknown;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
handleGraphClick: (type: string) => OnClickPluginOpts['onClick'];
|
||||
widget: Widgets;
|
||||
topLevelOperationsIsLoading: boolean;
|
||||
}
|
||||
|
||||
export default TopLevelOperation;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import getTopOperations from 'api/metrics/getTopOperations';
|
||||
import TopOperationsTable from 'container/MetricsApplication/TopOperationsTable';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { PayloadProps } from 'types/api/metrics/getTopOperations';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
|
||||
function TopOperation(): JSX.Element {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { servicename: encodedServiceName } = useAppParams<{
|
||||
servicename?: string;
|
||||
}>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const [isEntryPoint, setIsEntryPoint] = useState<boolean>(false);
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
const selectedTags = useMemo(
|
||||
() => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [],
|
||||
[queries],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useQuery<PayloadProps>({
|
||||
queryKey: [minTime, maxTime, servicename, selectedTags, isEntryPoint],
|
||||
queryFn: (): Promise<PayloadProps> =>
|
||||
getTopOperations({
|
||||
service: servicename || '',
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
selectedTags,
|
||||
isEntryPoint,
|
||||
}),
|
||||
});
|
||||
|
||||
const topOperationData = data || [];
|
||||
|
||||
return (
|
||||
<TopOperationsTable
|
||||
data={topOperationData}
|
||||
isLoading={isLoading}
|
||||
isEntryPoint={isEntryPoint}
|
||||
onEntryPointToggle={setIsEntryPoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopOperation;
|
||||
@@ -1,123 +0,0 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { topOperationMetricsDownloadOptions } from 'container/MetricsApplication/constant';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { topOperationQueries } from 'container/MetricsApplication/MetricsPageQueries/TopOperationQueries';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { title } from './config';
|
||||
import ColumnWithLink from './TableRenderer/ColumnWithLink';
|
||||
import { getTableColumnRenderer } from './TableRenderer/TableColumnRenderer';
|
||||
|
||||
function TopOperationMetrics(): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const {
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTime: globalSelectedInterval,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const selectedTraceTags = JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries) || [],
|
||||
);
|
||||
|
||||
const keyOperationWidget = useMemo(
|
||||
() =>
|
||||
getWidgetQueryBuilder({
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: topOperationQueries({
|
||||
servicename,
|
||||
}),
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
panelTypes: PANEL_TYPES.TABLE,
|
||||
}),
|
||||
[servicename],
|
||||
);
|
||||
|
||||
const updatedQuery = updateStepInterval(keyOperationWidget.query);
|
||||
|
||||
const isEmptyWidget = keyOperationWidget.id === PANEL_TYPES.EMPTY_WIDGET;
|
||||
|
||||
const { data, isLoading } = useGetQueryRange(
|
||||
{
|
||||
selectedTime: keyOperationWidget?.timePreferance,
|
||||
graphType: keyOperationWidget?.panelTypes,
|
||||
query: updatedQuery,
|
||||
globalSelectedInterval,
|
||||
variables: {},
|
||||
},
|
||||
ENTITY_VERSION_V4,
|
||||
{
|
||||
queryKey: [
|
||||
`GetMetricsQueryRange-${keyOperationWidget?.timePreferance}-${globalSelectedInterval}-${keyOperationWidget?.id}`,
|
||||
keyOperationWidget,
|
||||
maxTime,
|
||||
minTime,
|
||||
globalSelectedInterval,
|
||||
],
|
||||
keepPreviousData: true,
|
||||
enabled: !isEmptyWidget,
|
||||
refetchOnMount: false,
|
||||
onError: (error) => {
|
||||
notifications.error({ message: error.message });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const queryTableData = data?.payload?.data?.newResult?.data?.result || [];
|
||||
|
||||
const renderColumnCell = useMemo(
|
||||
() =>
|
||||
getTableColumnRenderer({
|
||||
columnName: 'operation',
|
||||
renderFunction: (record: RowData): ReactNode => (
|
||||
<ColumnWithLink
|
||||
servicename={servicename}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
selectedTraceTags={selectedTraceTags}
|
||||
record={record}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[servicename, minTime, maxTime, selectedTraceTags],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryTable
|
||||
title={title}
|
||||
query={updatedQuery}
|
||||
queryTableData={queryTableData}
|
||||
loading={isLoading}
|
||||
renderColumnCell={renderColumnCell}
|
||||
downloadOption={topOperationMetricsDownloadOptions}
|
||||
sticky
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopOperationMetrics;
|
||||
@@ -1 +0,0 @@
|
||||
export const title = (): string => 'Key Operations';
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
@@ -8,19 +6,6 @@ export interface IServiceName {
|
||||
servicename: string;
|
||||
}
|
||||
|
||||
export interface TopOperationQueryFactoryProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
}
|
||||
|
||||
export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
|
||||
legend: string;
|
||||
}
|
||||
|
||||
export interface ExternalCallProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
}
|
||||
|
||||
export interface BuilderQueriesProps {
|
||||
autocompleteData: BaseAutocompleteData[];
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
@@ -45,33 +30,3 @@ export interface BuilderQuerieswithFormulaProps {
|
||||
spaceAggregateOperators: MetricAggregateOperator[];
|
||||
dataSource: DataSource;
|
||||
}
|
||||
|
||||
export interface OperationPerSecProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
topLevelOperations: string[];
|
||||
}
|
||||
|
||||
export interface LatencyProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
isSpanMetricEnable?: boolean;
|
||||
topLevelOperationsRoute: string[];
|
||||
}
|
||||
|
||||
export interface ApDexProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
topLevelOperationsRoute: string[];
|
||||
threashold: number;
|
||||
}
|
||||
|
||||
export interface TableRendererProps {
|
||||
columnName: string;
|
||||
renderFunction: (record: RowData) => ReactNode;
|
||||
}
|
||||
|
||||
export interface ApDexMetricsQueryBuilderQueriesProps extends ApDexProps {
|
||||
delta: boolean;
|
||||
metricsBuckets: number[];
|
||||
}
|
||||
|
||||
@@ -1,171 +1,9 @@
|
||||
import React, { Dispatch, SetStateAction, useMemo, useRef } from 'react';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
|
||||
import { routeConfig } from 'container/SideNav/config';
|
||||
import { getQueryString } from 'container/SideNav/helper';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Dispatch, SetStateAction, useRef } from 'react';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
|
||||
import { traceFilterKeys } from 'constants/traceFilterKeys';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { secondsToMilliseconds } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export const dbSystemTags: Tags[] = [
|
||||
{
|
||||
Key: 'db.system.(string)',
|
||||
StringValues: [''],
|
||||
NumberValues: [],
|
||||
BoolValues: [],
|
||||
Operator: 'Exists',
|
||||
},
|
||||
];
|
||||
|
||||
interface OnViewTracePopupClickProps {
|
||||
servicename: string | undefined;
|
||||
selectedTraceTags: string;
|
||||
timestamp: number;
|
||||
apmToTraceQuery: Query;
|
||||
isViewLogsClicked?: boolean;
|
||||
stepInterval?: number;
|
||||
safeNavigate: (url: string, options?: { newTab?: boolean }) => void;
|
||||
}
|
||||
|
||||
interface OnViewAPIMonitoringPopupClickProps {
|
||||
servicename: string;
|
||||
timestamp: number;
|
||||
stepInterval?: number;
|
||||
domainName: string;
|
||||
isError: boolean;
|
||||
safeNavigate: (url: string, options?: { newTab?: boolean }) => void;
|
||||
}
|
||||
|
||||
export function generateExplorerPath(
|
||||
isViewLogsClicked: boolean | undefined,
|
||||
urlParams: URLSearchParams,
|
||||
servicename: string | undefined,
|
||||
selectedTraceTags: string,
|
||||
JSONCompositeQuery: string,
|
||||
queryString: string[],
|
||||
): string {
|
||||
const basePath = isViewLogsClicked
|
||||
? ROUTES.LOGS_EXPLORER
|
||||
: ROUTES.TRACES_EXPLORER;
|
||||
|
||||
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}&${queryString.join('&')}`;
|
||||
}
|
||||
|
||||
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
|
||||
|
||||
/**
|
||||
* Handles click events for viewing trace/logs popup
|
||||
* @param selectedTraceTags - Selected trace tags
|
||||
* @param servicename - Name of the service
|
||||
* @param timestamp - Timestamp in seconds
|
||||
* @param apmToTraceQuery - Query object
|
||||
* @param isViewLogsClicked - Whether this is for viewing logs vs traces
|
||||
* @param stepInterval - Time interval in seconds
|
||||
* @param safeNavigate - Navigation function
|
||||
|
||||
*/
|
||||
export function onViewTracePopupClick({
|
||||
selectedTraceTags,
|
||||
servicename,
|
||||
timestamp,
|
||||
apmToTraceQuery,
|
||||
isViewLogsClicked,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
}: OnViewTracePopupClickProps): (e?: React.MouseEvent) => void {
|
||||
return (e?: React.MouseEvent): void => {
|
||||
const endTime = secondsToMilliseconds(timestamp);
|
||||
const startTime = secondsToMilliseconds(timestamp - (stepInterval || 60));
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.set(QueryParams.startTime, startTime.toString());
|
||||
urlParams.set(QueryParams.endTime, endTime.toString());
|
||||
urlParams.delete(QueryParams.relativeTime);
|
||||
const avialableParams = routeConfig[ROUTES.TRACES_EXPLORER];
|
||||
const queryString = getQueryString(avialableParams, urlParams);
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(
|
||||
JSON.stringify(apmToTraceQuery),
|
||||
);
|
||||
|
||||
const newPath = generateExplorerPath(
|
||||
isViewLogsClicked,
|
||||
urlParams,
|
||||
servicename,
|
||||
selectedTraceTags,
|
||||
JSONCompositeQuery,
|
||||
queryString,
|
||||
);
|
||||
|
||||
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });
|
||||
};
|
||||
}
|
||||
|
||||
const generateAPIMonitoringPath = (
|
||||
domainName: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
filterExpression: string,
|
||||
): string => {
|
||||
const basePath = ROUTES.API_MONITORING;
|
||||
return `${basePath}?${createQueryParams({
|
||||
apiMonitoringParams: JSON.stringify({
|
||||
selectedDomain: domainName,
|
||||
selectedView: 'endpoint_stats',
|
||||
modalTimeRange: {
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
selectedInterval: 'custom',
|
||||
}),
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: filterExpression,
|
||||
})}`;
|
||||
};
|
||||
export function onViewAPIMonitoringPopupClick({
|
||||
servicename,
|
||||
timestamp,
|
||||
domainName,
|
||||
isError,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
}: OnViewAPIMonitoringPopupClickProps): (e?: React.MouseEvent) => void {
|
||||
return (e?: React.MouseEvent): void => {
|
||||
const endTime = timestamp + (stepInterval || 60);
|
||||
const startTime = timestamp - (stepInterval || 60);
|
||||
const filterExpression = [
|
||||
...(isError ? ["hasError IN ['true']"] : []),
|
||||
`service.name = ${formatValueForExpression(servicename)}`,
|
||||
].join(' AND ');
|
||||
const newPath = generateAPIMonitoringPath(
|
||||
domainName,
|
||||
startTime,
|
||||
endTime,
|
||||
filterExpression,
|
||||
);
|
||||
|
||||
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });
|
||||
};
|
||||
}
|
||||
|
||||
export function useGraphClickHandler(
|
||||
setSelectedTimeStamp: (n: number) => void | Dispatch<SetStateAction<number>>,
|
||||
setSelectedData?: (data: any) => void | Dispatch<SetStateAction<any>>,
|
||||
@@ -217,17 +55,6 @@ export function useGraphClickHandler(
|
||||
};
|
||||
}
|
||||
|
||||
export const handleNonInQueryRange = (tags: TagFilterItem[]): TagFilterItem[] =>
|
||||
tags.map((tag) => {
|
||||
if (tag.op === 'Not IN') {
|
||||
return {
|
||||
...tag,
|
||||
op: 'NIN',
|
||||
};
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
export function handleQueryChange(
|
||||
query: Query,
|
||||
attributeKeys: BaseAutocompleteData,
|
||||
@@ -258,114 +85,3 @@ export function handleQueryChange(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useGetAPMToLogsQueries({
|
||||
servicename,
|
||||
filters,
|
||||
}: {
|
||||
servicename: string;
|
||||
filters?: TagFilterItem[];
|
||||
}): Query {
|
||||
const finalFilters: TagFilterItem[] = [];
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
let updatedQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
const serviceName = {
|
||||
id: 'service.name--string--resource--true',
|
||||
dataType: DataTypes.String,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
};
|
||||
|
||||
if (filters?.length) {
|
||||
finalFilters.push(...filters);
|
||||
}
|
||||
updatedQuery = prepareQueryWithDefaultTimestamp(updatedQuery);
|
||||
return handleQueryChange(
|
||||
updatedQuery,
|
||||
serviceName,
|
||||
servicename,
|
||||
finalFilters,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export function useGetAPMToTracesQueries({
|
||||
servicename,
|
||||
isExternalCall,
|
||||
isDBCall,
|
||||
filters,
|
||||
}: {
|
||||
servicename: string;
|
||||
isExternalCall?: boolean;
|
||||
isDBCall?: boolean;
|
||||
filters?: TagFilterItem[];
|
||||
}): Query {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const resourceAttributesFilters = useMemo(
|
||||
() => resourceAttributesToTracesFilterItems(queries),
|
||||
[queries],
|
||||
);
|
||||
const finalFilters: TagFilterItem[] = [];
|
||||
let spanKindFilter: TagFilterItem;
|
||||
let dbCallFilter: TagFilterItem;
|
||||
|
||||
if (isExternalCall) {
|
||||
spanKindFilter = {
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'spanKind',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'spanKind--string--tag--true',
|
||||
},
|
||||
op: '=',
|
||||
value: 'Client',
|
||||
};
|
||||
finalFilters.push(spanKindFilter);
|
||||
}
|
||||
|
||||
if (isDBCall) {
|
||||
dbCallFilter = {
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'dbSystem',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'dbSystem--string--tag--true',
|
||||
},
|
||||
op: 'exists',
|
||||
value: '',
|
||||
};
|
||||
finalFilters.push(dbCallFilter);
|
||||
}
|
||||
|
||||
if (filters?.length) {
|
||||
finalFilters.push(...filters);
|
||||
}
|
||||
|
||||
if (resourceAttributesFilters?.length) {
|
||||
finalFilters.push(...resourceAttributesFilters);
|
||||
}
|
||||
|
||||
return useMemo(() => {
|
||||
const updatedQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.TRACE,
|
||||
DataSource.TRACES,
|
||||
);
|
||||
|
||||
return handleQueryChange(
|
||||
updatedQuery,
|
||||
traceFilterKeys.serviceName,
|
||||
servicename,
|
||||
finalFilters,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [servicename, queries, updateAllQueriesOperators]);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.top-operation {
|
||||
position: relative;
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 0px;
|
||||
z-index: 1;
|
||||
gap: 8px;
|
||||
}
|
||||
&__entry-point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
&__download {
|
||||
.ant-btn-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
import { Search } from '@signozhq/icons';
|
||||
import {
|
||||
InputRef,
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import Download from 'container/Download/Download';
|
||||
import { filterDropdown } from 'container/ServiceApplication/Filter/FilterDropdown';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useGetAPMToTracesQueries } from './Tabs/util';
|
||||
import {
|
||||
convertedTracesToDownloadData,
|
||||
getErrorRate,
|
||||
navigateToTrace,
|
||||
} from './utils';
|
||||
|
||||
import './TopOperationsTable.styles.scss';
|
||||
|
||||
function TopOperationsTable({
|
||||
data,
|
||||
isLoading,
|
||||
isEntryPoint,
|
||||
onEntryPointToggle,
|
||||
}: TopOperationsTableProps): JSX.Element {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { queries } = useResourceAttribute();
|
||||
|
||||
const selectedTraceTags: string = JSON.stringify(
|
||||
convertRawQueriesToTraceSelectedTags(queries) || [],
|
||||
);
|
||||
|
||||
const apmToTraceQuery = useGetAPMToTracesQueries({ servicename });
|
||||
|
||||
const params = useAppParams<'servicename'>();
|
||||
|
||||
const handleOnClick = (operation: string, openInNewTab: boolean): void => {
|
||||
const { servicename: encodedServiceName } = params;
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const opFilters: TagFilterItem[] = [
|
||||
{
|
||||
id: uuid().slice(0, 8),
|
||||
key: {
|
||||
key: 'name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
},
|
||||
op: 'in',
|
||||
value: [operation],
|
||||
},
|
||||
];
|
||||
|
||||
const preparedQuery: Query = {
|
||||
...apmToTraceQuery,
|
||||
builder: {
|
||||
...apmToTraceQuery.builder,
|
||||
queryData: apmToTraceQuery.builder.queryData?.map((item) => ({
|
||||
...item,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: [...(item.filters?.items || []), ...opFilters],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
navigateToTrace({
|
||||
servicename,
|
||||
operation,
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTraceTags,
|
||||
apmToTraceQuery: preparedQuery,
|
||||
safeNavigate,
|
||||
openInNewTab,
|
||||
});
|
||||
};
|
||||
|
||||
const getSearchOption = (): ColumnType<TopOperationList> => ({
|
||||
filterDropdown,
|
||||
filterIcon: <Search size="md" />,
|
||||
onFilter: (value, record): boolean =>
|
||||
record.name
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes((value as string).toLowerCase()),
|
||||
onFilterDropdownOpenChange: (visible): void => {
|
||||
if (visible) {
|
||||
setTimeout(() => searchInput.current?.select(), 100);
|
||||
}
|
||||
},
|
||||
render: (text: string): JSX.Element => (
|
||||
<Tooltip placement="topLeft" title={text}>
|
||||
<Typography.Link
|
||||
testId="top-operation-link"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
handleOnClick(text, true); // open in new tab
|
||||
} else {
|
||||
handleOnClick(text, false); // open in current tab
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
|
||||
const columns: ColumnsType<TopOperationList> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
...getSearchOption(),
|
||||
},
|
||||
{
|
||||
title: 'P50 (in ms)',
|
||||
dataIndex: 'p50',
|
||||
key: 'p50',
|
||||
width: 50,
|
||||
sorter: (a: TopOperationList, b: TopOperationList): number => a.p50 - b.p50,
|
||||
render: (value: number): string => (value / 1_000_000).toFixed(2),
|
||||
},
|
||||
{
|
||||
title: 'P95 (in ms)',
|
||||
dataIndex: 'p95',
|
||||
key: 'p95',
|
||||
width: 50,
|
||||
sorter: (a: TopOperationList, b: TopOperationList): number => a.p95 - b.p95,
|
||||
render: (value: number): string => (value / 1_000_000).toFixed(2),
|
||||
},
|
||||
{
|
||||
title: 'P99 (in ms)',
|
||||
dataIndex: 'p99',
|
||||
key: 'p99',
|
||||
width: 50,
|
||||
sorter: (a: TopOperationList, b: TopOperationList): number => a.p99 - b.p99,
|
||||
render: (value: number): string => (value / 1_000_000).toFixed(2),
|
||||
},
|
||||
{
|
||||
title: 'Number of Calls',
|
||||
dataIndex: 'numCalls',
|
||||
key: 'numCalls',
|
||||
width: 50,
|
||||
sorter: (a: TopOperationList, b: TopOperationList): number =>
|
||||
a.numCalls - b.numCalls,
|
||||
},
|
||||
{
|
||||
title: 'Error Rate',
|
||||
dataIndex: 'errorCount',
|
||||
key: 'errorCount',
|
||||
width: 50,
|
||||
sorter: (first: TopOperationList, second: TopOperationList): number =>
|
||||
getErrorRate(first) - getErrorRate(second),
|
||||
render: (_, record: TopOperationList): string =>
|
||||
`${getErrorRate(record).toFixed(2)} %`,
|
||||
},
|
||||
];
|
||||
|
||||
const downloadableData = convertedTracesToDownloadData(data);
|
||||
|
||||
const paginationConfig = {
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
|
||||
const entryPointSpanInfo = {
|
||||
text: 'Shows the spans where requests enter new services for the first time',
|
||||
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
|
||||
urlText: 'Learn more about Entrypoint Spans.',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="top-operation" data-testid="top-operations">
|
||||
<div className="top-operation__controls">
|
||||
<div className="top-operation__download">
|
||||
<Download
|
||||
data={downloadableData}
|
||||
isLoading={isLoading}
|
||||
fileName={`top-operations-${servicename}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="top-operation__entry-point">
|
||||
<Switch
|
||||
testId="top-operations-entrypoint-toggle"
|
||||
aria-label="Entrypoint Spans"
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
value={isEntryPoint}
|
||||
onChange={onEntryPointToggle}
|
||||
/>
|
||||
<span className="top-operation__entry-point-label">Entrypoint Spans</span>
|
||||
<TextToolTip
|
||||
text={entryPointSpanInfo.text}
|
||||
url={entryPointSpanInfo.url}
|
||||
useFilledIcon={false}
|
||||
urlText={entryPointSpanInfo.urlText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
showHeader
|
||||
title={(): string =>
|
||||
isEntryPoint ? 'Key Entrypoint Operations' : 'Key Operations'
|
||||
}
|
||||
tableLayout="fixed"
|
||||
dataSource={data}
|
||||
rowKey="name"
|
||||
pagination={paginationConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TopOperationList {
|
||||
p50: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
numCalls: number;
|
||||
name: string;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
interface TopOperationsTableProps {
|
||||
data: TopOperationList[];
|
||||
isLoading: boolean;
|
||||
isEntryPoint: boolean;
|
||||
onEntryPointToggle: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default TopOperationsTable;
|
||||
@@ -1,78 +0,0 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { TopOperationList } from '../TopOperationsTable';
|
||||
|
||||
interface TopOperation {
|
||||
numCalls: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export const getTopOperationList = ({
|
||||
errorCount,
|
||||
numCalls,
|
||||
}: TopOperation): TopOperationList =>
|
||||
({
|
||||
p50: 0,
|
||||
errorCount,
|
||||
name: 'test',
|
||||
numCalls,
|
||||
p95: 0,
|
||||
p99: 0,
|
||||
}) as TopOperationList;
|
||||
|
||||
export const defaultApiCallExpectation = {
|
||||
service: 'test-service',
|
||||
start: 1640995200000,
|
||||
end: 1641081600000,
|
||||
selectedTags: [],
|
||||
isEntryPoint: false,
|
||||
};
|
||||
|
||||
export const mockStore = configureStore([]);
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mockTopOperationsData: TopOperationList[] = [
|
||||
{
|
||||
name: 'GET /api/users',
|
||||
p50: 1000000,
|
||||
p95: 2000000,
|
||||
p99: 3000000,
|
||||
numCalls: 100,
|
||||
errorCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'POST /api/orders',
|
||||
p50: 1500000,
|
||||
p95: 2500000,
|
||||
p99: 3500000,
|
||||
numCalls: 80,
|
||||
errorCount: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockEntryPointData: TopOperationList[] = [
|
||||
{
|
||||
name: 'GET /api/health',
|
||||
p50: 500000,
|
||||
p95: 1000000,
|
||||
p99: 1500000,
|
||||
numCalls: 200,
|
||||
errorCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const createMockStore = (): any =>
|
||||
mockStore({
|
||||
globalTime: {
|
||||
minTime: 1640995200000,
|
||||
maxTime: 1641081600000,
|
||||
},
|
||||
});
|
||||
@@ -1,301 +0,0 @@
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { mockAppParams } from 'tests/router-mocks';
|
||||
|
||||
import {
|
||||
createMockStore,
|
||||
defaultApiCallExpectation,
|
||||
mockEntryPointData,
|
||||
mockTopOperationsData,
|
||||
queryClient,
|
||||
} from '../__mocks__/getTopOperation';
|
||||
import TopOperation from '../Tabs/Overview/TopOperation';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/useResourceAttribute');
|
||||
jest.mock('hooks/useResourceAttribute/utils', () => ({
|
||||
convertRawQueriesToTraceSelectedTags: (): any[] => [],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
mockAppParams({ servicename: encodeURIComponent('test-service') });
|
||||
|
||||
// Mock the util functions that TopOperationsTable uses
|
||||
jest.mock('../Tabs/util', () => ({
|
||||
useGetAPMToTracesQueries: (): any => ({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the resourceAttributesToTracesFilterItems function
|
||||
const mockedUseResourceAttribute = useResourceAttribute as jest.MockedFunction<
|
||||
typeof useResourceAttribute
|
||||
>;
|
||||
|
||||
// Constants
|
||||
const KEY_OPERATIONS_TEXT = 'Key Operations';
|
||||
const KEY_ENTRY_POINT_OPERATIONS_TEXT = 'Key Entrypoint Operations';
|
||||
const ENTRY_POINT_SPANS_TEXT = 'Entrypoint Spans';
|
||||
const TOP_OPERATIONS_ENDPOINT = 'top_operations';
|
||||
const ENTRY_POINT_OPERATIONS_ENDPOINT = 'entry_point_operations';
|
||||
|
||||
const renderComponent = (store = createMockStore()): any =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TopOperation />
|
||||
</QueryClientProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Helper function to wait for initial render and verify basic functionality
|
||||
const waitForInitialRender = async (): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to click toggle and wait for data to load
|
||||
const clickToggleAndWaitForDataLoad = async (): Promise<HTMLElement> => {
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
return toggleSwitch;
|
||||
};
|
||||
|
||||
describe('TopOperation API Integration', () => {
|
||||
let apiCalls: { endpoint: string; body: any }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
queryClient.clear();
|
||||
apiCalls = [];
|
||||
|
||||
mockedUseResourceAttribute.mockReturnValue({
|
||||
queries: [],
|
||||
} as any);
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v2/service/top_operations',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push({ endpoint: TOP_OPERATIONS_ENDPOINT, body });
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: mockTopOperationsData }),
|
||||
);
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v2/service/entry_point_operations',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push({ endpoint: ENTRY_POINT_OPERATIONS_ENDPOINT, body });
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: mockEntryPointData }),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders with default key operations on initial load', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitForInitialRender();
|
||||
|
||||
// Verify the toggle is present and unchecked
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls top_operations API on initial render', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait a bit more for API calls to be captured
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Verify that only the top_operations endpoint was called
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
expect(apiCalls[0].body).toStrictEqual({
|
||||
start: `${defaultApiCallExpectation.start}`,
|
||||
end: `${defaultApiCallExpectation.end}`,
|
||||
service: defaultApiCallExpectation.service,
|
||||
tags: defaultApiCallExpectation.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls entry_point_operations API when toggle is switched to entry point', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait for initial API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Clear previous API calls
|
||||
apiCalls = [];
|
||||
|
||||
// Toggle to entry point
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Wait for the API call to be captured
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Verify that the entry_point_operations endpoint was called
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
|
||||
expect(apiCalls[0].body).toStrictEqual({
|
||||
start: `${defaultApiCallExpectation.start}`,
|
||||
end: `${defaultApiCallExpectation.end}`,
|
||||
service: defaultApiCallExpectation.service,
|
||||
tags: defaultApiCallExpectation.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to entry point operations when toggle is clicked', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Find and click the toggle switch
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Check that the switch is now checked and title updates
|
||||
expect(toggleSwitch).toBeChecked();
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls correct APIs when toggling multiple times', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait for initial API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Should have called top_operations initially
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
|
||||
// Toggle to entry point
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Wait for the second API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
// Should now have called entry_point_operations
|
||||
expect(apiCalls).toHaveLength(2);
|
||||
expect(apiCalls[1].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
|
||||
|
||||
// Toggle back to regular operations
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for the third API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
// Should have called top_operations again
|
||||
expect(apiCalls).toHaveLength(3);
|
||||
expect(apiCalls[2].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('displays entry point toggle with correct label', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches back to key operations when toggle is clicked twice', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Toggle on (to entry point)
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
|
||||
// Toggle off (back to key operations)
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +1,7 @@
|
||||
import { DownloadOptions } from 'container/Download/Download.types';
|
||||
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
|
||||
import {
|
||||
MetricAggregateOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
export const legend = {
|
||||
address: '{{address}}',
|
||||
};
|
||||
|
||||
export const QUERYNAME_AND_EXPRESSION = ['A', 'B', 'C'];
|
||||
export const LATENCY_AGGREGATEOPERATOR = [
|
||||
TracesAggregatorOperator.P50,
|
||||
TracesAggregatorOperator.P90,
|
||||
TracesAggregatorOperator.P99,
|
||||
];
|
||||
export const LATENCY_AGGREGATEOPERATOR_SPAN_METRICS = [
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P99,
|
||||
];
|
||||
|
||||
export const OPERATION_LEGENDS = ['Operations'];
|
||||
|
||||
export const MENU_ITEMS = [MenuItemKeys.View, MenuItemKeys.CreateAlerts];
|
||||
|
||||
export const SERVICE_DETAIL_DRILLDOWN_ENABLED = true;
|
||||
|
||||
export enum FORMULA {
|
||||
ERROR_PERCENTAGE = 'A*100/B',
|
||||
DATABASE_CALLS_AVG_DURATION = 'A/B',
|
||||
// The apdex formula is (satisfied_count + 0.5 * tolerating_count + 0 * frustating_count) / total_count
|
||||
// The satisfied_count is B, tolerating_count is C, total_count is A
|
||||
// But why do we have (B+C)/2 instead of B + C/2?
|
||||
// The way we issue the query is latency <= threshold, which means we over count i.e
|
||||
// query B => durationNano <= 500ms
|
||||
// query C => durationNano <= 2000ms
|
||||
// Since <= 2000ms includes <= 500ms, we over count, to correct we subtract B/2
|
||||
// so the full expression would be (B + C/2) - B/2 = (B+C)/2
|
||||
// However, if you add a filter on durationNano > 500ms, (filterItemC in overviewQueries) the query would be
|
||||
// B + C/2
|
||||
APDEX_TRACES = '((B + C)/2)/A',
|
||||
// The delta span metrics store delta compared to previous reporting interval
|
||||
// but not the counts for the current interval. The bucket counts are cumulative
|
||||
APDEX_DELTA_SPAN_METRICS = '((B + C)/2)/A',
|
||||
// Cumulative span metrics store the counts for all buckets
|
||||
// so we need to subtract B/2 to correct the over counting
|
||||
APDEX_CUMULATIVE_SPAN_METRICS = '((B + C)/2)/A',
|
||||
}
|
||||
|
||||
export const TOP_LEVEL_OPERATIONS = ['{{.top_level_operations}}'];
|
||||
|
||||
export enum GraphTitle {
|
||||
APDEX = 'Apdex',
|
||||
LATENCY = 'Latency',
|
||||
RATE_PER_OPS = 'Rate (ops/s)',
|
||||
ERROR_PERCENTAGE = 'Error Percentage',
|
||||
DATABASE_CALLS_RPS = 'Database Calls RPS',
|
||||
DATABASE_CALLS_AVG_DURATION = 'Database Calls Avg Duration',
|
||||
EXTERNAL_CALL_ERROR_PERCENTAGE = 'External Call Error Percentage',
|
||||
EXTERNAL_CALL_DURATION = 'External Call duration',
|
||||
EXTERNAL_CALL_RPS_BY_ADDRESS = 'External Call RPS(by Address)',
|
||||
EXTERNAL_CALL_DURATION_BY_ADDRESS = 'External Call duration(by Address)',
|
||||
}
|
||||
|
||||
export enum KeyOperationTableHeader {
|
||||
P50 = 'P50',
|
||||
P90 = 'P90',
|
||||
@@ -101,22 +39,3 @@ export enum WidgetKeys {
|
||||
SignozLatencyBucket = 'signoz_latency.bucket',
|
||||
DbSystem = 'db.system',
|
||||
}
|
||||
|
||||
export const topOperationMetricsDownloadOptions: DownloadOptions = {
|
||||
isDownloadEnabled: true,
|
||||
fileName: 'top-operation',
|
||||
} as const;
|
||||
|
||||
export const SERVICE_CHART_ID = {
|
||||
latency: 'SERVICE_OVERVIEW_LATENCY',
|
||||
error: 'SERVICE_OVERVIEW_ERROR',
|
||||
rps: 'SERVICE_OVERVIEW_RPS',
|
||||
apdex: 'SERVICE_OVERVIEW_APDEX',
|
||||
errorPercentage: 'SERVICE_OVERVIEW_ERROR_PERCENTAGE',
|
||||
dbCallsRPS: 'SERVICE_DATABASE_CALLS_RPS',
|
||||
dbCallsAvgDuration: 'SERVICE_DATABASE_CALLS_AVG_DURATION',
|
||||
externalCallDurationByAddress: 'SERVICE_EXTERNAL_CALLS_DURATION_BY_ADDRESS',
|
||||
externalCallErrorPercentage: 'SERVICE_EXTERNAL_CALLS_ERROR_PERCENTAGE',
|
||||
externalCallDuration: 'SERVICE_EXTERNAL_CALLS_DURATION',
|
||||
externalCallRPSByAddress: 'SERVICE_EXTERNAL_CALLS_RPS_BY_ADDRESS',
|
||||
};
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
Card as CardComponent,
|
||||
Col as ColComponent,
|
||||
Row as RowComponent,
|
||||
} from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Card = styled(CardComponent)`
|
||||
&&& {
|
||||
height: 40vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Row = styled(RowComponent)`
|
||||
&&& {
|
||||
padding: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Col = styled(ColComponent)`
|
||||
display &&& {
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ColApDexContainer = styled(ColComponent)`
|
||||
padding: 0 !important;
|
||||
`;
|
||||
|
||||
export const ColErrorContainer = styled(ColComponent)`
|
||||
padding: 2rem 0 !important;
|
||||
`;
|
||||
|
||||
export const GraphContainer = styled.div`
|
||||
min-height: calc(40vh - 40px);
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const GraphTitle = styled(Typography)`
|
||||
&&& {
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
@@ -1,8 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { IServiceName } from './Tabs/types';
|
||||
|
||||
export interface GetWidgetQueryBuilderProps {
|
||||
query: Widgets['query'];
|
||||
@@ -13,23 +10,3 @@ export interface GetWidgetQueryBuilderProps {
|
||||
fillSpans?: Widgets['fillSpans'];
|
||||
columnUnits?: Widgets['columnUnits'];
|
||||
}
|
||||
|
||||
export interface NavigateToTraceProps {
|
||||
servicename: string;
|
||||
operation: string;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
selectedTraceTags: string;
|
||||
apmToTraceQuery: Query;
|
||||
safeNavigate: (path: string) => void;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseCallsRPSProps extends DatabaseCallProps {
|
||||
legend: string;
|
||||
}
|
||||
|
||||
export interface DatabaseCallProps {
|
||||
servicename: IServiceName['servicename'];
|
||||
tagFilterItems: TagFilterItem[];
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { getTopOperationList } from './__mocks__/getTopOperation';
|
||||
import { TopOperationList } from './TopOperationsTable';
|
||||
import {
|
||||
convertedTracesToDownloadData,
|
||||
getErrorRate,
|
||||
getNearestHighestBucketValue,
|
||||
} from './utils';
|
||||
|
||||
describe('Error Rate', () => {
|
||||
it('should return correct error rate', () => {
|
||||
const list: TopOperationList = getTopOperationList({
|
||||
errorCount: 10,
|
||||
numCalls: 100,
|
||||
});
|
||||
|
||||
expect(getErrorRate(list)).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle no errors gracefully', () => {
|
||||
const list = getTopOperationList({ errorCount: 0, numCalls: 100 });
|
||||
expect(getErrorRate(list)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle zero calls', () => {
|
||||
const list = getTopOperationList({ errorCount: 0, numCalls: 0 });
|
||||
expect(getErrorRate(list)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNearestHighestBucketValue', () => {
|
||||
it('should return nearest higher bucket value', () => {
|
||||
expect(getNearestHighestBucketValue(50, [10, 20, 30, 40, 60, 70])).toBe('60');
|
||||
});
|
||||
|
||||
it('should return +Inf for value higher than any bucket', () => {
|
||||
expect(getNearestHighestBucketValue(80, [10, 20, 30, 40, 60, 70])).toBe(
|
||||
'+Inf',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the first bucket for value lower than all buckets', () => {
|
||||
expect(getNearestHighestBucketValue(5, [10, 20, 30, 40, 60, 70])).toBe('10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertedTracesToDownloadData', () => {
|
||||
it('should convert trace data correctly', () => {
|
||||
const data = [
|
||||
{
|
||||
name: 'op1',
|
||||
p50: 50000000,
|
||||
p95: 95000000,
|
||||
p99: 99000000,
|
||||
numCalls: 100,
|
||||
errorCount: 10,
|
||||
},
|
||||
];
|
||||
|
||||
expect(convertedTracesToDownloadData(data)).toStrictEqual([
|
||||
{
|
||||
Name: 'op1',
|
||||
'P50 (in ms)': '50.00',
|
||||
'P95 (in ms)': '95.00',
|
||||
'P99 (in ms)': '99.00',
|
||||
'Number of calls': '100',
|
||||
'Error Rate (%)': '10.00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import { TopOperationList } from './TopOperationsTable';
|
||||
import { NavigateToTraceProps } from './types';
|
||||
|
||||
export const getErrorRate = (list: TopOperationList): number => {
|
||||
if (list.errorCount === 0 && list.numCalls === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (list.errorCount / list.numCalls) * 100;
|
||||
};
|
||||
|
||||
export const navigateToTrace = ({
|
||||
servicename,
|
||||
operation,
|
||||
minTime,
|
||||
maxTime,
|
||||
selectedTraceTags,
|
||||
apmToTraceQuery,
|
||||
safeNavigate,
|
||||
openInNewTab = false,
|
||||
}: NavigateToTraceProps): void => {
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(
|
||||
QueryParams.startTime,
|
||||
Math.floor(minTime / 1_000_000).toString(),
|
||||
);
|
||||
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
|
||||
|
||||
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
|
||||
|
||||
const newTraceExplorerPath = `${
|
||||
ROUTES.TRACES_EXPLORER
|
||||
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(withBasePath(newTraceExplorerPath), '_blank');
|
||||
} else {
|
||||
safeNavigate(newTraceExplorerPath);
|
||||
}
|
||||
};
|
||||
|
||||
export const getNearestHighestBucketValue = (
|
||||
value: number,
|
||||
buckets: number[],
|
||||
): string => {
|
||||
// sort the buckets
|
||||
buckets.sort((a, b) => a - b);
|
||||
const nearestBucket = buckets.find((bucket) => bucket >= value);
|
||||
return nearestBucket?.toString() || '+Inf';
|
||||
};
|
||||
|
||||
export const convertMilSecToNanoSec = (value: number): number =>
|
||||
value * 1000000000;
|
||||
|
||||
export const convertedTracesToDownloadData = (
|
||||
originalData: TopOperationList[],
|
||||
): Record<string, string>[] =>
|
||||
originalData.map((item) => {
|
||||
const newObj: Record<string, string> = {
|
||||
Name: item.name,
|
||||
'P50 (in ms)': (item.p50 / 1000000).toFixed(2),
|
||||
'P95 (in ms)': (item.p95 / 1000000).toFixed(2),
|
||||
'P99 (in ms)': (item.p99 / 1000000).toFixed(2),
|
||||
'Number of calls': item.numCalls.toString(),
|
||||
'Error Rate (%)': getErrorRate(item).toFixed(2),
|
||||
};
|
||||
|
||||
return newObj;
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
65
frontend/src/container/SystemDashboard/SystemDashboard.tsx
Normal file
65
frontend/src/container/SystemDashboard/SystemDashboard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Spinner } from '@signozhq/ui/spinner';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { buildDashboardReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
|
||||
import type { DashboardControlsInput } from 'pages/DashboardPage/DashboardContainer/controls/types';
|
||||
|
||||
import type { SystemDashboardHost } from './systemDashboardHost';
|
||||
import { useSystemDashboard } from './useSystemDashboard';
|
||||
import styles from './SystemDashboard.module.scss';
|
||||
|
||||
interface SystemDashboardProps {
|
||||
host: SystemDashboardHost;
|
||||
controls?: DashboardControlsInput;
|
||||
}
|
||||
|
||||
/** A shipped, read-only system dashboard, embedded in a feature page. */
|
||||
function SystemDashboard({
|
||||
host,
|
||||
controls,
|
||||
}: SystemDashboardProps): JSX.Element {
|
||||
const { dashboard, isLoading, isError, error, refetch } =
|
||||
useSystemDashboard(host);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<Spinner size={24} aria-label="Loading dashboard" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error?.response?.status === 403) {
|
||||
return (
|
||||
<div className={styles.errorState} data-testid="system-dashboard-forbidden">
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={[buildDashboardReadPermission('*')]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<div className={styles.errorState} data-testid="system-dashboard-error">
|
||||
<Typography.Title>Failed to load dashboard</Typography.Title>
|
||||
<Typography.Text>
|
||||
{error?.response?.data?.error?.message ?? error?.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer
|
||||
dashboard={dashboard}
|
||||
refetch={refetch}
|
||||
source={host.source}
|
||||
readOnly
|
||||
controls={controls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemDashboard;
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
DashboardtypesSourceDTO,
|
||||
type GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import SystemDashboard from '../SystemDashboard';
|
||||
import { createSystemDashboardHost } from '../systemDashboardHost';
|
||||
|
||||
const HOST = createSystemDashboardHost('test-overview', 'test-overview-id');
|
||||
const ENDPOINT = '*/api/v2/dashboards/system/test-overview';
|
||||
|
||||
jest.mock('pages/DashboardPage/DashboardContainer', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
dashboard,
|
||||
readOnly,
|
||||
}: {
|
||||
dashboard: { id: string; spec: { display: { name: string } } };
|
||||
readOnly: boolean;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
data-testid="dashboard-container"
|
||||
data-dashboard-id={dashboard.id}
|
||||
data-read-only={String(readOnly)}
|
||||
>
|
||||
{dashboard.spec.display.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const response: GetSystemDashboard200 = {
|
||||
status: 'success',
|
||||
data: {
|
||||
orgId: 'org',
|
||||
locked: true,
|
||||
name: 'signoz---test-overview',
|
||||
schemaVersion: 'v6',
|
||||
source: DashboardtypesSourceDTO.system,
|
||||
tags: [],
|
||||
spec: {
|
||||
display: { name: 'Test overview' },
|
||||
variables: [],
|
||||
panels: {},
|
||||
layouts: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('SystemDashboard', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
it('mounts the named system dashboard read-only under the host id', async () => {
|
||||
server.use(rest.get(ENDPOINT, (_req, res, ctx) => res(ctx.json(response))));
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
const container = await screen.findByTestId('dashboard-container');
|
||||
expect(container).toHaveTextContent('Test overview');
|
||||
expect(container).toHaveAttribute('data-dashboard-id', 'test-overview-id');
|
||||
expect(container).toHaveAttribute('data-read-only', 'true');
|
||||
});
|
||||
|
||||
it('tells a user without dashboard read access why it is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(403),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'forbidden', message: 'forbidden' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('system-dashboard-forbidden'),
|
||||
).resolves.toHaveTextContent('is not authorized to perform read:dashboard:*');
|
||||
});
|
||||
|
||||
it('shows why the dashboard failed to load', async () => {
|
||||
server.use(
|
||||
rest.get(ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'not_found', message: 'not provisioned' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SystemDashboard host={HOST} />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('system-dashboard-error'),
|
||||
).resolves.toHaveTextContent('not provisioned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getGetSystemDashboardQueryKey } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
GetSystemDashboard200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardQuerySource } from 'pages/DashboardPage/DashboardContainer/store/slices/editContextSlice';
|
||||
|
||||
export interface SystemDashboardHost {
|
||||
/** Definition name without the `signoz---` prefix. */
|
||||
name: string;
|
||||
select: (
|
||||
response: GetSystemDashboard200,
|
||||
) => DashboardtypesGettableDashboardV2DTO;
|
||||
source: DashboardQuerySource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call at module level: `select` and `source` must stay referentially stable.
|
||||
* System dashboards carry no `id`; `id` keys the store's per-dashboard state
|
||||
* (saved variable values), so keep it stable across releases.
|
||||
*/
|
||||
export function createSystemDashboardHost(
|
||||
name: string,
|
||||
id: string,
|
||||
): SystemDashboardHost {
|
||||
const select = (
|
||||
response: GetSystemDashboard200,
|
||||
): DashboardtypesGettableDashboardV2DTO =>
|
||||
({ ...response.data, id }) as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
return {
|
||||
name,
|
||||
select,
|
||||
source: { queryKey: getGetSystemDashboardQueryKey({ name }), select },
|
||||
};
|
||||
}
|
||||
41
frontend/src/container/SystemDashboard/useSystemDashboard.ts
Normal file
41
frontend/src/container/SystemDashboard/useSystemDashboard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useGetSystemDashboard } from 'api/generated/services/dashboard';
|
||||
import type { GetSystemDashboardQueryError } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SystemDashboardHost } from './systemDashboardHost';
|
||||
|
||||
export interface UseSystemDashboardResult {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: GetSystemDashboardQueryError | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useSystemDashboard(
|
||||
host: SystemDashboardHost,
|
||||
): UseSystemDashboardResult {
|
||||
const {
|
||||
data: dashboard,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetSystemDashboard(
|
||||
{ name: host.name },
|
||||
{
|
||||
query: {
|
||||
select: host.select,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const refetchDashboard = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { dashboard, isLoading, isError, error, refetch: refetchDashboard };
|
||||
}
|
||||
@@ -195,6 +195,7 @@ export const routesToSkip = [
|
||||
ROUTES.NOT_FOUND,
|
||||
ROUTES.METER_EXPLORER,
|
||||
ROUTES.METER,
|
||||
ROUTES.SERVICE_METRICS,
|
||||
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
DisplayThresholdContainer,
|
||||
TypographHeading,
|
||||
Typography,
|
||||
} from 'container/WidgetCard/Header/styles';
|
||||
import { DisplayThresholdProps } from 'container/WidgetCard/Header/types';
|
||||
|
||||
function DisplayThreshold({ threshold }: DisplayThresholdProps): JSX.Element {
|
||||
return (
|
||||
<DisplayThresholdContainer>
|
||||
<TypographHeading>Threshold </TypographHeading>
|
||||
<Typography>{threshold || <SolidInfoCircle size="md" />}</Typography>
|
||||
</DisplayThresholdContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisplayThreshold;
|
||||
@@ -1,58 +0,0 @@
|
||||
import { grey } from '@ant-design/colors';
|
||||
import { Typography as TypographyComponent } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const HeaderContainer = styled.div<{ hover: boolean }>`
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
export const HeaderContentContainer = styled.span`
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const ThesholdContainer = styled.span`
|
||||
margin-top: -0.3rem;
|
||||
`;
|
||||
|
||||
export const DisplayThresholdContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const WidgetHeaderContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
export const ArrowContainer = styled.span<{ hover: boolean }>`
|
||||
visibility: ${({ hover }): string => (hover ? 'visible' : 'hidden')};
|
||||
`;
|
||||
|
||||
export const Typography = styled(TypographyComponent)`
|
||||
&&& {
|
||||
width: auto;
|
||||
margin-left: 0.2rem;
|
||||
color: ${grey[2]};
|
||||
}
|
||||
`;
|
||||
|
||||
export const TypographHeading = styled(TypographyComponent)`
|
||||
&&& {
|
||||
color: ${grey[2]};
|
||||
}
|
||||
`;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ReactNode } from 'react';
|
||||
import type { DropdownActionItemType } from '@signozhq/ui/dropdown';
|
||||
|
||||
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
|
||||
@@ -7,7 +6,3 @@ export type MenuItem = DropdownActionItemType & {
|
||||
value: MenuItemKeys;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
export interface DisplayThresholdProps {
|
||||
threshold: ReactNode;
|
||||
}
|
||||
|
||||
@@ -39,54 +39,6 @@ export const Card = styled(CardComponent)<CardProps>`
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const CardContainer = styled.div<Props>`
|
||||
overflow: auto;
|
||||
|
||||
&.row-card {
|
||||
.row-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: ${({ isDarkMode }): string =>
|
||||
isDarkMode ? 'var(--bg-ink-400)' : 'var(--bg-vanilla-300)'};
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.enable-resize {
|
||||
:hover {
|
||||
.react-resizable-handle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-position: bottom right;
|
||||
padding: 0 3px 3px 0;
|
||||
background-repeat: no-repeat;
|
||||
background-origin: content-box;
|
||||
box-sizing: border-box;
|
||||
cursor: se-resize;
|
||||
|
||||
${({ isDarkMode }): StyledCSS => {
|
||||
const uri = `data:image/svg+xml,%3Csvg viewBox='0 0 6 6' style='background-color:%23ffffff00' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' xml:space='preserve' x='0px' y='0px' width='6px' height='6px'%0A%3E%3Cg opacity='0.302'%3E%3Cpath d='M 6 6 L 0 6 L 0 4.2 L 4 4.2 L 4.2 4.2 L 4.2 0 L 6 0 L 6 6 L 6 6 Z' fill='${
|
||||
isDarkMode ? 'white' : 'grey'
|
||||
}'/%3E%3C/g%3E%3C/svg%3E`;
|
||||
|
||||
return css`
|
||||
background-image: ${(): string => `url("${uri}")`};
|
||||
`;
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ReactGridLayout = styled(ReactGridLayoutComponent)`
|
||||
border: 1px solid #434343;
|
||||
margin-top: 1rem;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { getMetricMeta } from 'api/metrics/ApDex/getMetricMeta';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { MetricMetaProps } from 'types/api/metrics/getApDex';
|
||||
|
||||
export const useGetMetricMeta = (
|
||||
metricName: string,
|
||||
servicename: string,
|
||||
): UseQueryResult<AxiosResponse<MetricMetaProps>, AxiosError> =>
|
||||
useQuery<AxiosResponse<MetricMetaProps>, AxiosError>({
|
||||
queryKey: [{ metricName, servicename }],
|
||||
queryFn: async () => getMetricMeta(metricName, servicename),
|
||||
});
|
||||
@@ -29,3 +29,78 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.celery-task-detail-drawer {
|
||||
.ant-drawer-wrapper-body {
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 0px;
|
||||
|
||||
.ant-card {
|
||||
border: none;
|
||||
.ant-card-body {
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
.ant-table {
|
||||
background: var(--l1-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
.ant-drawer-header-title {
|
||||
.ant-drawer-close {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
button > svg {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.45px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-footer {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
|
||||
.footer-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getStepInterval } from 'components/CeleryTask/CeleryTaskGraph/CeleryTaskGraphUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { getWidgetQuery } from 'pages/MessagingQueues/MQDetails/MetricPage/MetricPageUtil';
|
||||
@@ -11,6 +10,30 @@ import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const getStepInterval = (startTime: number, endTime: number): number => {
|
||||
const diffInMinutes = (endTime - startTime) / 1000000 / (60 * 1000); // Convert to minutes
|
||||
|
||||
if (diffInMinutes <= 15) {
|
||||
return 60;
|
||||
} // 15 min or less
|
||||
if (diffInMinutes <= 30) {
|
||||
return 60;
|
||||
} // 30 min or less
|
||||
if (diffInMinutes <= 60) {
|
||||
return 120;
|
||||
} // 1 hour or less
|
||||
if (diffInMinutes <= 360) {
|
||||
return 520;
|
||||
} // 6 hours or less
|
||||
if (diffInMinutes <= 1440) {
|
||||
return 2440;
|
||||
} // 1 day or less
|
||||
if (diffInMinutes <= 10080) {
|
||||
return 10080;
|
||||
} // 1 week or less
|
||||
return 54000; // More than a week (use monthly interval)
|
||||
};
|
||||
|
||||
export const celeryOverviewRequestRateWidgetData = (
|
||||
filters?: TagFilterItem[],
|
||||
): Widgets =>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
.celery-task-container {
|
||||
.celery-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
.celery-content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.celery-content-header-title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 24px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.celery-content-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,19 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import CeleryTaskConfigOptions from 'components/CeleryTask/CeleryTaskConfigOptions/CeleryTaskConfigOptions';
|
||||
import CeleryTaskDetail, {
|
||||
CaptureDataProps,
|
||||
} from 'components/CeleryTask/CeleryTaskDetail/CeleryTaskDetail';
|
||||
import CeleryTaskGraphGrid from 'components/CeleryTask/CeleryTaskGraph/CeleryTaskGraphGrid';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import SystemDashboard from 'container/SystemDashboard/SystemDashboard';
|
||||
import { createSystemDashboardHost } from 'container/SystemDashboard/systemDashboardHost';
|
||||
import { EMBEDDED } from 'pages/DashboardPage/DashboardContainer/controls/presets';
|
||||
|
||||
import './CeleryTask.styles.scss';
|
||||
const CELERY_TASKS = createSystemDashboardHost('celery-tasks', 'celery-tasks');
|
||||
|
||||
export default function CeleryTask(): JSX.Element {
|
||||
const [task, setTask] = useState<CaptureDataProps | null>(null);
|
||||
const loggedRef = useRef(false);
|
||||
|
||||
const taskName = useUrlQuery().get(QueryParams.taskName);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskName && !loggedRef.current) {
|
||||
logEvent('MQ Celery: Task name filter', {
|
||||
taskName,
|
||||
});
|
||||
loggedRef.current = true;
|
||||
}
|
||||
}, [taskName]);
|
||||
|
||||
const onTaskClick = (captureData: CaptureDataProps): void => {
|
||||
setTask(captureData);
|
||||
};
|
||||
void logEvent('MQ Celery: Task view', {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="celery-task-container">
|
||||
<div className="celery-content">
|
||||
<div className="celery-content-header">
|
||||
<p className="celery-content-header-title">Celery</p>
|
||||
<div className="celery-content-header-right">
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal={false} />
|
||||
</div>
|
||||
</div>
|
||||
<CeleryTaskGraphGrid
|
||||
onClick={onTaskClick}
|
||||
queryEnabled={!task}
|
||||
configureOptionComponent={<CeleryTaskConfigOptions />}
|
||||
/>
|
||||
</div>
|
||||
{!!task && (
|
||||
<CeleryTaskDetail
|
||||
onClose={(): void => {
|
||||
setTask(null);
|
||||
}}
|
||||
widgetData={task.widgetData}
|
||||
taskData={task}
|
||||
drawerOpen={!!task}
|
||||
/>
|
||||
)}
|
||||
<div data-testid="celery-task-page">
|
||||
<SystemDashboard host={CELERY_TASKS} controls={EMBEDDED} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import {
|
||||
@@ -26,19 +26,21 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
import type {
|
||||
AuthZDropdownItemType,
|
||||
AuthZGroupChildType,
|
||||
} from 'lib/authz/components/AuthZDropdown/types';
|
||||
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { DashboardCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
|
||||
interface DashboardActionsProps {
|
||||
title: string;
|
||||
@@ -66,13 +68,14 @@ function DashboardActions({
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const show = useDashboardControlGroup('dashboardActions');
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { disabledTooltip: lockDisabledTooltip } = useDashboardLockPermission({
|
||||
dashboardId: dashboard.id,
|
||||
source: dashboard.source,
|
||||
enabled: show.lock,
|
||||
});
|
||||
|
||||
// Cloning creates a new dashboard, so it needs `create` too, and no lock gate.
|
||||
@@ -81,8 +84,6 @@ function DashboardActions({
|
||||
[editChecks],
|
||||
);
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
|
||||
const [isCloning, setIsCloning] = useState<boolean>(false);
|
||||
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
|
||||
@@ -94,14 +95,6 @@ function DashboardActions({
|
||||
panelCount: Object.keys(dashboard.spec.panels).length,
|
||||
});
|
||||
|
||||
// Open the settings drawer when something in the tree requests it (e.g. the
|
||||
// variables bar's "Add variable" button).
|
||||
useEffect(() => {
|
||||
if (settingsRequest) {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}
|
||||
}, [settingsRequest]);
|
||||
|
||||
const { addSection, isSaving: isAddingSection } = useAddSection({
|
||||
layouts: dashboard.spec.layouts,
|
||||
});
|
||||
@@ -143,8 +136,8 @@ function DashboardActions({
|
||||
void logEvent(DashboardDetailEvents.SettingsOpened, {
|
||||
dashboardId: dashboard.id,
|
||||
});
|
||||
setIsSettingsDrawerOpen(true);
|
||||
}, [dashboard.id]);
|
||||
requestSettings({ tab: 'Overview' });
|
||||
}, [dashboard.id, requestSettings]);
|
||||
|
||||
const handleOpenJsonEditor = useCallback((): void => {
|
||||
void logEvent(DashboardDetailEvents.JsonEditorOpened, {
|
||||
@@ -162,61 +155,68 @@ function DashboardActions({
|
||||
void handle.enter();
|
||||
}, [dashboard.id, handle]);
|
||||
|
||||
// Unavailable items stay in the menu, carrying the reason.
|
||||
const menuItems = useMemo<AuthZDropdownItemType[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'group',
|
||||
value: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'rename',
|
||||
testId: 'dashboard-action-rename',
|
||||
label: 'Rename',
|
||||
prefix: <PenLine size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: onOpenRename,
|
||||
},
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
{
|
||||
type: 'item',
|
||||
value: 'clone',
|
||||
testId: 'dashboard-action-clone',
|
||||
label: 'Clone dashboard',
|
||||
prefix: <Copy size={14} />,
|
||||
checks: cloneChecks,
|
||||
loading: isCloning,
|
||||
loadingTooltip: 'Cloning dashboard',
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'lock',
|
||||
testId: 'dashboard-action-lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
prefix: <LockKeyhole size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(lockDisabledTooltip),
|
||||
onClick: onLockToggle,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'fullscreen',
|
||||
testId: 'dashboard-action-fullscreen',
|
||||
label: 'Full screen',
|
||||
prefix: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
value: 'group-layout',
|
||||
label: 'Layout',
|
||||
items: [
|
||||
// Unavailable items stay in the menu, carrying the reason; hidden ones are dropped.
|
||||
const menuItems = useMemo<AuthZDropdownItemType[]>(() => {
|
||||
const dashboardItems: AuthZGroupChildType[] = [
|
||||
...(show.rename
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'rename',
|
||||
testId: 'dashboard-action-rename',
|
||||
label: 'Rename',
|
||||
prefix: <PenLine size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: onOpenRename,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
...(show.clone
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'clone',
|
||||
testId: 'dashboard-action-clone',
|
||||
label: 'Clone dashboard',
|
||||
prefix: <Copy size={14} />,
|
||||
checks: cloneChecks,
|
||||
loading: isCloning,
|
||||
loadingTooltip: 'Cloning dashboard',
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.lock
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'lock',
|
||||
testId: 'dashboard-action-lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
prefix: <LockKeyhole size={14} />,
|
||||
checks: editChecks,
|
||||
...blockedBy(lockDisabledTooltip),
|
||||
onClick: onLockToggle,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.fullscreen
|
||||
? [
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'fullscreen',
|
||||
testId: 'dashboard-action-fullscreen',
|
||||
label: 'Full screen',
|
||||
prefix: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const layoutItems: AuthZGroupChildType[] = show.newSection
|
||||
? [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'new-section',
|
||||
@@ -227,101 +227,124 @@ function DashboardActions({
|
||||
...blockedBy(editDisabledTooltip),
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator', value: 'divider-danger' },
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete',
|
||||
testId: 'dashboard-action-delete',
|
||||
label: 'Delete dashboard',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
checks: deleteChecks,
|
||||
// Independent of read/update, but a locked dashboard can't be removed.
|
||||
...blockedBy(deleteDisabledTooltip),
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
],
|
||||
[
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
cloneChecks,
|
||||
isCloning,
|
||||
lockDisabledTooltip,
|
||||
isDashboardLocked,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handleEnterFullScreen,
|
||||
confirmDeleteDashboard,
|
||||
],
|
||||
);
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
...(dashboardItems.length
|
||||
? [
|
||||
{
|
||||
type: 'group' as const,
|
||||
value: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
items: dashboardItems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(layoutItems.length
|
||||
? [
|
||||
{
|
||||
type: 'group' as const,
|
||||
value: 'group-layout',
|
||||
label: 'Layout',
|
||||
items: layoutItems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(show.delete
|
||||
? [
|
||||
{ type: 'separator' as const, value: 'divider-danger' },
|
||||
{
|
||||
type: 'item' as const,
|
||||
value: 'delete',
|
||||
testId: 'dashboard-action-delete',
|
||||
label: 'Delete dashboard',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
checks: deleteChecks,
|
||||
// Independent of read/update, but a locked dashboard can't be removed.
|
||||
...blockedBy(deleteDisabledTooltip),
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}, [
|
||||
show,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
cloneChecks,
|
||||
isCloning,
|
||||
lockDisabledTooltip,
|
||||
isDashboardLocked,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handleEnterFullScreen,
|
||||
confirmDeleteDashboard,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
{menuItems.length > 0 && (
|
||||
<AuthZDropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</AuthZDropdown>
|
||||
)}
|
||||
{show.configure && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{show.json && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</AuthZDropdown>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={handleOpenSettings}
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={handleOpenJsonEditor}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
JSON
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={handleOpenJsonEditor}
|
||||
size="md"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{show.newPanel && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
|
||||
@@ -24,9 +24,11 @@ import { openInNewTab } from 'utils/navigation';
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
|
||||
import TagsOverflowTooltip from './TagsOverflowTooltip';
|
||||
import { getLockTooltip } from './utils';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../controls/useDashboardControlGroup';
|
||||
|
||||
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
|
||||
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
|
||||
@@ -75,27 +77,19 @@ function DashboardInfo({
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const { isEditable: canEdit } = useDashboardEditContext();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const show = useDashboardControlGroup('title');
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
const hasTags = show.tags && tags.length > 0;
|
||||
const hasDescription = show.description && !isEmpty(description);
|
||||
|
||||
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
|
||||
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else if (lockDisabledTooltip) {
|
||||
lockTooltip = lockDisabledTooltip;
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
: 'This dashboard is unlocked';
|
||||
}
|
||||
const lockTooltip = getLockTooltip({
|
||||
isLocked: isDashboardLocked,
|
||||
canToggle: !!onToggleLock,
|
||||
disabledReason: lockDisabledTooltip,
|
||||
});
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
@@ -128,66 +122,69 @@ function DashboardInfo({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
value={dashboardId}
|
||||
ariaLabel="Copy dashboard ID"
|
||||
testId="dashboard-copy-id"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
{show.name && (
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
)}
|
||||
|
||||
{show.name &&
|
||||
(isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor} onBlur={onEditorBlur}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
value={dashboardId}
|
||||
ariaLabel="Copy dashboard ID"
|
||||
testId="dashboard-copy-id"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
|
||||
{hasDescription && (
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
@@ -205,7 +202,7 @@ function DashboardInfo({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
{show.publicBadge && isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible. Click to open the public page.">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -222,7 +219,7 @@ function DashboardInfo({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{showLockToggle && (
|
||||
{show.lock && showLockToggle && (
|
||||
<Tooltip title={onToggleLock ? lockTooltip : undefined}>
|
||||
<Button
|
||||
disabledTooltip={lockTooltip}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
export function getLockTooltip({
|
||||
isLocked,
|
||||
canToggle,
|
||||
disabledReason,
|
||||
}: {
|
||||
isLocked: boolean;
|
||||
canToggle: boolean;
|
||||
disabledReason?: string;
|
||||
}): string {
|
||||
if (canToggle) {
|
||||
return isLocked ? 'Locked — click to unlock' : 'Unlocked — click to lock';
|
||||
}
|
||||
if (disabledReason) {
|
||||
return disabledReason;
|
||||
}
|
||||
return isLocked ? 'This dashboard is locked' : 'This dashboard is unlocked';
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { FullScreenHandle } from 'react-full-screen';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { dashboardResponse } from '../../../stories/__story_mockdata__/dashboard';
|
||||
import type { DashboardControlsInput } from '../../controls/types';
|
||||
|
||||
import { usePublicDashboardMeta } from '../../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import DashboardPageToolbar from '../index';
|
||||
|
||||
const { data: dashboard } = dashboardResponse({
|
||||
panels: 0,
|
||||
sectioned: false,
|
||||
variables: [],
|
||||
locked: true,
|
||||
});
|
||||
|
||||
const handle: FullScreenHandle = {
|
||||
active: false,
|
||||
enter: jest.fn(),
|
||||
exit: jest.fn(),
|
||||
node: { current: null },
|
||||
};
|
||||
|
||||
jest.mock('../DashboardActions/DashboardActions', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="dashboard-actions" />,
|
||||
}));
|
||||
jest.mock('../DashboardInfo/DashboardInfo', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="dashboard-info" />,
|
||||
}));
|
||||
jest.mock('../../VariablesBar/VariablesBar', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="variables-bar" />,
|
||||
}));
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="time-selection" />,
|
||||
}));
|
||||
jest.mock(
|
||||
'../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal',
|
||||
() => ({ __esModule: true, default: (): null => null }),
|
||||
);
|
||||
jest.mock('../../hooks/useCreatePanel', () => ({
|
||||
useCreatePanel: (): unknown => ({
|
||||
isPickerOpen: false,
|
||||
openPicker: jest.fn(),
|
||||
closePicker: jest.fn(),
|
||||
createPanel: jest.fn(),
|
||||
targetLayoutIndex: 0,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): unknown => ({ patchAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('hooks/dashboards/useToggleDashboardLock', () => ({
|
||||
useToggleDashboardLock: (): unknown => ({ toggleLock: jest.fn() }),
|
||||
}));
|
||||
jest.mock('hooks/dashboards/useDashboardLockPermission', () => ({
|
||||
useDashboardLockPermission: jest.fn(() => ({
|
||||
canToggleLock: false,
|
||||
isLoading: false,
|
||||
disabledTooltip: '',
|
||||
})),
|
||||
}));
|
||||
jest.mock(
|
||||
'../../DashboardSettings/PublicDashboard/usePublicDashboardMeta',
|
||||
() => ({
|
||||
usePublicDashboardMeta: jest.fn(() => ({
|
||||
isPublic: false,
|
||||
publicMeta: undefined,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
function renderToolbar(
|
||||
ctx: { readOnly?: boolean; controls?: DashboardControlsInput } = {},
|
||||
): void {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: dashboard.id, refetch: jest.fn(), ...ctx });
|
||||
render(<DashboardPageToolbar dashboard={dashboard} handle={handle} />);
|
||||
}
|
||||
|
||||
describe('DashboardPageToolbar - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders every row and checks lock + share by default', () => {
|
||||
renderToolbar();
|
||||
|
||||
expect(screen.getByTestId('dashboard-info')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-actions')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('time-selection')).toBeInTheDocument();
|
||||
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith(
|
||||
dashboard.id,
|
||||
{
|
||||
enabled: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the lock permission check on a read-only mount', () => {
|
||||
renderToolbar({ readOnly: true });
|
||||
|
||||
expect(jest.mocked(useDashboardLockPermission)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the title row and actions the host hides, and their requests', () => {
|
||||
renderToolbar({
|
||||
readOnly: true,
|
||||
controls: { title: false, dashboardActions: false },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('dashboard-info')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-actions')).not.toBeInTheDocument();
|
||||
expect(jest.mocked(usePublicDashboardMeta)).toHaveBeenCalledWith(
|
||||
dashboard.id,
|
||||
{
|
||||
enabled: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the time range and keeps the variables bar mounted but hidden', () => {
|
||||
renderToolbar({
|
||||
controls: { toolbar: { timeRange: false, variables: false } },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('time-selection')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('variables-bar')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import { useDashboardControlGroup } from '../controls/useDashboardControlGroup';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
@@ -56,6 +57,11 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
[dashboard.tags],
|
||||
);
|
||||
|
||||
const showTitle = useDashboardControlGroup('title');
|
||||
const showActions = useDashboardControlGroup('dashboardActions');
|
||||
const showToolbar = useDashboardControlGroup('toolbar');
|
||||
const hasTitleRow = Object.values(showTitle).some(Boolean);
|
||||
const hasActions = Object.values(showActions).some(Boolean);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
@@ -71,10 +77,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
useDashboardLockPermission({
|
||||
dashboardId: id,
|
||||
source: dashboard.source,
|
||||
enabled: showTitle.lock || showActions.lock,
|
||||
});
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id, {
|
||||
enabled: showTitle.publicBadge,
|
||||
});
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
// Shared with the list's row menu — it owns the API call, the toast and the
|
||||
@@ -155,48 +164,66 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onStartEdit={startEdit}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
{(hasTitleRow || hasActions) && (
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
{hasTitleRow && (
|
||||
<DashboardInfo
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onStartEdit={startEdit}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
{hasActions && (
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: the time selector floats top-right (declared first so the
|
||||
variables bar's content wraps around it); the variables bar
|
||||
collapses to one line and, when expanded, wraps full-width under it. */}
|
||||
<div className={styles.toolbarRow}>
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
{showToolbar.timeRange && (
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={showToolbar.autoRefresh}
|
||||
hideShareModal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showToolbar.variables ? (
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
) : (
|
||||
// Still mounted: the selectors seed and auto-select the values panels query with.
|
||||
<div hidden>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
open={isPickerOpen}
|
||||
|
||||
@@ -26,9 +26,13 @@ const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
|
||||
*/
|
||||
export function usePublicDashboardMeta(
|
||||
dashboardId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): UsePublicDashboardMetaReturn {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
|
||||
const enabled =
|
||||
(options?.enabled ?? true) &&
|
||||
!!dashboardId &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
|
||||
@@ -2,33 +2,17 @@ import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useContextLinkVariables } from '../useContextLinkVariables';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const mockUseDashboardVariableNames = jest.fn();
|
||||
const mockUseQueryBuilder = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
|
||||
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters',
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({
|
||||
dtoToFormModel: (dto: { name: string }): { name: string } => ({
|
||||
name: dto.name,
|
||||
}),
|
||||
useDashboardVariableNames: (): unknown => mockUseDashboardVariableNames(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -45,11 +29,7 @@ describe('useContextLinkVariables', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mockUseGetDashboardV2.mockReturnValue({
|
||||
data: {
|
||||
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
|
||||
},
|
||||
});
|
||||
mockUseDashboardVariableNames.mockReturnValue(['region', 'tier']);
|
||||
});
|
||||
|
||||
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
|
||||
@@ -69,9 +49,7 @@ describe('useContextLinkVariables', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { builder: { queryData: [] } },
|
||||
});
|
||||
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
|
||||
// dashboard is present, so the empty case is an empty spec, not `undefined`.
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
mockUseDashboardVariableNames.mockReturnValue([]);
|
||||
|
||||
const { result } = renderHook(() => useContextLinkVariables());
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import styles from './DashboardEmptyState.module.scss';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../../controls/useDashboardControl';
|
||||
|
||||
interface DashboardEmptyStateProps {
|
||||
canAddPanel: boolean;
|
||||
@@ -24,9 +25,11 @@ function DashboardEmptyState({
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const requestSettings = useDashboardStore((s) => s.requestSettings);
|
||||
const showConfigure = useDashboardControl('dashboardActions.configure');
|
||||
const showNewPanel = useDashboardControl('dashboardActions.newPanel');
|
||||
|
||||
return (
|
||||
<section className={styles.emptyState}>
|
||||
<section className={styles.emptyState} data-testid="dashboard-empty-state">
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heading}>
|
||||
<img src={dashboardEmojiUrl} alt="" className={styles.emoji} />
|
||||
@@ -39,62 +42,66 @@ function DashboardEmptyState({
|
||||
</div>
|
||||
|
||||
<div className={styles.steps}>
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<Configure size={14} className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Configure your new dashboard
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Give it a name, add description, tags and variables
|
||||
</Typography.Text>
|
||||
{showConfigure && (
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<Configure size={14} className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Configure your new dashboard
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Give it a name, add description, tags and variables
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
width="140px"
|
||||
prefix={<Configure size="md" />}
|
||||
disabled={!isEditable}
|
||||
onClick={(): void => requestSettings({ tab: 'Overview' })}
|
||||
testId="empty-configure"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
width="140px"
|
||||
prefix={<Configure size="md" />}
|
||||
disabled={!isEditable}
|
||||
onClick={(): void => requestSettings({ tab: 'Overview' })}
|
||||
testId="empty-configure"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
{showNewPanel && (
|
||||
<div className={styles.step}>
|
||||
<div className={styles.stepText}>
|
||||
<img src={landscapeUrl} alt="" className={styles.stepIcon} />
|
||||
<div className={styles.stepCopy}>
|
||||
<Typography.Text className={styles.stepTitle}>
|
||||
Add panels
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.stepInfo}>
|
||||
Add panels to visualize your data
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
disabled={!canAddPanel}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size="md" />}
|
||||
disabled={!canAddPanel}
|
||||
onClick={(): void => openPicker()}
|
||||
testId="add-panel"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PanelTypeSelectionModal
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelActionItems } from '../usePanelActionItems';
|
||||
|
||||
/** Keys of the disabled items, in order. */
|
||||
@@ -494,4 +495,43 @@ describe('usePanelActionItems', () => {
|
||||
click(actionRow(result.current.items, 'create-alert'));
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
describe('host controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('drops every writing action on a read-only mount', () => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
expect(itemKeys(result.current)).toStrictEqual([
|
||||
'view-panel',
|
||||
'separator',
|
||||
'download',
|
||||
'create-alert',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the actions the host hides', () => {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
controls: { panelActions: { createAlert: false, delete: false } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
expect(itemKeys(result.current)).not.toContain('create-alert');
|
||||
expect(itemKeys(result.current)).not.toContain('delete-panel');
|
||||
expect(itemKeys(result.current)).toContain('edit-panel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/t
|
||||
import { blockedBy } from 'lib/authz/components/AuthZDropdown/utils';
|
||||
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
@@ -66,6 +67,7 @@ export function usePanelActionItems({
|
||||
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const show = useDashboardControlGroup('panelActions');
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
@@ -103,7 +105,7 @@ export function usePanelActionItems({
|
||||
const editGate = { checks: editChecks, ...blockedBy(editDisabledTooltip) };
|
||||
|
||||
const panelGroup: AuthZDropdownItemType[] = [];
|
||||
if (panelCapabilities.view) {
|
||||
if (show.view && panelCapabilities.view) {
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
value: 'view-panel',
|
||||
@@ -112,7 +114,7 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openView(panelId, panel),
|
||||
});
|
||||
}
|
||||
if (panelCapabilities.edit) {
|
||||
if (show.edit && panelCapabilities.edit) {
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
value: 'edit-panel',
|
||||
@@ -122,7 +124,7 @@ export function usePanelActionItems({
|
||||
onClick: (): void => openPanelEditor(panelId, { panel }),
|
||||
});
|
||||
}
|
||||
if (panelCapabilities.clone) {
|
||||
if (show.clone && panelCapabilities.clone) {
|
||||
// Needs section context to place the copy; disabled without it.
|
||||
panelGroup.push({
|
||||
type: 'item',
|
||||
@@ -142,13 +144,13 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
const dataGroup: AuthZDropdownItemType[] = [];
|
||||
if (downloadItem) {
|
||||
if (show.download && downloadItem) {
|
||||
dataGroup.push(downloadItem);
|
||||
}
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on editability (V1 parity).
|
||||
if (panelCapabilities.createAlert) {
|
||||
if (show.createAlert && panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
type: 'item',
|
||||
value: 'create-alert',
|
||||
@@ -159,8 +161,9 @@ export function usePanelActionItems({
|
||||
});
|
||||
}
|
||||
|
||||
const moveGroup: AuthZDropdownItemType[] =
|
||||
isEditable && panelActions
|
||||
const moveGroup: AuthZDropdownItemType[] = !show.move
|
||||
? []
|
||||
: isEditable && panelActions
|
||||
? buildMoveItems({
|
||||
sections,
|
||||
currentLayoutIndex: panelActions.currentLayoutIndex,
|
||||
@@ -177,17 +180,19 @@ export function usePanelActionItems({
|
||||
},
|
||||
];
|
||||
|
||||
const deleteGroup: AuthZDropdownItemType[] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete-panel',
|
||||
label: 'Delete panel',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
...editGate,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
const deleteGroup: AuthZDropdownItemType[] = !show.delete
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete-panel',
|
||||
label: 'Delete panel',
|
||||
prefix: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
...editGate,
|
||||
onClick: (): void => requestDelete(),
|
||||
},
|
||||
];
|
||||
|
||||
return [panelGroup, dataGroup, moveGroup, deleteGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
@@ -197,6 +202,7 @@ export function usePanelActionItems({
|
||||
: [{ type: 'separator', value: `separator-${index}` }, ...group],
|
||||
);
|
||||
}, [
|
||||
show,
|
||||
isEditable,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../../../controls/useDashboardControl';
|
||||
|
||||
interface ViewPanelModalHeaderBaseProps {
|
||||
onSwitchToEdit: () => void;
|
||||
@@ -70,6 +71,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
} = useDashboardEditContext();
|
||||
const showSwitchToEdit = useDashboardControl('viewModal.switchToEdit');
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({
|
||||
@@ -87,20 +89,22 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
testId="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
{showSwitchToEdit && (
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
disabled={!canSwitchToEdit}
|
||||
onClick={onSwitchToEdit}
|
||||
testId="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{query && (
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import ViewPanelModalHeader from '../ViewPanelModalHeader';
|
||||
import type { DashboardControlsInput } from '../../../../controls/types';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): unknown => ({
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems',
|
||||
() => ({ usePanelTypeSelectItems: (): unknown[] => [] }),
|
||||
);
|
||||
|
||||
function renderHeader(
|
||||
readOnly: boolean,
|
||||
controls?: DashboardControlsInput,
|
||||
): void {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: 'dash-1',
|
||||
refetch: jest.fn(),
|
||||
readOnly,
|
||||
controls,
|
||||
});
|
||||
render(
|
||||
<ViewPanelModalHeader
|
||||
mode="static"
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={jest.fn()}
|
||||
onSwitchToEdit={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ViewPanelModalHeader - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('offers Switch to Edit Mode on an editable mount', () => {
|
||||
renderHeader(false);
|
||||
|
||||
expect(screen.getByTestId('view-panel-switch-to-edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Switch to Edit Mode on a read-only mount', () => {
|
||||
renderHeader(true);
|
||||
|
||||
expect(screen.getByTestId('view-panel-type-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-switch-to-edit'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Switch to Edit Mode when the host hides it', () => {
|
||||
renderHeader(false, { viewModal: false });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-switch-to-edit'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,15 @@ describe('useDrilldown', () => {
|
||||
expect(result.current.enableDrillDown).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when every builder query is disabled, leaving only formulas to draw', () => {
|
||||
mockGetBuilderQueries.mockReturnValue([
|
||||
{ name: 'A', disabled: true },
|
||||
{ name: 'B', disabled: true },
|
||||
]);
|
||||
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
|
||||
expect(result.current.enableDrillDown).toBe(false);
|
||||
});
|
||||
|
||||
it.each([EQueryType.PROM, EQueryType.CLICKHOUSE, undefined])(
|
||||
'is false when the query type is %s (not the builder)',
|
||||
(queryType) => {
|
||||
|
||||
@@ -18,17 +18,29 @@ let mockVariables: Array<{
|
||||
let mockSelectionMap: Record<string, { value: unknown; allSelected: boolean }> =
|
||||
{};
|
||||
|
||||
let mockIsEditable = true;
|
||||
const mockSetVariableValue = jest.fn();
|
||||
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const DYNAMIC_KIND = 'signoz/DynamicVariable';
|
||||
const QUERY_KIND = 'signoz/QueryVariable';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => ({
|
||||
data: { data: { spec: { variables: mockVariables } } },
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired',
|
||||
() => ({
|
||||
useDashboardFetchRequired: (): unknown => ({
|
||||
dashboard: { spec: { variables: mockVariables } },
|
||||
variables: mockVariables,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): unknown => ({ isEditable: mockIsEditable }),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useOptimisticPatch',
|
||||
() => ({
|
||||
@@ -97,6 +109,7 @@ function renderItems(): void {
|
||||
|
||||
describe('useDrilldownDashboardVariables', () => {
|
||||
beforeEach(() => {
|
||||
mockIsEditable = true;
|
||||
jest.clearAllMocks();
|
||||
mockVariables = [];
|
||||
mockSelectionMap = {};
|
||||
@@ -171,6 +184,17 @@ describe('useDrilldownDashboardVariables', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('offers nothing to create on a dashboard the viewer cannot edit', () => {
|
||||
mockVariables = [];
|
||||
mockIsEditable = false;
|
||||
const { result } = renderHook(() =>
|
||||
useDrilldownDashboardVariables({ filters, onClose: jest.fn() }),
|
||||
);
|
||||
|
||||
expect(result.current.actions).toStrictEqual([]);
|
||||
expect(result.current.hasFieldVariables).toBe(false);
|
||||
});
|
||||
|
||||
it('offers Create and persists a new dynamic variable when none matches', async () => {
|
||||
mockVariables = [];
|
||||
renderItems();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
|
||||
import { CHARTS_ONLY } from '../../../../controls/presets';
|
||||
import type { DashboardControlsInput } from '../../../../controls/types';
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import { usePanelInteractions } from '../usePanelInteractions';
|
||||
|
||||
function dragSelect(controls?: DashboardControlsInput): void {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: 'd1', refetch: jest.fn(), controls });
|
||||
const { result } = renderHook(() => usePanelInteractions(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
result.current.onDragSelect(1_000, 2_000);
|
||||
}
|
||||
|
||||
describe('usePanelInteractions drag-to-zoom', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('writes the dragged range to the URL', () => {
|
||||
dragSelect();
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('startTime=1000&endTime=2000'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the host turns zoom off', () => {
|
||||
dragSelect({ panelActions: { zoom: false } });
|
||||
|
||||
expect(safeNavigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still zooms when the host hides the toolbar but leaves zoom on', () => {
|
||||
dragSelect({ toolbar: false });
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still zooms on a charts-only mount', () => {
|
||||
dragSelect(CHARTS_ONLY);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { useDrilldownCoordinates } from './useDrilldownCoordinates';
|
||||
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
|
||||
import { useDrilldownFilter } from './useDrilldownFilter';
|
||||
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
|
||||
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
|
||||
import { useViewPanel } from './useViewPanel';
|
||||
|
||||
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
|
||||
@@ -83,13 +84,16 @@ export function useDrilldown(
|
||||
const queries = panel.spec.queries;
|
||||
|
||||
// Drilldown only for builder-authored panels with a query to drill into (PromQL /
|
||||
// ClickHouse have no builder context to refine).
|
||||
// ClickHouse have no builder context to refine). A panel whose builder queries
|
||||
// are all disabled draws only formulas, which have no series to drill into.
|
||||
const showDrilldown = useDashboardControl('panelActions.drilldown');
|
||||
const enableDrillDown = useMemo(
|
||||
() =>
|
||||
showDrilldown &&
|
||||
getPanelDefinition(kind).actions.drilldown &&
|
||||
getPanelQueryType(panel) === EQueryType.QUERY_BUILDER &&
|
||||
getBuilderQueries(queries).length > 0,
|
||||
[kind, panel, queries],
|
||||
getBuilderQueries(queries).some((query) => !query.disabled),
|
||||
[showDrilldown, kind, panel, queries],
|
||||
);
|
||||
|
||||
const v1Query = useMemo(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type VariableFormModel,
|
||||
} from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableFormModel';
|
||||
import { buildVariablesPatch } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/utils/variablePatchOps';
|
||||
import { useDashboardEditContext } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext';
|
||||
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
|
||||
import { useOptimisticPatch } from 'pages/DashboardPage/DashboardContainer/hooks/useOptimisticPatch';
|
||||
import { selectVariableValues } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
@@ -44,7 +45,7 @@ export interface DrilldownVariableAction {
|
||||
}
|
||||
|
||||
export interface UseDrilldownDashboardVariablesApi {
|
||||
/** Whether the clicked point exposes any field to bind to a variable (gates the base-menu entry). */
|
||||
/** Whether the submenu has any entry to offer (gates the base-menu entry). */
|
||||
hasFieldVariables: boolean;
|
||||
/** Resolved menu entries, one per group-by field on the clicked point. */
|
||||
actions: DrilldownVariableAction[];
|
||||
@@ -74,6 +75,8 @@ export function useDrilldownDashboardVariables({
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((state) => state.setVariableValue);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
// Create writes the dashboard spec; Set/Unset only change this view's selection.
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
|
||||
const fieldVariables = useMemo<[string, string | number][]>(
|
||||
() =>
|
||||
@@ -137,51 +140,57 @@ export function useDrilldownDashboardVariables({
|
||||
|
||||
const actions = useMemo<DrilldownVariableAction[]>(
|
||||
() =>
|
||||
fieldVariables.map(([fieldName, fieldValue]) => {
|
||||
const existing = dynamicVariables.find(
|
||||
(v) => v.dynamicAttribute === fieldName,
|
||||
);
|
||||
if (!existing) {
|
||||
fieldVariables
|
||||
.map(([fieldName, fieldValue]) => {
|
||||
const existing = dynamicVariables.find(
|
||||
(v) => v.dynamicAttribute === fieldName,
|
||||
);
|
||||
if (!existing) {
|
||||
if (!isEditable) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fieldName,
|
||||
fieldValue,
|
||||
kind: DrilldownVariableActionKind.Create,
|
||||
onClick: (): void => {
|
||||
void handleCreate(fieldName, fieldValue);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const current = selection[existing.name]?.value;
|
||||
const isSame = Array.isArray(current)
|
||||
? current.length === 1 && current[0] === fieldValue
|
||||
: current === fieldValue;
|
||||
|
||||
// Multi-select values must be arrays (a scalar renders as empty in the selector).
|
||||
const cleared = existing.multiSelect ? [] : '';
|
||||
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
|
||||
|
||||
return {
|
||||
fieldName,
|
||||
fieldValue,
|
||||
kind: DrilldownVariableActionKind.Create,
|
||||
kind: isSame
|
||||
? DrilldownVariableActionKind.Unset
|
||||
: DrilldownVariableActionKind.Set,
|
||||
onClick: (): void => {
|
||||
void handleCreate(fieldName, fieldValue);
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'setVariable',
|
||||
dashboardId,
|
||||
});
|
||||
setSelection(existing.name, {
|
||||
value: isSame ? cleared : assigned,
|
||||
allSelected: false,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const current = selection[existing.name]?.value;
|
||||
const isSame = Array.isArray(current)
|
||||
? current.length === 1 && current[0] === fieldValue
|
||||
: current === fieldValue;
|
||||
|
||||
// Multi-select values must be arrays (a scalar renders as empty in the selector).
|
||||
const cleared = existing.multiSelect ? [] : '';
|
||||
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
|
||||
|
||||
return {
|
||||
fieldName,
|
||||
fieldValue,
|
||||
kind: isSame
|
||||
? DrilldownVariableActionKind.Unset
|
||||
: DrilldownVariableActionKind.Set,
|
||||
onClick: (): void => {
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'setVariable',
|
||||
dashboardId,
|
||||
});
|
||||
setSelection(existing.name, {
|
||||
value: isSame ? cleared : assigned,
|
||||
allSelected: false,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}),
|
||||
})
|
||||
.filter((action): action is DrilldownVariableAction => action !== null),
|
||||
[
|
||||
fieldVariables,
|
||||
isEditable,
|
||||
dynamicVariables,
|
||||
selection,
|
||||
setSelection,
|
||||
@@ -191,5 +200,5 @@ export function useDrilldownDashboardVariables({
|
||||
],
|
||||
);
|
||||
|
||||
return { hasFieldVariables: fieldVariables.length > 0, actions };
|
||||
return { hasFieldVariables: actions.length > 0, actions };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { useDashboardControl } from 'pages/DashboardPage/DashboardContainer/controls/useDashboardControl';
|
||||
|
||||
export interface PanelInteractions {
|
||||
/** Drag-select a chart range → write it to the URL + global time so every panel re-fetches the same range. */
|
||||
@@ -43,8 +44,12 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
[syncMode, syncFilterMode, dashboardId],
|
||||
);
|
||||
|
||||
const canZoom = useDashboardControl('panelActions.zoom');
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
if (!canZoom) {
|
||||
return;
|
||||
}
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
@@ -57,7 +62,7 @@ export function usePanelInteractions(): PanelInteractions {
|
||||
void logEvent(DashboardDetailEvents.PanelZoomed, { context: 'panel' });
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, safeNavigate, urlQuery],
|
||||
[canZoom, dispatch, pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
return { onDragSelect, dashboardPreference };
|
||||
|
||||
@@ -8,12 +8,12 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useCreatePanel } from '../../../hooks/useCreatePanel';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import { useCloneSection } from '../hooks/useCloneSection';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { usePanelPickerHighlight } from '../hooks/usePanelPickerHighlight';
|
||||
import { usePanelPickerReveal } from '../hooks/usePanelPickerReveal';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import { useSectionHeaderActions } from '../hooks/useSectionHeaderActions';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
import SectionTitleModal from '../SectionTitleModal';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
@@ -66,7 +66,14 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteSection]);
|
||||
|
||||
const cloneSection = useCloneSection();
|
||||
const openRename = useCallback((): void => setIsRenaming(true), []);
|
||||
const openDelete = useCallback((): void => setIsDeleteOpen(true), []);
|
||||
const actions = useSectionHeaderActions({
|
||||
section,
|
||||
openPicker,
|
||||
openRename,
|
||||
openDelete,
|
||||
});
|
||||
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
useScrollIntoView(section.id, sectionRef);
|
||||
@@ -81,6 +88,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
layoutIndex={section.layoutIndex}
|
||||
sections={sections}
|
||||
placeholderKind={pickerTarget?.panelKind}
|
||||
flush={!section.title}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -116,35 +124,30 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
dragHandle={dragHandle}
|
||||
disabledChecks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
actions={{
|
||||
onRename: (): void => setIsRenaming(true),
|
||||
onAddPanel: (): void => openPicker(section.layoutIndex),
|
||||
onCloneSection: (): void => void cloneSection(section),
|
||||
onDeleteSection: (): void => setIsDeleteOpen(true),
|
||||
}}
|
||||
actions={actions}
|
||||
/>
|
||||
{open &&
|
||||
(section.items.length > 0 || pickerTarget ? (
|
||||
grid
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
(section.items.length > 0 || pickerTarget
|
||||
? grid
|
||||
: actions.onAddPanel && (
|
||||
<div className={styles.emptySection}>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={
|
||||
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
|
||||
}
|
||||
testId={`section-add-panel-${section.id}`}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
))}
|
||||
<SectionTitleModal
|
||||
open={isRenaming}
|
||||
heading="Rename section"
|
||||
|
||||
@@ -25,7 +25,7 @@ function SectionActionsMenu({
|
||||
onRename,
|
||||
onCloneSection,
|
||||
onDeleteSection,
|
||||
}: SectionActionsMenuProps): JSX.Element {
|
||||
}: SectionActionsMenuProps): JSX.Element | null {
|
||||
const items = useMemo<AuthZDropdownItemType[]>(() => {
|
||||
const gate = { checks: disabledChecks, ...blockedBy(disabledTooltip) };
|
||||
const result: AuthZDropdownItemType[] = [];
|
||||
@@ -83,6 +83,10 @@ function SectionActionsMenu({
|
||||
onDeleteSection,
|
||||
]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
|
||||
@@ -23,6 +23,8 @@ interface SectionGridProps {
|
||||
sections?: DashboardSection[];
|
||||
/** Shows where a new panel of this kind will land. */
|
||||
placeholderKind?: PanelKind;
|
||||
/** No side padding: panels share the toolbar's edges. For a grid without section chrome. */
|
||||
flush?: boolean;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
@@ -30,6 +32,7 @@ function SectionGrid({
|
||||
layoutIndex,
|
||||
sections,
|
||||
placeholderKind,
|
||||
flush = false,
|
||||
}: SectionGridProps): JSX.Element {
|
||||
const { isEditable } = useDashboardEditContext();
|
||||
|
||||
@@ -65,6 +68,7 @@ function SectionGrid({
|
||||
onDragStop={handleLayoutChange}
|
||||
onResizeStop={handleLayoutChange}
|
||||
margin={[GRID_MARGIN, GRID_MARGIN]}
|
||||
containerPadding={flush ? [0, GRID_MARGIN] : [GRID_MARGIN, GRID_MARGIN]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
// A layout item can reference a panel id that no longer exists in the
|
||||
|
||||
@@ -16,12 +16,12 @@ export interface SectionDragHandle {
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
/** Editable-mode section actions — present together or not at all. */
|
||||
/** Section actions; an absent one isn't offered. */
|
||||
export interface SectionHeaderActions {
|
||||
onRename: () => void;
|
||||
onAddPanel: () => void;
|
||||
onCloneSection: () => void;
|
||||
onDeleteSection: () => void;
|
||||
onRename?: () => void;
|
||||
onAddPanel?: () => void;
|
||||
onCloneSection?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useDashboardControlGroup } from '../../../controls/useDashboardControlGroup';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { SectionHeaderActions } from '../SectionHeader/SectionHeader';
|
||||
import { useCloneSection } from './useCloneSection';
|
||||
|
||||
/** The section menu's actions, minus the ones the host hides (`sectionActions`). */
|
||||
export function useSectionHeaderActions({
|
||||
section,
|
||||
openPicker,
|
||||
openRename,
|
||||
openDelete,
|
||||
}: {
|
||||
section: DashboardSection;
|
||||
openPicker: (layoutIndex: number) => void;
|
||||
openRename: () => void;
|
||||
openDelete: () => void;
|
||||
}): SectionHeaderActions {
|
||||
const show = useDashboardControlGroup('sectionActions');
|
||||
const cloneSection = useCloneSection();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRename: show.rename ? openRename : undefined,
|
||||
onAddPanel: show.addPanel
|
||||
? (): void => openPicker(section.layoutIndex)
|
||||
: undefined,
|
||||
onCloneSection: show.clone
|
||||
? (): void => void cloneSection(section)
|
||||
: undefined,
|
||||
onDeleteSection: show.delete ? openDelete : undefined,
|
||||
}),
|
||||
[show, openRename, openPicker, section, cloneSection, openDelete],
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { resolveDefaultSelection } from './utils/resolveVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
import { useDashboardControl } from '../controls/useDashboardControl';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -39,6 +40,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
// Persisted per dashboard so the full/collapsed view survives reloads.
|
||||
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
|
||||
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
|
||||
const showAddVariable = useDashboardControl('toolbar.addVariable');
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
@@ -49,7 +51,7 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
|
||||
// Editors can add a variable even before any exist; viewers with no variables
|
||||
// have nothing to show.
|
||||
if (variables.length === 0 && !isEditable) {
|
||||
if (variables.length === 0 && !(isEditable && showAddVariable)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,13 +133,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
{/* After the more/less trigger, in every state. Kept inline (not block)
|
||||
so the row still flows under the floated time selector, and always
|
||||
mounted so measuring never toggles it. */}
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
{showAddVariable && (
|
||||
<span className={styles.addSlot}>
|
||||
<AddVariableIcon
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { dashboardResponse } from '../../../stories/__story_mockdata__/dashboard';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import VariablesBar from '../VariablesBar';
|
||||
import type { DashboardControlsInput } from '../../controls/types';
|
||||
|
||||
const { data: dashboard } = dashboardResponse({
|
||||
panels: 0,
|
||||
sectioned: false,
|
||||
variables: [],
|
||||
locked: false,
|
||||
});
|
||||
|
||||
jest.mock('../../hooks/useDashboardEditContext', () => ({
|
||||
useDashboardEditContext: (): unknown => ({
|
||||
isEditable: false,
|
||||
editChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}));
|
||||
jest.mock('../hooks/useVariableSelection', () => ({
|
||||
useVariableSelection: (): unknown => ({
|
||||
variables: [{ name: 'env' }],
|
||||
selection: {},
|
||||
setSelection: jest.fn(),
|
||||
autoSelect: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../components/VariableSelector/VariableSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="variable-selector" />,
|
||||
}));
|
||||
|
||||
function renderBar(readOnly: boolean, controls?: DashboardControlsInput): void {
|
||||
useDashboardStore.getState().setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
refetch: jest.fn(),
|
||||
readOnly,
|
||||
controls,
|
||||
});
|
||||
render(<VariablesBar dashboard={dashboard} />);
|
||||
}
|
||||
|
||||
describe('VariablesBar - controls', () => {
|
||||
afterEach(() => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setEditContext({ dashboardId: '', refetch: jest.fn() });
|
||||
});
|
||||
|
||||
it('offers the add-variable trigger, disabled, to a viewer', () => {
|
||||
renderBar(false);
|
||||
|
||||
expect(screen.getByTestId('dashboard-variables-add')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the selectors but no add-variable trigger', () => {
|
||||
renderBar(true);
|
||||
|
||||
expect(screen.getByTestId('variable-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-variables-add'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no add-variable trigger when the host hides it', () => {
|
||||
renderBar(false, { toolbar: { addVariable: false } });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-variables-add'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,26 @@ describe('useSeedVariableSelection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps URL values a custom list does not offer, as typed values', () => {
|
||||
mockUrlValues = { ops: ['overflow_operation', 'GET /cart'] };
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'ops',
|
||||
type: 'CUSTOM',
|
||||
multiSelect: true,
|
||||
customValue: 'overflow_operation',
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'ops')).toStrictEqual({
|
||||
value: ['overflow_operation', 'GET /cart'],
|
||||
allSelected: false,
|
||||
customValues: ['GET /cart'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the stored value when the URL has no entry', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
|
||||
@@ -71,6 +71,15 @@ function fromUrlValue(
|
||||
if (raw === ALL_SELECTED && model.multiSelect && model.showAllOption) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
// Values a link carries that the custom list doesn't offer were typed by whoever
|
||||
// made it (or set by a host page); keep them as typed instead of dropping them.
|
||||
if (model.type === 'CUSTOM' && model.multiSelect && Array.isArray(raw)) {
|
||||
const options = knownVariableOptions(model);
|
||||
const customValues = raw.map(String).filter((v) => !options.includes(v));
|
||||
if (customValues.length > 0) {
|
||||
return { value: raw, allSelected: false, customValues };
|
||||
}
|
||||
}
|
||||
return { value: raw, allSelected: false };
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user