feat: feature flag is updated (#2666)

* feat: flag is updated

* feat: feature flag is updated

* feat: onrefetch is added on several actions on app

* chore: tab is updated

* chore: creating dashbaord error is handled

* fix: message is fixed

* chore: jest test is updated
This commit is contained in:
Palash Gupta 2023-05-19 12:19:42 +05:30 committed by GitHub
parent e7f5adc8a9
commit 604d98be05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 645 additions and 323 deletions

View File

@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/features/getFeatures';
const getFeaturesFlags = async (): Promise<
SuccessResponse<PayloadProps> | ErrorResponse
> => {
try {
const response = await axios.get(`/featureFlags`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getFeaturesFlags;

View File

@ -1,7 +0,0 @@
// keep this consistent with backend model>features.go
export enum FeatureKeys {
SSO = 'SSO',
ENTERPRISE_PLAN = 'ENTERPRISE_PLAN',
BASIC_PLAN = 'BASIC_PLAN',
DISABLE_UPSELL = 'DISABLE_UPSELL',
}

View File

@ -1,6 +1,11 @@
// keep this consistent with backend constants.go
export enum FeatureKeys {
SSO = 'SSO',
ENTERPRISE_PLAN = 'ENTERPRISE_PLAN',
BASIC_PLAN = 'BASIC_PLAN',
DurationSort = 'DurationSort',
TimestampSort = 'TimestampSort',
SMART_TRACE_DETAIL = 'SMART_TRACE_DETAIL',
CUSTOM_METRICS_FUNCTION = 'CUSTOM_METRICS_FUNCTION',
QUERY_BUILDER_PANELS = 'QUERY_BUILDER_PANELS',
QUERY_BUILDER_ALERTS = 'QUERY_BUILDER_ALERTS',
DISABLE_UPSELL = 'DISABLE_UPSELL',
}

View File

@ -0,0 +1,3 @@
export const REACT_QUERY_KEY = {
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
};

View File

@ -18,7 +18,7 @@ import {
UPDATE_CONFIGS,
UPDATE_CURRENT_ERROR,
UPDATE_CURRENT_VERSION,
UPDATE_FEATURE_FLAGS,
UPDATE_FEATURE_FLAG_RESPONSE,
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app';
@ -129,19 +129,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
message: t('oops_something_went_wrong_version'),
});
}
if (
getFeaturesResponse.isFetched &&
getFeaturesResponse.isSuccess &&
getFeaturesResponse.data &&
getFeaturesResponse.data.payload
) {
dispatch({
type: UPDATE_FEATURE_FLAGS,
payload: {
...getFeaturesResponse.data.payload,
},
});
}
if (
getUserVersionResponse.isFetched &&
@ -173,20 +160,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
});
}
if (
getFeaturesResponse.isFetched &&
getFeaturesResponse.isSuccess &&
getFeaturesResponse.data &&
getFeaturesResponse.data.payload
) {
dispatch({
type: UPDATE_FEATURE_FLAGS,
payload: {
...getFeaturesResponse.data.payload,
},
});
}
if (
getDynamicConfigsResponse.isFetched &&
getDynamicConfigsResponse.isSuccess &&
@ -226,6 +199,29 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
notifications,
]);
useEffect(() => {
if (
getFeaturesResponse.isFetched &&
getFeaturesResponse.isSuccess &&
getFeaturesResponse.data &&
getFeaturesResponse.data.payload
) {
dispatch({
type: UPDATE_FEATURE_FLAG_RESPONSE,
payload: {
featureFlag: getFeaturesResponse.data.payload,
refetch: getFeaturesResponse.refetch,
},
});
}
}, [
dispatch,
getFeaturesResponse.data,
getFeaturesResponse.isFetched,
getFeaturesResponse.isSuccess,
getFeaturesResponse.refetch,
]);
const isToDisplayLayout = isLoggedIn;
return (

View File

@ -36,8 +36,16 @@ export const alertDefaults: AlertDef = {
},
},
promQueries: {},
chQueries: {},
queryType: EQueryType.QUERY_BUILDER,
chQueries: {
A: {
name: 'A',
query: ``,
rawQuery: ``,
legend: '',
disabled: false,
},
},
queryType: EQueryType.CLICKHOUSE,
panelType: PANEL_TYPES.TIME_SERIES,
},
op: defaultCompareOp,

View File

