feat: show failed payment banner and block the workspace integration (#6560)

* feat: added new API endpoint for fetching the active license

* feat: add setup for apis on frontend

* feat: frontend infrastructure changes for app context and workspace suspended

* feat: added workspace suspended component

* feat: send back to application if workspace is not suspended

* feat: added the missing creative

* chore: only move to suspended state when state is payment_failed

* chore: address review comments

* fix: tab naming
This commit is contained in:
Vikrant Gupta 2024-12-02 19:58:38 +05:30 committed by GitHub
parent 507c0600cd
commit 6384b25af3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 708 additions and 49 deletions

View File

@ -183,17 +183,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *baseapp.AuthMiddlew
Methods(http.MethodGet) Methods(http.MethodGet)
// v3 // v3
router.HandleFunc("/api/v3/licenses", router.HandleFunc("/api/v3/licenses", am.ViewAccess(ah.listLicensesV3)).Methods(http.MethodGet)
am.ViewAccess(ah.listLicensesV3)). router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.applyLicenseV3)).Methods(http.MethodPost)
Methods(http.MethodGet) router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.refreshLicensesV3)).Methods(http.MethodPut)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.getActiveLicenseV3)).Methods(http.MethodGet)
router.HandleFunc("/api/v3/licenses",
am.AdminAccess(ah.applyLicenseV3)).
Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses",
am.AdminAccess(ah.refreshLicensesV3)).
Methods(http.MethodPut)
// v4 // v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost) router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

View File

