Merge pull request #4177 from SigNoz/release/v0.35.0

Release/v0.35.0
This commit is contained in:
Prashant Shahi 2023-12-06 22:15:19 +05:30 committed by GitHub
commit 16502feaad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 2298 additions and 1281 deletions

View File

@ -34,7 +34,7 @@ jobs:
id: short-sha
- name: Get branch name
id: branch-name
uses: tj-actions/branch-names@v5.1
uses: tj-actions/branch-names@v7.0.7
- name: Set docker tag environment
run: |
if [ '${{ steps.branch-name.outputs.is_tag }}' == 'true' ]; then
@ -78,7 +78,7 @@ jobs:
id: short-sha
- name: Get branch name
id: branch-name
uses: tj-actions/branch-names@v5.1
uses: tj-actions/branch-names@v7.0.7
- name: Set docker tag environment
run: |
if [ '${{ steps.branch-name.outputs.is_tag }}' == 'true' ]; then
@ -127,7 +127,7 @@ jobs:
id: short-sha
- name: Get branch name
id: branch-name
uses: tj-actions/branch-names@v5.1
uses: tj-actions/branch-names@v7.0.7
- name: Set docker tag environment
run: |
if [ '${{ steps.branch-name.outputs.is_tag }}' == 'true' ]; then
@ -176,7 +176,7 @@ jobs:
id: short-sha
- name: Get branch name
id: branch-name
uses: tj-actions/branch-names@v5.1
uses: tj-actions/branch-names@v7.0.7
- name: Set docker tag environment
run: |
if [ '${{ steps.branch-name.outputs.is_tag }}' == 'true' ]; then

View File

@ -29,7 +29,7 @@ jobs:
export PATH="/usr/local/go/bin/:$PATH" # needed for Golang to work
docker system prune --force
docker pull signoz/signoz-otel-collector:main
docker pull signoz/signoz/signoz-schema-migrator:main
docker pull signoz/signoz-schema-migrator:main
cd ~/signoz
git status
git add .

View File

