Compare commits

...

10 Commits

Author SHA1 Message Date
SagarRajput-7
e12651fee2 Merge branch 'main' into cancel-subscription-button 2026-04-28 01:08:49 +05:30
SagarRajput-7
feda734a6a feat(billing-page): added test cases 2026-04-28 01:07:43 +05:30
Prakhar Dewan
d1c9864f52 chore: remove unused files (#11033)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove unused files

* chore: add knip.json file
2026-04-27 19:23:12 +00:00
SagarRajput-7
bee9813387 feat(billing-page): added license condition and log event 2026-04-28 00:37:42 +05:30
Manika Malhotra
f80b650390 chore(onboarding): add open-source tooling to interest in signoz option (#11083) 2026-04-27 14:55:09 +00:00
SagarRajput-7
38e95f2897 feat(billing-page): used css module 2026-04-27 19:20:26 +05:30
SagarRajput-7
37e793ac56 feat(billing-page): added test cases 2026-04-27 18:45:02 +05:30
SagarRajput-7
cc62223b47 feat(billing-page): semantic token correction 2026-04-27 18:32:53 +05:30
SagarRajput-7
0c680afa65 Merge branch 'main' into cancel-subscription-button 2026-04-27 18:06:21 +05:30
SagarRajput-7
bb04a3794f feat(billing-page): added cancel subscription option in billing page 2026-04-27 18:04:42 +05:30
25 changed files with 337 additions and 324 deletions

5
frontend/knip.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"project": ["src/**/*.ts", "src/**/*.tsx"],
"ignore": ["src/api/generated/**/*.ts"]
}

View File

@@ -105,7 +105,7 @@ function createMockLicense(
status: '',
updated_at: '0',
},
state: LicenseState.ACTIVE,
state: LicenseState.ACTIVATED,
status: LicenseStatus.VALID,
platform: LicensePlatform.CLOUD,
created_at: '0',
@@ -931,7 +931,7 @@ describe('PrivateRoute', () => {
isFetchingActiveLicense: false,
activeLicense: createMockLicense({
platform: LicensePlatform.CLOUD,
state: LicenseState.ACTIVE,
state: LicenseState.ACTIVATED,
}),
},
isCloudUser: true,
@@ -1522,7 +1522,7 @@ describe('PrivateRoute', () => {
isFetchingActiveLicense: false,
activeLicense: createMockLicense({
platform: LicensePlatform.CLOUD,
state: LicenseState.ACTIVE,
state: LicenseState.ACTIVATED,
}),
trialInfo: createMockTrialInfo({ workSpaceBlock: false }),
user: createMockUser({ role: USER_ROLES.ADMIN as ROLES }),

View File

@@ -4,7 +4,13 @@ import {
notOfTrailResponse,
trialConvertedToSubscriptionResponse,
} from 'mocks-server/__mockdata__/licenses';
import { act, render, screen } from 'tests/test-utils';
import { act, render, screen, getAppContextMock } from 'tests/test-utils';
import APIError from 'types/api/error';
import {
LicensePlatform,
LicenseResModel,
LicenseState,
} from 'types/api/licensesV3/getActive';
import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
@@ -20,7 +26,7 @@ window.ResizeObserver =
describe('BillingContainer', () => {
jest.setTimeout(30000);
test('Component should render', async () => {
it('Component should render', async () => {
render(<BillingContainer />);
const dataInjection = screen.getByRole('columnheader', {
@@ -61,7 +67,7 @@ describe('BillingContainer', () => {
jest.useRealTimers();
});
test('OnTrail', async () => {
it('OnTrail', async () => {
// Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining"
render(
@@ -73,17 +79,19 @@ describe('BillingContainer', () => {
// If the component schedules any setTimeout on mount, flush them:
jest.runOnlyPendingTimers();
expect(await screen.findByText('Free Trial')).toBeInTheDocument();
expect(await screen.findByText('billing')).toBeInTheDocument();
expect(await screen.findByText(/\$0/i)).toBeInTheDocument();
await expect(screen.findByText('Free Trial')).resolves.toBeInTheDocument();
await expect(screen.findByText('billing')).resolves.toBeInTheDocument();
await expect(screen.findByText(/\$0/i)).resolves.toBeInTheDocument();
expect(
await screen.findByText(
await expect(
screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
),
).toBeInTheDocument();
).resolves.toBeInTheDocument();
expect(await screen.findByText(/1 days_remaining/i)).toBeInTheDocument();
await expect(
screen.findByText(/1 days_remaining/i),
).resolves.toBeInTheDocument();
const upgradeButtons = await screen.findAllByRole('button', {
name: /upgrade_plan/i,
@@ -91,13 +99,19 @@ describe('BillingContainer', () => {
expect(upgradeButtons).toHaveLength(2);
expect(upgradeButtons[1]).toBeInTheDocument();
expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument();
expect(
await screen.findByRole('link', { name: /here/i }),
).toBeInTheDocument();
await expect(
screen.findByText(/checkout_plans/i),
).resolves.toBeInTheDocument();
await expect(
screen.findByRole('link', { name: /here/i }),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
test('OnTrail but trialConvertedToSubscription', async () => {
it('OnTrail but trialConvertedToSubscription', async () => {
await act(async () => {
render(
<BillingContainer />,
@@ -134,10 +148,89 @@ describe('BillingContainer', () => {
const dayRemainingInBillingPeriod =
await screen.findByText(/1 days_remaining/i);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
});
test('Not on ontrail', async () => {
describe('CancelSubscriptionBanner visibility', () => {
const baseActiveLicense = getAppContextMock('ADMIN')
.activeLicense as LicenseResModel;
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
render(<BillingContainer />);
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
it.each([
['EXPIRED', LicenseState.EXPIRED],
['TERMINATED', LicenseState.TERMINATED],
['CANCELLED', LicenseState.CANCELLED],
['EVALUATION_EXPIRED', LicenseState.EVALUATION_EXPIRED],
['DEFAULTED', LicenseState.DEFAULTED],
['ISSUED', LicenseState.ISSUED],
['EVALUATING', LicenseState.EVALUATING],
])('should not render when license state is %s', async (_, state) => {
render(
<BillingContainer />,
{},
{
appContextOverrides: {
activeLicense: { ...baseActiveLicense, state },
},
},
);
await screen.findByText('billing');
expect(
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
const makeAPIError = (statusCode: number): APIError =>
new APIError({
httpStatusCode: statusCode as any,
error: { code: 'error', message: 'error', url: '', errors: [] },
});
it.each([
[
'Self-Hosted platform',
{
activeLicense: {
...baseActiveLicense,
platform: LicensePlatform.SELF_HOSTED,
},
activeLicenseFetchError: null,
},
],
[
'Community Enterprise user (license API 404)',
{
activeLicense: null,
activeLicenseFetchError: makeAPIError(404),
},
],
[
'Community user (license API 501)',
{
activeLicense: null,
activeLicenseFetchError: makeAPIError(501),
},
],
])('should not render for %s', async (_, overrides) => {
render(<BillingContainer />, {}, { appContextOverrides: overrides });
await screen.findByText('billing');
expect(
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
});
it('Not on ontrail', async () => {
const { findByText } = render(
<BillingContainer />,
{},

View File

@@ -34,10 +34,12 @@ import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph';
import { prepareCsvData } from './BillingUsageGraph/utils';
import './BillingContainer.styles.scss';
import { LicenseState } from 'types/api/licensesV3/getActive';
interface DataType {
key: string;
@@ -317,7 +319,7 @@ export default function BillingContainer(): JSX.Element {
const handleBilling = useCallback(async () => {
if (!trialInfo?.trialConvertedToSubscription) {
logEvent('Billing : Upgrade Plan', {
void logEvent('Billing : Upgrade Plan', {
user: pick(user, ['email', 'userId', 'name']),
org,
});
@@ -326,7 +328,7 @@ export default function BillingContainer(): JSX.Element {
url: getBaseUrl(),
});
} else {
logEvent('Billing : Manage Billing', {
void logEvent('Billing : Manage Billing', {
user: pick(user, ['email', 'userId', 'name']),
org,
});
@@ -535,6 +537,10 @@ export default function BillingContainer(): JSX.Element {
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
<CancelSubscriptionBanner />
)}
{!trialInfo?.trialConvertedToSubscription && (
<div className="upgrade-plan-benefits">
<Row

View File

@@ -0,0 +1,35 @@
.banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--padding-4);
border-radius: 4px;
border: 1px solid var(--callout-error-border);
background-color: var(--callout-error-background);
margin: var(--spacing-4) 0 var(--spacing-12);
}
.info {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--callout-error-title);
}
.subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--callout-error-icon);
}
.dialogBody {
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,68 @@
import { render, screen, userEvent } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
jest.mock('utils/basePath', () => ({
getBasePath: (): string => '/',
withBasePath: (path: string): string => path,
getAbsoluteUrl: (path: string): string => `https://test.signoz.io${path}`,
getBaseUrl: (): string => 'https://test.signoz.io',
}));
describe('CancelSubscriptionBanner', () => {
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
screen.getByText('Cancel Subscription', { selector: 'span' }),
).toBeInTheDocument();
expect(
screen.getByText('Cancel your SigNoz subscription.'),
).toBeInTheDocument();
});
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
screen.getByText(/reach out to our support team/i),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /keep subscription/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /contact support/i }),
).toBeInTheDocument();
});
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
jest.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'a') {
return mockAnchor as unknown as HTMLAnchorElement;
}
return realCreateElement(tag);
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.click(screen.getByRole('button', { name: /contact support/i }));
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');
expect(mockClick).toHaveBeenCalledTimes(1);
jest.restoreAllMocks();
});
});

View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import { X } from '@signozhq/icons';
import { Button, DialogWrapper } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const { user, org } = useAppContext();
const handleOpenCancelDialog = (): void => {
void logEvent('Billing : Cancel Subscription Clicked', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
setOpen(true);
};
const handleContactSupport = (): void => {
void logEvent('Billing : Cancel Subscription Confirmed', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
const subject = encodeURIComponent('Cancel My SigNoz Subscription');
const orgName = org?.[0]?.displayName ?? '';
const body = encodeURIComponent(
[
'Hi SigNoz Team,',
'',
'I would like to cancel my SigNoz Cloud subscription.',
'Please find my account details below.',
'',
'Account Details:',
` • SigNoz URL: ${getBaseUrl()}`,
...(orgName ? [` • Organization: ${orgName}`] : []),
` • Account Email: ${user?.email ?? ''}`,
'',
'Reason for Cancellation:',
'[Please share the reason for cancellation]',
'',
'Additional feedback (optional):',
'[Any other feedback]',
'',
'Regards,',
'[user name or team name]',
].join('\n'),
);
const link = document.createElement('a');
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
};
const footer = (
<>
<Button
variant="solid"
color="secondary"
onClick={(): void => setOpen(false)}
>
Keep Subscription
</Button>
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
Contact Support
</Button>
</>
);
return (
<>
<div className={styles.banner}>
<div className={styles.info}>
<span className={styles.title}>Cancel Subscription</span>
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
</div>
<Button
variant="solid"
color="destructive"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
>
Cancel Subscription
</Button>
</div>
<DialogWrapper
open={open}
onOpenChange={setOpen}
title="Cancel your subscription"
width="narrow"
showCloseButton
footer={footer}
>
<p className={styles.dialogBody}>
To cancel your SigNoz subscription, please reach out to our support team.
We&apos;ll be happy to assist you.
</p>
</DialogWrapper>
</>
);
}
export default CancelSubscriptionBanner;

View File

@@ -27,6 +27,7 @@ const interestedInOptions: Record<string, string> = {
singleTool:
'Single Tool (logs, metrics & traces) to reduce operational overhead',
correlateSignals: 'Correlate signals for faster troubleshooting',
openSourceTooling: 'Prefer open-source tooling',
};
export function AboutSigNozQuestions({

View File

@@ -1,22 +0,0 @@
import ROUTES from 'constants/routes';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import GeneralSettings from 'container/GeneralSettings';
import { t } from 'i18next';
export const alertsRoutesConfig = [
{
Component: GeneralSettings,
name: t('routes.general'),
route: ROUTES.SETTINGS,
key: ROUTES.SETTINGS,
},
{
Component: (): JSX.Element => (
<CreateAlertChannels preType={ChannelType.Slack} />
),
name: t('routes.alert_channels'),
route: ROUTES.CHANNELS_NEW,
key: ROUTES.CHANNELS_NEW,
},
];

View File

@@ -1,19 +0,0 @@
import { useLocation } from 'react-router-dom';
import RouteTab from 'components/RouteTab';
import history from 'lib/history';
import { alertsRoutesConfig } from './config';
function SettingsPage(): JSX.Element {
const { pathname } = useLocation();
return (
<RouteTab
history={history}
routes={alertsRoutesConfig}
activeKey={pathname}
/>
);
}
export default SettingsPage;

View File

@@ -1,54 +0,0 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { CircleCheck, Siren } from 'lucide-react';
import { getDurationFromNow } from 'utils/timeUtils';
import { AlertStatusProps, StatusConfig } from './types';
import './AlertStatus.styles.scss';
export default function AlertStatus({
status,
timestamp,
}: AlertStatusProps): JSX.Element {
const statusConfig: StatusConfig = useMemo(
() => ({
firing: {
icon: <Siren size={14} color={Color.TEXT_VANILLA_400} />,
text: 'Firing since',
extraInfo: timestamp ? (
<>
<div></div>
<div className="time">{getDurationFromNow(timestamp)}</div>
</>
) : null,
className: 'alert-status-info--firing',
},
resolved: {
icon: (
<CircleCheck
size={14}
fill={Color.BG_VANILLA_400}
color={Color.BG_INK_400}
/>
),
text: 'Resolved',
extraInfo: null,
className: 'alert-status-info--resolved',
},
}),
[timestamp],
);
const currentStatus = statusConfig[status];
return (
<div className={`alert-status-info ${currentStatus.className}`}>
<div className="alert-status-info__icon">{currentStatus.icon}</div>
<div className="alert-status-info__details">
<div className="text">{currentStatus.text}</div>
{currentStatus.extraInfo}
</div>
</div>
);
}

View File

@@ -1,18 +0,0 @@
export type AlertStatusProps =
| { status: 'firing'; timestamp: number }
| { status: 'resolved'; timestamp?: number };
export type StatusConfig = {
firing: {
icon: JSX.Element;
text: string;
extraInfo: JSX.Element | null;
className: string;
};
resolved: {
icon: JSX.Element;
text: string;
extraInfo: JSX.Element | null;
className: string;
};
};

View File

@@ -1,3 +0,0 @@
import AlertHistory from 'container/AlertHistory';
export default AlertHistory;

View File

@@ -1,86 +0,0 @@
import {
FiltersType,
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
export const ExceptionsQuickFiltersConfig: IQuickFiltersConfig[] = [
{
type: FiltersType.CHECKBOX,
title: 'Environment',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Service Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Hostname',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'host.name',
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'K8s Cluster Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'k8s.cluster.name',
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'K8s Deployment Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'k8s.deployment.name',
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'K8s Namespace Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'k8s.namespace.name',
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'K8s Pod Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'k8s.pod.name',
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: false,
},
];

View File

@@ -1,13 +0,0 @@
import BillingContainer from 'container/BillingContainer/BillingContainer';
import './BillingPage.styles.scss';
function BillingPage(): JSX.Element {
return (
<div className="billingPageContainer">
<BillingContainer />
</div>
);
}
export default BillingPage;

View File

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

View File

@@ -1,18 +0,0 @@
import { Typography } from 'antd';
import styled from 'styled-components';
export const Title = styled(Typography)`
&&& {
margin-top: 1rem;
margin-bottom: 1rem;
}
`;
export const ButtonContainer = styled.div`
&&& {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 1rem;
}
`;

View File

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

View File

@@ -1,14 +0,0 @@
import { Col } from 'antd';
import { themeColors } from 'constants/theme';
import styled from 'styled-components';
export const WrapperStyled = styled.div`
display: flex;
flex-direction: column;
flex: 1;
color: ${themeColors.lightWhite};
`;
export const ButtonWrapperStyled = styled(Col)`
margin-left: auto;
`;

View File

@@ -1,7 +0,0 @@
export const removeSourcePageFromPath = (path: string): string => {
const lastSlashIndex = path.lastIndexOf('/');
if (lastSlashIndex !== -1) {
return path.substring(0, lastSlashIndex);
}
return path;
};

View File

@@ -1,6 +0,0 @@
import MySettingsContainer from 'container/MySettings';
function MySettings(): JSX.Element {
return <MySettingsContainer />;
}
export default MySettings;

View File

@@ -1,25 +0,0 @@
import { Button, Typography } from 'antd';
import SomethingWentWrongAsset from 'assets/SomethingWentWrong';
import { Container } from 'components/NotFound/styles';
import ROUTES from 'constants/routes';
import history from 'lib/history';
function SomethingWentWrong(): JSX.Element {
return (
<Container>
<SomethingWentWrongAsset />
<Typography.Title level={3}>Oops! Something went wrong</Typography.Title>
<Button
type="primary"
onClick={(): void => {
history.push(ROUTES.HOME);
}}
className="periscope-btn primary"
>
Return to Home
</Button>
</Container>
);
}
export default SomethingWentWrong;

View File

@@ -1,10 +0,0 @@
import styled from 'styled-components';
export const Container = styled.div`
margin: 1rem 0;
`;
export const ActionsWrapper = styled.div`
display: flex;
justify-content: flex-end;
`;

View File

@@ -119,7 +119,7 @@ export function getAppContextMock(
status: '',
updated_at: '0',
},
state: LicenseState.ACTIVE,
state: LicenseState.ACTIVATED,
status: LicenseStatus.VALID,
platform: LicensePlatform.CLOUD,
created_at: '0',

View File

@@ -11,7 +11,7 @@ export enum LicenseStatus {
export enum LicenseState {
DEFAULTED = 'DEFAULTED',
ACTIVE = 'ACTIVE',
ACTIVATED = 'ACTIVATED',
EXPIRED = 'EXPIRED',
ISSUED = 'ISSUED',
EVALUATING = 'EVALUATING',