@ -122,6 +122,23 @@ func (ah *APIHandler) listLicensesV3(w http.ResponseWriter, r *http.Request) {
ah.Respond(w, convertLicenseV3ToListLicenseResponse(licenses)) ah.Respond(w, convertLicenseV3ToListLicenseResponse(licenses))
} }
func (ah *APIHandler) getActiveLicenseV3(w http.ResponseWriter, r *http.Request) {
activeLicense, err := ah.LM().GetRepo().GetActiveLicenseV3(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
// return 404 not found if there is no active license
if activeLicense == nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no active license found")}, nil)
return
}
// TODO deprecate this when we move away from key for stripe
activeLicense.Data["key"] = activeLicense.Key
render.Success(w, http.StatusOK, activeLicense.Data)
}
// this function is called by zeus when inserting licenses in the query-service // this function is called by zeus when inserting licenses in the query-service
func (ah *APIHandler) applyLicenseV3(w http.ResponseWriter, r *http.Request) { func (ah *APIHandler) applyLicenseV3(w http.ResponseWriter, r *http.Request) {
var licenseKey ApplyLicenseRequest var licenseKey ApplyLicenseRequest

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 408 KiB

View File

@ -0,0 +1,12 @@
{
"workspaceSuspended": "Your workspace is locked",
"gotQuestions": "Got Questions?",
"contactUs": "Contact Us",
"actionHeader": "Pay to continue",
"actionDescription": "Pay now to keep enjoying all the great features youve been using.",
"yourDataIsSafe": "Your data is safe with us until",
"actNow": "Act now to avoid any disruptions and continue where you left off.",
"contactAdmin": "Contact your admin to proceed with the upgrade.",
"continueMyJourney": "Settle your bill to continue",
"somethingWentWrong": "Something went wrong"
}

View File

@ -37,6 +37,7 @@
"PASSWORD_RESET": "SigNoz | Password Reset", "PASSWORD_RESET": "SigNoz | Password Reset",
"LIST_LICENSES": "SigNoz | List of Licenses", "LIST_LICENSES": "SigNoz | List of Licenses",
"WORKSPACE_LOCKED": "SigNoz | Workspace Locked", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked",
"WORKSPACE_SUSPENDED": "SigNoz | Workspace Suspended",
"SUPPORT": "SigNoz | Support", "SUPPORT": "SigNoz | Support",
"DEFAULT": "Open source Observability Platform | SigNoz", "DEFAULT": "Open source Observability Platform | SigNoz",
"ALERT_HISTORY": "SigNoz | Alert Rule History", "ALERT_HISTORY": "SigNoz | Alert Rule History",

View File

@ -0,0 +1,12 @@
{
"workspaceSuspended": "Your workspace is locked",
"gotQuestions": "Got Questions?",
"contactUs": "Contact Us",
"actionHeader": "Pay to continue",
"actionDescription": "Pay now to keep enjoying all the great features youve been using.",
"yourDataIsSafe": "Your data is safe with us until",
"actNow": "Act now to avoid any disruptions and continue where you left off.",
"contactAdmin": "Contact your admin to proceed with the upgrade.",
"continueMyJourney": "Settle your bill to continue",
"somethingWentWrong": "Something went wrong"
}

View File

@ -45,6 +45,7 @@
"PASSWORD_RESET": "SigNoz | Password Reset", "PASSWORD_RESET": "SigNoz | Password Reset",
"LIST_LICENSES": "SigNoz | List of Licenses", "LIST_LICENSES": "SigNoz | List of Licenses",
"WORKSPACE_LOCKED": "SigNoz | Workspace Locked", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked",
"WORKSPACE_SUSPENDED": "SigNoz | Workspace Suspended",
"SUPPORT": "SigNoz | Support", "SUPPORT": "SigNoz | Support",
"LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views", "LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views",
"TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views", "TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views",

View File

@ -10,6 +10,7 @@ import useLicense from 'hooks/useLicense';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history'; import history from 'lib/history';
import { isEmpty, isNull } from 'lodash-es'; import { isEmpty, isNull } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { ReactChild, useEffect, useMemo, useState } from 'react'; import { ReactChild, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
@ -20,6 +21,7 @@ import { AppState } from 'store/reducers';
import { getInitialUserTokenRefreshToken } from 'store/utils'; import { getInitialUserTokenRefreshToken } from 'store/utils';
import AppActions from 'types/actions'; import AppActions from 'types/actions';
import { UPDATE_USER_IS_FETCH } from 'types/actions/app'; import { UPDATE_USER_IS_FETCH } from 'types/actions/app';
import { LicenseState, LicenseStatus } from 'types/api/licensesV3/getActive';
import { Organization } from 'types/api/user/getOrganization'; import { Organization } from 'types/api/user/getOrganization';
import AppReducer from 'types/reducer/app'; import AppReducer from 'types/reducer/app';
import { isCloudUser } from 'utils/app'; import { isCloudUser } from 'utils/app';
@ -49,6 +51,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
isFetchingOrgPreferences, isFetchingOrgPreferences,
} = useSelector<AppState, AppReducer>((state) => state.app); } = useSelector<AppState, AppReducer>((state) => state.app);
const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext();
const mapRoutes = useMemo( const mapRoutes = useMemo(
() => () =>
new Map( new Map(
@ -249,6 +253,33 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
} }
}, [isFetchingLicensesData]); }, [isFetchingLicensesData]);
const navigateToWorkSpaceSuspended = (route: any): void => {
const { path } = route;
if (path && path !== ROUTES.WORKSPACE_SUSPENDED) {
history.push(ROUTES.WORKSPACE_SUSPENDED);
dispatch({
type: UPDATE_USER_IS_FETCH,
payload: {
isUserFetching: false,
},
});
}
};
useEffect(() => {
if (!isFetchingActiveLicenseV3 && activeLicenseV3) {
const shouldSuspendWorkspace =
activeLicenseV3.status === LicenseStatus.SUSPENDED &&
activeLicenseV3.state === LicenseState.PAYMENT_FAILED;
if (shouldSuspendWorkspace) {
navigateToWorkSpaceSuspended(currentRoute);
}
}
}, [isFetchingActiveLicenseV3, activeLicenseV3]);
useEffect(() => { useEffect(() => {
if (org && org.length > 0 && org[0].id !== undefined) { if (org && org.length > 0 && org[0].id !== undefined) {
setOrgData(org[0]); setOrgData(org[0]);

View File

@ -22,6 +22,7 @@ import history from 'lib/history';
import { identity, pick, pickBy } from 'lodash-es'; import { identity, pick, pickBy } from 'lodash-es';
import posthog from 'posthog-js'; import posthog from 'posthog-js';
import AlertRuleProvider from 'providers/Alert'; import AlertRuleProvider from 'providers/Alert';
import { AppProvider } from 'providers/App/App';
import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
@ -291,42 +292,44 @@ function App(): JSX.Element {
}, []); }, []);
return ( return (
<ConfigProvider theme={themeConfig}> <AppProvider>
<Router history={history}> <ConfigProvider theme={themeConfig}>
<CompatRouter> <Router history={history}>
<NotificationProvider> <CompatRouter>
<PrivateRoute> <NotificationProvider>
<ResourceProvider> <PrivateRoute>
<QueryBuilderProvider> <ResourceProvider>
<DashboardProvider> <QueryBuilderProvider>
<KeyboardHotkeysProvider> <DashboardProvider>
<AlertRuleProvider> <KeyboardHotkeysProvider>
<AppLayout> <AlertRuleProvider>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}> <AppLayout>
<Switch> <Suspense fallback={<Spinner size="large" tip="Loading..." />}>
{routes.map(({ path, component, exact }) => ( <Switch>
<Route {routes.map(({ path, component, exact }) => (
key={`${path}`} <Route
exact={exact} key={`${path}`}
path={path} exact={exact}
component={component} path={path}
/> component={component}
))} />
))}
<Route path="*" component={NotFound} /> <Route path="*" component={NotFound} />
</Switch> </Switch>
</Suspense> </Suspense>
</AppLayout> </AppLayout>
</AlertRuleProvider> </AlertRuleProvider>
</KeyboardHotkeysProvider> </KeyboardHotkeysProvider>
</DashboardProvider> </DashboardProvider>
</QueryBuilderProvider> </QueryBuilderProvider>
</ResourceProvider> </ResourceProvider>
</PrivateRoute> </PrivateRoute>
</NotificationProvider> </NotificationProvider>
</CompatRouter> </CompatRouter>
</Router> </Router>
</ConfigProvider> </ConfigProvider>
</AppProvider>
); );
} }

