From b3b245ebc531a008aa3b3703e47cf22ed55e2d38 Mon Sep 17 00:00:00 2001 From: swapnil-signoz Date: Fri, 5 Jun 2026 18:12:18 +0530 Subject: [PATCH 1/2] feat: adding get cloud integration service for account handler (#11497) * feat: adding get cloud integration service for account handler * feat: adding integration test for new API endpoint * ci: fix py fmt lint * refactor: cloudfront integration service (#11570) Co-authored-by: Gaurav Tewari * fix: update tests --------- Co-authored-by: Gaurav Tewari Co-authored-by: Gaurav Tewari --- docs/api/openapi.yml | 74 ++++++++++++ .../services/cloudintegration/index.ts | 113 ++++++++++++++++++ .../api/generated/services/sigNoz.schemas.ts | 13 ++ .../ServiceDetails/ServiceDetails.tsx | 56 +++++---- .../__tests__/ServiceDetailsS3Sync.test.tsx | 2 +- .../AmazonWebServices/__tests__/mockData.ts | 2 +- .../signozapiserver/cloudintegration.go | 20 ++++ .../cloudintegration/cloudintegration.go | 1 + .../implcloudintegration/handler.go | 45 +++++++ .../tests/cloudintegrations/05_services.py | 28 +++++ 10 files changed, 332 insertions(+), 22 deletions(-) diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 47c1592f5de..74d82cbf287 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -8162,6 +8162,80 @@ paths: tags: - cloudintegration /api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}: + get: + deprecated: false + description: This endpoint gets a service and its configuration for the specified + cloud integration account + operationId: GetAccountService + parameters: + - in: path + name: cloud_provider + required: true + schema: + type: string + - in: path + name: id + required: true + schema: + type: string + - in: path + name: service_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/CloudintegrationtypesService' + status: + type: string + required: + - status + - data + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - ADMIN + - tokenizer: + - ADMIN + summary: Get service for account + tags: + - cloudintegration put: deprecated: false description: This endpoint updates a service for the specified cloud provider diff --git a/frontend/src/api/generated/services/cloudintegration/index.ts b/frontend/src/api/generated/services/cloudintegration/index.ts index 7a6049e5bce..873cfd2addc 100644 --- a/frontend/src/api/generated/services/cloudintegration/index.ts +++ b/frontend/src/api/generated/services/cloudintegration/index.ts @@ -31,6 +31,8 @@ import type { DisconnectAccountPathParameters, GetAccount200, GetAccountPathParameters, + GetAccountService200, + GetAccountServicePathParameters, GetConnectionCredentials200, GetConnectionCredentialsPathParameters, GetService200, @@ -743,6 +745,117 @@ export const invalidateListAccountServicesMetadata = async ( return queryClient; }; +/** + * This endpoint gets a service and its configuration for the specified cloud integration account + * @summary Get service for account + */ +export const getAccountService = ( + { cloudProvider, id, serviceId }: GetAccountServicePathParameters, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`, + method: 'GET', + signal, + }); +}; + +export const getGetAccountServiceQueryKey = ({ + cloudProvider, + id, + serviceId, +}: GetAccountServicePathParameters) => { + return [ + `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`, + ] as const; +}; + +export const getGetAccountServiceQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + { cloudProvider, id, serviceId }: GetAccountServicePathParameters, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getGetAccountServiceQueryKey({ cloudProvider, id, serviceId }); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getAccountService({ cloudProvider, id, serviceId }, signal); + + return { + queryKey, + queryFn, + enabled: !!(cloudProvider && id && serviceId), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAccountServiceQueryResult = NonNullable< + Awaited> +>; +export type GetAccountServiceQueryError = ErrorType; + +/** + * @summary Get service for account + */ + +export function useGetAccountService< + TData = Awaited>, + TError = ErrorType, +>( + { cloudProvider, id, serviceId }: GetAccountServicePathParameters, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAccountServiceQueryOptions( + { cloudProvider, id, serviceId }, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get service for account + */ +export const invalidateGetAccountService = async ( + queryClient: QueryClient, + { cloudProvider, id, serviceId }: GetAccountServicePathParameters, + options?: InvalidateOptions, +): Promise => { + await queryClient.invalidateQueries( + { queryKey: getGetAccountServiceQueryKey({ cloudProvider, id, serviceId }) }, + options, + ); + + return queryClient; +}; + /** * This endpoint updates a service for the specified cloud provider * @summary Update service diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 23698cf1182..936989f4acc 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -8707,6 +8707,19 @@ export type ListAccountServicesMetadata200 = { status: string; }; +export type GetAccountServicePathParameters = { + cloudProvider: string; + id: string; + serviceId: string; +}; +export type GetAccountService200 = { + data: CloudintegrationtypesServiceDTO; + /** + * @type string + */ + status: string; +}; + export type UpdateServicePathParameters = { cloudProvider: string; id: string; diff --git a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/ServiceDetails/ServiceDetails.tsx b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/ServiceDetails/ServiceDetails.tsx index 9cb5be99088..ce03341cb95 100644 --- a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/ServiceDetails/ServiceDetails.tsx +++ b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/ServiceDetails/ServiceDetails.tsx @@ -9,8 +9,9 @@ import { Skeleton } from 'antd'; import logEvent from 'api/common/logEvent'; import { getListAccountServicesMetadataQueryKey, - invalidateGetService, + invalidateGetAccountService, invalidateListAccountServicesMetadata, + useGetAccountService, useGetService, useUpdateService, } from 'api/generated/services/cloudintegration'; @@ -118,30 +119,50 @@ function ServiceDetails({ const cloudAccountId = urlQuery.get('cloudAccountId'); const serviceId = urlQuery.get('service'); const isReadOnly = !cloudAccountId; - const serviceQueryParams = cloudAccountId - ? { cloud_integration_id: cloudAccountId } - : undefined; const { - queryKey: _queryKey, - data: serviceDetailsData, - isLoading: isServiceDetailsLoading, - } = useGetService( + queryKey: _accountServiceQueryKey, + data: accountServiceData, + isLoading: isAccountServiceLoading, + } = useGetAccountService( { cloudProvider: type, + id: cloudAccountId || '', serviceId: serviceId || '', }, { - ...serviceQueryParams, + query: { + enabled: !!serviceId && !!cloudAccountId, + select: (response): ServiceDetailsData => response.data, + }, + }, + ); + + const { + queryKey: _readOnlyServiceQueryKey, + data: readOnlyServiceData, + isLoading: isReadOnlyServiceLoading, + } = useGetService( + { + cloudProvider: type, + serviceId: serviceId || '', }, + undefined, { query: { - enabled: !!serviceId, + enabled: !!serviceId && !cloudAccountId, select: (response): ServiceDetailsData => response.data, }, }, ); + const serviceDetailsData = cloudAccountId + ? accountServiceData + : readOnlyServiceData; + const isServiceDetailsLoading = cloudAccountId + ? isAccountServiceLoading + : isReadOnlyServiceLoading; + const integrationConfig = type === IntegrationType.AWS_SERVICES ? serviceDetailsData?.cloudIntegrationService?.config?.aws @@ -268,16 +289,11 @@ function ServiceDetails({ }, ); - invalidateGetService( - queryClient, - { - cloudProvider: type, - serviceId, - }, - { - cloud_integration_id: cloudAccountId, - }, - ); + invalidateGetAccountService(queryClient, { + cloudProvider: type, + id: cloudAccountId, + serviceId, + }); invalidateListAccountServicesMetadata(queryClient, { cloudProvider: type, diff --git a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/ServiceDetailsS3Sync.test.tsx b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/ServiceDetailsS3Sync.test.tsx index ac9cc5c1b7c..12451e4c447 100644 --- a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/ServiceDetailsS3Sync.test.tsx +++ b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/ServiceDetailsS3Sync.test.tsx @@ -64,7 +64,7 @@ describe('ServiceDetails for S3 Sync service', () => { (_req, res, ctx) => res(ctx.json(accountsResponse)), ), rest.get( - 'http://localhost/api/v1/cloud_integrations/aws/services/:serviceId', + 'http://localhost/api/v1/cloud_integrations/aws/accounts/:accountId/services/:serviceId', (req, res, ctx) => res( ctx.json( diff --git a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/mockData.ts b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/mockData.ts index 3e0cf3ed4f4..a47a62fa521 100644 --- a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/mockData.ts +++ b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/mockData.ts @@ -32,7 +32,7 @@ const accountsResponse: ListAccounts200 = { }, }; -/** Response shape for GET /cloud_integrations/aws/services/:serviceId (used by ServiceDetails). */ +/** Response shape for GET /cloud_integrations/aws/accounts/:accountId/services/:serviceId (used by ServiceDetails). */ const buildServiceDetailsResponse = ( serviceId: string, initialConfigLogsS3Buckets: Record = {}, diff --git a/pkg/apiserver/signozapiserver/cloudintegration.go b/pkg/apiserver/signozapiserver/cloudintegration.go index fb879124ed5..09ae925fb07 100644 --- a/pkg/apiserver/signozapiserver/cloudintegration.go +++ b/pkg/apiserver/signozapiserver/cloudintegration.go @@ -212,6 +212,26 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error { return err } + if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New( + provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetAccountService), + handler.OpenAPIDef{ + ID: "GetAccountService", + Tags: []string{"cloudintegration"}, + Summary: "Get service for account", + Description: "This endpoint gets a service and its configuration for the specified cloud integration account", + Request: nil, + RequestContentType: "", + Response: new(citypes.Service), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound}, + Deprecated: false, + SecuritySchemes: newSecuritySchemes(types.RoleAdmin), + }, + )).Methods(http.MethodGet).GetError(); err != nil { + return err + } + // Agent check-in endpoint is kept same as older one to maintain backward compatibility with already deployed agents. // In the future, this endpoint will be deprecated and a new endpoint will be introduced for consistency with above endpoints. if err := router.Handle("/api/v1/cloud-integrations/{cloud_provider}/agent-check-in", handler.New( diff --git a/pkg/modules/cloudintegration/cloudintegration.go b/pkg/modules/cloudintegration/cloudintegration.go index 1f99a59f460..51346ad9b67 100644 --- a/pkg/modules/cloudintegration/cloudintegration.go +++ b/pkg/modules/cloudintegration/cloudintegration.go @@ -77,6 +77,7 @@ type Handler interface { ListServicesMetadata(http.ResponseWriter, *http.Request) ListAccountServicesMetadata(http.ResponseWriter, *http.Request) GetService(http.ResponseWriter, *http.Request) + GetAccountService(http.ResponseWriter, *http.Request) UpdateService(http.ResponseWriter, *http.Request) AgentCheckIn(http.ResponseWriter, *http.Request) } diff --git a/pkg/modules/cloudintegration/implcloudintegration/handler.go b/pkg/modules/cloudintegration/implcloudintegration/handler.go index b65148366fd..32435d4ae0a 100644 --- a/pkg/modules/cloudintegration/implcloudintegration/handler.go +++ b/pkg/modules/cloudintegration/implcloudintegration/handler.go @@ -360,6 +360,51 @@ func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) { render.Success(rw, http.StatusOK, svc) } +func (handler *handler) GetAccountService(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"]) + if err != nil { + render.Error(rw, err) + return + } + + serviceID, err := cloudintegrationtypes.NewServiceID(provider, mux.Vars(r)["service_id"]) + if err != nil { + render.Error(rw, err) + return + } + + cloudIntegrationID, err := valuer.NewUUID(mux.Vars(r)["id"]) + if err != nil { + render.Error(rw, err) + return + } + + orgID := valuer.MustNewUUID(claims.OrgID) + + _, err = handler.module.GetConnectedAccount(ctx, orgID, cloudIntegrationID, provider) + if err != nil { + render.Error(rw, err) + return + } + + svc, err := handler.module.GetService(ctx, orgID, serviceID, provider, cloudIntegrationID) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, svc) +} + func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() diff --git a/tests/integration/tests/cloudintegrations/05_services.py b/tests/integration/tests/cloudintegrations/05_services.py index 6731851c57d..f9d83c43c7c 100644 --- a/tests/integration/tests/cloudintegrations/05_services.py +++ b/tests/integration/tests/cloudintegrations/05_services.py @@ -183,6 +183,34 @@ def test_get_service_details_with_account( assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null before any service config is set" +def test_get_account_service( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], + create_cloud_integration_account: Callable, +) -> None: + """Get service for a specific account — all disabled by default.""" + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER) + account_id = account["id"] + + checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())) + assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}" + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + + assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}" + + data = response.json()["data"] + assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'" + assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null before any config is set" + + def test_get_service_not_found( signoz: types.SigNoz, create_user_admin: types.Operation, # pylint: disable=unused-argument From 9222845ce8911b8c99e42695d4a8eab286adbbd0 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:52:26 +0530 Subject: [PATCH 2/2] feat(billing): migrate BillingUsageGraph from uPlotLib to uPlotV2 and added stacking (#11579) * feat(billing): migrate BillingUsageGraph from uPlotLib to uPlotV2 and added stacking * feat(billing): revamp billing page UI to match Settings Revamp designs * feat(billing): refactor and feedback fixes * feat(billing): test case fix and css refactor * feat(billing): css fix * feat(billing): migrate BillingContainer and BillingUsageGraph to CSS modules * feat(billing): feedback fixes --- frontend/src/api/billing/getUsage.ts | 29 +- .../RefreshPaymentStatus.tsx | 41 +-- .../BillingContainer.module.scss | 199 +++++++++++++ .../BillingContainer.styles.scss | 70 ----- .../BillingContainer.test.tsx | 2 +- .../BillingContainer/BillingContainer.tsx | 226 +++++++------- .../BillingBarChartTooltip.module.scss | 9 + .../BillingBarChartTooltip.tsx | 95 ++++++ .../BillingUsageGraph.module.scss | 37 +++ .../BillingUsageGraph.styles.scss | 23 -- .../BillingUsageGraph/BillingUsageGraph.tsx | 279 +++++++----------- .../__tests__/BillingBarChartTooltip.test.tsx | 165 +++++++++++ .../__tests__/prepareBillingBarConfig.test.ts | 101 +++++++ .../BillingUsageGraph/__tests__/utils.test.ts | 145 +++++++++ .../prepareBillingBarConfig.ts | 71 +++++ .../BillingUsageGraph/utils.ts | 71 +++-- .../TooltipHeader/TooltipHeader.tsx | 9 +- .../pages/WorkspaceLocked/WorkspaceLocked.tsx | 2 +- .../WorkspaceSuspended/WorkspaceSuspended.tsx | 2 +- 19 files changed, 1155 insertions(+), 421 deletions(-) create mode 100644 frontend/src/container/BillingContainer/BillingContainer.module.scss delete mode 100644 frontend/src/container/BillingContainer/BillingContainer.styles.scss create mode 100644 frontend/src/container/BillingContainer/BillingUsageGraph/BillingBarChartTooltip.module.scss create mode 100644 frontend/src/container/BillingContainer/BillingUsageGraph/BillingBarChartTooltip.tsx create mode 100644 frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.module.scss delete mode 100644 frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.styles.scss create mode 100644 frontend/src/container/BillingContainer/BillingUsageGraph/__tests__/BillingBarChartTooltip.test.tsx create mode 100644 frontend/src/container/BillingContainer/BillingUsageGraph/__tests__/prepareBillingBarConfig.test.ts create mode 100644 frontend/src/container/BillingContainer/BillingUsageGraph/__tests__/utils.test.ts create mode 100644 frontend/src/container/BillingContainer/BillingUsageGraph/prepareBillingBarConfig.ts diff --git a/frontend/src/api/billing/getUsage.ts b/frontend/src/api/billing/getUsage.ts index da7b6ebd634..90ae5c86add 100644 --- a/frontend/src/api/billing/getUsage.ts +++ b/frontend/src/api/billing/getUsage.ts @@ -3,13 +3,36 @@ import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; +export interface DayBreakdownEntry { + timestamp: number; + total: number; + quantity: number; + count: number; + size: number; +} + +export interface TierEntry { + quantity: number; + unitPrice: number; + tierCost: number; +} + +export interface BreakdownEntry { + type: string; + unit: string; + dayWiseBreakdown: { + breakdown: DayBreakdownEntry[]; + }; + tiers?: TierEntry[]; +} + export interface UsageResponsePayloadProps { - billingPeriodStart: Date; - billingPeriodEnd: Date; + billingPeriodStart: number; + billingPeriodEnd: number; details: { total: number; baseFee: number; - breakdown: []; + breakdown: BreakdownEntry[]; billTotal: number; }; discount: number; diff --git a/frontend/src/components/RefreshPaymentStatus/RefreshPaymentStatus.tsx b/frontend/src/components/RefreshPaymentStatus/RefreshPaymentStatus.tsx index 30be7a10f24..0c5dccd5713 100644 --- a/frontend/src/components/RefreshPaymentStatus/RefreshPaymentStatus.tsx +++ b/frontend/src/components/RefreshPaymentStatus/RefreshPaymentStatus.tsx @@ -1,17 +1,17 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Tooltip } from 'antd'; import refreshPaymentStatus from 'api/v3/licenses/put'; -import cx from 'classnames'; +import { Button } from '@signozhq/ui/button'; +import { TooltipSimple } from '@signozhq/ui/tooltip'; import { RefreshCcw } from '@signozhq/icons'; import { useAppContext } from 'providers/App/App'; function RefreshPaymentStatus({ - btnShape, type, + className, }: { - btnShape?: 'default' | 'round' | 'circle'; type?: 'button' | 'text' | 'tooltip'; + className?: string; }): JSX.Element { const { t } = useTranslation(['failedPayment']); const { activeLicenseRefetch } = useAppContext(); @@ -31,26 +31,33 @@ function RefreshPaymentStatus({ setIsLoading(false); }; + const button = ( + + ); + return ( - - - + {type === 'tooltip' ? ( + {button} + ) : ( + button + )} ); } RefreshPaymentStatus.defaultProps = { - btnShape: 'default', type: 'button', + className: undefined, }; export default RefreshPaymentStatus; diff --git a/frontend/src/container/BillingContainer/BillingContainer.module.scss b/frontend/src/container/BillingContainer/BillingContainer.module.scss new file mode 100644 index 00000000000..60a33ffbd2a --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingContainer.module.scss @@ -0,0 +1,199 @@ +.billingContainer { + margin-bottom: var(--spacing-20); + padding-top: 36px; + width: 90%; + margin: 0 auto; + + .pageHeader { + margin-bottom: var(--spacing-8); + + .pageHeaderTitle { + font-weight: var(--label-medium-500-font-weight); + font-size: var(--label-medium-500-font-size); + line-height: 32px; + letter-spacing: -0.08px; + color: var(--l1-foreground); + } + + .pageHeaderSubtitle { + font-size: var(--font-size-sm); + line-height: var(--line-height-20); + letter-spacing: -0.07px; + color: var(--l2-foreground); + } + } + + .pageInfoTitle { + margin: 0; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-20); + letter-spacing: -0.07px; + color: var(--l1-foreground); + } + + .pageInfoSubtitle { + margin: 0; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-18); + letter-spacing: -0.07px; + color: var(--l2-foreground); + } + + .pageInfo { + :global(.ant-card) { + padding: var(--padding-3); + } + + .billingManageBtn { + background: var(--l3-background); + + &:hover { + background: var(--l3-background-hover); + } + } + } + + .billingSummary { + margin: var(--spacing-12) var(--spacing-4); + } + + .billingDetails { + margin: var(--spacing-12) 0; + border: 1px solid var(--l1-border); + border-radius: 2px; + overflow: hidden; + + :global { + .ant-table { + background: var(--l2-background); + } + + .ant-table-thead > tr > th { + height: 52px; + padding: 0 var(--padding-4); + color: var(--l3-foreground); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + letter-spacing: 0.48px; + text-transform: uppercase; + } + + .ant-table-tbody > tr > td { + height: 52px; + padding: 0 var(--padding-4); + background: var(--l2-background); + border-bottom: 1px solid var(--l1-border); + color: var(--l2-foreground); + font-size: var(--font-size-sm); + + &:first-child { + color: var(--l1-foreground); + } + + &:not(:first-child) { + font-feature-settings: + 'zero' 1, + 'lnum' 1, + 'tnum' 1; + } + } + + .ant-table-tbody > tr:last-child > td { + border-bottom: none; + } + + .ant-table-tbody > tr:hover > td { + background: var(--l2-background) !important; + } + } + + .billingDetailsHeaderCell { + position: relative; + background: var(--l2-background) !important; + border: none !important; + border-bottom: 1px solid var(--l1-border) !important; + box-shadow: none !important; + + &::after { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-end: 0; + width: 2px; + background: var(--l2-background); + z-index: 1; + } + } + } + + .upgradePlanBenefits { + margin: 0 var(--spacing-4); + border: 1px solid var(--l1-border); + border-radius: 5px; + padding: 0 var(--padding-12); + + .planBenefits { + .planBenefit { + display: flex; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-8) 0; + } + } + } + + .billingGraphSection { + border: 1px solid var(--l1-border); + border-radius: 4px; + overflow: hidden; + margin-bottom: var(--spacing-4); + + .billingGraphFooter { + display: flex; + gap: var(--spacing-4); + padding: var(--padding-3) var(--padding-4); + border-top: 1px solid var(--l1-border); + background: var(--l2-background); + + .billingFooterBtn { + background: var(--l3-background); + + &:hover { + background: var(--l3-background-hover); + } + } + } + } + + .emptyGraphCard { + :global(.ant-card-body) { + height: 40vh; + display: flex; + justify-content: center; + align-items: center; + } + } + + .billingUpdateNote { + margin-top: var(--spacing-8); + font-family: var(--font-family-inter); + font-size: var(--font-size-sm); + font-style: normal; + font-weight: var(--font-weight-normal); + line-height: 22px; + letter-spacing: -0.07px; + } + + :global { + .ant-skeleton.ant-skeleton-element.ant-skeleton-active { + width: 100%; + min-width: 100%; + } + + .ant-skeleton.ant-skeleton-element .ant-skeleton-input { + min-width: 100% !important; + } + } +} diff --git a/frontend/src/container/BillingContainer/BillingContainer.styles.scss b/frontend/src/container/BillingContainer/BillingContainer.styles.scss deleted file mode 100644 index e209cf671e5..00000000000 --- a/frontend/src/container/BillingContainer/BillingContainer.styles.scss +++ /dev/null @@ -1,70 +0,0 @@ -.billing-container { - margin-bottom: 40px; - padding-top: 36px; - width: 90%; - margin: 0 auto; - - .billing-summary { - margin: 24px 8px; - } - - .billing-details { - margin: 24px 0px; - - .ant-table-title { - color: var(--l2-foreground); - background-color: var(--l3-background); - } - - .ant-table-cell { - background-color: var(--l1-background); - border-color: var(--l1-border); - } - - .ant-table-tbody { - td { - border-color: var(--l1-border); - } - } - } - - .upgrade-plan-benefits { - margin: 0px 8px; - border: 1px solid var(--l1-border); - border-radius: 5px; - padding: 0 48px; - .plan-benefits { - .plan-benefit { - display: flex; - align-items: center; - gap: 16px; - margin: 16px 0; - } - } - } - - .empty-graph-card { - .ant-card-body { - height: 40vh; - display: flex; - justify-content: center; - align-items: center; - } - } - - .billing-update-note { - text-align: left; - font-size: 13px; - color: var(--l2-foreground); - margin-top: 16px; - } -} - -.ant-skeleton.ant-skeleton-element.ant-skeleton-active { - width: 100%; - min-width: 100%; -} - -.ant-skeleton.ant-skeleton-element .ant-skeleton-input { - min-width: 100% !important; -} diff --git a/frontend/src/container/BillingContainer/BillingContainer.test.tsx b/frontend/src/container/BillingContainer/BillingContainer.test.tsx index 3a480541aad..817c088639c 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.test.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.test.tsx @@ -38,7 +38,7 @@ describe('BillingContainer', () => { }); expect(pricePerUnit).toBeInTheDocument(); const cost = await screen.findByRole('columnheader', { - name: /cost \(billing period to date\)/i, + name: /cost/i, }); expect(cost).toBeInTheDocument(); diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx index 28a3c2513c8..d6c8fc3cf8c 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -1,12 +1,11 @@ +import { Callout } from '@signozhq/ui/callout'; +import { Button } from '@signozhq/ui/button'; import { Typography } from '@signozhq/ui/typography'; -import { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery } from 'react-query'; -import { CircleCheck, CloudDownload } from '@signozhq/icons'; -import { Color } from '@signozhq/design-tokens'; +import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons'; import { - Alert, - Button, Card, Col, Flex, @@ -16,7 +15,10 @@ import { TableColumnsType as ColumnsType, } from 'antd'; import { Badge } from '@signozhq/ui/badge'; -import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage'; +import getUsage, { + BreakdownEntry, + UsageResponsePayloadProps, +} from 'api/billing/getUsage'; import logEvent from 'api/common/logEvent'; import updateCreditCardApi from 'api/v1/checkout/create'; import manageCreditCardApi from 'api/v1/portal/create'; @@ -29,7 +31,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import { useNotifications } from 'hooks/useNotifications'; import { isEmpty, pick } from 'lodash-es'; import { useAppContext } from 'providers/App/App'; -import { SuccessResponseV2 } from 'types/api'; +import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api'; import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; import { getBaseUrl } from 'utils/basePath'; import { getFormattedDate, getRemainingDays } from 'utils/timeUtils'; @@ -38,7 +40,7 @@ import CancelSubscriptionBanner from './CancelSubscriptionBanner'; import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph'; import { prepareCsvData } from './BillingUsageGraph/utils'; -import './BillingContainer.styles.scss'; +import styles from './BillingContainer.module.scss'; import { LicenseState } from 'types/api/licensesV3/getActive'; interface DataType { @@ -115,7 +117,7 @@ const dummyColumns: ColumnsType = [ render: renderSkeletonInput, }, { - title: 'Cost (Billing period to date)', + title: 'Cost', dataIndex: 'cost', key: 'cost', render: renderSkeletonInput, @@ -130,7 +132,7 @@ export default function BillingContainer(): JSX.Element { const [billAmount, setBillAmount] = useState(0); const [daysRemaining, setDaysRemaining] = useState(0); const [isFreeTrial, setIsFreeTrial] = useState(false); - const [data, setData] = useState([]); + const [data, setData] = useState([]); const [apiResponse, setApiResponse] = useState< Partial >({}); @@ -150,7 +152,7 @@ export default function BillingContainer(): JSX.Element { const { isCloudUser: isCloudUserVal } = useGetTenantLicense(); const processUsageData = useCallback( - (data: any): void => { + (data: SuccessResponse | ErrorResponse): void => { if (isEmpty(data?.payload)) { return; } @@ -158,27 +160,23 @@ export default function BillingContainer(): JSX.Element { details: { breakdown = [], billTotal }, billingPeriodStart, billingPeriodEnd, - } = data?.payload || {}; - const formattedUsageData: any[] = []; + } = (data as SuccessResponse).payload; + const formattedUsageData: DataType[] = []; if (breakdown && Array.isArray(breakdown)) { for (let index = 0; index < breakdown.length; index += 1) { - const element = breakdown[index]; - - element?.tiers.forEach( - ( - tier: { quantity: number; unitPrice: number; tierCost: number }, - i: number, - ) => { - formattedUsageData.push({ - key: `${index}${i}`, - name: i === 0 ? element?.type : '', - dataIngested: `${tier.quantity} ${element?.unit}`, - pricePerUnit: tier.unitPrice, - cost: `$ ${tier.tierCost}`, - }); - }, - ); + const element: BreakdownEntry = breakdown[index]; + + element?.tiers?.forEach((tier, i: number) => { + formattedUsageData.push({ + key: `${index}${i}`, + name: i === 0 ? element?.type : '', + unit: element?.unit ?? '', + dataIngested: `${tier.quantity} ${element?.unit}`, + pricePerUnit: String(tier.unitPrice), + cost: `$ ${tier.tierCost}`, + }); + }); } } @@ -251,16 +249,19 @@ export default function BillingContainer(): JSX.Element { title: 'Data Ingested', dataIndex: 'dataIngested', key: 'dataIngested', + align: 'right', }, { title: 'Price per Unit', dataIndex: 'pricePerUnit', key: 'pricePerUnit', + align: 'right', }, { - title: 'Cost (Billing period to date)', + title: 'Cost', dataIndex: 'cost', key: 'cost', + align: 'right', }, ]; @@ -345,23 +346,6 @@ export default function BillingContainer(): JSX.Element { updateCreditCard, ]); - const BillingUsageGraphCallback = useCallback( - () => - !isLoading && !isFetchingBillingData ? ( - <> - -
- Note: Billing metrics are updated once every 24 hours. -
- - ) : ( - - - - ), - [apiResponse, billAmount, isLoading, isFetchingBillingData], - ); - const subscriptionPastDueMessage = (): JSX.Element => ( {`We were not able to process payments for your account. Please update your card details `} @@ -415,12 +399,12 @@ export default function BillingContainer(): JSX.Element { trialInfo?.gracePeriodEnd; return ( -
- - +
+ + {t('billing')} - + {t('manage_billing_and_costs')} @@ -428,50 +412,36 @@ export default function BillingContainer(): JSX.Element { - - + +

{isCloudUserVal ? t('teams_cloud') : t('teams')}{' '} {isFreeTrial ? Free Trial : ''} - +

{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? ( - +

{daysRemaining} {daysRemainingStr} - +

) : null}
- - - - - - +
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && ( @@ -485,8 +455,8 @@ export default function BillingContainer(): JSX.Element { {!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? headerText && ( - + + {subscriptionPastDueMessage()} + ) : ( ))}
- +
+ {!isLoading && !isFetchingBillingData ? ( + + ) : ( + + + + )} + {!isLoading && !isFetchingBillingData && ( +
+ + +
+ )} +
+ {!isLoading && !isFetchingBillingData && ( + + Billing metrics are updated once every 24 hours. + + )} -
+
{!isLoading && !isFetchingBillingData && ( ): JSX.Element => { + const { background: _, boxShadow: __, ...safeStyle } = style ?? {}; + return ( + - + + {t('upgrade_now_text')} - + {t('Your billing will start only after the trial period')} - + {t('checkout_plans')}   @@ -583,9 +596,10 @@ export default function BillingContainer(): JSX.Element { - + )} diff --git a/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx index 590d0be5c0e..97a5ad06a7b 100644 --- a/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx +++ b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx @@ -143,7 +143,7 @@ function WorkspaceSuspended(): JSX.Element { > {t('continueMyJourney')} - + )}
+ ); + }, + }, + }} /> )} @@ -546,7 +559,7 @@ export default function BillingContainer(): JSX.Element { )} {!trialInfo?.trialConvertedToSubscription && ( -
+
-