@ -2,7 +2,7 @@ import { Button, Tabs } from 'antd';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryBuilder } from 'container/QueryBuilder';
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { IChQueries, IPromQueries } from 'types/api/alerts/compositeQuery';
@ -91,11 +91,14 @@ function QuerySection({
},
];
const items = [
{ label: t('tab_qb'), key: EQueryType.QUERY_BUILDER },
{ label: t('tab_chquery'), key: EQueryType.CLICKHOUSE },
{ label: t('tab_promql'), key: EQueryType.PROM },
];
const items = useMemo(
() => [
{ label: t('tab_qb'), key: EQueryType.QUERY_BUILDER },
{ label: t('tab_chquery'), key: EQueryType.CLICKHOUSE },
{ label: t('tab_promql'), key: EQueryType.PROM },
],
[t],
);
const renderTabs = (typ: AlertTypes): JSX.Element | null => {
switch (typ) {

View File

@ -1,11 +1,13 @@
import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons';
import { Col, FormInstance, Modal, Typography } from 'antd';
import { Col, FormInstance, Modal, Tooltip, Typography } from 'antd';
import saveAlertApi from 'api/alerts/save';
import testAlertApi from 'api/alerts/testAlert';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { MESSAGE, useIsFeatureDisabled } from 'hooks/useFeatureFlag';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
@ -145,6 +147,7 @@ function FormAlertRules({
// onQueryCategoryChange handles changes to query category
// in state as well as sets additional defaults
const onQueryCategoryChange = (val: EQueryType): void => {
console.log('onQueryCategoryChange', val);
setQueryCategory(val);
if (val === EQueryType.PROM) {
setAlertDef({
@ -298,6 +301,10 @@ function FormAlertRules({
initQuery,
]);
const isAlertAvialable = useIsFeatureDisabled(
FeatureKeys.QUERY_BUILDER_ALERTS,
);
const saveRule = useCallback(async () => {
if (!isFormValid()) {
return;
@ -437,6 +444,12 @@ function FormAlertRules({
selectedInterval={toChartInterval(alertDef.evalWindow)}
/>
);
const isNewRule = ruleId === 0;
const isAlertAvialableToSave =
isAlertAvialable && isNewRule && queryCategory === EQueryType.QUERY_BUILDER;
return (
<>
{Element}
@ -469,14 +482,18 @@ function FormAlertRules({
{renderBasicInfo()}
<ButtonContainer>
<ActionButton
loading={loading || false}
type="primary"
onClick={onSaveHandler}
icon={<SaveOutlined />}
>
{ruleId > 0 ? t('button_savechanges') : t('button_createrule')}
</ActionButton>
<Tooltip title={isAlertAvialableToSave ? MESSAGE.ALERT : ''}>
<ActionButton
loading={loading || false}
type="primary"
onClick={onSaveHandler}
icon={<SaveOutlined />}
disabled={isAlertAvialableToSave}
>
{isNewRule ? t('button_createrule') : t('button_savechanges')}
</ActionButton>
</Tooltip>
<ActionButton
loading={loading || false}
type="default"

View File

@ -2,12 +2,14 @@ import { Typography } from 'antd';
import { ChartData } from 'chart.js';
import Spinner from 'components/Spinner';
import GridGraphComponent from 'container/GridGraphComponent';
import { useNotifications } from 'hooks/useNotifications';
import usePreviousValue from 'hooks/usePreviousValue';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import getChartData from 'lib/getChartData';
import isEmpty from 'lodash-es/isEmpty';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { Layout } from 'react-grid-layout';
import { useTranslation } from 'react-i18next';
import { useInView } from 'react-intersection-observer';
import { useQuery } from 'react-query';
import { connect, useSelector } from 'react-redux';
@ -20,8 +22,8 @@ import {
import { GetMetricQueryRange } from 'store/actions/dashboard/getQueryResults';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { GlobalTime } from 'types/actions/globalTime';
import { Widgets } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import DashboardReducer from 'types/reducer/dashboards';
import { GlobalReducer } from 'types/reducer/globalTime';
@ -46,18 +48,22 @@ function GridCardGraph({
initialInView: true,
});
const { notifications } = useNotifications();
const { t } = useTranslation(['common']);
const [errorMessage, setErrorMessage] = useState<string | undefined>('');
const [hovered, setHovered] = useState(false);
const [modal, setModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
const { minTime, maxTime } = useSelector<AppState, GlobalTime>(
(state) => state.globalTime,
);
const { selectedTime: globalSelectedInterval } = useSelector<
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const { dashboards } = useSelector<AppState, DashboardReducer>(
(state) => state.dashboards,
);
@ -122,9 +128,27 @@ function GridCardGraph({
const widgetId = isEmptyWidget ? layout[0].i : widget?.id;
deleteWidget({ widgetId, setLayout });
onToggleModal(setDeleteModal);
}, [deleteWidget, layout, onToggleModal, setLayout, widget]);
featureResponse
.refetch()
.then(() => {
deleteWidget({ widgetId, setLayout });
onToggleModal(setDeleteModal);
})
.catch(() => {
notifications.error({
message: t('common:something_went_wrong'),
});
});
}, [
widget,
layout,
featureResponse,
deleteWidget,
setLayout,
onToggleModal,
notifications,
t,
]);
const getModals = (): JSX.Element => (
<>

View File

@ -1,6 +1,9 @@
import { PlusOutlined, SaveFilled } from '@ant-design/icons';
import { Typography } from 'antd';
import { FeatureKeys } from 'constants/features';
import useComponentPermission from 'hooks/useComponentPermission';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useFeatureFlag, { MESSAGE } from 'hooks/useFeatureFlag';
import React from 'react';
import { Layout } from 'react-grid-layout';
import { useSelector } from 'react-redux';
@ -14,6 +17,7 @@ import {
ButtonContainer,
Card,
CardContainer,
NoPanelAvialable,
ReactGridLayout,
} from './styles';
@ -35,6 +39,8 @@ function GraphLayout({
role,
);
const queryBuilderFeature = useFeatureFlag(FeatureKeys.QUERY_BUILDER_PANELS);
return (
<>
<ButtonContainer>
@ -74,9 +80,31 @@ function GraphLayout({
onLayoutChange={onLayoutChangeHandler}
draggableHandle=".drag-handle"
>
{layouts.map(({ Component, ...rest }) => {
{layouts.map(({ Component, ...rest }, layoutIndex) => {
const currentWidget = (widgets || [])?.find((e) => e.id === rest.i);
const usageLimit = queryBuilderFeature?.usage_limit || 0;
const isPanelNotAvialable = usageLimit > 0 && usageLimit <= layoutIndex;
if (isPanelNotAvialable) {
return (
<CardContainer
data-grid={rest}
isDarkMode={isDarkMode}
key={currentWidget?.id}
>
<Card>
<Typography.Text type="danger">
<NoPanelAvialable isDarkMode={isDarkMode}>
{MESSAGE.WIDGET.replace('{{widget}}', usageLimit.toString())}
</NoPanelAvialable>
</Typography.Text>
</Card>
</CardContainer>
);
}
return (
<CardContainer
isDarkMode={isDarkMode}

View File

@ -84,6 +84,8 @@ function GridGraph(props: Props): JSX.Element {
[dispatch],
);
const { notifications } = useNotifications();
useEffect(() => {
(async (): Promise<void> => {
if (!isAddWidget) {
@ -119,6 +121,12 @@ function GridGraph(props: Props): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const errorMessage = t('common:something_went_wrong');
const onLayoutSaveHandler = useCallback(
async (layout: Layout[]) => {
try {
@ -128,44 +136,62 @@ function GridGraph(props: Props): JSX.Element {
errorMessage: '',
loading: true,
}));
const updatedDashboard: Dashboard = {
...selectedDashboard,
data: {
title: data.title,
description: data.description,
name: data.name,
tags: data.tags,
widgets: data.widgets,
variables: data.variables,
layout,
},
uuid: selectedDashboard.uuid,
};
// Save layout only when users has the has the permission to do so.
if (saveLayoutPermission) {
const response = await updateDashboardApi(updatedDashboard);
if (response.statusCode === 200) {
setSaveLayoutState((state) => ({
...state,
error: false,
errorMessage: '',
loading: false,
}));
dispatch({
type: UPDATE_DASHBOARD,
payload: updatedDashboard,
});
} else {
featureResponse
.refetch()
.then(async () => {
const updatedDashboard: Dashboard = {
...selectedDashboard,
data: {
title: data.title,
description: data.description,
name: data.name,
tags: data.tags,
widgets: data.widgets,
variables: data.variables,
layout,
},
uuid: selectedDashboard.uuid,
};
// Save layout only when users has the has the permission to do so.
if (saveLayoutPermission) {
const response = await updateDashboardApi(updatedDashboard);
if (response.statusCode === 200) {
setSaveLayoutState((state) => ({
...state,
error: false,
errorMessage: '',
loading: false,
}));
dispatch({
type: UPDATE_DASHBOARD,
payload: updatedDashboard,
});
} else {
setSaveLayoutState((state) => ({
...state,
error: true,
errorMessage: response.error || errorMessage,
loading: false,
}));
}
}
})
.catch(() => {
setSaveLayoutState((state) => ({
...state,
error: true,
errorMessage: response.error || 'Something went wrong',
errorMessage,
loading: false,
}));
}
}
notifications.error({
message: errorMessage,
});
});
} catch (error) {
console.error(error);
notifications.error({
message: errorMessage,
});
}
},
[
@ -176,6 +202,9 @@ function GridGraph(props: Props): JSX.Element {
data.variables,
data.widgets,
dispatch,
errorMessage,
featureResponse,
notifications,
saveLayoutPermission,
selectedDashboard,
],
@ -207,8 +236,6 @@ function GridGraph(props: Props): JSX.Element {
[widgets, onDragSelect],
);
const { notifications } = useNotifications();
const onEmptyWidgetHandler = useCallback(async () => {
try {
const id = 'empty';
@ -239,10 +266,10 @@ function GridGraph(props: Props): JSX.Element {
setLayoutFunction(layout);
} catch (error) {
notifications.error({
message: error instanceof Error ? error.toString() : 'Something went wrong',
message: error instanceof Error ? error.toString() : errorMessage,
});
}
}, [data, selectedDashboard, setLayoutFunction, notifications]);
}, [data, selectedDashboard, setLayoutFunction, notifications, errorMessage]);
const onLayoutChangeHandler = async (layout: Layout[]): Promise<void> => {
setLayoutFunction(layout);
@ -253,43 +280,57 @@ function GridGraph(props: Props): JSX.Element {
const onAddPanelHandler = useCallback(() => {
try {
setAddPanelLoading(true);
const isEmptyLayoutPresent =
layouts.find((e) => e.i === 'empty') !== undefined;
featureResponse
.refetch()
.then(() => {
const isEmptyLayoutPresent =
layouts.find((e) => e.i === 'empty') !== undefined;
if (!isEmptyLayoutPresent) {
onEmptyWidgetHandler()
.then(() => {
setAddPanelLoading(false);
if (!isEmptyLayoutPresent) {
onEmptyWidgetHandler()
.then(() => {
setAddPanelLoading(false);
toggleAddWidget(true);
})
.catch(() => {
notifications.error({
message: errorMessage,
});
});
} else {
toggleAddWidget(true);
})
.catch(() => {
notifications.error(t('something_went_wrong'));
});
} else {
toggleAddWidget(true);
setAddPanelLoading(false);
}
setAddPanelLoading(false);
}
})
.catch(() =>
notifications.error({
message: errorMessage,
}),
);
} catch (error) {
if (typeof error === 'string') {
notifications.error({
message: error || t('something_went_wrong'),
});
}
notifications.error({
message: errorMessage,
});
}
}, [layouts, onEmptyWidgetHandler, t, toggleAddWidget, notifications]);
}, [
featureResponse,
layouts,
onEmptyWidgetHandler,
toggleAddWidget,
notifications,
errorMessage,
]);
return (
<GraphLayoutContainer
{...{
addPanelLoading,
layouts,
onAddPanelHandler,
onLayoutChangeHandler,
onLayoutSaveHandler,
saveLayoutState,
widgets,
setLayout,
}}
addPanelLoading={addPanelLoading}
layouts={layouts}
onAddPanelHandler={onAddPanelHandler}
onLayoutChangeHandler={onLayoutChangeHandler}
onLayoutSaveHandler={onLayoutSaveHandler}
saveLayoutState={saveLayoutState}
setLayout={setLayout}
widgets={widgets}
/>
);
}

View File

@ -79,3 +79,11 @@ export const Button = styled(ButtonComponent)`
align-items: center;
}
`;
export const NoPanelAvialable = styled.div<Props>`
display: flex;
justify-content: center;
align-items: center;
height: 100%;
`;

View File

@ -1,7 +1,6 @@
import { Typography } from 'antd';
import { FeatureKeys } from 'constants/features';
import { Spin, Typography } from 'antd';
import ROUTES from 'constants/routes';
import useFeatureFlags from 'hooks/useFeatureFlag';
import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense';
import history from 'lib/history';
import React from 'react';
@ -12,7 +11,22 @@ import {
} from './styles';
function ManageLicense({ onToggle }: ManageLicenseProps): JSX.Element {
const isEnterprise = useFeatureFlags(FeatureKeys.ENTERPRISE_PLAN);
const { data, isLoading } = useLicense();
const onManageLicense = (): void => {
onToggle();
history.push(ROUTES.LIST_LICENSES);
};
if (isLoading || data?.payload === undefined) {
return <Spin />;
}
const isEnterprise = data?.payload?.some(
(license) =>
license.isCurrent && license.planKey === LICENSE_PLAN_KEY.ENTERPRISE_PLAN,
);
return (
<>
<Typography>SIGNOZ STATUS</Typography>
@ -23,14 +37,7 @@ function ManageLicense({ onToggle }: ManageLicenseProps): JSX.Element {
<Typography>{!isEnterprise ? 'Free Plan' : 'Enterprise Plan'} </Typography>
</ManageLicenseWrapper>
<Typography.Link
onClick={(): void => {
onToggle();
history.push(ROUTES.LIST_LICENSES);
}}
>
Manage Licenses
</Typography.Link>
<Typography.Link onClick={onManageLicense}>Manage Licenses</Typography.Link>
</ManageLicenseContainer>
</>
);

View File

@ -1,15 +1,14 @@
import { Button, Form, Input } from 'antd';
import getFeaturesFlags from 'api/features/getFeatureFlags';
import apply from 'api/licenses/apply';
import { useNotifications } from 'hooks/useNotifications';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { QueryObserverResult, RefetchOptions, useQuery } from 'react-query';
import { useDispatch } from 'react-redux';
import { Dispatch } from 'redux';
import { AppAction, UPDATE_FEATURE_FLAGS } from 'types/actions/app';
import { QueryObserverResult, RefetchOptions } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/licenses/getAll';
import AppReducer from 'types/reducer/app';
import { ApplyForm, ApplyFormContainer, LicenseInput } from './styles';
@ -21,12 +20,9 @@ function ApplyLicenseForm({
const { t } = useTranslation(['licenses']);
const [key, setKey] = useState('');
const [loading, setLoading] = useState(false);
const dispatch = useDispatch<Dispatch<AppAction>>();
const { refetch } = useQuery({
queryFn: getFeaturesFlags,
queryKey: 'getFeatureFlags',
enabled: false,
});
const { featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const { notifications } = useNotifications();
@ -47,16 +43,8 @@ function ApplyLicenseForm({
});
if (response.statusCode === 200) {
const [featureFlagsResponse] = await Promise.all([
refetch(),
licenseRefetch(),
]);
if (featureFlagsResponse.data?.payload) {
dispatch({
type: UPDATE_FEATURE_FLAGS,
payload: featureFlagsResponse.data.payload,
});
}
await Promise.all([featureResponse?.refetch(), licenseRefetch()]);
notifications.success({
message: 'Success',
description: t('license_applied'),

View File

@ -1,19 +1,15 @@
import { Tabs, Typography } from 'antd';
import getAll from 'api/licenses/getAll';
import Spinner from 'components/Spinner';
import useLicense from 'hooks/useLicense';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import ApplyLicenseForm from './ApplyLicenseForm';
import ListLicenses from './ListLicenses';
function Licenses(): JSX.Element {
const { t } = useTranslation(['licenses']);
const { data, isError, isLoading, refetch } = useQuery({
queryFn: getAll,
queryKey: 'getAllLicenses',
});
const { data, isError, isLoading, refetch } = useLicense();
if (isError || data?.error) {
return <Typography>{data?.error}</Typography>;

View File

@ -2,8 +2,11 @@ import { NotificationInstance } from 'antd/es/notification/interface';
import deleteAlerts from 'api/alerts/delete';
import { State } from 'hooks/useFetch';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { PayloadProps as DeleteAlertPayloadProps } from 'types/api/alerts/delete';
import { GettableAlert } from 'types/api/alerts/get';
import AppReducer from 'types/reducer/app';
import { ColumnButton } from './styles';
@ -22,15 +25,14 @@ function DeleteAlert({
payload: undefined,
});
const { featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const defaultErrorMessage = 'Something went wrong';
const onDeleteHandler = async (id: number): Promise<void> => {
try {
setDeleteAlertState((state) => ({
...state,
loading: true,
}));
const response = await deleteAlerts({
id,
});
@ -72,11 +74,32 @@ function DeleteAlert({
}
};
const onClickHandler = (): void => {
setDeleteAlertState((state) => ({
...state,
loading: true,
}));
featureResponse
.refetch()
.then(() => {
onDeleteHandler(id);
})
.catch(() => {
setDeleteAlertState((state) => ({
...state,
loading: false,
}));
notifications.error({
message: defaultErrorMessage,
});
});
};
return (
<ColumnButton
disabled={deleteAlertState.loading || false}
loading={deleteAlertState.loading || false}
onClick={(): Promise<void> => onDeleteHandler(id)}
onClick={onClickHandler}
type="link"
>
Delete

View File

@ -26,7 +26,9 @@ import ToggleAlertState from './ToggleAlertState';
function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
const [data, setData] = useState<GettableAlert[]>(allAlertRules || []);
const { t } = useTranslation('common');
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
const { role, featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const [addNewAlert, action] = useComponentPermission(
['add_new_alert', 'action'],
role,
@ -48,12 +50,28 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
})();
}, 30000);
const handleError = useCallback((): void => {
notificationsApi.error({
message: t('something_went_wrong'),
});
}, [notificationsApi, t]);
const onClickNewAlertHandler = useCallback(() => {
history.push(ROUTES.ALERTS_NEW);
}, []);
featureResponse
.refetch()
.then(() => {
history.push(ROUTES.ALERTS_NEW);
})
.catch(handleError);
}, [featureResponse, handleError]);
const onEditHandler = (id: string): void => {
history.push(`${ROUTES.EDIT_ALERTS}?ruleId=${id}`);
featureResponse
.refetch()
.then(() => {
history.push(`${ROUTES.EDIT_ALERTS}?ruleId=${id}`);
})
.catch(handleError);
};
const columns: ColumnsType<GettableAlert> = [

View File

@ -4,6 +4,7 @@ import { Button, Modal, Space, Typography, Upload, UploadProps } from 'antd';
import createDashboard from 'api/dashboard/create';
import Editor from 'components/Editor';
import ROUTES from 'constants/routes';
import { MESSAGE } from 'hooks/useFeatureFlag';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import React, { useState } from 'react';
@ -28,6 +29,8 @@ function ImportJSON({
const [isCreateDashboardError, setIsCreateDashboardError] = useState<boolean>(
false,
);
const [isFeatureAlert, setIsFeatureAlert] = useState<boolean>(false);
const dispatch = useDispatch<Dispatch<AppActions>>();
const [dashboardCreating, setDashboardCreating] = useState<boolean>(false);
@ -99,6 +102,15 @@ function ImportJSON({
}),
);
}, 10);
} else if (response.error === 'feature usage exceeded') {
setIsFeatureAlert(true);
notifications.error({
message:
response.error ||
t('something_went_wrong', {
ns: 'common',
}),
});
} else {
setIsCreateDashboardError(true);
notifications.error({
@ -112,6 +124,7 @@ function ImportJSON({
setDashboardCreating(false);
} catch {
setDashboardCreating(false);
setIsFeatureAlert(false);
setIsCreateDashboardError(true);
}
@ -124,6 +137,13 @@ function ImportJSON({
</Space>
);
const onCancelHandler = (): void => {
setIsUploadJSONError(false);
setIsCreateDashboardError(false);
setIsFeatureAlert(false);
onModalHandler();
};
return (
<Modal
open={isImportJSONModalVisible}
@ -131,7 +151,7 @@ function ImportJSON({
maskClosable
destroyOnClose
width="70vw"
onCancel={onModalHandler}
onCancel={onCancelHandler}
title={
<>
<Typography.Title level={4}>{t('import_json')}</Typography.Title>
@ -148,6 +168,11 @@ function ImportJSON({
{t('load_json')}
</Button>
{isCreateDashboardError && getErrorNode(t('error_loading_json'))}
{isFeatureAlert && (
<Typography.Text type="danger">
{MESSAGE.CREATE_DASHBOARD}
</Typography.Text>
)}
</FooterContainer>
}
>

View File

@ -74,49 +74,52 @@ function ListOfAllDashboard(): JSX.Element {
errorMessage: '',
});
const columns: TableColumnProps<Data>[] = [
{
title: 'Name',
dataIndex: 'name',
width: 100,
render: Name,
},
{
title: 'Description',
width: 100,
dataIndex: 'description',
},
{
title: 'Tags (can be multiple)',
dataIndex: 'tags',
width: 80,
render: Tags,
},
{
title: 'Created At',
dataIndex: 'createdBy',
width: 80,
sorter: (a: Data, b: Data): number => {
const prev = new Date(a.createdBy).getTime();
const next = new Date(b.createdBy).getTime();
return prev - next;
const columns: TableColumnProps<Data>[] = useMemo(
() => [
{
title: 'Name',
dataIndex: 'name',
width: 100,
render: Name,
},
render: Createdby,
},
{
title: 'Last Updated Time',
width: 90,
dataIndex: 'lastUpdatedTime',
sorter: (a: Data, b: Data): number => {
const prev = new Date(a.lastUpdatedTime).getTime();
const next = new Date(b.lastUpdatedTime).getTime();
return prev - next;
{
title: 'Description',
width: 100,
dataIndex: 'description',
},
render: DateComponent,
},
];
{
title: 'Tags (can be multiple)',
dataIndex: 'tags',
width: 80,
render: Tags,
},
{
title: 'Created At',
dataIndex: 'createdBy',
width: 80,
sorter: (a: Data, b: Data): number => {
const prev = new Date(a.createdBy).getTime();
const next = new Date(b.createdBy).getTime();
return prev - next;
},
render: Createdby,
},
{
title: 'Last Updated Time',
width: 90,
dataIndex: 'lastUpdatedTime',
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,
},
],
[],
);
if (action) {
columns.push({
@ -199,7 +202,7 @@ function ListOfAllDashboard(): JSX.Element {
setUploadedGrafana(uploadedGrafana);
};
const getMenuItems = useCallback(() => {
const getMenuItems = useMemo(() => {
const menuItems: ItemType[] = [];
if (createNewDashboard) {
menuItems.push({
@ -227,7 +230,7 @@ function ListOfAllDashboard(): JSX.Element {
const menu: MenuProps = useMemo(
() => ({
items: getMenuItems(),
items: getMenuItems,
}),
[getMenuItems],
);
@ -245,7 +248,7 @@ function ListOfAllDashboard(): JSX.Element {
}}
/>
{newDashboard && (
<Dropdown trigger={['click']} menu={menu}>
<Dropdown disabled={loading} trigger={['click']} menu={menu}>
<NewDashboardButton
icon={<PlusOutlined />}
type="primary"
@ -260,11 +263,12 @@ function ListOfAllDashboard(): JSX.Element {
</Row>
),
[
getText,
newDashboard,
newDashboardState.error,
newDashboardState.loading,
loading,
menu,
newDashboardState.loading,
newDashboardState.error,
getText,
],
);

View File

@ -34,10 +34,12 @@ function QuerySection({ updateQuery, selectedGraph }: QueryProps): JSX.Element {
CLICKHOUSE: uuid(),
PROM: uuid(),
});
const { dashboards, isLoadingQueryResult } = useSelector<
AppState,
DashboardReducer
>((state) => state.dashboards);
const [selectedDashboards] = dashboards;
const { search } = useLocation();
const { widgets } = selectedDashboards.data;

View File

@ -1,7 +1,11 @@
import { Button, Modal, Typography } from 'antd';
import { LockFilled } from '@ant-design/icons';
import { Button, Modal, Tooltip, Typography } from 'antd';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import { GRAPH_TYPES } from 'container/NewDashboard/ComponentsSlider';
import { ITEMS } from 'container/NewDashboard/ComponentsSlider/menuItems';
import { MESSAGE, useIsFeatureDisabled } from 'hooks/useFeatureFlag';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import history from 'lib/history';
import { DashboardWidgetPageParams } from 'pages/DashboardWidget';
@ -22,6 +26,7 @@ import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { FLUSH_DASHBOARD } from 'types/actions/dashboard';
import { Widgets } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import DashboardReducer from 'types/reducer/dashboards';
import { GlobalReducer } from 'types/reducer/globalTime';
@ -51,6 +56,10 @@ function NewWidget({
GlobalReducer
>((state) => state.globalTime);
const { featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const [selectedDashboard] = dashboards;
const { widgets } = selectedDashboard.data;
@ -99,22 +108,34 @@ function NewWidget({
enum: selectedWidget?.timePreferance || 'GLOBAL_TIME',
});
const { notifications } = useNotifications();
const onClickSaveHandler = useCallback(() => {
// update the global state
saveSettingOfPanel({
uuid: selectedDashboard.uuid,
description,
isStacked: stacked,
nullZeroValues: selectedNullZeroValue,
opacity,
timePreferance: selectedTime.enum,
title,
yAxisUnit,
widgetId: query.get('widgetId') || '',
dashboardId,
graphType,
});
featureResponse
.refetch()
.then(() => {
saveSettingOfPanel({
uuid: selectedDashboard.uuid,
description,
isStacked: stacked,
nullZeroValues: selectedNullZeroValue,
opacity,
timePreferance: selectedTime.enum,
title,
yAxisUnit,
widgetId: query.get('widgetId') || '',
dashboardId,
graphType,
});
})
.catch(() => {
notifications.error({
message: 'Something went wrong',
});
});
}, [
featureResponse,
saveSettingOfPanel,
selectedDashboard.uuid,
description,
@ -127,6 +148,7 @@ function NewWidget({
query,
dashboardId,
graphType,
notifications,
]);
const onClickDiscardHandler = useCallback(() => {
@ -167,13 +189,39 @@ function NewWidget({
getQueryResult();
}, [getQueryResult]);
const onSaveDashboard = useCallback((): void => {
setSaveModal(true);
}, []);
const isQueryBuilderActive = useIsFeatureDisabled(
FeatureKeys.QUERY_BUILDER_PANELS,
);
return (
<Container>
<ButtonContainer>
<Button type="primary" onClick={(): void => setSaveModal(true)}>
Save
</Button>
{/* <Button onClick={onClickApplyHandler}>Apply</Button> */}
{isQueryBuilderActive && (
<Tooltip title={MESSAGE.PANEL}>
<Button
icon={<LockFilled />}
type="primary"
disabled={isQueryBuilderActive}
onClick={onSaveDashboard}
>
Save
</Button>
</Tooltip>
)}
{!isQueryBuilderActive && (
<Button
type="primary"
disabled={isQueryBuilderActive}
onClick={onSaveDashboard}
>
Save
</Button>
)}
<Button onClick={onClickDiscardHandler}>Discard</Button>
</ButtonContainer>

View File

@ -3,8 +3,8 @@ import { PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Typography } from 'antd';
import { useForm } from 'antd/es/form/Form';
import createDomainApi from 'api/SAML/postDomain';
import { FeatureKeys } from 'constants/featureKeys';
import useFeatureFlag from 'hooks/useFeatureFlag';
import { FeatureKeys } from 'constants/features';
import useFeatureFlag from 'hooks/useFeatureFlag/useFeatureFlag';
import { useNotifications } from 'hooks/useNotifications';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -18,7 +18,7 @@ function AddDomain({ refetch }: Props): JSX.Element {
const { t } = useTranslation(['common', 'organizationsettings']);
const [isAddDomains, setIsDomain] = useState(false);
const [form] = useForm<FormProps>();
const SSOFlag = useFeatureFlag(FeatureKeys.SSO);
const isSsoFlagEnabled = useFeatureFlag(FeatureKeys.SSO);
const { org } = useSelector<AppState, AppReducer>((state) => state.app);
@ -58,7 +58,7 @@ function AddDomain({ refetch }: Props): JSX.Element {
ns: 'organizationsettings',
})}
</Typography.Title>
{SSOFlag && (
{isSsoFlagEnabled && (
<Button
onClick={(): void => setIsDomain(true)}
type="primary"

View File

@ -7,8 +7,8 @@ import updateDomain from 'api/SAML/updateDomain';
import { ResizeTable } from 'components/ResizeTable';
import TextToolTip from 'components/TextToolTip';
import { SIGNOZ_UPGRADE_PLAN_URL } from 'constants/app';
import { FeatureKeys } from 'constants/featureKeys';
import useFeatureFlag from 'hooks/useFeatureFlag';
import { FeatureKeys } from 'constants/features';
import useFeatureFlag from 'hooks/useFeatureFlag/useFeatureFlag';
import { useNotifications } from 'hooks/useNotifications';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@ -1,6 +1,6 @@
import { Divider, Space } from 'antd';
import { FeatureKeys } from 'constants/featureKeys';
import useFeatureFlag from 'hooks/useFeatureFlag';
import { FeatureKeys } from 'constants/features';
import { useIsFeatureDisabled } from 'hooks/useFeatureFlag';
import React from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@ -14,8 +14,11 @@ import PendingInvitesContainer from './PendingInvitesContainer';
function OrganizationSettings(): JSX.Element {
const { org } = useSelector<AppState, AppReducer>((state) => state.app);
const sso = useFeatureFlag(FeatureKeys.SSO);
const noUpsell = useFeatureFlag(FeatureKeys.DISABLE_UPSELL);
const isNotSSO = useIsFeatureDisabled(FeatureKeys.SSO);
const isNoUpSell = useIsFeatureDisabled(FeatureKeys.DISABLE_UPSELL);
const isAuthDomain = !isNoUpSell || (isNoUpSell && !isNotSSO);
if (!org) {
return <div />;
@ -38,7 +41,7 @@ function OrganizationSettings(): JSX.Element {
<Divider />
<Members />
<Divider />
{(!noUpsell || (noUpsell && sso)) && <AuthDomains />}
{isAuthDomain && <AuthDomains />}
</>
);
}

View File

@ -16,21 +16,6 @@ import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator';
import { useTag } from './useTag';
import { useTagValidation } from './useTagValidation';
interface IAutoComplete {
updateTag: (value: string) => void;
handleSearch: (value: string) => void;
handleClearTag: (value: string) => void;
handleSelect: (value: string) => void;
handleKeyDown: (event: React.KeyboardEvent) => void;
options: Option[];
tags: string[];
searchValue: string;
isMulti: boolean;
isFetching: boolean;
setSearchKey: (value: string) => void;
searchKey: string;
}
export const useAutoComplete = (query: IBuilderQuery): IAutoComplete => {
const [searchValue, setSearchValue] = useState<string>('');
const [searchKey, setSearchKey] = useState<string>('');
@ -134,3 +119,18 @@ export const useAutoComplete = (query: IBuilderQuery): IAutoComplete => {
searchKey,
};
};
interface IAutoComplete {
updateTag: (value: string) => void;
handleSearch: (value: string) => void;
handleClearTag: (value: string) => void;
handleSelect: (value: string) => void;
handleKeyDown: (event: React.KeyboardEvent) => void;
options: Option[];
tags: string[];
searchValue: string;
isMulti: boolean;
isFetching: boolean;
setSearchKey: (value: string) => void;
searchKey: string;
}

View File

@ -1,13 +0,0 @@
import _get from 'lodash-es/get';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import AppReducer from 'types/reducer/app';
const useFeatureFlag = (flagKey: string): boolean => {
const { featureFlags } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
return _get(featureFlags, flagKey, false);
};
export default useFeatureFlag;

View File

@ -0,0 +1,9 @@
export const MESSAGE = {
PANEL:
'You have exceeded the number of logs and traces based panels using query builder that are allowed in the community edition.',
ALERT:
'You have exceeded the number of alerts that are allowed in the community edition.',
WIDGET: 'You have reached limit of {{widget}} free widgets.',
CREATE_DASHBOARD:
'You have reached limit of creating the query builder based dashboard panels.',
};

View File

@ -0,0 +1,7 @@
import { MESSAGE } from './constant';
import useFeatureFlag from './useFeatureFlag';
import useIsFeatureDisabled from './useIsFeatureDisabled';
export default useFeatureFlag;
export { MESSAGE, useIsFeatureDisabled };

View File

@ -0,0 +1,29 @@
import { FeatureKeys } from 'constants/features';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { PayloadProps as FeatureFlagPayload } from 'types/api/features/getFeaturesFlags';
import AppReducer from 'types/reducer/app';
const useFeatureFlag = (
flagKey: keyof typeof FeatureKeys,
): FlatArray<FeatureFlagPayload, 1> | undefined => {
const { featureResponse = [] } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
if (featureResponse === null) return undefined;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const featureResponseData = featureResponse.data as FeatureFlagPayload;
const feature = featureResponseData?.find((flag) => flag.name === flagKey);
if (!feature) {
return undefined;
}
return feature;
};
export default useFeatureFlag;

View File

@ -0,0 +1,11 @@
import { FeatureKeys } from 'constants/features';
import useFeatureFlag from './useFeatureFlag';
const useIsFeatureDisabled = (props: keyof typeof FeatureKeys): boolean => {
const feature = useFeatureFlag(props);
return !feature?.active ?? false;
};
export default useIsFeatureDisabled;

View File

@ -0,0 +1,3 @@
export const LICENSE_PLAN_KEY = {
ENTERPRISE_PLAN: 'ENTERPRISE_PLAN',
};

View File

@ -0,0 +1,6 @@
import { LICENSE_PLAN_KEY } from './constant';
import useLicense from './useLicense';
export default useLicense;
export { LICENSE_PLAN_KEY };

View File

@ -0,0 +1,25 @@
import getAll from 'api/licenses/getAll';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/licenses/getAll';
import AppReducer from 'types/reducer/app';
const useLicense = (): UseLicense => {
const { user } = useSelector<AppState, AppReducer>((state) => state.app);
return useQuery({
queryFn: getAll,
queryKey: [REACT_QUERY_KEY.GET_ALL_LICENCES, user?.email],
enabled: !!user?.email,
});
};
type UseLicense = UseQueryResult<
SuccessResponse<PayloadProps> | ErrorResponse,
unknown
>;
export default useLicense;

View File

@ -9,7 +9,7 @@ import {
UPDATE_CONFIGS,
UPDATE_CURRENT_ERROR,
UPDATE_CURRENT_VERSION,
UPDATE_FEATURE_FLAGS,
UPDATE_FEATURE_FLAG_RESPONSE,
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
UPDATE_ORG,
@ -47,7 +47,10 @@ const InitialValue: InitialValueTypes = {
isSideBarCollapsed: getLocalStorageKey(IS_SIDEBAR_COLLAPSED) === 'true',
currentVersion: '',
latestVersion: '',
featureFlags: {},
featureResponse: {
data: null,
refetch: Promise.resolve,
},
isCurrentVersionError: false,
isLatestVersionError: false,
user: getInitialUser(),
@ -80,10 +83,13 @@ const appReducer = (
};
}
case UPDATE_FEATURE_FLAGS: {
case UPDATE_FEATURE_FLAG_RESPONSE: {
return {
...state,
featureFlags: { ...action.payload },
featureResponse: {
data: action.payload.featureFlag,
refetch: action.payload.refetch,
},
};
}

View File

@ -1,3 +1,4 @@
import { QueryObserverBaseResult } from 'react-query';
import { PayloadProps as FeatureFlagPayload } from 'types/api/features/getFeaturesFlags';
import {
Organization,
@ -22,9 +23,9 @@ export const UPDATE_USER_ORG_ROLE = 'UPDATE_USER_ORG_ROLE';
export const UPDATE_USER = 'UPDATE_USER';
export const UPDATE_ORG_NAME = 'UPDATE_ORG_NAME';
export const UPDATE_ORG = 'UPDATE_ORG';
export const UPDATE_FEATURE_FLAGS = 'UPDATE_FEATURE_FLAGS';
export const UPDATE_CONFIGS = 'UPDATE_CONFIGS';
export const UPDATE_USER_FLAG = 'UPDATE_USER_FLAG';
export const UPDATE_FEATURE_FLAG_RESPONSE = 'UPDATE_FEATURE_FLAG_RESPONSE';
export interface LoggedInUser {
type: typeof LOGGED_IN;
@ -38,10 +39,6 @@ export interface SideBarCollapse {
payload: boolean;
}
export interface UpdateFeatureFlags {
type: typeof UPDATE_FEATURE_FLAGS;
payload: null | FeatureFlagPayload;
}
export interface UpdateAppVersion {
type: typeof UPDATE_CURRENT_VERSION;
payload: {
@ -130,6 +127,14 @@ export interface UpdateConfigs {
};
}
export interface UpdateFeatureFlag {
type: typeof UPDATE_FEATURE_FLAG_RESPONSE;
payload: {
featureFlag: FeatureFlagPayload;
refetch: QueryObserverBaseResult['refetch'];
};
}
export type AppAction =
| LoggedInUser
| SideBarCollapse
@ -142,6 +147,6 @@ export type AppAction =
| UpdateUser
| UpdateOrgName
| UpdateOrg
| UpdateFeatureFlags
| UpdateConfigs
| UpdateUserFlag;
| UpdateUserFlag
| UpdateFeatureFlag;

View File

@ -1,3 +0,0 @@
export interface PayloadProps {
[key: string]: boolean;
}

View File

@ -1,3 +1,19 @@
export interface PayloadProps {
[key: string]: boolean;
export type FeaturesFlag =
| 'DurationSort'
| 'TimestampSort'
| 'SMART_TRACE_DETAIL'
| 'CUSTOM_METRICS_FUNCTION'
| 'QUERY_BUILDER_PANELS'
| 'QUERY_BUILDER_ALERTS'
| 'DISABLE_UPSELL'
| 'SSO';
interface FeatureFlagProps {
name: FeaturesFlag;
active: boolean;
usage: number;
usage_limit: number;
route: string;
}
export type PayloadProps = FeatureFlagProps[];

View File

@ -1,3 +1,4 @@
import { QueryObserverBaseResult } from 'react-query';
import { PayloadProps as ConfigPayload } from 'types/api/dynamicConfigs/getDynamicConfigs';
import { PayloadProps as FeatureFlagPayload } from 'types/api/features/getFeaturesFlags';
import { PayloadProps as OrgPayload } from 'types/api/user/getOrganization';
@ -26,9 +27,12 @@ export default interface AppReducer {
isUserFetchingError: boolean;
role: ROLES | null;
org: OrgPayload | null;
featureFlags: null | FeatureFlagPayload;
configs: ConfigPayload;
userFlags: null | UserFlags;
ee: 'Y' | 'N';
setupCompleted: boolean;
featureResponse: {
data: FeatureFlagPayload | null;
refetch: QueryObserverBaseResult['refetch'];
};
}