@ -146,7 +146,7 @@ services:
condition: on-failure
query-service:
image: signoz/query-service:0.34.4
image: signoz/query-service:0.35.0
command:
[
"-config=/root/config/prometheus.yml",
@ -186,7 +186,7 @@ services:
<<: *db-depend
frontend:
image: signoz/frontend:0.34.4
image: signoz/frontend:0.35.0
deploy:
restart_policy:
condition: on-failure

View File

@ -164,7 +164,7 @@ services:
# Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md`
query-service:
image: signoz/query-service:${DOCKER_TAG:-0.34.4}
image: signoz/query-service:${DOCKER_TAG:-0.35.0}
container_name: signoz-query-service
command:
[
@ -203,7 +203,7 @@ services:
<<: *db-depend
frontend:
image: signoz/frontend:${DOCKER_TAG:-0.34.4}
image: signoz/frontend:${DOCKER_TAG:-0.35.0}
container_name: signoz-frontend
restart: on-failure
depends_on:

View File

@ -1,5 +1,5 @@
# use a minimal alpine image
FROM alpine:3.18.3
FROM alpine:3.18.5
# Add Maintainer Info
LABEL maintainer="signoz"

View File

@ -49,7 +49,8 @@ export const Onboarding = Loadable(
);
export const DashboardPage = Loadable(
() => import(/* webpackChunkName: "DashboardPage" */ 'pages/Dashboard'),
() =>
import(/* webpackChunkName: "DashboardPage" */ 'pages/DashboardsListPage'),
);
export const NewDashboardPage = Loadable(

View File

@ -4,7 +4,7 @@ import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/update';
const update = async (
const updateDashboard = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
@ -23,4 +23,4 @@ const update = async (
}
};
export default update;
export default updateDashboard;

View File

@ -0,0 +1,30 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
Props,
VariableResponseProps,
} from 'types/api/dashboard/variables/query';
const dashboardVariablesQuery = async (
props: Props,
): Promise<SuccessResponse<VariableResponseProps> | ErrorResponse> => {
try {
const response = await axios.post(`/variables/query`, props);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
const formattedError = ErrorResponseHandler(error as AxiosError);
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw { message: 'Error fetching data', details: formattedError };
}
};
export default dashboardVariablesQuery;

View File

@ -1,24 +0,0 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/dashboard/variables/query';
const query = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post(`/variables/query`, props);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default query;

View File

@ -153,7 +153,7 @@ export const deleteViewHandler = ({
if (viewId === viewKey) {
redirectWithQueryBuilderData(
updateAllQueriesOperators(
initialQueriesMap.traces,
initialQueriesMap[sourcePage],
panelType || PANEL_TYPES.LIST,
sourcePage,
),

View File

@ -27,4 +27,5 @@ export enum QueryParams {
viewName = 'viewName',
viewKey = 'viewKey',
expandedWidgetId = 'expandedWidgetId',
pagination = 'pagination',
}

View File

@ -2,6 +2,7 @@ import { InfoCircleOutlined } from '@ant-design/icons';
import Spinner from 'components/Spinner';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import GridPanelSwitch from 'container/GridPanelSwitch';
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
@ -19,7 +20,7 @@ import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import { ChartContainer, FailedMessageContainer } from './styles';
import { covertIntoDataFormats } from './utils';
import { getThresholdLabel } from './utils';
export interface ChartPreviewProps {
name: string;
@ -50,12 +51,6 @@ function ChartPreview({
(state) => state.globalTime,
);
const thresholdValue = covertIntoDataFormats({
value: threshold,
sourceUnit: alertDef?.condition.targetUnit,
targetUnit: query?.unit,
});
const canQuery = useMemo((): boolean => {
if (!query || query == null) {
return false;
@ -110,6 +105,9 @@ function ChartPreview({
const isDarkMode = useIsDarkMode();
const optionName =
getFormatNameByOptionId(alertDef?.condition.targetUnit || '') || '';
const options = useMemo(
() =>
getUPlotChartOptions({
@ -124,10 +122,16 @@ function ChartPreview({
keyIndex: 0,
moveThreshold: (): void => {},
selectedGraph: PANEL_TYPES.TIME_SERIES, // no impact
thresholdValue,
thresholdValue: threshold,
thresholdLabel: `${t(
'preview_chart_threshold_label',
)} (y=${thresholdValue} ${query?.unit || ''})`,
)} (y=${getThresholdLabel(
optionName,
threshold,
alertDef?.condition.targetUnit,
query?.unit,
)})`,
thresholdUnit: alertDef?.condition.targetUnit,
},
],
}),
@ -136,8 +140,10 @@ function ChartPreview({
queryResponse?.data?.payload,
containerDimensions,
isDarkMode,
threshold,
t,
thresholdValue,
optionName,
alertDef?.condition.targetUnit,
],
);

View File

@ -51,6 +51,21 @@ export function covertIntoDataFormats({
return Number.isNaN(result) ? 0 : result;
}
export const getThresholdLabel = (
optionName: string,
value: number,
unit?: string,
yAxisUnit?: string,
): string => {
if (
unit === MiscellaneousFormats.PercentUnit ||
yAxisUnit === MiscellaneousFormats.PercentUnit
) {
return `${value * 100}%`;
}
return `${value} ${optionName}`;
};
interface IUnit {
value: number;
sourceUnit?: string;

View File

@ -25,19 +25,26 @@ export const getDefaultTableDataSet = (
data: uPlot.AlignedData,
): ExtendedChartDataset[] =>
options.series.map(
(item: uPlot.Series, index: number): ExtendedChartDataset => ({
...item,
index,
show: true,
sum: convertToTwoDecimalsOrZero(
(data[index] as number[]).reduce((a, b) => a + b, 0),
),
avg: convertToTwoDecimalsOrZero(
(data[index] as number[]).reduce((a, b) => a + b, 0) / data[index].length,
),
max: convertToTwoDecimalsOrZero(Math.max(...(data[index] as number[]))),
min: convertToTwoDecimalsOrZero(Math.min(...(data[index] as number[]))),
}),
(item: uPlot.Series, index: number): ExtendedChartDataset => {
let arr: number[];
if (data[index]) {
arr = data[index] as number[];
} else {
arr = [];
}
return {
...item,
index,
show: true,
sum: convertToTwoDecimalsOrZero(arr.reduce((a, b) => a + b, 0) || 0),
avg: convertToTwoDecimalsOrZero(
(arr.reduce((a, b) => a + b, 0) || 0) / (arr.length || 1),
),
max: convertToTwoDecimalsOrZero(Math.max(...arr)),
min: convertToTwoDecimalsOrZero(Math.min(...arr)),
};
},
);
export const getAbbreviatedLabel = (label: string): string => {

View File

@ -47,7 +47,7 @@ function WidgetGraphComponent({
const [deleteModal, setDeleteModal] = useState(false);
const [hovered, setHovered] = useState(false);
const { notifications } = useNotifications();
const { pathname } = useLocation();
const { pathname, search } = useLocation();
const params = useUrlQuery();
@ -183,10 +183,20 @@ function WidgetGraphComponent({
const queryParams = {
[QueryParams.expandedWidgetId]: widget.id,
};
const updatedSearch = createQueryParams(queryParams);
const existingSearch = new URLSearchParams(search);
const isExpandedWidgetIdPresent = existingSearch.has(
QueryParams.expandedWidgetId,
);
if (isExpandedWidgetIdPresent) {
existingSearch.delete(QueryParams.expandedWidgetId);
}
const separator = existingSearch.toString() ? '&' : '';
const newSearch = `${existingSearch}${separator}${updatedSearch}`;
history.push({
pathname,
search: createQueryParams(queryParams),
search: newSearch,
});
};
@ -199,9 +209,12 @@ function WidgetGraphComponent({
};
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
history.push({
pathname,
search: createQueryParams({}),
search: createQueryParams(updatedQueryParams),
});
};

View File

@ -0,0 +1,30 @@
.widget-header-container {
display: flex;
justify-content: space-between;
align-items: center;
height: 30px;
width: 100%;
padding: 0.5rem;
}
.widget-header-title {
max-width: 80%;
}
.widget-header-actions {
display: flex;
align-items: center;
}
.widget-header-more-options {
visibility: hidden;
border: none;
box-shadow: none;
}
.widget-header-hover {
visibility: visible;
}
.widget-api-actions {
padding-right: 0.25rem;
}

View File

@ -1,21 +1,23 @@
import './WidgetHeader.styles.scss';
import {
AlertOutlined,
CopyOutlined,
DeleteOutlined,
DownOutlined,
EditFilled,
ExclamationCircleOutlined,
FullscreenOutlined,
MoreOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { Dropdown, MenuProps, Tooltip, Typography } from 'antd';
import { Button, Dropdown, MenuProps, Tooltip, Typography } from 'antd';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import history from 'lib/history';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { ReactNode, useCallback, useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@ -23,23 +25,9 @@ import { ErrorResponse, SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import AppReducer from 'types/reducer/app';
import { popupContainer } from 'utils/selectPopupContainer';
import {
errorTooltipPosition,
overlayStyles,
spinnerStyles,
tooltipStyles,
WARNING_MESSAGE,
} from './config';
import { errorTooltipPosition, WARNING_MESSAGE } from './config';
import { MENUITEM_KEYS_VS_LABELS, MenuItemKeys } from './contants';
import {
ArrowContainer,
HeaderContainer,
HeaderContentContainer,
ThesholdContainer,
WidgetHeaderContainer,
} from './styles';
import { MenuItem } from './types';
import { generateMenuList, isTWidgetOptions } from './utils';
@ -72,9 +60,6 @@ function WidgetHeader({
headerMenuList,
isWarning,
}: IWidgetHeaderProps): JSX.Element | null {
const [localHover, setLocalHover] = useState(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
const onEditHandler = useCallback((): void => {
const widgetId = widget.id;
history.push(
@ -112,7 +97,6 @@ function WidgetHeader({
if (functionToCall) {
functionToCall();
setIsOpen(false);
}
}
},
@ -169,10 +153,6 @@ function WidgetHeader({
const updatedMenuList = useMemo(() => generateMenuList(actions), [actions]);
const onClickHandler = (): void => {
setIsOpen(!isOpen);
};
const menu = useMemo(
() => ({
items: updatedMenuList,
@ -186,49 +166,49 @@ function WidgetHeader({
}
return (
<WidgetHeaderContainer>
<Dropdown
getPopupContainer={popupContainer}
destroyPopupOnHide
open={isOpen}
onOpenChange={setIsOpen}
menu={menu}
trigger={['click']}
overlayStyle={overlayStyles}
<div className="widget-header-container">
<Typography.Text
ellipsis
data-testid={title}
className="widget-header-title"
>
<HeaderContainer
onMouseOver={(): void => setLocalHover(true)}
onMouseOut={(): void => setLocalHover(false)}
hover={localHover}
onClick={onClickHandler}
>
<HeaderContentContainer>
<Typography.Text style={{ maxWidth: '80%' }} ellipsis data-testid={title}>
{title}
</Typography.Text>
<ArrowContainer hover={parentHover}>
<DownOutlined />
</ArrowContainer>
</HeaderContentContainer>
</HeaderContainer>
</Dropdown>
{title}
</Typography.Text>
<div className="widget-header-actions">
<div className="widget-api-actions">{threshold}</div>
{queryResponse.isFetching && !queryResponse.isError && (
<Spinner style={{ paddingRight: '0.25rem' }} />
)}
{queryResponse.isError && (
<Tooltip
title={errorMessage}
placement={errorTooltipPosition}
className="widget-api-actions"
>
<ExclamationCircleOutlined />
</Tooltip>
)}
<ThesholdContainer>{threshold}</ThesholdContainer>
{queryResponse.isFetching && !queryResponse.isError && (
<Spinner height="5vh" style={spinnerStyles} />
)}
{queryResponse.isError && (
<Tooltip title={errorMessage} placement={errorTooltipPosition}>
<ExclamationCircleOutlined style={tooltipStyles} />
</Tooltip>
)}
{isWarning && (
<Tooltip title={WARNING_MESSAGE} placement={errorTooltipPosition}>
<WarningOutlined style={tooltipStyles} />
</Tooltip>
)}
</WidgetHeaderContainer>
{isWarning && (
<Tooltip
title={WARNING_MESSAGE}
placement={errorTooltipPosition}
className="widget-api-actions"
>
<WarningOutlined />
</Tooltip>
)}
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<Button
type="default"
icon={<MoreOutlined />}
className={`widget-header-more-options ${
parentHover ? 'widget-header-hover' : ''
}`}
/>
</Dropdown>
</div>
</div>
);
}

View File

@ -41,8 +41,6 @@ export const WidgetHeaderContainer = styled.div`
export const ArrowContainer = styled.span<{ hover: boolean }>`
visibility: ${({ hover }): string => (hover ? 'visible' : 'hidden')};
position: absolute;
right: -1rem;
`;
export const Typography = styled(TypographyComponent)`

View File

@ -8,5 +8,18 @@
.upgrade-link {
padding: 0px;
padding-right: 4px;
display: inline !important;
color: white;
text-decoration: underline;
text-decoration-color: white;
text-decoration-thickness: 2px;
text-underline-offset: 2px;
&:hover {
color: white;
text-decoration: underline;
text-decoration-color: white;
text-decoration-thickness: 2px;
text-underline-offset: 2px;
}
}

View File

@ -1,3 +1,6 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/anchor-is-valid */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import './Header.styles.scss';
import {
@ -135,16 +138,17 @@ function HeaderContainer(): JSX.Element {
<>
{showTrialExpiryBanner && (
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on
You are in free trial period. Your free trial will end on{' '}
<span>
{getFormattedDate(licenseData?.payload?.trialEnd || Date.now())}.
</span>
{role === 'ADMIN' ? (
<span>
Please
<Button className="upgrade-link" type="link" onClick={handleUpgrade}>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</Button>
</a>
to continue using SigNoz features.
</span>
) : (

View File

@ -0,0 +1,378 @@
import { PlusOutlined } from '@ant-design/icons';
import { Card, Col, Dropdown, Input, Row, TableColumnProps } from 'antd';
import { ItemType } from 'antd/es/menu/hooks/useItems';
import createDashboard from 'api/dashboard/create';
import { AxiosError } from 'axios';
import {
DynamicColumnsKey,
TableDataSource,
} from 'components/ResizeTable/contants';
import DynamicColumnTable from 'components/ResizeTable/DynamicColumnTable';
import LabelColumn from 'components/TableRenderer/LabelColumn';
import TextToolTip from 'components/TextToolTip';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import history from 'lib/history';
import { Key, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { generatePath } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { Dashboard } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import DateComponent from '../../components/ResizeTable/TableComponent/DateComponent';
import ImportJSON from './ImportJSON';
import { ButtonContainer, NewDashboardButton, TableContainer } from './styles';
import DeleteButton from './TableComponents/DeleteButton';
import Name from './TableComponents/Name';
const { Search } = Input;
function DashboardsList(): JSX.Element {
const {
data: dashboardListResponse = [],
isLoading: isDashboardListLoading,
refetch: refetchDashboardList,
} = useGetAllDashboard();
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
const [action, createNewDashboard] = useComponentPermission(
['action', 'create_new_dashboards'],
role,
);
const { t } = useTranslation('dashboard');
const [
isImportJSONModalVisible,
setIsImportJSONModalVisible,
] = useState<boolean>(false);
const [uploadedGrafana, setUploadedGrafana] = useState<boolean>(false);
const [isFilteringDashboards, setIsFilteringDashboards] = useState(false);
const [dashboards, setDashboards] = useState<Dashboard[]>();
const sortDashboardsByCreatedAt = (dashboards: Dashboard[]): void => {
const sortedDashboards = dashboards.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
setDashboards(sortedDashboards);
};
useEffect(() => {
sortDashboardsByCreatedAt(dashboardListResponse);
}, [dashboardListResponse]);
const [newDashboardState, setNewDashboardState] = useState({
loading: false,
error: false,
errorMessage: '',
});
const dynamicColumns: TableColumnProps<Data>[] = [
{
title: 'Created At',
dataIndex: 'createdAt',
width: 30,
key: DynamicColumnsKey.CreatedAt,
sorter: (a: Data, b: Data): number => {
console.log({ a });
const prev = new Date(a.createdAt).getTime();
const next = new Date(b.createdAt).getTime();
return prev - next;
},
render: DateComponent,
},
{
title: 'Created By',
dataIndex: 'createdBy',
width: 30,
key: DynamicColumnsKey.CreatedBy,
},
{
title: 'Last Updated Time',
width: 30,
dataIndex: 'lastUpdatedTime',
key: DynamicColumnsKey.UpdatedAt,
sorter: (a: Data, b: Data): number => {
const prev = new Date(a.lastUpdatedTime).getTime();
const next = new Date(b.lastUpdatedTime).getTime();
return prev - next;
},
render: DateComponent,
},
{
title: 'Last Updated By',
dataIndex: 'lastUpdatedBy',
width: 30,
key: DynamicColumnsKey.UpdatedBy,
},
];
const columns = useMemo(() => {
const tableColumns: TableColumnProps<Data>[] = [
{
title: 'Name',
dataIndex: 'name',
width: 40,
render: Name,
},
{
title: 'Description',
width: 50,
dataIndex: 'description',
},
{
title: 'Tags',
dataIndex: 'tags',
width: 50,
render: (value): JSX.Element => <LabelColumn labels={value} />,
},
];
if (action) {
tableColumns.push({
title: 'Action',
dataIndex: '',
width: 40,
render: DeleteButton,
});
}
return tableColumns;
}, [action]);
const data: Data[] =
dashboards?.map((e) => ({
createdAt: e.created_at,
description: e.data.description || '',
id: e.uuid,
lastUpdatedTime: e.updated_at,
name: e.data.title,
tags: e.data.tags || [],
key: e.uuid,
createdBy: e.created_by,
isLocked: !!e.isLocked || false,
lastUpdatedBy: e.updated_by,
refetchDashboardList,
})) || [];
const onNewDashboardHandler = useCallback(async () => {
try {
setNewDashboardState({
...newDashboardState,
loading: true,
});
const response = await createDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
});
if (response.statusCode === 200) {
history.push(
generatePath(ROUTES.DASHBOARD, {
dashboardId: response.payload.uuid,
}),
);
} else {
setNewDashboardState({
...newDashboardState,
loading: false,
error: true,
errorMessage: response.error || 'Something went wrong',
});
}
} catch (error) {
setNewDashboardState({
...newDashboardState,
error: true,
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
});
}
}, [newDashboardState, t]);
const getText = useCallback(() => {
if (!newDashboardState.error && !newDashboardState.loading) {
return 'New Dashboard';
}
if (newDashboardState.loading) {
return 'Loading';
}
return newDashboardState.errorMessage;
}, [
newDashboardState.error,
newDashboardState.errorMessage,
newDashboardState.loading,
]);
const onModalHandler = (uploadedGrafana: boolean): void => {
setIsImportJSONModalVisible((state) => !state);
setUploadedGrafana(uploadedGrafana);
};
const getMenuItems = useMemo(() => {
const menuItems: ItemType[] = [
{
key: t('import_json').toString(),
label: t('import_json'),
onClick: (): void => onModalHandler(false),
},
{
key: t('import_grafana_json').toString(),
label: t('import_grafana_json'),
onClick: (): void => onModalHandler(true),
disabled: true,
},
];
if (createNewDashboard) {
menuItems.unshift({
key: t('create_dashboard').toString(),
label: t('create_dashboard'),
disabled: isDashboardListLoading,
onClick: onNewDashboardHandler,
});
}
return menuItems;
}, [createNewDashboard, isDashboardListLoading, onNewDashboardHandler, t]);
const searchArrayOfObjects = (searchValue: string): any[] => {
// Convert the searchValue to lowercase for case-insensitive search
const searchValueLowerCase = searchValue.toLowerCase();
// Use the filter method to find matching objects
return dashboardListResponse.filter((item: any) => {
// Convert each property value to lowercase for case-insensitive search
const itemValues = Object.values(item?.data).map((value: any) =>
value.toString().toLowerCase(),
);
// Check if any property value contains the searchValue
return itemValues.some((value) => value.includes(searchValueLowerCase));
});
};
const handleSearch = useDebouncedFn((event: unknown): void => {
setIsFilteringDashboards(true);
const searchText = (event as React.BaseSyntheticEvent)?.target?.value || '';
const filteredDashboards = searchArrayOfObjects(searchText);
setDashboards(filteredDashboards);
setIsFilteringDashboards(false);
}, 500);
const GetHeader = useMemo(
() => (
<Row gutter={16} align="middle">
<Col span={18}>
<Search
disabled={isDashboardListLoading}
placeholder="Search by Name, Description, Tags"
onChange={handleSearch}
loading={isFilteringDashboards}
style={{ marginBottom: 16, marginTop: 16 }}
/>
</Col>
<Col
span={6}
style={{
display: 'flex',
justifyContent: 'flex-end',
}}
>
<ButtonContainer>
<TextToolTip
{...{
text: `More details on how to create dashboards`,
url: 'https://signoz.io/docs/userguide/dashboards',
}}
/>
</ButtonContainer>
<Dropdown
menu={{ items: getMenuItems }}
disabled={isDashboardListLoading}
placement="bottomRight"
>
<NewDashboardButton
icon={<PlusOutlined />}
type="primary"
data-testid="create-new-dashboard"
loading={newDashboardState.loading}
danger={newDashboardState.error}
>
{getText()}
</NewDashboardButton>
</Dropdown>
</Col>
</Row>
),
[
isDashboardListLoading,
handleSearch,
isFilteringDashboards,
getMenuItems,
newDashboardState.loading,
newDashboardState.error,
getText,
],
);
return (
<Card>
{GetHeader}
<TableContainer>
<ImportJSON
isImportJSONModalVisible={isImportJSONModalVisible}
uploadedGrafana={uploadedGrafana}
onModalHandler={(): void => onModalHandler(false)}
/>
<DynamicColumnTable
tablesource={TableDataSource.Dashboard}
dynamicColumns={dynamicColumns}
columns={columns}
pagination={{
pageSize: 10,
defaultPageSize: 10,
total: data?.length || 0,
}}
showHeader
bordered
sticky
loading={isDashboardListLoading}
dataSource={data}
showSorterTooltip
/>
</TableContainer>
</Card>
);
}
export interface Data {
key: Key;
name: string;
description: string;
tags: string[];
createdBy: string;
createdAt: string;
lastUpdatedTime: string;
lastUpdatedBy: string;
isLocked: boolean;
id: string;
}
export default DashboardsList;

View File

@ -2,7 +2,7 @@ import { Typography } from 'antd';
import convertDateToAmAndPm from 'lib/convertDateToAmAndPm';
import getFormattedDate from 'lib/getFormatedDate';
import { Data } from '..';
import { Data } from '../DashboardsList';
function Created(createdBy: Data['createdBy']): JSX.Element {
const time = new Date(createdBy);

View File

@ -11,7 +11,7 @@ import { AppState } from 'store/reducers';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { Data } from '..';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
interface DeleteButtonProps {

View File

@ -2,7 +2,7 @@ import { LockFilled } from '@ant-design/icons';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Data } from '..';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
function Name(name: Data['name'], data: Data): JSX.Element {

View File

@ -1,7 +1,7 @@
/* eslint-disable react/destructuring-assignment */
import { Tag } from 'antd';
import { Data } from '../index';
import { Data } from '../DashboardsList';
function Tags(data: Data['tags']): JSX.Element {
return (

View File

@ -1,378 +1,3 @@
import { PlusOutlined } from '@ant-design/icons';
import { Card, Col, Dropdown, Input, Row, TableColumnProps } from 'antd';
import { ItemType } from 'antd/es/menu/hooks/useItems';
import createDashboard from 'api/dashboard/create';
import { AxiosError } from 'axios';
import {
DynamicColumnsKey,
TableDataSource,
} from 'components/ResizeTable/contants';
import DynamicColumnTable from 'components/ResizeTable/DynamicColumnTable';
import LabelColumn from 'components/TableRenderer/LabelColumn';
import TextToolTip from 'components/TextToolTip';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import history from 'lib/history';
import { Key, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { generatePath } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { Dashboard } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import DashboardsList from './DashboardsList';
import DateComponent from '../../components/ResizeTable/TableComponent/DateComponent';
import ImportJSON from './ImportJSON';
import { ButtonContainer, NewDashboardButton, TableContainer } from './styles';
import DeleteButton from './TableComponents/DeleteButton';
import Name from './TableComponents/Name';
const { Search } = Input;
function ListOfAllDashboard(): JSX.Element {
const {
data: dashboardListResponse = [],
isLoading: isDashboardListLoading,
refetch: refetchDashboardList,
} = useGetAllDashboard();
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
const [action, createNewDashboard] = useComponentPermission(
['action', 'create_new_dashboards'],
role,
);
const { t } = useTranslation('dashboard');
const [
isImportJSONModalVisible,
setIsImportJSONModalVisible,
] = useState<boolean>(false);
const [uploadedGrafana, setUploadedGrafana] = useState<boolean>(false);
const [isFilteringDashboards, setIsFilteringDashboards] = useState(false);
const [dashboards, setDashboards] = useState<Dashboard[]>();
const sortDashboardsByCreatedAt = (dashboards: Dashboard[]): void => {
const sortedDashboards = dashboards.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
setDashboards(sortedDashboards);
};
useEffect(() => {
sortDashboardsByCreatedAt(dashboardListResponse);
}, [dashboardListResponse]);
const [newDashboardState, setNewDashboardState] = useState({
loading: false,
error: false,
errorMessage: '',
});
const dynamicColumns: TableColumnProps<Data>[] = [
{
title: 'Created At',
dataIndex: 'createdAt',
width: 30,
key: DynamicColumnsKey.CreatedAt,
sorter: (a: Data, b: Data): number => {
console.log({ a });
const prev = new Date(a.createdAt).getTime();
const next = new Date(b.createdAt).getTime();
return prev - next;
},
render: DateComponent,
},
{
title: 'Created By',
dataIndex: 'createdBy',
width: 30,
key: DynamicColumnsKey.CreatedBy,
},
{
title: 'Last Updated Time',
width: 30,
dataIndex: 'lastUpdatedTime',
key: DynamicColumnsKey.UpdatedAt,
sorter: (a: Data, b: Data): number => {
const prev = new Date(a.lastUpdatedTime).getTime();
const next = new Date(b.lastUpdatedTime).getTime();
return prev - next;
},
render: DateComponent,
},
{
title: 'Last Updated By',
dataIndex: 'lastUpdatedBy',
width: 30,
key: DynamicColumnsKey.UpdatedBy,
},
];
const columns = useMemo(() => {
const tableColumns: TableColumnProps<Data>[] = [
{
title: 'Name',
dataIndex: 'name',
width: 40,
render: Name,
},
{
title: 'Description',
width: 50,
dataIndex: 'description',
},
{
title: 'Tags',
dataIndex: 'tags',
width: 50,
render: (value): JSX.Element => <LabelColumn labels={value} />,
},
];
if (action) {
tableColumns.push({
title: 'Action',
dataIndex: '',
width: 40,
render: DeleteButton,
});
}
return tableColumns;
}, [action]);
const data: Data[] =
dashboards?.map((e) => ({
createdAt: e.created_at,
description: e.data.description || '',
id: e.uuid,
lastUpdatedTime: e.updated_at,
name: e.data.title,
tags: e.data.tags || [],
key: e.uuid,
createdBy: e.created_by,
isLocked: !!e.isLocked || false,
lastUpdatedBy: e.updated_by,
refetchDashboardList,
})) || [];
const onNewDashboardHandler = useCallback(async () => {
try {
setNewDashboardState({
...newDashboardState,
loading: true,
});
const response = await createDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
});
if (response.statusCode === 200) {
history.push(
generatePath(ROUTES.DASHBOARD, {
dashboardId: response.payload.uuid,
}),
);
} else {
setNewDashboardState({
...newDashboardState,
loading: false,
error: true,
errorMessage: response.error || 'Something went wrong',
});
}
} catch (error) {
setNewDashboardState({
...newDashboardState,
error: true,
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
});
}
}, [newDashboardState, t]);
const getText = useCallback(() => {
if (!newDashboardState.error && !newDashboardState.loading) {
return 'New Dashboard';
}
if (newDashboardState.loading) {
return 'Loading';
}
return newDashboardState.errorMessage;
}, [
newDashboardState.error,
newDashboardState.errorMessage,
newDashboardState.loading,
]);
const onModalHandler = (uploadedGrafana: boolean): void => {
setIsImportJSONModalVisible((state) => !state);
setUploadedGrafana(uploadedGrafana);
};
const getMenuItems = useMemo(() => {
const menuItems: ItemType[] = [
{
key: t('import_json').toString(),
label: t('import_json'),
onClick: (): void => onModalHandler(false),
},
{
key: t('import_grafana_json').toString(),
label: t('import_grafana_json'),
onClick: (): void => onModalHandler(true),
disabled: true,
},
];
if (createNewDashboard) {
menuItems.unshift({
key: t('create_dashboard').toString(),
label: t('create_dashboard'),
disabled: isDashboardListLoading,
onClick: onNewDashboardHandler,
});
}
return menuItems;
}, [createNewDashboard, isDashboardListLoading, onNewDashboardHandler, t]);
const searchArrayOfObjects = (searchValue: string): any[] => {
// Convert the searchValue to lowercase for case-insensitive search
const searchValueLowerCase = searchValue.toLowerCase();
// Use the filter method to find matching objects
return dashboardListResponse.filter((item: any) => {
// Convert each property value to lowercase for case-insensitive search
const itemValues = Object.values(item?.data).map((value: any) =>
value.toString().toLowerCase(),
);
// Check if any property value contains the searchValue
return itemValues.some((value) => value.includes(searchValueLowerCase));
});
};
const handleSearch = useDebouncedFn((event: unknown): void => {
setIsFilteringDashboards(true);
const searchText = (event as React.BaseSyntheticEvent)?.target?.value || '';
const filteredDashboards = searchArrayOfObjects(searchText);
setDashboards(filteredDashboards);
setIsFilteringDashboards(false);
}, 500);
const GetHeader = useMemo(
() => (
<Row gutter={16} align="middle">
<Col span={18}>
<Search
disabled={isDashboardListLoading}
placeholder="Search by Name, Description, Tags"
onChange={handleSearch}
loading={isFilteringDashboards}
style={{ marginBottom: 16, marginTop: 16 }}
/>
</Col>
<Col
span={6}
style={{
display: 'flex',
justifyContent: 'flex-end',
}}
>
<ButtonContainer>
<TextToolTip
{...{
text: `More details on how to create dashboards`,
url: 'https://signoz.io/docs/userguide/dashboards',
}}
/>
</ButtonContainer>
<Dropdown
menu={{ items: getMenuItems }}
disabled={isDashboardListLoading}
placement="bottomRight"
>
<NewDashboardButton
icon={<PlusOutlined />}
type="primary"
data-testid="create-new-dashboard"
loading={newDashboardState.loading}
danger={newDashboardState.error}
>
{getText()}
</NewDashboardButton>
</Dropdown>
</Col>
</Row>
),
[
isDashboardListLoading,
handleSearch,
isFilteringDashboards,
getMenuItems,
newDashboardState.loading,
newDashboardState.error,
getText,
],
);
return (
<Card>
{GetHeader}
<TableContainer>
<ImportJSON
isImportJSONModalVisible={isImportJSONModalVisible}
uploadedGrafana={uploadedGrafana}
onModalHandler={(): void => onModalHandler(false)}
/>
<DynamicColumnTable
tablesource={TableDataSource.Dashboard}
dynamicColumns={dynamicColumns}
columns={columns}
pagination={{
pageSize: 10,
defaultPageSize: 10,
total: data?.length || 0,
}}
showHeader
bordered
sticky
loading={isDashboardListLoading}
dataSource={data}
showSorterTooltip
/>
</TableContainer>
</Card>
);
}
export interface Data {
key: Key;
name: string;
description: string;
tags: string[];
createdBy: string;
createdAt: string;
lastUpdatedTime: string;
lastUpdatedBy: string;
isLocked: boolean;
id: string;
}
export default ListOfAllDashboard;
export default DashboardsList;

View File

@ -8,9 +8,10 @@ export const getWidgetQueryBuilder = ({
title = '',
panelTypes,
yAxisUnit = '',
id,
}: GetWidgetQueryBuilderProps): Widgets => ({
description: '',
id: v4(),
id: id || v4(),
isStacked: false,
nullZeroValues: '',
opacity: '0',

View File

@ -16,7 +16,7 @@ import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { GraphTitle, MENU_ITEMS } from '../constant';
import { GraphTitle, MENU_ITEMS, SERVICE_CHART_ID } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
import { Button } from './styles';
@ -66,6 +66,7 @@ function DBCall(): JSX.Element {
title: GraphTitle.DATABASE_CALLS_RPS,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'reqps',
id: SERVICE_CHART_ID.dbCallsRPS,
}),
[servicename, tagFilterItems],
);
@ -85,6 +86,7 @@ function DBCall(): JSX.Element {
title: GraphTitle.DATABASE_CALLS_AVG_DURATION,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ms',
id: SERVICE_CHART_ID.dbCallsAvgDuration,
}),
[servicename, tagFilterItems],
);

View File

@ -17,7 +17,7 @@ import { useParams } from 'react-router-dom';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { GraphTitle, legend, MENU_ITEMS } from '../constant';
import { GraphTitle, legend, MENU_ITEMS, SERVICE_CHART_ID } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
import { Button } from './styles';
@ -57,6 +57,7 @@ function External(): JSX.Element {
title: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: '%',
id: SERVICE_CHART_ID.externalCallErrorPercentage,
}),
[servicename, tagFilterItems],
);
@ -82,6 +83,7 @@ function External(): JSX.Element {
title: GraphTitle.EXTERNAL_CALL_DURATION,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ms',
id: SERVICE_CHART_ID.externalCallDuration,
}),
[servicename, tagFilterItems],
);
@ -103,6 +105,7 @@ function External(): JSX.Element {
title: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'reqps',
id: SERVICE_CHART_ID.externalCallRPSByAddress,
}),
[servicename, tagFilterItems],
);
@ -124,6 +127,7 @@ function External(): JSX.Element {
title: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ms',
id: SERVICE_CHART_ID.externalCallDurationByAddress,
}),
[servicename, tagFilterItems],
);

View File

@ -26,7 +26,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'types/reducer/trace';
import { v4 as uuid } from 'uuid';
import { GraphTitle } from '../constant';
import { GraphTitle, SERVICE_CHART_ID } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import {
errorPercentage,
@ -131,6 +131,7 @@ function Application(): JSX.Element {
title: GraphTitle.RATE_PER_OPS,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ops',
id: SERVICE_CHART_ID.rps,
}),
[servicename, tagFilterItems, topLevelOperationsRoute],
);
@ -152,6 +153,7 @@ function Application(): JSX.Element {
title: GraphTitle.ERROR_PERCENTAGE,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: '%',
id: SERVICE_CHART_ID.errorPercentage,
}),
[servicename, tagFilterItems, topLevelOperationsRoute],
);

View File

@ -8,7 +8,10 @@ import {
import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/GridCardLayout/GridCard';
import DisplayThreshold from 'container/GridCardLayout/WidgetHeader/DisplayThreshold';
import { GraphTitle } from 'container/MetricsApplication/constant';
import {
GraphTitle,
SERVICE_CHART_ID,
} from 'container/MetricsApplication/constant';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { apDexMetricsQueryBuilderQueries } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
import { ReactNode, useMemo } from 'react';
@ -59,6 +62,7 @@ function ApDexMetrics({
</Space>
),
panelTypes: PANEL_TYPES.TIME_SERIES,
id: SERVICE_CHART_ID.apdex,
}),
[
delta,

View File

@ -1,7 +1,10 @@
import { FeatureKeys } from 'constants/features';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/GridCardLayout/GridCard';
import { GraphTitle } from 'container/MetricsApplication/constant';
import {
GraphTitle,
SERVICE_CHART_ID,
} 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';
@ -59,6 +62,7 @@ function ServiceOverview({
title: GraphTitle.LATENCY,
panelTypes: PANEL_TYPES.TIME_SERIES,
yAxisUnit: 'ns',
id: SERVICE_CHART_ID.latency,
}),
[servicename, isSpanMetricEnable, topLevelOperationsRoute, tagFilterItems],
);

View File

@ -79,3 +79,17 @@ 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',
};

View File

@ -9,6 +9,7 @@ export interface GetWidgetQueryBuilderProps {
title?: ReactNode;
panelTypes: Widgets['panelTypes'];
yAxisUnit?: Widgets['yAxisUnit'];
id?: Widgets['id'];
}
export interface NavigateToTraceProps {

View File

@ -50,7 +50,7 @@ function DashboardDescription(): JSX.Element {
return (
<Card>
<Row gutter={16}>
<Col flex={1} span={12}>
<Col flex={1} span={9}>
<Typography.Title
level={4}
style={{ padding: 0, margin: 0 }}
@ -80,12 +80,12 @@ function DashboardDescription(): JSX.Element {
</div>
)}
</Col>
<Col span={8}>
<Col span={12}>
<Row justify="end">
<DashboardVariableSelection />
</Row>
</Col>
<Col span={4} style={{ textAlign: 'right' }}>
<Col span={3} style={{ textAlign: 'right' }}>
{selectedData && (
<ShareModal
isJSONModalVisible={openDashboardJSON}

View File

@ -0,0 +1,8 @@
.query-container {
display: flex;
flex-flow: row wrap;
min-width: 0;
gap: 1rem;
margin-bottom: 1rem;
flex-direction: column;
}

View File

@ -1,21 +1,16 @@
/* eslint-disable sonarjs/cognitive-complexity */
import './VariableItem.styles.scss';
import { orange } from '@ant-design/colors';
import {
Button,
Col,
Divider,
Input,
Select,
Switch,
Tag,
Typography,
} from 'antd';
import query from 'api/dashboard/variables/query';
import { Button, Divider, Input, Select, Switch, Tag, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import Editor from 'components/Editor';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { map } from 'lodash-es';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import {
IDashboardVariable,
TSortVariableValuesType,
@ -79,8 +74,6 @@ function VariableItem({
);
const [previewValues, setPreviewValues] = useState<string[]>([]);
// Internal states
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
// Error messages
const [errorName, setErrorName] = useState<boolean>(false);
const [errorPreview, setErrorPreview] = useState<string | null>(null);
@ -131,232 +124,268 @@ function VariableItem({
};
// Fetches the preview values for the SQL variable query
const handleQueryResult = async (): Promise<void> => {
setPreviewLoading(true);
setErrorPreview(null);
try {
const variableQueryResponse = await query({
query: variableQueryValue,
variables: variablePropsToPayloadVariables(existingVariables),
});
setPreviewLoading(false);
if (variableQueryResponse.error) {
let message = variableQueryResponse.error;
if (variableQueryResponse.error.includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorPreview(message);
return;
}
if (variableQueryResponse.payload?.variableValues)
setPreviewValues(
sortValues(
variableQueryResponse.payload?.variableValues || [],
variableSortType,
) as never,
);
} catch (e) {
console.error(e);
}
const handleQueryResult = (response: any): void => {
if (response?.payload?.variableValues)
setPreviewValues(
sortValues(
response.payload?.variableValues || [],
variableSortType,
) as never,
);
};
const { isFetching: previewLoading, refetch: runQuery } = useQuery(
[REACT_QUERY_KEY.DASHBOARD_BY_ID, variableData.name, variableName],
{
enabled: false,
queryFn: () =>
dashboardVariablesQuery({
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
}),
refetchOnWindowFocus: false,
onSuccess: (response) => {
handleQueryResult(response);
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if (details.error.includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorPreview(message);
}
},
},
);
const handleTestRunQuery = useCallback(() => {
runQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Col>
{/* <Typography.Title level={3}>Add Variable</Typography.Title> */}
<VariableItemRow>
<LabelContainer>
<Typography>Name</Typography>
</LabelContainer>
<div>
<Input
placeholder="Unique name of the variable"
style={{ width: 400 }}
value={variableName}
onChange={(e): void => {
setVariableName(e.target.value);
setErrorName(
!validateName(e.target.value) && e.target.value !== variableData.name,
);
}}
/>
<div className="variable-item-container">
<div className="variable-item-content">
{/* <Typography.Title level={3}>Add Variable</Typography.Title> */}
<VariableItemRow>
<LabelContainer>
<Typography>Name</Typography>
</LabelContainer>
<div>
<Typography.Text type="warning">
{errorName ? 'Variable name already exists' : ''}
</Typography.Text>
</div>
</div>
</VariableItemRow>
<VariableItemRow>
<LabelContainer>
<Typography>Description</Typography>
</LabelContainer>
<Input.TextArea
value={variableDescription}
placeholder="Write description of the variable"
style={{ width: 400 }}
onChange={(e): void => setVariableDescription(e.target.value)}
/>
</VariableItemRow>
<VariableItemRow>
<LabelContainer>
<Typography>Type</Typography>
</LabelContainer>
<Select
defaultActiveFirstOption
style={{ width: 400 }}
onChange={(e: TVariableQueryType): void => {
setQueryType(e);
}}
value={queryType}
>
<Option value={VariableQueryTypeArr[0]}>Query</Option>
<Option value={VariableQueryTypeArr[1]}>Textbox</Option>
<Option value={VariableQueryTypeArr[2]}>Custom</Option>
</Select>
</VariableItemRow>
<Typography.Title
level={5}
style={{ marginTop: '1rem', marginBottom: '1rem' }}
>
Options
</Typography.Title>
{queryType === 'QUERY' && (
<VariableItemRow>
<LabelContainer>
<Typography>Query</Typography>
</LabelContainer>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
language="sql"
value={variableQueryValue}
onChange={(e): void => setVariableQueryValue(e)}
height="300px"
/>
<Button
type="primary"
onClick={handleQueryResult}
style={{
position: 'absolute',
bottom: 0,
}}
loading={previewLoading}
>
Test Run Query
</Button>
</div>
</VariableItemRow>
)}
{queryType === 'CUSTOM' && (
<VariableItemRow>
<LabelContainer>
<Typography>Values separated by comma</Typography>
</LabelContainer>
<Input.TextArea
value={variableCustomValue}
placeholder="1, 10, mykey, mykey:myvalue"
style={{ width: 400 }}
onChange={(e): void => {
setVariableCustomValue(e.target.value);
setPreviewValues(
sortValues(
commaValuesParser(e.target.value),
variableSortType,
) as never,
);
}}
/>
</VariableItemRow>
)}
{queryType === 'TEXTBOX' && (
<VariableItemRow>
<LabelContainer>
<Typography>Default Value</Typography>
</LabelContainer>
<Input
value={variableTextboxValue}
onChange={(e): void => {
setVariableTextboxValue(e.target.value);
}}
placeholder="Default value if any"
style={{ width: 400 }}
/>
</VariableItemRow>
)}
{(queryType === 'QUERY' || queryType === 'CUSTOM') && (
<>
<VariableItemRow>
<LabelContainer>
<Typography>Preview of Values</Typography>
</LabelContainer>
<div style={{ flex: 1 }}>
{errorPreview ? (
<Typography style={{ color: orange[5] }}>{errorPreview}</Typography>
) : (
map(previewValues, (value, idx) => (
<Tag key={`${value}${idx}`}>{value.toString()}</Tag>
))
)}
</div>
</VariableItemRow>
<VariableItemRow>
<LabelContainer>
<Typography>Sort</Typography>
</LabelContainer>
<Select
defaultActiveFirstOption
<Input
placeholder="Unique name of the variable"
style={{ width: 400 }}
defaultValue={VariableSortTypeArr[0]}
value={variableSortType}
onChange={(value: TSortVariableValuesType): void =>
setVariableSortType(value)
}
>
<Option value={VariableSortTypeArr[0]}>Disabled</Option>
<Option value={VariableSortTypeArr[1]}>Ascending</Option>
<Option value={VariableSortTypeArr[2]}>Descending</Option>
</Select>
</VariableItemRow>
value={variableName}
onChange={(e): void => {
setVariableName(e.target.value);
setErrorName(
!validateName(e.target.value) && e.target.value !== variableData.name,
);
}}
/>
<div>
<Typography.Text type="warning">
{errorName ? 'Variable name already exists' : ''}
</Typography.Text>
</div>
</div>
</VariableItemRow>
<VariableItemRow>
<LabelContainer>
<Typography>Description</Typography>
</LabelContainer>
<Input.TextArea
value={variableDescription}
placeholder="Write description of the variable"
style={{ width: 400 }}
onChange={(e): void => setVariableDescription(e.target.value)}
/>
</VariableItemRow>
<VariableItemRow>
<LabelContainer>
<Typography>Type</Typography>
</LabelContainer>
<Select
defaultActiveFirstOption
style={{ width: 400 }}
onChange={(e: TVariableQueryType): void => {
setQueryType(e);
}}
value={queryType}
>
<Option value={VariableQueryTypeArr[0]}>Query</Option>
<Option value={VariableQueryTypeArr[1]}>Textbox</Option>
<Option value={VariableQueryTypeArr[2]}>Custom</Option>
</Select>
</VariableItemRow>
<Typography.Title
level={5}
style={{ marginTop: '1rem', marginBottom: '1rem' }}
>
Options
</Typography.Title>
{queryType === 'QUERY' && (
<div className="query-container">
<LabelContainer>
<Typography>Query</Typography>
</LabelContainer>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
language="sql"
value={variableQueryValue}
onChange={(e): void => setVariableQueryValue(e)}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: {
enabled: false,
},
}}
/>
<Button
type="primary"
size="small"
onClick={handleTestRunQuery}
style={{
position: 'absolute',
bottom: 0,
}}
loading={previewLoading}
>
Test Run Query
</Button>
</div>
</div>
)}
{queryType === 'CUSTOM' && (
<VariableItemRow>
<LabelContainer>
<Typography>Enable multiple values to be checked</Typography>
<Typography>Values separated by comma</Typography>
</LabelContainer>
<Switch
checked={variableMultiSelect}
<Input.TextArea
value={variableCustomValue}
placeholder="1, 10, mykey, mykey:myvalue"
style={{ width: 400 }}
onChange={(e): void => {
setVariableMultiSelect(e);
if (!e) {
setVariableShowALLOption(false);
}
setVariableCustomValue(e.target.value);
setPreviewValues(
sortValues(
commaValuesParser(e.target.value),
variableSortType,
) as never,
);
}}
/>
</VariableItemRow>
{variableMultiSelect && (
)}
{queryType === 'TEXTBOX' && (
<VariableItemRow>
<LabelContainer>
<Typography>Default Value</Typography>
</LabelContainer>
<Input
value={variableTextboxValue}
onChange={(e): void => {
setVariableTextboxValue(e.target.value);
}}
placeholder="Default value if any"
style={{ width: 400 }}
/>
</VariableItemRow>
)}
{(queryType === 'QUERY' || queryType === 'CUSTOM') && (
<>
<VariableItemRow>
<LabelContainer>
<Typography>Include an option for ALL values</Typography>
<Typography>Preview of Values</Typography>
</LabelContainer>
<div style={{ flex: 1 }}>
{errorPreview ? (
<Typography style={{ color: orange[5] }}>{errorPreview}</Typography>
) : (
map(previewValues, (value, idx) => (
<Tag key={`${value}${idx}`}>{value.toString()}</Tag>
))
)}
</div>
</VariableItemRow>
<VariableItemRow>
<LabelContainer>
<Typography>Sort</Typography>
</LabelContainer>
<Select
defaultActiveFirstOption
style={{ width: 400 }}
defaultValue={VariableSortTypeArr[0]}
value={variableSortType}
onChange={(value: TSortVariableValuesType): void =>
setVariableSortType(value)
}
>
<Option value={VariableSortTypeArr[0]}>Disabled</Option>
<Option value={VariableSortTypeArr[1]}>Ascending</Option>
<Option value={VariableSortTypeArr[2]}>Descending</Option>
</Select>
</VariableItemRow>
<VariableItemRow>
<LabelContainer>
<Typography>Enable multiple values to be checked</Typography>
</LabelContainer>
<Switch
checked={variableShowALLOption}
onChange={(e): void => setVariableShowALLOption(e)}
checked={variableMultiSelect}
onChange={(e): void => {
setVariableMultiSelect(e);
if (!e) {
setVariableShowALLOption(false);
}
}}
/>
</VariableItemRow>
)}
</>
)}
<Divider />
<VariableItemRow>
<Button type="primary" onClick={handleSave} disabled={errorName}>
Save
</Button>
<Button type="dashed" onClick={onCancel}>
Cancel
</Button>
</VariableItemRow>
</Col>
{variableMultiSelect && (
<VariableItemRow>
<LabelContainer>
<Typography>Include an option for ALL values</Typography>
</LabelContainer>
<Switch
checked={variableShowALLOption}
onChange={(e): void => setVariableShowALLOption(e)}
/>
</VariableItemRow>
)}
</>
)}
</div>
<div className="variable-item-footer">
<Divider />
<VariableItemRow>
<Button type="primary" onClick={handleSave} disabled={errorName}>
Save
</Button>
<Button type="default" onClick={onCancel}>
Cancel
</Button>
</VariableItemRow>
</div>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { Button, Modal, Row, Space, Tag } from 'antd';
import { ResizeTable } from 'components/ResizeTable';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { PencilIcon, TrashIcon } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -134,7 +135,7 @@ function VariablesSetting(): JSX.Element {
key: 'name',
},
{
title: 'Definition',
title: 'Description',
dataIndex: 'description',
width: 100,
key: 'description',
@ -147,19 +148,19 @@ function VariablesSetting(): JSX.Element {
<Space>
<Button
type="text"
style={{ padding: 0, cursor: 'pointer', color: blue[5] }}
style={{ padding: 8, cursor: 'pointer', color: blue[5] }}
onClick={(): void => onVariableViewModeEnter('EDIT', _)}
>
Edit
<PencilIcon size={14} />
</Button>
<Button
type="text"
style={{ padding: 0, color: red[6], cursor: 'pointer' }}
style={{ padding: 8, color: red[6], cursor: 'pointer' }}
onClick={(): void => {
if (_.name) onVariableDeleteHandler(_.name);
}}
>
Delete
<TrashIcon size={14} />
</Button>
</Space>
),
@ -187,9 +188,10 @@ function VariablesSetting(): JSX.Element {
onVariableViewModeEnter('ADD', {} as IDashboardVariable)
}
>
<PlusOutlined /> New Variables
<PlusOutlined /> Add Variable
</Button>
</Row>
<ResizeTable columns={columns} dataSource={variablesTableData} />
</>
)}

View File

@ -13,7 +13,7 @@ function DashboardSettingsContent(): JSX.Element {
{ label: 'Variables', key: 'variables', children: <VariablesSetting /> },
];
return <Tabs items={items} />;
return <Tabs items={items} animated />;
}
export default DashboardSettingsContent;

View File

@ -0,0 +1,8 @@
.variable-name {
font-size: 0.8rem;
min-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: gray;
}

View File

@ -0,0 +1,110 @@
import { Row } from 'antd';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { map, sortBy } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import VariableItem from './VariableItem';
function DashboardVariableSelection(): JSX.Element | null {
const { selectedDashboard, setSelectedDashboard } = useDashboard();
const { data } = selectedDashboard || {};
const { variables } = data || {};
const [update, setUpdate] = useState<boolean>(false);
const [lastUpdatedVar, setLastUpdatedVar] = useState<string>('');
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
const onVarChanged = (name: string): void => {
setLastUpdatedVar(name);
setUpdate(!update);
};
const updateMutation = useUpdateDashboard();
const { notifications } = useNotifications();
const updateVariables = (
name: string,
updatedVariablesData: Dashboard['data']['variables'],
): void => {
if (!selectedDashboard) {
return;
}
updateMutation.mutateAsync(
{
...selectedDashboard,
data: {
...selectedDashboard.data,
variables: updatedVariablesData,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.payload) {
setSelectedDashboard(updatedDashboard.payload);
}
},
onError: () => {
notifications.error({
message: `Error updating ${name} variable`,
});
},
},
);
};
const onValueUpdate = (
name: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
): void => {
const updatedVariablesData = { ...variables };
updatedVariablesData[name].selectedValue = value;
updatedVariablesData[name].allSelected = allSelected;
console.log('onValue Update', name);
if (role !== 'VIEWER' && selectedDashboard) {
updateVariables(name, updatedVariablesData);
}
onVarChanged(name);
setUpdate(!update);
};
if (!variables) {
return null;
}
const variablesKeys = sortBy(Object.keys(variables));
return (
<Row>
{variablesKeys &&
map(variablesKeys, (variableName) => (
<VariableItem
key={`${variableName}${variables[variableName].modificationUUID}`}
existingVariables={variables}
lastUpdatedVar={lastUpdatedVar}
variableData={{
name: variableName,
...variables[variableName],
change: update,
}}
onValueUpdate={onValueUpdate}
/>
))}
</Row>
);
}
export default memo(DashboardVariableSelection);

View File

@ -1,6 +1,13 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import React, { useEffect } from 'react';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
@ -25,7 +32,6 @@ const mockCustomVariableData: IDashboardVariable = {
};
const mockOnValueUpdate = jest.fn();
const mockOnAllSelectedUpdate = jest.fn();
describe('VariableItem', () => {
let useEffectSpy: jest.SpyInstance;
@ -41,13 +47,14 @@ describe('VariableItem', () => {
test('renders component with default props', () => {
render(
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
onAllSelectedUpdate={mockOnAllSelectedUpdate}
lastUpdatedVar=""
/>,
<MockQueryClientProvider>
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
lastUpdatedVar=""
/>
</MockQueryClientProvider>,
);
expect(screen.getByText('$testVariable')).toBeInTheDocument();
@ -55,45 +62,55 @@ describe('VariableItem', () => {
test('renders Input when the variable type is TEXTBOX', () => {
render(
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
onAllSelectedUpdate={mockOnAllSelectedUpdate}
lastUpdatedVar=""
/>,
<MockQueryClientProvider>
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
lastUpdatedVar=""
/>
</MockQueryClientProvider>,
);
expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument();
});
test('calls onChange event handler when Input value changes', () => {
test('calls onChange event handler when Input value changes', async () => {
render(
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
onAllSelectedUpdate={mockOnAllSelectedUpdate}
lastUpdatedVar=""
/>,
<MockQueryClientProvider>
<VariableItem
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
lastUpdatedVar=""
/>
</MockQueryClientProvider>,
);
const inputElement = screen.getByPlaceholderText('Enter value');
fireEvent.change(inputElement, { target: { value: 'newValue' } });
expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
expect(mockOnValueUpdate).toHaveBeenCalledWith('testVariable', 'newValue');
expect(mockOnAllSelectedUpdate).toHaveBeenCalledTimes(1);
expect(mockOnAllSelectedUpdate).toHaveBeenCalledWith('testVariable', false);
act(() => {
const inputElement = screen.getByPlaceholderText('Enter value');
fireEvent.change(inputElement, { target: { value: 'newValue' } });
});
await waitFor(() => {
// expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'testVariable',
'newValue',
false,
);
});
});
test('renders a Select element when variable type is CUSTOM', () => {
render(
<VariableItem
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
onAllSelectedUpdate={mockOnAllSelectedUpdate}
lastUpdatedVar=""
/>,
<MockQueryClientProvider>
<VariableItem
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
lastUpdatedVar=""
/>
</MockQueryClientProvider>,
);
expect(screen.getByText('$customVariable')).toBeInTheDocument();
@ -107,13 +124,14 @@ describe('VariableItem', () => {
};
render(
<VariableItem
variableData={customVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
onAllSelectedUpdate={mockOnAllSelectedUpdate}
lastUpdatedVar=""
/>,
<MockQueryClientProvider>
<VariableItem
variableData={customVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
lastUpdatedVar=""
/>
</MockQueryClientProvider>,
);
expect(screen.getByTitle('ALL')).toBeInTheDocument();
@ -121,48 +139,16 @@ describe('VariableItem', () => {
test('calls useEffect when the component mounts', () => {
render(
<VariableItem
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
onAllSelectedUpdate={mockOnAllSelectedUpdate}
lastUpdatedVar=""
/>,
<MockQueryClientProvider>
<VariableItem
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
lastUpdatedVar=""
/>
</MockQueryClientProvider>,
);
expect(useEffect).toHaveBeenCalled();
});
test('calls useEffect only once when the component mounts', () => {
// Render the component
const { rerender } = render(
<VariableItem
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
onAllSelectedUpdate={mockOnAllSelectedUpdate}
lastUpdatedVar=""
/>,
);
// Create an updated version of the mock data
const updatedMockCustomVariableData = {
...mockCustomVariableData,
selectedValue: 'option1',
};
// Re-render the component with the updated data
rerender(
<VariableItem
variableData={updatedMockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
onAllSelectedUpdate={mockOnAllSelectedUpdate}
lastUpdatedVar=""
/>,
);
// Check if the useEffect is called with the correct arguments
expect(useEffectSpy).toHaveBeenCalledTimes(4);
});
});

View File

@ -1,27 +1,35 @@
import './DashboardVariableSelection.styles.scss';
import { orange } from '@ant-design/colors';
import { WarningOutlined } from '@ant-design/icons';
import { Input, Popover, Select, Typography } from 'antd';
import query from 'api/dashboard/variables/query';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import map from 'lodash-es/map';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { variablePropsToPayloadVariables } from '../utils';
import { SelectItemStyle, VariableContainer, VariableName } from './styles';
import { SelectItemStyle, VariableContainer, VariableValue } from './styles';
import { areArraysEqual } from './util';
const ALL_SELECT_VALUE = '__ALL__';
const variableRegexPattern = /\{\{\s*?\.([^\s}]+)\s*?\}\}/g;
interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
arg1: IDashboardVariable['selectedValue'],
allSelected: boolean,
) => void;
onAllSelectedUpdate: (name: string, arg1: boolean) => void;
lastUpdatedVar: string;
}
@ -38,48 +46,74 @@ function VariableItem({
variableData,
existingVariables,
onValueUpdate,
onAllSelectedUpdate,
lastUpdatedVar,
}: VariableItemProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [variableValue, setVaribleValue] = useState(
variableData?.selectedValue?.toString() || '',
);
const debouncedVariableValue = useDebounce(variableValue, 500);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
/* eslint-disable sonarjs/cognitive-complexity */
const getOptions = useCallback(async (): Promise<void> => {
if (variableData.type === 'QUERY') {
useEffect(() => {
const { selectedValue } = variableData;
if (selectedValue) {
setVaribleValue(selectedValue?.toString());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variableData]);
const getDependentVariables = (queryValue: string): string[] => {
const matches = queryValue.match(variableRegexPattern);
// Extract variable names from the matches array without {{ . }}
return matches
? matches.map((match) => match.replace(variableRegexPattern, '$1'))
: [];
};
const getQueryKey = (variableData: IDashboardVariable): string[] => {
let dependentVariablesStr = '';
const dependentVariables = getDependentVariables(
variableData.queryValue || '',
);
const variableName = variableData.name || '';
dependentVariables?.forEach((element) => {
dependentVariablesStr += `${element}${existingVariables[element]?.selectedValue}`;
});
const variableKey = dependentVariablesStr.replace(/\s/g, '');
return [REACT_QUERY_KEY.DASHBOARD_BY_ID, variableName, variableKey];
};
// eslint-disable-next-line sonarjs/cognitive-complexity
const getOptions = (variablesRes: VariableResponseProps | null): void => {
if (variablesRes && variableData.type === 'QUERY') {
try {
setErrorMessage(null);
setIsLoading(true);
const response = await query({
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
});
setIsLoading(false);
if (response.error) {
let message = response.error;
if (response.error.includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorMessage(message);
return;
}
if (response.payload?.variableValues) {
if (
variablesRes?.variableValues &&
Array.isArray(variablesRes?.variableValues)
) {
const newOptionsData = sortValues(
response.payload?.variableValues,
variablesRes?.variableValues,
variableData.sort,
);
// Since there is a chance of a variable being dependent on other
// variables, we need to check if the optionsData has changed
// If it has changed, we need to update the dependent variable
// So we compare the new optionsData with the old optionsData
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
/* eslint-disable no-useless-escape */
const re = new RegExp(`\\{\\{\\s*?\\.${lastUpdatedVar}\\s*?\\}\\}`); // regex for `{{.var}}`
@ -104,10 +138,10 @@ function VariableItem({
[value] = newOptionsData;
}
if (variableData.name) {
onValueUpdate(variableData.name, value);
onAllSelectedUpdate(variableData.name, allSelected);
onValueUpdate(variableData.name, value, allSelected);
}
}
setOptionsData(newOptionsData);
}
}
@ -122,19 +156,37 @@ function VariableItem({
) as never,
);
}
}, [
variableData,
existingVariables,
onValueUpdate,
onAllSelectedUpdate,
optionsData,
lastUpdatedVar,
]);
useEffect(() => {
getOptions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variableData, existingVariables]);
};
const { isLoading } = useQuery(getQueryKey(variableData), {
enabled: variableData && variableData.type === 'QUERY',
queryFn: () =>
dashboardVariablesQuery({
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
}),
refetchOnWindowFocus: false,
onSuccess: (response) => {
getOptions(response.payload);
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if (details.error.includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorMessage(message);
}
},
});
const handleChange = (value: string | string[]): void => {
if (variableData.name)
@ -143,11 +195,9 @@ function VariableItem({
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE)) ||
(Array.isArray(value) && value.length === 0)
) {
onValueUpdate(variableData.name, optionsData);
onAllSelectedUpdate(variableData.name, true);
onValueUpdate(variableData.name, optionsData, true);
} else {
onValueUpdate(variableData.name, value);
onAllSelectedUpdate(variableData.name, false);
onValueUpdate(variableData.name, value, false);
}
};
@ -165,61 +215,86 @@ function VariableItem({
? 'multiple'
: undefined;
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
useEffect(() => {
if (debouncedVariableValue !== variableData?.selectedValue?.toString()) {
handleChange(debouncedVariableValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedVariableValue]);
useEffect(() => {
// Fetch options for CUSTOM Type
if (variableData.type === 'CUSTOM') {
getOptions(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<VariableContainer>
<VariableName>${variableData.name}</VariableName>
{variableData.type === 'TEXTBOX' ? (
<Input
placeholder="Enter value"
bordered={false}
value={variableData.selectedValue?.toString()}
onChange={(e): void => {
handleChange(e.target.value || '');
}}
style={{
width:
50 + ((variableData.selectedValue?.toString()?.length || 0) * 7 || 50),
}}
/>
) : (
!errorMessage && (
<Select
value={selectValue}
onChange={handleChange}
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
</Typography.Text>
<VariableValue>
{variableData.type === 'TEXTBOX' ? (
<Input
placeholder="Enter value"
bordered={false}
placeholder="Select value"
mode={mode}
dropdownMatchSelectWidth={false}
style={SelectItemStyle}
loading={isLoading}
showArrow
showSearch
data-testid="variable-select"
>
{enableSelectAll && (
<Select.Option data-testid="option-ALL" value={ALL_SELECT_VALUE}>
ALL
</Select.Option>
)}
{map(optionsData, (option) => (
<Select.Option
data-testid={`option-${option}`}
key={option.toString()}
value={option}
>
{option.toString()}
</Select.Option>
))}
</Select>
)
)}
{errorMessage && (
<span style={{ margin: '0 0.5rem' }}>
<Popover placement="top" content={<Typography>{errorMessage}</Typography>}>
<WarningOutlined style={{ color: orange[5] }} />
</Popover>
</span>
)}
value={variableValue}
onChange={(e): void => {
setVaribleValue(e.target.value || '');
}}
style={{
width:
50 + ((variableData.selectedValue?.toString()?.length || 0) * 7 || 50),
}}
/>
) : (
!errorMessage &&
optionsData && (
<Select
value={selectValue}
onChange={handleChange}
bordered={false}
placeholder="Select value"
mode={mode}
dropdownMatchSelectWidth={false}
style={SelectItemStyle}
loading={isLoading}
showArrow
showSearch
data-testid="variable-select"
>
{enableSelectAll && (
<Select.Option data-testid="option-ALL" value={ALL_SELECT_VALUE}>
ALL
</Select.Option>
)}
{map(optionsData, (option) => (
<Select.Option
data-testid={`option-${option}`}
key={option.toString()}
value={option}
>
{option.toString()}
</Select.Option>
))}
</Select>
)
)}
{errorMessage && (
<span style={{ margin: '0 0.5rem' }}>
<Popover
placement="top"
content={<Typography>{errorMessage}</Typography>}
>
<WarningOutlined style={{ color: orange[5] }} />
</Popover>
</span>
)}
</VariableValue>
</VariableContainer>
);
}

View File

@ -1,117 +1,3 @@
import { Row } from 'antd';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { map, sortBy } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import DashboardVariableSelection from './DashboardVariableSelection';
import VariableItem from './VariableItem';
function DashboardVariableSelection(): JSX.Element | null {
const { selectedDashboard, setSelectedDashboard } = useDashboard();
const { data } = selectedDashboard || {};
const { variables } = data || {};
const [update, setUpdate] = useState<boolean>(false);
const [lastUpdatedVar, setLastUpdatedVar] = useState<string>('');
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
const onVarChanged = (name: string): void => {
setLastUpdatedVar(name);
setUpdate(!update);
};
const updateMutation = useUpdateDashboard();
const { notifications } = useNotifications();
const updateVariables = (
updatedVariablesData: Dashboard['data']['variables'],
): void => {
if (!selectedDashboard) {
return;
}
updateMutation.mutateAsync(
{
...selectedDashboard,
data: {
...selectedDashboard.data,
variables: updatedVariablesData,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.payload) {
setSelectedDashboard(updatedDashboard.payload);
notifications.success({
message: 'Variable updated successfully',
});
}
},
onError: () => {
notifications.error({
message: 'Error while updating variable',
});
},
},
);
};
const onValueUpdate = (
name: string,
value: IDashboardVariable['selectedValue'],
): void => {
const updatedVariablesData = { ...variables };
updatedVariablesData[name].selectedValue = value;
if (role !== 'VIEWER' && selectedDashboard) {
updateVariables(updatedVariablesData);
}
onVarChanged(name);
};
const onAllSelectedUpdate = (
name: string,
value: IDashboardVariable['allSelected'],
): void => {
const updatedVariablesData = { ...variables };
updatedVariablesData[name].allSelected = value;
if (role !== 'VIEWER') {
updateVariables(updatedVariablesData);
}
onVarChanged(name);
};
if (!variables) {
return null;
}
return (
<Row>
{map(sortBy(Object.keys(variables)), (variableName) => (
<VariableItem
key={`${variableName}${variables[variableName].modificationUUID}`}
existingVariables={variables}
variableData={{
name: variableName,
...variables[variableName],
change: update,
}}
onValueUpdate={onValueUpdate}
onAllSelectedUpdate={onAllSelectedUpdate}
lastUpdatedVar={lastUpdatedVar}
/>
))}
</Row>
);
}
export default memo(DashboardVariableSelection);
export default DashboardVariableSelection;

View File

@ -3,19 +3,40 @@ import { Typography } from 'antd';
import styled from 'styled-components';
export const VariableContainer = styled.div`
max-width: 100%;
border: 1px solid ${grey[1]}66;
border-radius: 2px;
padding: 0;
padding-left: 0.5rem;
margin-right: 8px;
display: flex;
align-items: center;
margin-bottom: 0.3rem;
gap: 4px;
padding: 4px;
`;
export const VariableName = styled(Typography)`
font-size: 0.8rem;
font-style: italic;
color: ${grey[0]};
min-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
`;
export const VariableValue = styled(Typography)`
font-size: 0.8rem;
color: ${grey[0]};
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
max-width: 300px;
`;
export const SelectItemStyle = {

View File

@ -6,6 +6,8 @@ import {
CategoryNames,
DataFormats,
DataRateFormats,
HelperCategory,
HelperFormat,
MiscellaneousFormats,
ThroughputFormats,
TimeFormats,
@ -119,3 +121,10 @@ export const getCategoryByOptionId = (id: string): Category | undefined =>
export const isCategoryName = (name: string): name is CategoryNames =>
alertsCategory.some((category) => category.name === name);
const allFormats: HelperFormat[] = alertsCategory.flatMap(
(category: HelperCategory) => category.formats,
);
export const getFormatNameByOptionId = (id: string): string | undefined =>
allFormats.find((format) => format.id === id)?.name;

View File

@ -107,48 +107,8 @@ function RightContainer({
}
/>
{/* <TextContainer>
<Typography>Stacked Graphs :</Typography>
<Switch
checked={stacked}
onChange={(): void => {
setStacked((value) => !value);
}}
/>
</TextContainer> */}
{/* <Title light={'true'}>Fill Opacity: </Title> */}
{/* <Slider
value={parseInt(opacity, 10)}
marks={{
0: '0',
33: '33',
66: '66',
100: '100',
}}
onChange={(number): void => onChangeHandler(setOpacity, number.toString())}
step={1}
/> */}
{/* <Title light={'true'}>Null/Zero values: </Title>
<NullButtonContainer>
{nullValueButtons.map((button) => (
<Button
type={button.check === selectedNullZeroValue ? 'primary' : 'default'}
key={button.name}
onClick={(): void =>
onChangeHandler(setSelectedNullZeroValue, button.check)
}
>
{button.name}
</Button>
))}
</NullButtonContainer> */}
<Space style={{ marginTop: 10 }} direction="vertical">
<Typography>Fill span gaps</Typography>
<Typography>Fill gaps</Typography>
<Switch
checked={isFillSpans}

View File

@ -362,3 +362,13 @@ export type Category = {
};
export type DataTypeCategories = Category[];
export interface HelperFormat {
name: string;
id: string;
}
export interface HelperCategory {
name: string;
formats: Format[];
}

View File

@ -11,6 +11,7 @@ import ROUTES from 'constants/routes';
import { stepsMap } from 'container/OnboardingContainer/constants/stepsConfig';
import { DataSourceType } from 'container/OnboardingContainer/Steps/DataSource/DataSource';
import { hasFrameworks } from 'container/OnboardingContainer/utils/dataSourceUtils';
import useAnalytics from 'hooks/analytics/useAnalytics';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { useState } from 'react';
@ -71,6 +72,7 @@ export default function ModuleStepsContainer({
const [current, setCurrent] = useState(0);
const [metaData, setMetaData] = useState<MetaDataProps[]>(defaultMetaData);
const { trackEvent } = useAnalytics();
const lastStepIndex = selectedModuleSteps.length - 1;
const isValidForm = (): boolean => {
@ -126,6 +128,10 @@ export default function ModuleStepsContainer({
};
const redirectToModules = (): void => {
trackEvent('Onboarding Complete', {
module: selectedModule.id,
});
if (selectedModule.id === ModulesMap.APM) {
history.push(ROUTES.APPLICATION);
} else if (selectedModule.id === ModulesMap.LogsManagement) {

View File

@ -0,0 +1,25 @@
import { Input, InputProps } from 'antd';
import { ChangeEventHandler, useState } from 'react';
function CSVInput({ value, onChange, ...otherProps }: InputProps): JSX.Element {
const [inputValue, setInputValue] = useState(
((value as string[]) || []).join(', '),
);
const onChangeHandler = (onChange as unknown) as (v: string[]) => void;
const onInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const newValue = e.target.value;
setInputValue(newValue);
if (onChangeHandler) {
const splitValues = newValue.split(',').map((v) => v.trim());
onChangeHandler(splitValues);
}
};
// eslint-disable-next-line react/jsx-props-no-spreading
return <Input value={inputValue} onChange={onInputChange} {...otherProps} />;
}
export default CSVInput;

View File

@ -0,0 +1,107 @@
import './styles.scss';
import { Form, Input, Select } from 'antd';
import { ModalFooterTitle } from 'container/PipelinePage/styles';
import { useTranslation } from 'react-i18next';
import { formValidationRules } from '../config';
import { processorFields, ProcessorFormField } from './config';
import CSVInput from './FormFields/CSVInput';
import { FormWrapper, PipelineIndexIcon, StyledSelect } from './styles';
function ProcessorFieldInput({
fieldData,
}: ProcessorFieldInputProps): JSX.Element | null {
const { t } = useTranslation('pipeline');
// Watch form values so we can evaluate shouldRender on
// conditional fields when form values are updated.
const form = Form.useFormInstance();
Form.useWatch(fieldData?.dependencies || [], form);
if (fieldData.shouldRender && !fieldData.shouldRender(form)) {
return null;
}
// Do not render display elements for hidden inputs.
if (fieldData?.hidden) {
return (
<Form.Item
name={fieldData.name}
initialValue={fieldData.initialValue}
dependencies={fieldData.dependencies || []}
style={{ display: 'none' }}
>
<Input type="hidden" />
</Form.Item>
);
}
let inputField;
if (fieldData?.options) {
inputField = (
<StyledSelect>
{fieldData.options.map(({ value, label }) => (
<Select.Option key={value + label} value={value}>
{label}
</Select.Option>
))}
</StyledSelect>
);
} else if (Array.isArray(fieldData?.initialValue)) {
inputField = <CSVInput placeholder={t(fieldData.placeholder)} />;
} else {
inputField = <Input placeholder={t(fieldData.placeholder)} />;
}
return (
<div
className={
fieldData?.compact
? 'compact-processor-field-container'
: 'processor-field-container'
}
>
{!fieldData?.compact && (
<PipelineIndexIcon size="small">
{Number(fieldData.id) + 1}
</PipelineIndexIcon>
)}
<FormWrapper>
<Form.Item
required={false}
label={<ModalFooterTitle>{fieldData.fieldName}</ModalFooterTitle>}
name={fieldData.name}
initialValue={fieldData.initialValue}
rules={fieldData.rules ? fieldData.rules : formValidationRules}
dependencies={fieldData.dependencies || []}
>
{inputField}
</Form.Item>
</FormWrapper>
</div>
);
}
interface ProcessorFieldInputProps {
fieldData: ProcessorFormField;
}
function ProcessorForm({ processorType }: ProcessorFormProps): JSX.Element {
return (
<div className="processor-form-container">
{processorFields[processorType]?.map((fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
/>
))}
</div>
);
}
interface ProcessorFormProps {
processorType: string;
}
export default ProcessorForm;

View File

@ -1,5 +1,7 @@
import { FormInstance } from 'antd';
import { Rule, RuleRender } from 'antd/es/form';
import { NamePath } from 'antd/es/form/interface';
import { ProcessorData } from 'types/api/pipeline/def';
type ProcessorType = {
key: string;
@ -14,6 +16,8 @@ export const processorTypes: Array<ProcessorType> = [
{ key: 'regex_parser', value: 'regex_parser', label: 'Regex' },
{ key: 'json_parser', value: 'json_parser', label: 'Json Parser' },
{ key: 'trace_parser', value: 'trace_parser', label: 'Trace Parser' },
{ key: 'time_parser', value: 'time_parser', label: 'Timestamp Parser' },
{ key: 'severity_parser', value: 'severity_parser', label: 'Severity Parser' },
{ key: 'add', value: 'add', label: 'Add' },
{ key: 'remove', value: 'remove', label: 'Remove' },
// { key: 'retain', value: 'retain', label: 'Retain' }, @Chintan - Commented as per Nitya's suggestion
@ -23,14 +27,31 @@ export const processorTypes: Array<ProcessorType> = [
export const DEFAULT_PROCESSOR_TYPE = processorTypes[0].value;
export type ProcessorFieldOption = {
label: string;
value: string;
};
// TODO(Raj): Refactor Processor Form code after putting e2e UI tests in place.
export type ProcessorFormField = {
id: number;
fieldName: string;
placeholder: string;
name: string | NamePath;
rules?: Array<Rule>;
initialValue?: string;
hidden?: boolean;
initialValue?: boolean | string | Array<string>;
dependencies?: Array<string | NamePath>;
options?: Array<ProcessorFieldOption>;
shouldRender?: (form: FormInstance) => boolean;
onFormValuesChanged?: (
changedValues: ProcessorData,
form: FormInstance,
) => void;
// Should this field have its own row or should it
// be packed with other compact fields.
compact?: boolean;
};
const traceParserFieldValidator: RuleRender = (form) => ({
@ -206,6 +227,182 @@ export const processorFields: { [key: string]: Array<ProcessorFormField> } = {
],
},
],
time_parser: [
{
id: 1,
fieldName: 'Name of Timestamp Parsing Processor',
placeholder: 'processor_name_placeholder',
name: 'name',
},
{
id: 2,
fieldName: 'Parse Timestamp Value From',
placeholder: 'processor_parsefrom_placeholder',
name: 'parse_from',
initialValue: 'attributes.timestamp',
},
{
id: 3,
fieldName: 'Timestamp Format Type',
placeholder: '',
name: 'layout_type',
initialValue: 'strptime',
options: [
{
label: 'Unix Epoch',
value: 'epoch',
},
{
label: 'strptime Format',
value: 'strptime',
},
],
onFormValuesChanged: (
changedValues: ProcessorData,
form: FormInstance,
): void => {
if (changedValues?.layout_type) {
const newLayoutValue =
changedValues.layout_type === 'strptime' ? '%Y-%m-%dT%H:%M:%S.%f%z' : 's';
form.setFieldValue('layout', newLayoutValue);
}
},
},
{
id: 4,
fieldName: 'Epoch Format',
placeholder: '',
name: 'layout',
dependencies: ['layout_type'],
shouldRender: (form: FormInstance): boolean => {
const layoutType = form.getFieldValue('layout_type');
return layoutType === 'epoch';
},
initialValue: 's',
options: [
{
label: 'seconds',
value: 's',
},
{
label: 'milliseconds',
value: 'ms',
},
{
label: 'microseconds',
value: 'us',
},
{
label: 'nanoseconds',
value: 'ns',
},
{
label: 'seconds.milliseconds (eg: 1136214245.123)',
value: 's.ms',
},
{
label: 'seconds.microseconds (eg: 1136214245.123456)',
value: 's.us',
},
{
label: 'seconds.nanoseconds (eg: 1136214245.123456789)',
value: 's.ns',
},
],
},
{
id: 4,
fieldName: 'Timestamp Format',
placeholder: 'strptime directives based format. Eg: %Y-%m-%dT%H:%M:%S.%f%z',
name: 'layout',
dependencies: ['layout_type'],
shouldRender: (form: FormInstance): boolean => {
const layoutType = form.getFieldValue('layout_type');
return layoutType === 'strptime';
},
initialValue: '%Y-%m-%dT%H:%M:%S.%f%z',
},
],
severity_parser: [
{
id: 1,
fieldName: 'Name of Severity Parsing Processor',
placeholder: 'processor_name_placeholder',
name: 'name',
},
{
id: 2,
fieldName: 'Parse Severity Value From',
placeholder: 'processor_parsefrom_placeholder',
name: 'parse_from',
initialValue: 'attributes.logLevel',
},
{
id: 3,
fieldName: 'Values for level TRACE',
placeholder: 'Specify comma separated values. Eg: trace, 0',
name: ['mapping', 'trace'],
rules: [],
initialValue: ['trace'],
compact: true,
},
{
id: 4,
fieldName: 'Values for level DEBUG',
placeholder: 'Specify comma separated values. Eg: debug, 2xx',
name: ['mapping', 'debug'],
rules: [],
initialValue: ['debug'],
compact: true,
},
{
id: 5,
fieldName: 'Values for level INFO',
placeholder: 'Specify comma separated values. Eg: info, 3xx',
name: ['mapping', 'info'],
rules: [],
initialValue: ['info'],
compact: true,
},
{
id: 6,
fieldName: 'Values for level WARN',
placeholder: 'Specify comma separated values. Eg: warning, 4xx',
name: ['mapping', 'warn'],
rules: [],
initialValue: ['warn'],
compact: true,
},
{
id: 7,
fieldName: 'Values for level ERROR',
placeholder: 'Specify comma separated values. Eg: error, 5xx',
name: ['mapping', 'error'],
rules: [],
initialValue: ['error'],
compact: true,
},
{
id: 8,
fieldName: 'Values for level FATAL',
placeholder: 'Specify comma separated values. Eg: fatal, panic',
name: ['mapping', 'fatal'],
rules: [],
initialValue: ['fatal'],
compact: true,
},
{
id: 9,
fieldName: 'Override Severity Text',
placeholder:
'Should the parsed severity set both severity and severityText?',
name: ['overwrite_text'],
rules: [],
initialValue: true,
hidden: true,
},
],
retain: [
{
id: 1,

View File

@ -11,9 +11,9 @@ import { v4 } from 'uuid';
import { ModalButtonWrapper, ModalTitle } from '../styles';
import { getEditedDataSource, getRecordIndex } from '../utils';
import { DEFAULT_PROCESSOR_TYPE } from './config';
import { DEFAULT_PROCESSOR_TYPE, processorFields } from './config';
import TypeSelect from './FormFields/TypeSelect';
import { renderProcessorForm } from './utils';
import ProcessorForm from './ProcessorForm';
function AddNewProcessor({
isActionType,
@ -141,6 +141,17 @@ function AddNewProcessor({
const isOpen = useMemo(() => isEdit || isAdd, [isAdd, isEdit]);
const onFormValuesChanged = useCallback(
(changedValues: ProcessorData): void => {
processorFields[processorType].forEach((field) => {
if (field.onFormValuesChanged) {
field.onFormValuesChanged(changedValues, form);
}
});
},
[form, processorType],
);
return (
<Modal
title={<ModalTitle level={4}>{modalTitle}</ModalTitle>}
@ -157,9 +168,10 @@ function AddNewProcessor({
onFinish={onFinish}
autoComplete="off"
form={form}
onValuesChange={onFormValuesChanged}
>
<TypeSelect value={processorType} onChange={handleProcessorType} />
{renderProcessorForm(processorType)}
<ProcessorForm processorType={processorType} />
<Divider plain />
<Form.Item>
<ModalButtonWrapper>

View File

@ -0,0 +1,27 @@
.processor-form-container {
position: relative;
width: 100%;
display: flex;
flex-wrap: wrap
}
.processor-field-container {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 0rem;
gap: 1rem;
width: 100%;
}
.compact-processor-field-container {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 0rem;
min-width: 40%;
flex-grow: 1;
margin-left: 2.5rem;
}

View File

@ -1,9 +0,0 @@
import { processorFields, ProcessorFormField } from './config';
import NameInput from './FormFields/NameInput';
export const renderProcessorForm = (
processorType: string,
): Array<JSX.Element> =>
processorFields[processorType]?.map((fieldData: ProcessorFormField) => (
<NameInput key={fieldData.id} fieldData={fieldData} />
));

View File

@ -27,7 +27,8 @@ const usePipelinePreview = ({
// ILog allows both number and string while the API needs a number
const simulationInput = inputLogs.map((l) => ({
...l,
timestamp: new Date(l.timestamp).getTime(),
// log timestamps in query service API are unix nanos
timestamp: new Date(l.timestamp).getTime() * 10 ** 6,
}));
const response = useQuery<PipelineSimulationResponse, AxiosError>({
@ -42,9 +43,15 @@ const usePipelinePreview = ({
const { isFetching, isError, data, error } = response;
const outputLogs = (data?.logs || []).map((l: ILog) => ({
...l,
// log timestamps in query service API are unix nanos
timestamp: (l.timestamp as number) / 10 ** 6,
}));
return {
isLoading: isFetching,
outputLogs: data?.logs || [],
outputLogs,
isError,
errorMsg: error?.response?.data?.error || '',
};

View File

@ -68,11 +68,7 @@ export const getOptions = (routes: string): Option[] => {
return Options;
};
export const routesToHideBreadCrumbs = [
ROUTES.SUPPORT,
ROUTES.ALL_DASHBOARD,
ROUTES.DASHBOARD,
];
export const routesToHideBreadCrumbs = [ROUTES.SUPPORT, ROUTES.ALL_DASHBOARD];
export const routesToSkip = [
ROUTES.SETTINGS,

View File

@ -1,11 +1,12 @@
import { ResizeTable } from 'components/ResizeTable';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination, URL_PAGINATION } from 'hooks/queryPagination';
import { Pagination } from 'hooks/queryPagination';
import useDragColumns from 'hooks/useDragColumns';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
@ -44,7 +45,7 @@ function ListView(): JSX.Element {
);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
URL_PAGINATION,
QueryParams.pagination,
);
const { data, isFetching, isError } = useGetQueryRange(

View File

@ -1,10 +1,11 @@
import { Typography } from 'antd';
import { ResizeTable } from 'components/ResizeTable';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination, URL_PAGINATION } from 'hooks/queryPagination';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { memo, useMemo } from 'react';
import { useSelector } from 'react-redux';
@ -24,7 +25,7 @@ function TracesView(): JSX.Element {
>((state) => state.globalTime);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
URL_PAGINATION,
QueryParams.pagination,
);
const { data, isLoading } = useGetQueryRange(

View File

@ -1,3 +1 @@
export const URL_PAGINATION = 'pagination';
export const DEFAULT_PER_PAGE_OPTIONS: number[] = [25, 50, 100, 200];

View File

@ -1,8 +1,9 @@
import { QueryParams } from 'constants/query';
import { ControlsProps } from 'container/Controls';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { useCallback, useEffect, useMemo } from 'react';
import { DEFAULT_PER_PAGE_OPTIONS, URL_PAGINATION } from './config';
import { DEFAULT_PER_PAGE_OPTIONS } from './config';
import { Pagination } from './types';
import {
checkIsValidPaginationData,
@ -22,7 +23,7 @@ const useQueryPagination = (
query: paginationQuery,
queryData: paginationQueryData,
redirectWithQuery: redirectWithCurrentPagination,
} = useUrlQueryData<Pagination>(URL_PAGINATION);
} = useUrlQueryData<Pagination>(QueryParams.pagination);
const handleCountItemsPerPageChange = useCallback(
(newLimit: Pagination['limit']) => {

View File

@ -115,7 +115,7 @@
</script>
<script type="text/javascript">
//Set your SEGMENT_ID
//Set your CLARITY_PROJECT_ID
const CLARITY_PROJECT_ID =
'<%= htmlWebpackPlugin.options.CLARITY_PROJECT_ID %>';

View File

@ -265,10 +265,15 @@ function findUnitObject(
export function convertValue(
value: number,
currentUnit: string,
targetUnit: string,
currentUnit?: string,
targetUnit?: string,
): number | null {
if (targetUnit === 'none') {
if (
targetUnit === 'none' ||
!currentUnit ||
!targetUnit ||
currentUnit === targetUnit
) {
return value;
}
const currentUnitObj = findUnitObject(currentUnit);

View File

@ -15,6 +15,7 @@ import onClickPlugin, { OnClickPluginOpts } from './plugins/onClickPlugin';
import tooltipPlugin from './plugins/tooltipPlugin';
import getAxes from './utils/getAxes';
import getSeries from './utils/getSeriesData';
import { getYAxisScale } from './utils/getYAxisScale';
interface GetUPlotChartOptions {
id?: string;
@ -79,7 +80,11 @@ export const getUPlotChartOptions = ({
auto: true, // Automatically adjust scale range
},
y: {
auto: true,
...getYAxisScale(
thresholds,
apiResponse?.data.newResult.data.result,
yAxisUnit,
),
},
},
plugins: [

View File

@ -29,6 +29,8 @@ const generateTooltipContent = (
): HTMLElement => {
const container = document.createElement('div');
container.classList.add('tooltip-container');
const overlay = document.getElementById('overlay');
let tooltipCount = 0;
let tooltipTitle = '';
const formattedData: Record<string, UplotTooltipDataProps> = {};
@ -49,28 +51,40 @@ const generateTooltipContent = (
const { metric = {}, queryName = '', legend = '' } =
seriesList[index - 1] || {};
const value = data[index][idx];
const label = getLabelName(metric, queryName || '', legend || '');
const value = data[index][idx] || 0;
const tooltipValue = getToolTipValue(value, yAxisUnit);
if (value) {
const tooltipValue = getToolTipValue(value, yAxisUnit);
const dataObj = {
show: item.show || false,
color: colors[(index - 1) % colors.length],
label,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
focus: item?._focus || false,
value,
tooltipValue,
textContent: `${label} : ${tooltipValue}`,
};
const dataObj = {
show: item.show || false,
color: colors[(index - 1) % colors.length],
label,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
focus: item?._focus || false,
value,
tooltipValue,
textContent: `${label} : ${tooltipValue}`,
};
formattedData[label] = dataObj;
tooltipCount += 1;
formattedData[label] = dataObj;
}
}
});
}
// Show tooltip only if atleast only series has a value at the hovered timestamp
if (tooltipCount <= 0) {
if (overlay && overlay.style.display === 'block') {
overlay.style.display = 'none';
}
return container;
}
const sortedData: Record<
string,
UplotTooltipDataProps
@ -116,8 +130,6 @@ const generateTooltipContent = (
});
}
const overlay = document.getElementById('overlay');
if (overlay && overlay.style.display === 'none') {
overlay.style.display = 'block';
}

View File

@ -1,44 +1,65 @@
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
// eslint-disable-next-line sonarjs/cognitive-complexity
function fillMissingTimestamps(
sortedTimestamps: number[],
subsetArray: any[],
fillSpans: boolean | undefined,
): any[] {
const filledArray = [];
function getXAxisTimestamps(seriesList: QueryData[]): number[] {
const timestamps = new Set();
let subsetIndex = 0;
// eslint-disable-next-line no-restricted-syntax
for (const timestamp of sortedTimestamps) {
if (
subsetIndex < subsetArray.length &&
timestamp === subsetArray[subsetIndex][0]
) {
// Timestamp is present in subsetArray
const seriesPointData = subsetArray[subsetIndex];
seriesList.forEach((series: { values: [number, string][] }) => {
series.values.forEach((value) => {
timestamps.add(value[0]);
});
});
if (
seriesPointData &&
Array.isArray(seriesPointData) &&
seriesPointData.length > 0 &&
seriesPointData[1] !== 'NaN'
) {
filledArray.push(subsetArray[subsetIndex]);
} else {
const value = fillSpans ? 0 : null;
filledArray.push([seriesPointData[0], value]);
}
const timestampsArr: number[] | unknown[] = Array.from(timestamps) || [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return timestampsArr.sort((a, b) => a - b);
}
subsetIndex += 1;
} else {
// Timestamp is missing in subsetArray, fill with [timestamp, 0]
function fillMissingXAxisTimestamps(
timestampArr: number[],
data: any[],
fillSpans: boolean,
): any {
// Generate a set of all timestamps in the range
const allTimestampsSet = new Set(timestampArr);
const processedData = JSON.parse(JSON.stringify(data));
// Fill missing timestamps with null values
processedData.forEach((entry: { values: (number | null)[][] }) => {
const existingTimestamps = new Set(entry.values.map((value) => value[0]));
const missingTimestamps = Array.from(allTimestampsSet).filter(
(timestamp) => !existingTimestamps.has(timestamp),
);
missingTimestamps.forEach((timestamp) => {
const value = fillSpans ? 0 : null;
filledArray.push([timestamp, value]);
}
}
return filledArray;
entry.values.push([timestamp, value]);
});
entry.values.forEach((v) => {
if (Number.isNaN(v[1])) {
const replaceValue = fillSpans ? 0 : null;
// eslint-disable-next-line no-param-reassign
v[1] = replaceValue;
} else if (v[1] !== null) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line no-param-reassign
v[1] = parseFloat(v[1]);
}
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
entry.values.sort((a, b) => a[0] - b[0]);
});
return processedData.map((entry: { values: [number, string][] }) =>
entry.values.map((value) => value[1]),
);
}
export const getUPlotChartData = (
@ -46,43 +67,12 @@ export const getUPlotChartData = (
fillSpans?: boolean,
): any[] => {
const seriesList = apiResponse?.data?.result || [];
const uPlotData = [];
// this helps us identify the series with the max number of values and helps define the x axis - timestamps
const xSeries = seriesList.reduce(
(maxObj, currentObj) =>
currentObj.values.length > maxObj.values.length ? currentObj : maxObj,
seriesList[0],
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(
timestampArr,
seriesList,
fillSpans || false,
);
// sort seriesList
for (let index = 0; index < seriesList.length; index += 1) {
seriesList[index]?.values?.sort((a, b) => a[0] - b[0]);
}
const timestampArr = xSeries?.values?.map((v) => v[0]);
// timestamp
uPlotData.push(timestampArr);
// for each series, push the values
seriesList.forEach((series) => {
const updatedSeries = fillMissingTimestamps(
timestampArr,
series?.values || [],
fillSpans,
);
const seriesData =
updatedSeries?.map((v) => {
if (v[1] === null) {
return v[1];
}
return parseFloat(v[1]);
}) || [];
uPlotData.push(seriesData);
});
return uPlotData;
return [timestampArr, ...yAxisValuesArr];
};

View File

@ -0,0 +1,83 @@
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { convertValue } from 'lib/getConvertedValue';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
function findMinMaxValues(data: QueryDataV3[]): [number, number] {
let min = 0;
let max = 0;
data?.forEach((entry) => {
entry.series?.forEach((series) => {
series.values.forEach((valueObj) => {
const value = parseFloat(valueObj.value);
if (!value) return;
min = Math.min(min, value);
max = Math.max(max, value);
});
});
});
return [min, max];
}
function findMinMaxThresholdValues(
thresholds: ThresholdProps[],
yAxisUnit?: string,
): [number, number] {
let minThresholdValue = 0;
let maxThresholdValue = 0;
thresholds.forEach((entry) => {
const { thresholdValue, thresholdUnit } = entry;
if (thresholdValue === undefined) return;
minThresholdValue = Math.min(
minThresholdValue,
convertValue(thresholdValue, thresholdUnit, yAxisUnit) || 0,
);
maxThresholdValue = Math.max(
maxThresholdValue,
convertValue(thresholdValue, thresholdUnit, yAxisUnit) || 0,
);
});
return [minThresholdValue, maxThresholdValue];
}
function getRange(
thresholds: ThresholdProps[],
series: QueryDataV3[],
yAxisUnit?: string,
): [number, number] {
const [minThresholdValue, maxThresholdValue] = findMinMaxThresholdValues(
thresholds,
yAxisUnit,
);
const [minSeriesValue, maxSeriesValue] = findMinMaxValues(series);
const min = Math.min(minThresholdValue, minSeriesValue);
const max = Math.max(maxThresholdValue, maxSeriesValue);
return [min, max];
}
function areAllSeriesEmpty(series: QueryDataV3[]): boolean {
return series.every((entry) => {
if (!entry.series) return true;
return entry.series.every((series) => series.values.length === 0);
});
}
export const getYAxisScale = (
thresholds?: ThresholdProps[],
series?: QueryDataV3[],
yAxisUnit?: string,
): {
auto: boolean;
range?: [number, number];
} => {
if (!thresholds || !series) return { auto: true };
if (areAllSeriesEmpty(series)) return { auto: true };
const [min, max] = getRange(thresholds, series, yAxisUnit);
return { auto: false, range: [min, max] };
};

View File

@ -3,7 +3,7 @@ import ReleaseNote from 'components/ReleaseNote';
import ListOfAllDashboard from 'container/ListOfDashboard';
import { useLocation } from 'react-router-dom';
function Dashboard(): JSX.Element {
function DashboardsListPage(): JSX.Element {
const location = useLocation();
return (
@ -14,4 +14,4 @@ function Dashboard(): JSX.Element {
);
}
export default Dashboard;
export default DashboardsListPage;

View File

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

View File

@ -0,0 +1,33 @@
import { Typography } from 'antd';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import NewDashboard from 'container/NewDashboard';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { ErrorType } from 'types/common';
function DashboardPage(): JSX.Element {
const { dashboardResponse } = useDashboard();
const { isFetching, isError, isLoading } = dashboardResponse;
const errorMessage = isError
? (dashboardResponse?.error as AxiosError)?.response?.data.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return <NewDashboard />;
}
export default DashboardPage;

View File

@ -1,33 +1,3 @@
import { Typography } from 'antd';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import NewDashboard from 'container/NewDashboard';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { ErrorType } from 'types/common';
import DashboardPage from './DashboardPage';
function NewDashboardPage(): JSX.Element {
const { dashboardResponse } = useDashboard();
const { isFetching, isError, isLoading } = dashboardResponse;
const errorMessage = isError
? (dashboardResponse?.error as AxiosError)?.response?.data.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return <NewDashboard />;
}
export default NewDashboardPage;
export default DashboardPage;

View File

@ -5,7 +5,9 @@ import Spinner from 'components/Spinner';
import ChangeHistory from 'container/PipelinePage/Layouts/ChangeHistory';
import PipelinePage from 'container/PipelinePage/Layouts/Pipeline';
import { useNotifications } from 'hooks/useNotifications';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useEffect, useMemo } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { SuccessResponse } from 'types/api';
@ -77,7 +79,11 @@ function Pipelines(): JSX.Element {
return <Spinner height="75vh" tip="Loading Pipelines..." />;
}
return <Tabs defaultActiveKey="pipelines" items={tabItems} />;
return (
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<Tabs defaultActiveKey="pipelines" items={tabItems} />;
</ErrorBoundary>
);
}
export default Pipelines;

View File

@ -494,6 +494,20 @@ export function QueryBuilderProvider({
unit: query.unit || initialQueryState.unit,
};
const pagination = urlQuery.get(QueryParams.pagination);
if (pagination) {
const parsedPagination = JSON.parse(pagination);
urlQuery.set(
QueryParams.pagination,
JSON.stringify({
limit: parsedPagination.limit,
offset: 0,
}),
);
}
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),

View File

@ -42,6 +42,11 @@ body {
border-radius: 50%;
}
}
&.u-off {
text-decoration: line-through;
text-decoration-thickness: 3px;
}
}
}

View File

@ -10,6 +10,6 @@ export type Props = {
variables: PayloadVariables;
};
export type PayloadProps = {
export type VariableResponseProps = {
variableValues: string[] | number[];
};

View File

@ -27,6 +27,10 @@ export interface ProcessorData {
trace_flags?: {
parse_from: string;
};
// time parser fields
layout_type?: string;
layout?: string;
}
export interface PipelineData {

View File

@ -1,5 +1,6 @@
{
"compilerOptions": {
"sourceMap": true,
"outDir": "./dist/",
"noImplicitAny": true,
"module": "esnext",
@ -20,11 +21,12 @@
"baseUrl": "./src",
"downlevelIteration": true,
"plugins": [{ "name": "typescript-plugin-css-modules" }],
"types": ["node", "jest"]
"types": ["node", "jest"],
},
"exclude": ["node_modules"],
"include": [
"./src",
"./src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts",
"./babel.config.js",
"./jest.config.ts",
"./.eslintrc.js",

View File

@ -46,7 +46,7 @@ if (process.env.BUNDLE_ANALYSER === 'true') {
*/
const config = {
mode: 'development',
devtool: 'source-map',
devtool: 'eval-source-map',
entry: resolve(__dirname, './src/index.tsx'),
devServer: {
historyApiFallback: true,

View File

@ -34,9 +34,9 @@
three-render-objects "1"
"@adobe/css-tools@^4.0.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.1.tgz#abfccb8ca78075a2b6187345c26243c1a0842f28"
integrity sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==
version "4.3.2"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.2.tgz#a6abc715fb6884851fca9dad37fc34739a04fd11"
integrity sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==
"@ampproject/remapping@^2.2.0":
version "2.2.1"

View File

@ -1,5 +1,5 @@
# use a minimal alpine image
FROM alpine:3.18.3
FROM alpine:3.18.5
# Add Maintainer Info
LABEL maintainer="signoz"

View File

@ -66,6 +66,10 @@ type PipelineOperator struct {
// time_parser fields.
Layout string `json:"layout,omitempty" yaml:"layout,omitempty"`
LayoutType string `json:"layout_type,omitempty" yaml:"layout_type,omitempty"`
// severity parser fields
SeverityMapping map[string][]string `json:"mapping,omitempty" yaml:"mapping,omitempty"`
OverwriteSeverityText bool `json:"overwrite_text,omitempty" yaml:"overwrite_text,omitempty"`
}
type TimestampParser struct {

View File

@ -138,6 +138,16 @@ func getOperators(ops []PipelineOperator) ([]PipelineOperator, error) {
}
// TODO(Raj): Maybe add support for gotime too eventually
} else if operator.Type == "severity_parser" {
parseFromParts := strings.Split(operator.ParseFrom, ".")
parseFromPath := strings.Join(parseFromParts, "?.")
operator.If = fmt.Sprintf(
`%s != nil && ( type(%s) == "string" || ( type(%s) in ["int", "float"] && %s == float(int(%s)) ) )`,
parseFromPath, parseFromPath, parseFromPath, parseFromPath, parseFromPath,
)
}
filteredOp = append(filteredOp, operator)

View File

@ -198,6 +198,18 @@ func isValidOperator(op PipelineOperator) error {
}
}
case "severity_parser":
if op.ParseFrom == "" {
return fmt.Errorf("parse from of severity parsing processor %s cannot be empty", op.ID)
}
validMappingLevels := []string{"trace", "debug", "info", "warn", "error", "fatal"}
for k, _ := range op.SeverityMapping {
if !slices.Contains(validMappingLevels, strings.ToLower(k)) {
return fmt.Errorf("%s is not a valid severity in processor %s", k, op.ID)
}
}
default:
return fmt.Errorf(fmt.Sprintf("operator type %s not supported for %s, use one of (grok_parser, regex_parser, copy, move, add, remove, trace_parser, retain)", op.Type, op.ID))
}

View File

@ -326,6 +326,40 @@ var operatorTest = []struct {
Layout: "%U",
},
IsValid: false,
}, {
Name: "Severity Parser - valid",
Operator: PipelineOperator{
ID: "severity",
Type: "severity_parser",
ParseFrom: "attributes.test_severity",
SeverityMapping: map[string][]string{
"trace": {"test_trace"},
"fatal": {"test_fatal"},
},
OverwriteSeverityText: true,
},
IsValid: true,
}, {
Name: "Severity Parser - Parse from is required",
Operator: PipelineOperator{
ID: "severity",
Type: "severity_parser",
SeverityMapping: map[string][]string{},
OverwriteSeverityText: true,
},
IsValid: false,
}, {
Name: "Severity Parser - mapping level must be valid",
Operator: PipelineOperator{
ID: "severity",
Type: "severity_parser",
ParseFrom: "attributes.test",
SeverityMapping: map[string][]string{
"not-a-level": {"bad-level"},
},
OverwriteSeverityText: true,
},
IsValid: false,
},
}

View File

@ -3,6 +3,7 @@ package logparsingpipeline
import (
"context"
"encoding/json"
"fmt"
"strconv"
"testing"
"time"
@ -91,15 +92,15 @@ func TestPipelinePreview(t *testing.T) {
},
}
matchingLog := makeTestLogEntry(
matchingLog := makeTestSignozLog(
"test log body",
map[string]string{
map[string]interface{}{
"method": "GET",
},
)
nonMatchingLog := makeTestLogEntry(
nonMatchingLog := makeTestSignozLog(
"test log body",
map[string]string{
map[string]interface{}{
"method": "POST",
},
)
@ -184,9 +185,9 @@ func TestGrokParsingProcessor(t *testing.T) {
},
}
testLog := makeTestLogEntry(
testLog := makeTestSignozLog(
"2023-10-26T04:38:00.602Z INFO route/server.go:71 HTTP request received",
map[string]string{
map[string]interface{}{
"method": "GET",
},
)
@ -314,18 +315,39 @@ func TestTraceParsingProcessor(t *testing.T) {
require.Equal("", result[0].SpanID)
}
func makeTestLogEntry(
func makeTestSignozLog(
body string,
attributes map[string]string,
attributes map[string]interface{},
) model.SignozLog {
return model.SignozLog{
Timestamp: uint64(time.Now().UnixNano()),
Body: body,
Attributes_string: attributes,
Resources_string: map[string]string{},
SeverityText: entry.Info.String(),
SeverityNumber: uint8(entry.Info),
SpanID: uuid.New().String(),
TraceID: uuid.New().String(),
testLog := model.SignozLog{
Timestamp: uint64(time.Now().UnixNano()),
Body: body,
Attributes_bool: map[string]bool{},
Attributes_string: map[string]string{},
Attributes_int64: map[string]int64{},
Attributes_float64: map[string]float64{},
Resources_string: map[string]string{},
SeverityText: entry.Info.String(),
SeverityNumber: uint8(entry.Info),
SpanID: uuid.New().String(),
TraceID: uuid.New().String(),
}
for k, v := range attributes {
switch v.(type) {
case bool:
testLog.Attributes_bool[k] = v.(bool)
case string:
testLog.Attributes_string[k] = v.(string)
case int:
testLog.Attributes_int64[k] = int64(v.(int))
case float64:
testLog.Attributes_float64[k] = v.(float64)
default:
panic(fmt.Sprintf("found attribute value of unsupported type %T in test log", v))
}
}
return testLog
}

View File

@ -0,0 +1,221 @@
package logparsingpipeline
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
"go.signoz.io/signoz/pkg/query-service/model"
v3 "go.signoz.io/signoz/pkg/query-service/model/v3"
)
func TestSeverityParsingProcessor(t *testing.T) {
require := require.New(t)
testPipelines := []Pipeline{
{
OrderId: 1,
Name: "pipeline1",
Alias: "pipeline1",
Enabled: true,
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []PipelineOperator{},
},
}
var severityParserOp PipelineOperator
err := json.Unmarshal([]byte(`
{
"orderId": 1,
"enabled": true,
"type": "severity_parser",
"name": "Test severity parser",
"id": "test-severity-parser",
"parse_from": "attributes.test_severity",
"mapping": {
"trace": ["test_trace"],
"debug": ["test_debug", "2xx"],
"info": ["test_info", "3xx"],
"warn": ["test_warn", "4xx"],
"error": ["test_error", "5xx"],
"fatal": ["test_fatal"]
},
"overwrite_text": true
}
`), &severityParserOp)
require.Nil(err)
testPipelines[0].Config = append(testPipelines[0].Config, severityParserOp)
testCases := []struct {
severityValues []interface{}
expectedSeverityText string
expectedSeverityNumber uint8
}{
{
severityValues: []interface{}{
"test_trace", "TEST_TRACE", "trace", "Trace",
},
expectedSeverityText: "TRACE",
expectedSeverityNumber: 1,
},
{
severityValues: []interface{}{
"test_debug", "TEST_DEBUG", "debug", "DEBUG", 202.0,
},
expectedSeverityText: "DEBUG",
expectedSeverityNumber: 5,
}, {
severityValues: []interface{}{
"test_info", "TEST_INFO", "info", "INFO", 302.0,
},
expectedSeverityText: "INFO",
expectedSeverityNumber: 9,
}, {
severityValues: []interface{}{
"test_warn", "TEST_WARN", "warn", "WARN", 404.0,
},
expectedSeverityText: "WARN",
expectedSeverityNumber: 13,
}, {
severityValues: []interface{}{
"test_error", "TEST_ERROR", "error", "ERROR", 500.0,
},
expectedSeverityText: "ERROR",
expectedSeverityNumber: 17,
}, {
severityValues: []interface{}{
"test_fatal", "TEST_FATAL", "fatal", "FATAL",
},
expectedSeverityText: "FATAL",
expectedSeverityNumber: 21,
},
}
for _, testCase := range testCases {
inputLogs := []model.SignozLog{}
for _, severityAttribValue := range testCase.severityValues {
inputLogs = append(inputLogs, makeTestSignozLog(
"test log",
map[string]interface{}{
"method": "GET",
"test_severity": severityAttribValue,
},
))
}
result, collectorWarnAndErrorLogs, err := SimulatePipelinesProcessing(
context.Background(),
testPipelines,
inputLogs,
)
require.Nil(err)
require.Equal(len(inputLogs), len(result))
require.Equal(0, len(collectorWarnAndErrorLogs), strings.Join(collectorWarnAndErrorLogs, "\n"))
processed := result[0]
require.Equal(testCase.expectedSeverityNumber, processed.SeverityNumber)
require.Equal(testCase.expectedSeverityText, processed.SeverityText)
}
}
func TestNoCollectorErrorsFromSeverityParserForMismatchedLogs(t *testing.T) {
require := require.New(t)
testPipelineFilter := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
}
makeTestPipeline := func(config []PipelineOperator) Pipeline {
return Pipeline{
OrderId: 1,
Name: "pipeline1",
Alias: "pipeline1",
Enabled: true,
Filter: testPipelineFilter,
Config: config,
}
}
type pipelineTestCase struct {
Name string
Operator PipelineOperator
NonMatchingLog model.SignozLog
}
testCases := []pipelineTestCase{
{
"severity parser should ignore logs with missing field",
PipelineOperator{
ID: "severity",
Type: "severity_parser",
Enabled: true,
Name: "severity parser",
ParseFrom: "attributes.test_severity",
SeverityMapping: map[string][]string{
"debug": {"debug"},
},
OverwriteSeverityText: true,
},
makeTestSignozLog("mismatching log", map[string]interface{}{
"method": "GET",
}),
}, {
"severity parser should ignore logs with invalid values.",
PipelineOperator{
ID: "severity",
Type: "severity_parser",
Enabled: true,
Name: "severity parser",
ParseFrom: "attributes.test_severity",
SeverityMapping: map[string][]string{
"debug": {"debug"},
},
OverwriteSeverityText: true,
},
makeTestSignozLog("mismatching log", map[string]interface{}{
"method": "GET",
"test_severity": 200.3,
}),
},
}
for _, testCase := range testCases {
testPipelines := []Pipeline{makeTestPipeline([]PipelineOperator{testCase.Operator})}
result, collectorWarnAndErrorLogs, err := SimulatePipelinesProcessing(
context.Background(),
testPipelines,
[]model.SignozLog{testCase.NonMatchingLog},
)
require.Nil(err)
require.Equal(0, len(collectorWarnAndErrorLogs), strings.Join(collectorWarnAndErrorLogs, "\n"))
require.Equal(1, len(result))
}
}

View File

@ -108,9 +108,9 @@ func TestTimestampParsingProcessor(t *testing.T) {
testPipelines[0].Config = append(testPipelines[0].Config, timestampParserOp)
testTimestampStr := "2023-11-27T12:03:28.239907+0530"
testLog := makeTestLogEntry(
testLog := makeTestSignozLog(
"test log",
map[string]string{
map[string]interface{}{
"method": "GET",
"test_timestamp": testTimestampStr,
},