View File

@ -206,6 +206,13 @@ export const WorkspaceBlocked = Loadable(
import(/* webpackChunkName: "WorkspaceLocked" */ 'pages/WorkspaceLocked'), import(/* webpackChunkName: "WorkspaceLocked" */ 'pages/WorkspaceLocked'),
); );
export const WorkspaceSuspended = Loadable(
() =>
import(
/* webpackChunkName: "WorkspaceSuspended" */ 'pages/WorkspaceSuspended/WorkspaceSuspended'
),
);
export const ShortcutsPage = Loadable( export const ShortcutsPage = Loadable(
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Shortcuts'), () => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Shortcuts'),
); );

View File

@ -53,6 +53,7 @@ import {
UnAuthorized, UnAuthorized,
UsageExplorerPage, UsageExplorerPage,
WorkspaceBlocked, WorkspaceBlocked,
WorkspaceSuspended,
} from './pageComponents'; } from './pageComponents';
const routes: AppRoutes[] = [ const routes: AppRoutes[] = [
@ -364,6 +365,13 @@ const routes: AppRoutes[] = [
isPrivate: true, isPrivate: true,
key: 'WORKSPACE_LOCKED', key: 'WORKSPACE_LOCKED',
}, },
{
path: ROUTES.WORKSPACE_SUSPENDED,
exact: true,
component: WorkspaceSuspended,
isPrivate: true,
key: 'WORKSPACE_SUSPENDED',
},
{ {
path: ROUTES.SHORTCUTS, path: ROUTES.SHORTCUTS,
exact: true, exact: true,

View File

@ -0,0 +1,18 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { LicenseV3EventQueueResModel } from 'types/api/licensesV3/getActive';
const getActive = async (): Promise<
SuccessResponse<LicenseV3EventQueueResModel> | ErrorResponse
> => {
const response = await axios.get('/licenses/active');
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getActive;

View File

@ -20,4 +20,5 @@ export const REACT_QUERY_KEY = {
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE', DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST', GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE', UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
}; };

View File

@ -55,6 +55,7 @@ const ROUTES = {
LOGS_SAVE_VIEWS: '/logs/saved-views', LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views', TRACES_SAVE_VIEWS: '/traces/saved-views',
WORKSPACE_LOCKED: '/workspace-locked', WORKSPACE_LOCKED: '/workspace-locked',
WORKSPACE_SUSPENDED: '/workspace-suspended',
SHORTCUTS: '/shortcuts', SHORTCUTS: '/shortcuts',
INTEGRATIONS: '/integrations', INTEGRATIONS: '/integrations',
MESSAGING_QUEUES: '/messaging-queues', MESSAGING_QUEUES: '/messaging-queues',

View File

@ -108,6 +108,13 @@
text-align: center; text-align: center;
} }
.payment-failed-banner {
padding: 8px;
background-color: var(--bg-sakura-500);
color: white;
text-align: center;
}
.upgrade-link { .upgrade-link {
padding: 0px; padding: 0px;
padding-right: 4px; padding-right: 4px;

View File

@ -5,25 +5,30 @@ import './AppLayout.styles.scss';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { Flex } from 'antd'; import { Flex } from 'antd';
import manageCreditCardApi from 'api/billing/manage';
import getUserLatestVersion from 'api/user/getLatestVersion'; import getUserLatestVersion from 'api/user/getLatestVersion';
import getUserVersion from 'api/user/getVersion'; import getUserVersion from 'api/user/getVersion';
import cx from 'classnames'; import cx from 'classnames';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway'; import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { FeatureKeys } from 'constants/features'; import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import SideNav from 'container/SideNav'; import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav'; import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import useFeatureFlags from 'hooks/useFeatureFlag'; import useFeatureFlags from 'hooks/useFeatureFlag';
import useLicense from 'hooks/useLicense'; import useLicense from 'hooks/useLicense';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history'; import history from 'lib/history';
import { isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useAppContext } from 'providers/App/App';
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQueries } from 'react-query'; import { useMutation, useQueries } from 'react-query';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
@ -35,9 +40,16 @@ import {
UPDATE_LATEST_VERSION, UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR, UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app'; } from 'types/actions/app';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import AppReducer from 'types/reducer/app'; import AppReducer from 'types/reducer/app';
import { isCloudUser } from 'utils/app'; import { isCloudUser } from 'utils/app';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils'; import {
getFormattedDate,
getFormattedDateWithMinutes,
getRemainingDays,
} from 'utils/timeUtils';
import { ChildrenContainer, Layout, LayoutContent } from './styles'; import { ChildrenContainer, Layout, LayoutContent } from './styles';
import { getRouteKey } from './utils'; import { getRouteKey } from './utils';
@ -48,8 +60,42 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
(state) => state.app, (state) => state.app,
); );
const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext();
const { notifications } = useNotifications(); const { notifications } = useNotifications();
const [
showPaymentFailedWarning,
setShowPaymentFailedWarning,
] = useState<boolean>(false);
const handleBillingOnSuccess = (
data: ErrorResponse | SuccessResponse<CheckoutSuccessPayloadProps, unknown>,
): void => {
if (data?.payload?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.payload.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (): void => {
notifications.error({
message: SOMETHING_WENT_WRONG,
});
};
const {
mutate: manageCreditCard,
isLoading: isLoadingManageBilling,
} = useMutation(manageCreditCardApi, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
onError: handleBillingOnError,
});
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { data: licenseData, isFetching } = useLicense(); const { data: licenseData, isFetching } = useLicense();
@ -212,6 +258,16 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
} }
}, [licenseData, isFetching]); }, [licenseData, isFetching]);
useEffect(() => {
if (
!isFetchingActiveLicenseV3 &&
!isNull(activeLicenseV3) &&
activeLicenseV3?.event_queue?.event === LicenseEvent.FAILED_PAYMENT
) {
setShowPaymentFailedWarning(true);
}
}, [activeLicenseV3, isFetchingActiveLicenseV3]);
useEffect(() => { useEffect(() => {
// after logging out hide the trial expiry banner // after logging out hide the trial expiry banner
if (!isLoggedIn) { if (!isLoggedIn) {
@ -225,6 +281,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
} }
}; };
const handleFailedPayment = (): void => {
manageCreditCard({
licenseKey: activeLicenseV3?.key || '',
successURL: window.location.href,
cancelURL: window.location.href,
});
};
const isLogsView = (): boolean => const isLogsView = (): boolean =>
routeKey === 'LOGS' || routeKey === 'LOGS' ||
routeKey === 'LOGS_EXPLORER' || routeKey === 'LOGS_EXPLORER' ||
@ -269,7 +333,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<title>{pageTitle}</title> <title>{pageTitle}</title>
</Helmet> </Helmet>
{showTrialExpiryBanner && ( {showTrialExpiryBanner && !showPaymentFailedWarning && (
<div className="trial-expiry-banner"> <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> <span>
@ -289,6 +353,36 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)} )}
</div> </div>
)} )}
{!showTrialExpiryBanner && showPaymentFailedWarning && (
<div className="payment-failed-banner">
Your bill payment has failed. Your workspace will get suspended on{' '}
<span>
{getFormattedDateWithMinutes(
dayjs(activeLicenseV3?.event_queue?.scheduled_at).unix() || Date.now(),
)}
.
</span>
{role === 'ADMIN' ? (
<span>
{' '}
Please{' '}
<a
className="upgrade-link"
onClick={(): void => {
if (!isLoadingManageBilling) {
handleFailedPayment();
}
}}
>
pay the bill
</a>
to continue using SigNoz features.
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</div>
)}
<Flex className={cx('app-layout', isDarkMode ? 'darkMode' : 'lightMode')}> <Flex className={cx('app-layout', isDarkMode ? 'darkMode' : 'lightMode')}>
{isToDisplayLayout && !renderFullScreen && ( {isToDisplayLayout && !renderFullScreen && (

View File

@ -27,6 +27,7 @@ const breadcrumbNameMap: Record<string, string> = {
[ROUTES.BILLING]: 'Billing', [ROUTES.BILLING]: 'Billing',
[ROUTES.SUPPORT]: 'Support', [ROUTES.SUPPORT]: 'Support',
[ROUTES.WORKSPACE_LOCKED]: 'Workspace Locked', [ROUTES.WORKSPACE_LOCKED]: 'Workspace Locked',
[ROUTES.WORKSPACE_SUSPENDED]: 'Workspace Suspended',
[ROUTES.MESSAGING_QUEUES]: 'Messaging Queues', [ROUTES.MESSAGING_QUEUES]: 'Messaging Queues',
}; };

View File

@ -122,6 +122,7 @@ export const routesToSkip = [
ROUTES.BILLING, ROUTES.BILLING,
ROUTES.SUPPORT, ROUTES.SUPPORT,
ROUTES.WORKSPACE_LOCKED, ROUTES.WORKSPACE_LOCKED,
ROUTES.WORKSPACE_SUSPENDED,
ROUTES.LOGS, ROUTES.LOGS,
ROUTES.MY_SETTINGS, ROUTES.MY_SETTINGS,
ROUTES.LIST_LICENSES, ROUTES.LIST_LICENSES,

View File

@ -196,6 +196,7 @@ export const routesToSkip = [
ROUTES.BILLING, ROUTES.BILLING,
ROUTES.SUPPORT, ROUTES.SUPPORT,
ROUTES.WORKSPACE_LOCKED, ROUTES.WORKSPACE_LOCKED,
ROUTES.WORKSPACE_SUSPENDED,
ROUTES.LOGS, ROUTES.LOGS,
ROUTES.MY_SETTINGS, ROUTES.MY_SETTINGS,
ROUTES.LIST_LICENSES, ROUTES.LIST_LICENSES,

View File

@ -0,0 +1,25 @@
import getActive from 'api/licensesV3/getActive';
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 { LicenseV3ResModel } from 'types/api/licensesV3/getActive';
import AppReducer from 'types/reducer/app';
const useActiveLicenseV3 = (): UseLicense => {
const { user } = useSelector<AppState, AppReducer>((state) => state.app);
return useQuery({
queryFn: getActive,
queryKey: [REACT_QUERY_KEY.GET_ACTIVE_LICENSE_V3, user?.email],
enabled: !!user?.email,
});
};
type UseLicense = UseQueryResult<
SuccessResponse<LicenseV3ResModel> | ErrorResponse,
unknown
>;
export default useActiveLicenseV3;

View File

@ -0,0 +1,162 @@
$light-theme: 'lightMode';
$dark-theme: 'darkMode';
@keyframes gradientFlow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.workspace-suspended {
&__modal {
.ant-modal-mask {
backdrop-filter: blur(2px);
}
}
&__tabs {
margin-top: 148px;
.ant-tabs {
&-nav {
&::before {
border-color: var(--bg-slate-500);
.#{$light-theme} & {
border-color: var(--bg-vanilla-300);
}
}
}
&-nav-wrap {
justify-content: center;
}
}
}
&__modal {
&__header {
display: flex;
justify-content: space-between;
align-items: center;
&__actions {
display: flex;
align-items: center;
gap: 16px;
}
}
.ant-modal-content {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.#{$light-theme} & {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}
.ant-modal-header {
background: transparent;
}
.ant-list {
&-item {
border-color: var(--bg-slate-500);
.#{$light-theme} & {
border-color: var(--bg-vanilla-300);
}
&-meta {
align-items: center !important;
&-title {
margin-bottom: 0 !important;
}
&-avatar {
display: flex;
}
}
}
}
&__title {
font-weight: 400;
color: var(--text-vanilla-400);
.#{$light-theme} & {
color: var(--text-ink-200);
}
}
&__cta {
margin-top: 54px;
}
}
&__container {
padding-top: 64px;
}
&__details {
width: 80%;
margin: 0 auto;
color: var(--text-vanilla-400, #c0c1c3);
text-align: center;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 150% */
.#{$light-theme} & {
color: var(--text-ink-200);
}
&__highlight {
color: var(--text-vanilla-100, #fff);
font-style: normal;
font-weight: 700;
line-height: 24px;
.#{$light-theme} & {
color: var(--text-ink-100);
}
}
}
&__title {
background: linear-gradient(
99deg,
#ead8fd 0%,
#7a97fa 33%,
#fd5ab2 66%,
#ead8fd 100%
);
background-size: 300% 300%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradientFlow 24s ease infinite;
margin-bottom: 18px;
}
&__creative {
display: flex;
justify-content: center;
align-items: center;
margin-top: 54px;
img {
width: -webkit-fill-available;
}
}
}

View File

@ -0,0 +1,179 @@
import './WorkspaceSuspended.styles.scss';
import {
Alert,
Button,
Col,
Modal,
Row,
Skeleton,
Space,
Typography,
} from 'antd';
import manageCreditCardApi from 'api/billing/manage';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { LicenseState, LicenseStatus } from 'types/api/licensesV3/getActive';
import AppReducer from 'types/reducer/app';
import { getFormattedDateWithMinutes } from 'utils/timeUtils';
function WorkspaceSuspended(): JSX.Element {
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
const isAdmin = role === 'ADMIN';
const { notifications } = useNotifications();
const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext();
const { t } = useTranslation(['failedPayment']);
const { mutate: manageCreditCard, isLoading } = useMutation(
manageCreditCardApi,
{
onSuccess: (data) => {
if (data.payload?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.payload.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
},
onError: () =>
notifications.error({
message: t('somethingWentWrong'),
}),
},
);
const handleUpdateCreditCard = useCallback(async () => {
manageCreditCard({
licenseKey: activeLicenseV3?.key || '',
successURL: window.location.origin,
cancelURL: window.location.origin,
});
}, [activeLicenseV3?.key, manageCreditCard]);
useEffect(() => {
if (!isFetchingActiveLicenseV3 && activeLicenseV3) {
const shouldSuspendWorkspace =
activeLicenseV3.status === LicenseStatus.SUSPENDED &&
activeLicenseV3.state === LicenseState.PAYMENT_FAILED;
if (!shouldSuspendWorkspace) {
history.push(ROUTES.APPLICATION);
}
}
}, [isFetchingActiveLicenseV3, activeLicenseV3]);
return (
<div>
<Modal
rootClassName="workspace-suspended__modal"
title={
<div className="workspace-suspended__modal__header">
<span className="workspace-suspended__modal__title">
{t('workspaceSuspended')}
</span>
<span className="workspace-suspended__modal__header__actions">
<Typography.Text className="workspace-suspended__modal__title">
Got Questions?
</Typography.Text>
<Button
type="default"
shape="round"
size="middle"
href="mailto:cloud-support@signoz.io"
role="button"
>
Contact Us
</Button>
</span>
</div>
}
open
closable={false}
footer={null}
width="65%"
>
<div className="workspace-suspended__container">
{isFetchingActiveLicenseV3 || !activeLicenseV3 ? (
<Skeleton />
) : (
<>
<Row justify="center" align="middle">
<Col>
<Space direction="vertical" align="center">
<Typography.Title level={2}>
<div className="workspace-suspended__title">{t('actionHeader')}</div>
</Typography.Title>
<Typography.Paragraph className="workspace-suspended__details">
{t('actionDescription')}
<br />
{t('yourDataIsSafe')}{' '}
<span className="workspace-suspended__details__highlight">
{getFormattedDateWithMinutes(
dayjs(activeLicenseV3?.event_queue?.scheduled_at).unix() ||
Date.now(),
)}
</span>{' '}
{t('actNow')}
</Typography.Paragraph>
</Space>
</Col>
</Row>
{!isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[16, 16]}
>
<Col>
<Alert
message="Contact your admin to proceed with the upgrade."
type="info"
/>
</Col>
</Row>
)}
{isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[16, 16]}
>
<Col>
<Button
type="primary"
shape="round"
size="middle"
loading={isLoading}
onClick={handleUpdateCreditCard}
>
{t('continueMyJourney')}
</Button>
</Col>
</Row>
)}
<div className="workspace-suspended__creative">
<img
src="/Images/feature-graphic-correlation.svg"
alt="correlation-graphic"
/>
</div>
</>
)}
</div>
</Modal>
</div>
);
}
export default WorkspaceSuspended;

View File

@ -0,0 +1,48 @@
import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3';
import { defaultTo } from 'lodash-es';
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { LicenseV3ResModel } from 'types/api/licensesV3/getActive';
interface IAppContext {
activeLicenseV3: LicenseV3ResModel | null;
isFetchingActiveLicenseV3: boolean;
}
const AppContext = createContext<IAppContext | undefined>(undefined);
export function AppProvider({ children }: PropsWithChildren): JSX.Element {
const [activeLicenseV3, setActiveLicenseV3] = useState<LicenseV3ResModel>();
const { data, isFetching } = useActiveLicenseV3();
useEffect(() => {
if (!isFetching && data?.payload) {
setActiveLicenseV3(data.payload);
}
}, [data, isFetching]);
const value: IAppContext = useMemo(
() => ({
activeLicenseV3: defaultTo(activeLicenseV3, null),
isFetchingActiveLicenseV3: isFetching,
}),
[activeLicenseV3, isFetching],
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export const useAppContext = (): IAppContext => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
};

View File

@ -0,0 +1,26 @@
export enum LicenseEvent {
FAILED_PAYMENT = 'FAILED_PAYMENT',
}
export enum LicenseStatus {
SUSPENDED = 'SUSPENDED',
}
export enum LicenseState {
PAYMENT_FAILED = 'PAYMENT_FAILED',
}
export type LicenseV3EventQueueResModel = {
event: LicenseEvent;
status: string;
scheduled_at: string;
created_at: string;
updated_at: string;
};
export type LicenseV3ResModel = {
key: string;
status: LicenseStatus;
state: LicenseState;
event_queue: LicenseV3EventQueueResModel;
};

View File

@ -93,6 +93,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_AWS_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'], GET_STARTED_AWS_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'], GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'], WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN', 'EDITOR', 'VIEWER'], BILLING: ['ADMIN', 'EDITOR', 'VIEWER'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'], SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'], SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@ -19,6 +19,14 @@ export const getFormattedDate = (epochTimestamp: number): string => {
return date.format('DD MMM YYYY'); return date.format('DD MMM YYYY');
}; };
export const getFormattedDateWithMinutes = (epochTimestamp: number): string => {
// Convert epoch timestamp to a date
const date = dayjs.unix(epochTimestamp);
// Format the date as "18 Nov 2013"
return date.format('DD MMM YYYY HH:mm');
};
export const getRemainingDays = (billingEndDate: number): number => { export const getRemainingDays = (billingEndDate: number): number => {
// Convert Epoch timestamps to Date objects // Convert Epoch timestamps to Date objects
const startDate = new Date(); // Convert seconds to milliseconds const startDate = new Date(); // Convert seconds to milliseconds