Compare commits

...

5 Commits

Author SHA1 Message Date
Vinícius Lourenço
9e9d1c7184 chore(adr): add initial adrs 2026-06-05 15:48:57 -03:00
Naman Verma
97191eb7a2 chore: replace perses/perses with perses/spec and introduce kind enum values (#11559)
* chore: replace perses/perses with perses/spec and introduce kind enum values

* fix: enforce query kind values

* chore: generate frontend api spec

* test: fix patch unit tests using correct query kind value

* feat: add validation to query builder request type
2026-06-05 17:00:53 +00:00
SagarRajput-7
9222845ce8 feat(billing): migrate BillingUsageGraph from uPlotLib to uPlotV2 and added stacking (#11579)
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
* 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
2026-06-05 13:22:26 +00:00
swapnil-signoz
b3b245ebc5 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 <tewarig@users.noreply.github.com>

* fix: update tests

---------

Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-05 12:42:18 +00:00
Ashwin Bhatkal
9c9016d49e feat(dashboards): V2 dashboard — sections, drag-and-drop & panel management (#11544)
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
* feat(dashboard-v2): session store — edit-context + persisted collapse

* feat(dashboard-v2): patch-op builders (RFC-6902) for layouts & panels

* feat(dashboard-v2): editable dashboard shell + within-section panel geometry

* feat(dashboard-v2): section layout — reorder & collapse

* feat(dashboard-v2): section lifecycle — add, rename, delete, migrate

* feat(dashboard-v2): panel management — add, delete, move
2026-06-05 06:33:48 +00:00
80 changed files with 3830 additions and 636 deletions

17
.adr/0000-template.md Normal file
View File

@@ -0,0 +1,17 @@
# Title
## Status
What is the status, such as proposed, accepted, rejected, deprecated, superseded, etc.?
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do because of this change?

View File

@@ -0,0 +1,30 @@
# Recording Architecture Decisions
## Status
Proposed
## Context
At SigNoz, architecture decisions are stored across internal communication channels, Notion, GitHub Issues, or directly
in PR comments and code.
When we need to understand the reasoning behind a large change or a specific decision in the codebase, finding it is
difficult because we must search across many different places.
## Decision
Instead of storing architecture decisions outside this codebase, we will use the structure proposed
by [ADR](https://github.com/architecture-decision-record/architecture-decision-record).
This is the first decision being stored, alongside [0000-template.md](./0000-template.md) which serves as the template
for new decisions.
The current template is basic and inspired
by [Michael Nygard's template](https://github.com/architecture-decision-record/architecture-decision-record/blob/main/locales/en/templates/decision-record-template-by-michael-nygard/index.md).
Over time, we can improve or change it to better fit our needs.
## Consequences
We can still store internal discussions or private matters in our internal communication channels. However, with this
approach, we have a single place to look for decisions about the codebase.

View File

@@ -0,0 +1,61 @@
# Migrate from ESLint to Oxlint
## Status
Accepted
## Context
Current implementation (02/17/2025) uses ESLint v7 (latest is v10):
- **Dependencies**: 12 (without eslint itself)
- **Execution time**: 113s locally, 185s on CI
- **Memory usage**: 3472mb
Our pain with Eslint is caused by the integration of it when create a commit, causing the git commit to take multiple
seconds instead of being instant or feel instant.
### Requirements for new tooling
- Fast execution (primary goal)
- Community/plugin support
- IDE support (Cursor/VSCode, JetBrains)
- Fewer dependencies (install speed, reduced threat vector)
### Alternatives evaluated
| Feature | ESLint | Biome | Oxlint |
|-------------------|--------|-------|--------|
| Lint Speed | 195s | 13s | 6s |
| Format Speed | 20s | 0.5s | 1.3s |
| Type-aware | Yes | Yes | Yes* |
| Plugin Support | Yes | No** | Yes |
| Dependencies | 12 | 1 | 6 |
| Replaces Prettier | No | Yes | Yes*** |
\* Type-aware via tsgolint (uses Go-based TypeScript compiler)
\** Biome uses GritQL for custom rules
\*** Via oxfmt
## Decision
Migrate to Oxlint.
References:
- https://app.notion.com/p/signoz/Linting-Formatting-30cfcc6bcd1980a7bb47f04a41e67c21#30cfcc6bcd1980d28398c2fd8bcb53fb
### Rationale
1. **VoidZero ecosystem alignment**: Oxlint is part of Oxc project under VoidZero initiative (includes Vite, Vitest,
Rolldown). Future migration to Vite benefits from consistent tooling.
2. **Performance**: Fastest option at 6s vs 195s (32x improvement). CI time: best among alternatives.
3. **JS plugin support**: Custom rules possible without learning GritQL. Example: `signoz/no-zustand-getstate-in-hooks`
rule already implemented.
## Consequences
- **Development speed**: 32x faster linting (6s vs 195s)
- **CI time**: Significant reduction in pipeline duration
- **Memory usage**: Lower footprint than ESLint

View File

@@ -0,0 +1,63 @@
# Migrate from Yarn to pnpm
## Status
Accepted
## Context
Yarn install time is one of the slowest part of the CI, around 108s locally or 120s on CI. Also, we are using v1 that is
on maintenance mode since 2020, and [will eventually reach EOL](https://github.com/yarnpkg/yarn/issues/9062).
### Requirements
- Faster install times
- Better CI performance
- Reduced attack surface for supply chain attacks
## Decision
We decided to migrate to pnpm mostly due to the usage of this package manager already by
our [Component Library](https://github.com/SigNoz/components). We had good experience and the performance was great.
### Performance improvements
| CI Job | Yarn | pnpm | Diff |
|-----------|--------|-------|----------|
| tsc/js | 2m56s | 1m21s | -95s |
| test/js | 10m30s | 8m20s | -130s |
| fmt/js | 2m25s | 33s | -112s |
| lint/js | 2m56s | 44s | -130s |
| authz | 11m9s | 9m24s | -105s |
| openapi | 2m41s | 1m7s | -94s |
| **Total** | | | **-11m** |
Install time: 108s → 16s (5.8x faster)
### Security hardening
Added `pnpm-workspace.yaml` with minimum release age:
```yaml
trustPolicy: no-downgrade
minimumReleaseAge: 2880 # 2d
minimumReleaseAgeStrict: true
minimumReleaseAgeExclude:
- '@signozhq/*'
blockExoticSubdeps: true
```
Prevents installing packages released less than 2 days ago — mitigates supply chain attacks where malicious code is
pushed and quickly removed.
## Consequences
- Replace `yarn` commands with `pnpm` equivalents
- `yarn add``pnpm add`
- `yarn install``pnpm install`
- `yarn run``pnpm run` (or just `pnpm <script>`)
### References
- PR #11158: https://github.com/SigNoz/signoz/pull/11158
- PR #11274: https://github.com/SigNoz/signoz/pull/11274

View File

@@ -2396,6 +2396,26 @@ components:
repeatVariable:
type: string
type: object
DashboardLink:
properties:
name:
type: string
renderVariables:
type: boolean
targetBlank:
type: boolean
tooltip:
type: string
url:
type: string
type: object
DashboardPanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
@@ -2526,7 +2546,7 @@ components:
type: array
links:
items:
$ref: '#/components/schemas/V1Link'
$ref: '#/components/schemas/DashboardLink'
type: array
panels:
additionalProperties:
@@ -2687,8 +2707,8 @@ components:
type: object
DashboardtypesLayout:
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec'
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec:
properties:
kind:
enum:
@@ -2771,7 +2791,7 @@ components:
DashboardtypesPanel:
properties:
kind:
type: string
$ref: '#/components/schemas/DashboardtypesPanelKind'
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
type: object
@@ -2782,6 +2802,10 @@ components:
unit:
type: string
type: object
DashboardtypesPanelKind:
enum:
- Panel
type: string
DashboardtypesPanelPlugin:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -2888,10 +2912,10 @@ components:
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/V1PanelDisplay'
$ref: '#/components/schemas/DashboardPanelDisplay'
links:
items:
$ref: '#/components/schemas/V1Link'
$ref: '#/components/schemas/DashboardLink'
type: array
plugin:
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
@@ -2964,7 +2988,7 @@ components:
DashboardtypesQuery:
properties:
kind:
type: string
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
type: object
@@ -3232,8 +3256,8 @@ components:
DashboardtypesVariable:
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpec'
DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpec:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
enum:
@@ -7251,26 +7275,6 @@ components:
required:
- id
type: object
V1Link:
properties:
name:
type: string
renderVariables:
type: boolean
targetBlank:
type: boolean
tooltip:
type: string
url:
type: string
type: object
V1PanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
VariableDefaultValue:
type: object
VariableDisplay:
@@ -8162,6 +8166,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

View File

@@ -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;

View File

@@ -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<GetAccountService200>({
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<ReturnType<typeof getAccountService>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAccountService>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetAccountServiceQueryKey({ cloudProvider, id, serviceId });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAccountService>>
> = ({ signal }) =>
getAccountService({ cloudProvider, id, serviceId }, signal);
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && id && serviceId),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAccountService>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAccountServiceQueryResult = NonNullable<
Awaited<ReturnType<typeof getAccountService>>
>;
export type GetAccountServiceQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service for account
*/
export function useGetAccountService<
TData = Awaited<ReturnType<typeof getAccountService>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAccountService>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAccountServiceQueryOptions(
{ cloudProvider, id, serviceId },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
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<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAccountServiceQueryKey({ cloudProvider, id, serviceId }) },
options,
);
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service

View File

@@ -3104,6 +3104,40 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export interface DashboardLinkDTO {
/**
* @type string
*/
name?: string;
/**
* @type boolean
*/
renderVariables?: boolean;
/**
* @type boolean
*/
targetBlank?: boolean;
/**
* @type string
*/
tooltip?: string;
/**
* @type string
*/
url?: string;
}
export interface DashboardPanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
@@ -3805,40 +3839,9 @@ export type DashboardtypesDashboardSpecDTODatasources = {
[key: string]: DashboardtypesDatasourceSpecDTO;
};
export interface V1PanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
export enum DashboardtypesPanelKindDTO {
Panel = 'Panel',
}
export interface V1LinkDTO {
/**
* @type string
*/
name?: string;
/**
* @type boolean
*/
renderVariables?: boolean;
/**
* @type boolean
*/
targetBlank?: boolean;
/**
* @type string
*/
tooltip?: string;
/**
* @type string
*/
url?: string;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
}
@@ -4080,6 +4083,13 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
time_series = 'time_series',
raw = 'raw',
raw_stream = 'raw_stream',
trace = 'trace',
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
}
@@ -4384,19 +4394,16 @@ export interface DashboardtypesQuerySpecDTO {
}
export interface DashboardtypesQueryDTO {
/**
* @type string
*/
kind?: string;
kind?: Querybuildertypesv5RequestTypeDTO;
spec?: DashboardtypesQuerySpecDTO;
}
export interface DashboardtypesPanelSpecDTO {
display?: V1PanelDisplayDTO;
display?: DashboardPanelDisplayDTO;
/**
* @type array
*/
links?: V1LinkDTO[];
links?: DashboardLinkDTO[];
plugin?: DashboardtypesPanelPluginDTO;
/**
* @type array
@@ -4405,10 +4412,7 @@ export interface DashboardtypesPanelSpecDTO {
}
export interface DashboardtypesPanelDTO {
/**
* @type string
*/
kind?: string;
kind?: DashboardtypesPanelKindDTO;
spec?: DashboardtypesPanelSpecDTO;
}
@@ -4422,20 +4426,20 @@ export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
export type DashboardtypesDashboardSpecDTOPanels =
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
export enum DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTOKind {
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
Grid = 'Grid',
}
export interface DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTO {
export interface DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTO {
/**
* @enum Grid
* @type string
*/
kind: DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTOKind;
kind: DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind;
spec: DashboardGridLayoutSpecDTO;
}
export type DashboardtypesLayoutDTO =
DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTO;
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTO;
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind {
ListVariable = 'ListVariable',
@@ -4539,21 +4543,21 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTO {
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTOKind;
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**
@@ -4572,7 +4576,7 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
links?: V1LinkDTO[];
links?: DashboardLinkDTO[];
/**
* @type object,null
*/
@@ -6954,13 +6958,6 @@ export type Querybuildertypesv5QueryRangeRequestDTOVariables = {
[key: string]: Querybuildertypesv5VariableItemDTO;
};
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
time_series = 'time_series',
raw = 'raw',
raw_stream = 'raw_stream',
trace = 'trace',
}
/**
* Request body for the v5 query range endpoint. Supports builder queries (traces, logs, metrics), formulas, joins, trace operators, PromQL, and ClickHouse SQL queries.
*/
@@ -8707,6 +8704,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;

View File

@@ -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 = (
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
);
return (
<span className="refresh-payment-status-btn-wrapper">
<Tooltip title={type === 'tooltip' ? t('refreshPaymentStatus') : ''}>
<Button
type={type === 'text' ? 'text' : 'default'}
shape={btnShape}
className={cx('periscope-btn', { text: type === 'text' })}
onClick={handleRefreshPaymentStatus}
icon={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
</Tooltip>
{type === 'tooltip' ? (
<TooltipSimple title={t('refreshPaymentStatus')}>{button}</TooltipSimple>
) : (
button
)}
</span>
);
}
RefreshPaymentStatus.defaultProps = {
btnShape: 'default',
type: 'button',
className: undefined,
};
export default RefreshPaymentStatus;

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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<DataType> = [
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<any[]>([]);
const [data, setData] = useState<DataType[]>([]);
const [apiResponse, setApiResponse] = useState<
Partial<UsageResponsePayloadProps>
>({});
@@ -150,7 +152,7 @@ export default function BillingContainer(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const processUsageData = useCallback(
(data: any): void => {
(data: SuccessResponse<UsageResponsePayloadProps> | 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<UsageResponsePayloadProps>).payload;
const formattedUsageData: DataType[] = [];
if (breakdown && Array.isArray(breakdown)) {
for (let index = 0; index < breakdown.length; index += 1) {
const element = breakdown[index];
const element: BreakdownEntry = 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}`,
});
},
);
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 ? (
<>
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
<div className="billing-update-note">
Note: Billing metrics are updated once every 24 hours.
</div>
</>
) : (
<Card className="empty-graph-card" bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
),
[apiResponse, billAmount, isLoading, isFetchingBillingData],
);
const subscriptionPastDueMessage = (): JSX.Element => (
<Typography>
{`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 (
<div className="billing-container">
<Flex vertical style={{ marginBottom: 16 }}>
<Typography.Text style={{ fontWeight: 500, fontSize: 18 }}>
<div className={styles.billingContainer}>
<Flex vertical gap={4} className={styles.pageHeader}>
<Typography.Text className={styles.pageHeaderTitle}>
{t('billing')}
</Typography.Text>
<Typography.Text color="muted">
<Typography.Text className={styles.pageHeaderSubtitle}>
{t('manage_billing_and_costs')}
</Typography.Text>
</Flex>
@@ -428,50 +412,36 @@ export default function BillingContainer(): JSX.Element {
<Card
bordered={false}
style={{ minHeight: 150, marginBottom: 16 }}
className="page-info"
className={styles.pageInfo}
>
<Flex justify="space-between" align="center">
<Flex vertical>
<Typography.Title level={5} style={{ marginTop: 2, fontWeight: 500 }}>
<Flex vertical gap={8}>
<p className={styles.pageInfoTitle}>
{isCloudUserVal ? t('teams_cloud') : t('teams')}{' '}
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
</Typography.Title>
</p>
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
<Typography.Text style={{ fontSize: 12, color: Color.BG_VANILLA_400 }}>
<p className={styles.pageInfoSubtitle}>
{daysRemaining} {daysRemainingStr}
</Typography.Text>
</p>
) : null}
</Flex>
<Flex gap={8}>
<Button
type="default"
size="middle"
loading={isLoadingBilling || isLoadingManageBilling}
disabled={isLoading || isFetchingBillingData}
onClick={handleCsvDownload}
className="periscope-btn"
>
<Flex align="center" justify="center" gap={4}>
<CloudDownload size="md" />
Download CSV
</Flex>
</Button>
<Button
data-testid="header-billing-button"
type="primary"
size="middle"
loading={isLoadingBilling || isLoadingManageBilling}
disabled={isLoading}
onClick={handleBilling}
>
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</Button>
<RefreshPaymentStatus type="tooltip" />
</Flex>
<Button
testId="header-billing-button"
variant="solid"
color="secondary"
size="md"
loading={isLoadingBilling || isLoadingManageBilling}
disabled={isLoading}
onClick={handleBilling}
prefix={<Landmark size={14} />}
className={styles.billingManageBtn}
>
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</Button>
</Flex>
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
@@ -485,8 +455,8 @@ export default function BillingContainer(): JSX.Element {
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage
? headerText && (
<Alert
message={headerText}
<Callout
title={headerText}
type="info"
showIcon
style={{ marginTop: 12 }}
@@ -503,8 +473,8 @@ export default function BillingContainer(): JSX.Element {
billingData &&
trialInfo?.gracePeriodEnd &&
showGracePeriodMessage ? (
<Alert
message={`Your data is safe with us until ${getFormattedDate(
<Callout
title={`Your data is safe with us until ${getFormattedDate(
trialInfo?.gracePeriodEnd || Date.now(),
)}. Please upgrade plan now to retain your data.`}
type="info"
@@ -515,26 +485,69 @@ export default function BillingContainer(): JSX.Element {
{isSubscriptionPastDue &&
(!isLoading && !isFetchingBillingData ? (
<Alert
message={subscriptionPastDueMessage()}
type="error"
showIcon
style={{ marginTop: 12 }}
/>
<Callout type="error" showIcon style={{ marginTop: 12 }}>
{subscriptionPastDueMessage()}
</Callout>
) : (
<Skeleton.Input active style={{ height: 20, marginTop: 20 }} />
))}
</Card>
<BillingUsageGraphCallback />
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
</div>
)}
</div>
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
<div className="billing-details">
<div className={styles.billingDetails}>
{!isLoading && !isFetchingBillingData && (
<Table
columns={columns}
dataSource={data}
pagination={false}
bordered={false}
components={{
header: {
cell: ({
style,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
return (
<th
{...props}
style={safeStyle}
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
/>
);
},
},
}}
/>
)}
@@ -546,7 +559,7 @@ export default function BillingContainer(): JSX.Element {
)}
{!trialInfo?.trialConvertedToSubscription && (
<div className="upgrade-plan-benefits">
<div className={styles.upgradePlanBenefits}>
<Row
justify="space-between"
align="middle"
@@ -555,16 +568,16 @@ export default function BillingContainer(): JSX.Element {
}}
gutter={[16, 16]}
>
<Col span={20} className="plan-benefits">
<Typography.Text className="plan-benefit">
<Col span={20} className={styles.planBenefits}>
<Typography.Text className={styles.planBenefit}>
<CircleCheck size="md" />
{t('upgrade_now_text')}
</Typography.Text>
<Typography.Text className="plan-benefit">
<Typography.Text className={styles.planBenefit}>
<CircleCheck size="md" />
{t('Your billing will start only after the trial period')}
</Typography.Text>
<Typography.Text className="plan-benefit">
<Typography.Text className={styles.planBenefit}>
<CircleCheck size="md" />
<span>
{t('checkout_plans')} &nbsp;
@@ -583,9 +596,10 @@ export default function BillingContainer(): JSX.Element {
</Col>
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
data-testid="upgrade-plan-button"
type="primary"
size="middle"
testId="upgrade-plan-button"
variant="solid"
color="primary"
size="md"
loading={isLoadingBilling || isLoadingManageBilling}
onClick={handleBilling}
>

View File

@@ -0,0 +1,9 @@
.headerRow {
padding: var(--spacing-8);
}
.itemList {
overflow-y: auto;
max-height: 300px;
padding: var(--padding-3);
}

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react';
import cx from 'classnames';
import TooltipHeader from 'lib/uPlotV2/components/Tooltip/components/TooltipHeader/TooltipHeader';
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
import {
TooltipContentItem,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import TooltipStyles from 'lib/uPlotV2/components/Tooltip/Tooltip.module.scss';
import Styles from './BillingBarChartTooltip.module.scss';
interface BillingBarChartTooltipProps extends TooltipRenderArgs {
billingApiResponse: MetricRangePayloadProps;
}
const CURRENCY_SYMBOL = '$';
export function BillingBarChartTooltip({
billingApiResponse,
uPlotInstance,
dataIndexes,
seriesIndex,
isPinned,
}: BillingBarChartTooltipProps): JSX.Element {
const content = useMemo((): TooltipContentItem[] => {
const baseItems = buildTooltipContent({
data: uPlotInstance.data,
series: uPlotInstance.series,
dataIndexes,
activeSeriesIndex: seriesIndex,
uPlotInstance,
yAxisUnit: '',
isStackedBarChart: true,
});
return baseItems.map((item) => {
const match = billingApiResponse.data.result.find(
(r) => (r.legend || r.queryName) === item.label,
);
if (!match) {
return item;
}
const seriesIdx = uPlotInstance.series.findIndex(
(s) => s.label === item.label,
);
if (seriesIdx === -1) {
return item;
}
const dataIndex = dataIndexes[seriesIdx];
const quantity = dataIndex != null ? match.quantity?.[dataIndex] : null;
const unit = match.unit ?? '';
const quantityStr =
quantity != null ? ` - ${getToolTipValue(quantity)} ${unit}` : '';
return {
...item,
tooltipValue: `${CURRENCY_SYMBOL}${getToolTipValue(item.value, '')}${quantityStr}`,
};
});
}, [uPlotInstance, seriesIndex, dataIndexes, billingApiResponse]);
const activeItem = content.find((item) => item.isActive) ?? null;
return (
<div
className={cx(TooltipStyles.container, {
[TooltipStyles.pinned]: isPinned,
})}
data-testid="uplot-tooltip-container"
>
<TooltipHeader
uPlotInstance={uPlotInstance}
showTooltipHeader
isPinned={isPinned}
activeItem={null}
headerRowClassName={Styles.headerRow}
dateFormat={DATE_TIME_FORMATS.MONTH_DATE}
/>
{activeItem != null && <span className={TooltipStyles.divider} />}
<div className={Styles.itemList} data-testid="uplot-tooltip-list">
{content.map((item) => (
<TooltipItem key={item.label} item={item} isItemActive={item.isActive} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
.graphContainer {
height: 100%;
}
.billingGraphCard {
:global {
.uplot-no-data {
display: flex;
align-items: center;
justify-content: center;
}
.ant-card-body {
height: 40vh;
.uplot-graph-container {
padding: 8px;
}
}
}
.totalSpent {
font-family: 'SF Mono', monospace;
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px;
}
.totalSpentTitle {
font-size: 12px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.48px;
color: var(--l2-foreground);
}
}

View File

@@ -1,23 +0,0 @@
.billing-graph-card {
.ant-card-body {
height: 40vh;
.uplot-graph-container {
padding: 8px;
}
}
.total-spent {
font-family: 'SF Mono' monospace;
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px;
}
.total-spent-title {
font-size: 12px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.48px;
color: var(--l2-foreground);
}
}

View File

@@ -1,221 +1,146 @@
import { useMemo, useRef } from 'react';
import { Color } from '@signozhq/design-tokens';
import { useCallback, useMemo, useRef } from 'react';
import { Card, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import Uplot from 'components/Uplot';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import tooltipPlugin from 'lib/uPlotLib/plugins/tooltipPlugin';
import getAxes from 'lib/uPlotLib/utils/getAxes';
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { getXAxisScale } from 'lib/uPlotLib/utils/getXAxisScale';
import { getYAxisScale } from 'lib/uPlotLib/utils/getYAxisScale';
import uPlot from 'uplot';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import type uPlot from 'uplot';
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
import {
calculateStartEndTime,
convertDataToMetricRangePayload,
fillMissingValuesForQuantities,
} from './utils';
import './BillingUsageGraph.styles.scss';
import '../../../lib/uPlotLib/uPlotLib.styles.scss';
import styles from './BillingUsageGraph.module.scss';
interface BillingUsageGraphProps {
data: any;
data: Partial<UsageResponsePayloadProps>;
billAmount: number;
}
const paths = (
u: any,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: boolean,
buildClip: boolean,
): uPlot.Series.PathBuilder => {
const s = u.series[seriesIdx];
const style = s.drawStyle;
const interp = s.lineInterpolation;
const renderer = getRenderer(style, interp);
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
};
const calculateStartEndTime = (
data: any,
): { startTime: number; endTime: number } => {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown: any) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry: any) => {
timestamps.push(entry?.timestamp);
});
});
const billingTime = [data?.billingPeriodStart, data?.billingPeriodEnd];
const startTime: number = Math.min(...timestamps, ...billingTime);
const endTime: number = Math.max(...timestamps, ...billingTime);
return { startTime, endTime };
};
const numberFormatter = new Intl.NumberFormat('en-US');
export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const { data, billAmount } = props;
// Added this to fix the issue where breakdown with one day data are causing the bars to spread across multiple days
data?.details?.breakdown?.forEach((breakdown: any) => {
if (breakdown?.dayWiseBreakdown?.breakdown?.length === 1) {
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
count: 0,
size: 0,
quantity: 0,
total: 0,
};
breakdown.dayWiseBreakdown.breakdown.push(nextDay);
}
});
const graphCompatibleData = useMemo(
() => convertDataToMetricRangePayload(data),
[data],
);
const chartData = getUPlotChartData(graphCompatibleData);
const graphRef = useRef<HTMLDivElement>(null);
const isDarkMode = useIsDarkMode();
const containerDimensions = useResizeObserver(graphRef);
const { startTime, endTime } = useMemo(
() => calculateStartEndTime(data),
[data],
);
const getGraphSeries = (color: string, label: string): any => ({
drawStyle: 'bars',
paths,
lineInterpolation: 'spline',
show: true,
label,
fill: color,
stroke: color,
width: 2,
spanGaps: true,
points: {
size: 5,
show: false,
stroke: color,
},
});
const uPlotSeries: any = useMemo(
() => [
{ label: 'Timestamp', stroke: 'purple' },
getGraphSeries(
'#7CEDBE',
graphCompatibleData.data.result[0]?.legend as string,
),
getGraphSeries(
'#4E74F8',
graphCompatibleData.data.result[1]?.legend as string,
),
getGraphSeries(
'#F24769',
graphCompatibleData.data.result[2]?.legend as string,
),
],
[graphCompatibleData.data.result],
);
const axesOptions = getAxes({ isDarkMode, yAxisUnit: '' });
const optionsForChart: uPlot.Options = useMemo(
() => ({
id: 'billing-usage-breakdown',
series: uPlotSeries,
width: containerDimensions.width,
height: containerDimensions.height - 30,
axes: [
{
...axesOptions[0],
grid: {
...axesOptions.grid,
show: false,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
},
{
...axesOptions[1],
stroke: isDarkMode ? Color.BG_SLATE_200 : Color.BG_INK_400,
},
],
scales: {
x: {
...getXAxisScale(startTime - 86400, endTime), // Minus 86400 from startTime to decrease a day to have a buffer start
},
y: {
...getYAxisScale({
series: graphCompatibleData?.data?.newResult?.data?.result,
yAxisUnit: '',
softMax: null,
softMin: null,
}),
},
},
legend: {
show: true,
live: false,
isolate: true,
},
cursor: {
lock: false,
focus: {
prox: 1e6,
bias: 1,
},
},
focus: {
alpha: 0.3,
},
padding: [32, 32, 16, 16],
plugins: [
tooltipPlugin({
apiResponse: fillMissingValuesForQuantities(
graphCompatibleData,
chartData[0],
),
yAxisUnit: '',
isBillingUsageGraphs: true,
isDarkMode,
// Single-day data causes bars to span multiple days — add a synthetic
// zero-value next-day entry so uPlot renders a correctly-sized single-day bar.
const normalizedData = useMemo(() => {
if (!data?.details?.breakdown) {
return data;
}
return {
...data,
details: {
...data.details,
breakdown: data.details.breakdown.map((breakdown) => {
if (breakdown?.dayWiseBreakdown?.breakdown?.length !== 1) {
return breakdown;
}
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
count: 0,
size: 0,
quantity: 0,
total: 0,
};
return {
...breakdown,
dayWiseBreakdown: {
...breakdown.dayWiseBreakdown,
breakdown: [...breakdown.dayWiseBreakdown.breakdown, nextDay],
},
};
}),
],
}),
[
axesOptions,
chartData,
containerDimensions.height,
containerDimensions.width,
endTime,
graphCompatibleData,
isDarkMode,
startTime,
uPlotSeries,
],
},
};
}, [data]);
const graphCompatibleData = useMemo(
() => convertDataToMetricRangePayload(normalizedData),
[normalizedData],
);
const numberFormatter = new Intl.NumberFormat('en-US');
const chartData = useMemo(
() => prepareChartData(graphCompatibleData) as uPlot.AlignedData,
[graphCompatibleData],
);
const filledApiResponse = useMemo(
(): MetricRangePayloadProps =>
fillMissingValuesForQuantities(
graphCompatibleData,
chartData[0] as number[],
),
[graphCompatibleData, chartData],
);
const { startTime, endTime } = useMemo(
() =>
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
[normalizedData],
);
const config = useMemo(
() =>
prepareBillingBarConfig({
isDarkMode,
// Subtract 86400s (one day) from startTime to add a buffer before first bar
minTimeScale: startTime !== undefined ? startTime - 86400 : undefined,
maxTimeScale: endTime,
apiResponse: graphCompatibleData,
}),
[isDarkMode, startTime, endTime, graphCompatibleData],
);
const renderBillingTooltip = useCallback(
(args: TooltipRenderArgs) => (
<BillingBarChartTooltip billingApiResponse={filledApiResponse} {...args} />
),
[filledApiResponse],
);
return (
<Card bordered={false} className="billing-graph-card">
<Card bordered={false} className={styles.billingGraphCard}>
<Flex justify="space-between">
<Flex vertical gap={6}>
<Typography.Text className="total-spent-title">
<Typography.Text className={styles.totalSpentTitle}>
TOTAL SPENT
</Typography.Text>
<Typography.Text className="total-spent">
<Typography.Text className={styles.totalSpent}>
${numberFormatter.format(billAmount)}
</Typography.Text>
</Flex>
</Flex>
<div ref={graphRef} style={{ height: '100%', paddingBottom: 48 }}>
<Uplot data={chartData} options={optionsForChart} />
<div ref={graphRef} className={styles.graphContainer}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
config={config}
data={chartData}
isStackedBarChart
legendConfig={{ position: LegendPosition.BOTTOM }}
customTooltip={renderBillingTooltip}
width={containerDimensions.width}
height={containerDimensions.height - 30}
canPinTooltip
/>
)}
</div>
</Card>
);

View File

@@ -0,0 +1,165 @@
import React from 'react';
import { render, screen } from 'tests/test-utils';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { BillingBarChartTooltip } from '../BillingBarChartTooltip';
// Mock buildTooltipContent so tests don't depend on uPlot stacking math
jest.mock('lib/uPlotV2/components/Tooltip/utils', () => ({
buildTooltipContent: jest.fn().mockReturnValue([
{
label: 'Logs',
value: 100,
tooltipValue: '$100.00',
color: '#7CEDBE',
isActive: true,
isHighlighted: false,
},
{
label: 'Traces',
value: 50,
tooltipValue: '$50.00',
color: '#4E74F8',
isActive: false,
isHighlighted: false,
},
]),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn().mockReturnValue(false),
}));
function makeUPlotInstance(seriesLabels: string[]): uPlot {
return {
data: [
[1000, 2000],
[100, 200],
[50, 80],
],
cursor: { idx: 0 },
series: [
{ label: 'Timestamp', show: true, stroke: '#000' },
...seriesLabels.map((label) => ({
label,
show: true,
stroke: '#aabbcc',
})),
],
} as unknown as uPlot;
}
function makeBillingApiResponse(
entries: { legend: string; quantity: (number | null)[]; unit: string }[],
): MetricRangePayloadProps {
return {
data: {
result: entries.map((e) => ({
legend: e.legend,
queryName: e.legend,
metric: {},
values: [[1000, '10']] as [number, string][],
quantity: e.quantity as number[],
unit: e.unit,
})),
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
};
}
const baseTooltipArgs = {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
seriesIndex: 1,
dataIndexes: [null, 0, 0],
};
describe('BillingBarChartTooltip', () => {
it('augments tooltipValue with quantity and unit for each series', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [1.5, 2.0], unit: 'GB' },
{ legend: 'Traces', quantity: [500, 800], unit: 'spans' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText(/1\.5 GB/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/500 spans/i).length).toBeGreaterThan(0);
});
it('omits quantity line when quantity at dataIndex is null', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [null, null], unit: 'GB' },
{ legend: 'Traces', quantity: [null, null], unit: 'spans' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.queryByText(/null GB/i)).not.toBeInTheDocument();
expect(screen.queryByText(/null spans/i)).not.toBeInTheDocument();
expect(screen.getByTestId('uplot-tooltip-container')).toBeInTheDocument();
});
it('formats dollar value via getToolTipValue — strips trailing zeros (0.3076 → $0.3)', () => {
const uPlotInstance = makeUPlotInstance(['Logs']);
const { buildTooltipContent } = jest.requireMock(
'lib/uPlotV2/components/Tooltip/utils',
) as { buildTooltipContent: jest.Mock };
buildTooltipContent.mockReturnValueOnce([
{
label: 'Logs',
value: 0.3076171875,
tooltipValue: '$0.31',
color: '#7CEDBE',
isActive: true,
isHighlighted: false,
},
]);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [1.23], unit: 'GB' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText(/\$0\.3 -/i).length).toBeGreaterThan(0);
});
it('passes through base tooltipValue when series is not in billingApiResponse', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText('$100.00').length).toBeGreaterThan(0);
expect(screen.getAllByText('$50.00').length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,101 @@
import { Color } from '@signozhq/design-tokens';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { prepareBillingBarConfig } from '../prepareBillingBarConfig';
const makeApiResponse = (legends: string[]): MetricRangePayloadProps => ({
data: {
result: legends.map((legend) => ({
legend,
queryName: legend,
metric: {},
values: [[1000, '10']],
})),
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
});
describe('prepareBillingBarConfig', () => {
const baseProps = { isDarkMode: false };
it('returns a builder with no series when apiResponse is undefined', () => {
const builder = prepareBillingBarConfig(baseProps);
const config = builder.getConfig();
expect(config.series).toHaveLength(1);
});
it('returns a builder with no series when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.series).toHaveLength(1);
});
it('adds one series per result entry with correct labels and colors', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
expect(config.series).toHaveLength(4);
expect(config.series?.[1]?.label).toBe('Logs');
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
expect(config.series?.[2]?.label).toBe('Traces');
expect(config.series?.[2]?.stroke).toBe(Color.BG_ROBIN_500);
expect(config.series?.[3]?.label).toBe('Metrics');
expect(config.series?.[3]?.stroke).toBe(Color.BG_SAKURA_500);
});
it('assigns fallback color (Amber500) for signals beyond the 3-color palette', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['A', 'B', 'C', 'D']),
});
const config = builder.getConfig();
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
});
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
expect(config.focus).toStrictEqual({ alpha: 0.3 });
});
it('sets no bands when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.bands).toBeUndefined();
});
it('uses queryName as label when legend is undefined', () => {
const apiResponse: MetricRangePayloadProps = {
data: {
result: [
{
legend: undefined as any,
queryName: 'Logs',
metric: {},
values: [[1000, '10']],
},
],
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
};
const builder = prepareBillingBarConfig({ isDarkMode: false, apiResponse });
const config = builder.getConfig();
expect(config.series?.[1]?.label).toBe('Logs');
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
});
});

View File

@@ -0,0 +1,145 @@
import {
calculateStartEndTime,
convertDataToMetricRangePayload,
} from '../utils';
const makeData = (
timestamps: number[],
billingPeriodStart?: number,
billingPeriodEnd?: number,
) => ({
billingPeriodStart,
billingPeriodEnd,
details: {
total: 0,
baseFee: 0,
billTotal: 0,
breakdown: [
{
type: 'Logs',
unit: 'GB',
dayWiseBreakdown: {
breakdown: timestamps.map((timestamp) => ({
timestamp,
total: 0,
quantity: 0,
count: 0,
size: 0,
})),
},
},
],
},
});
describe('convertDataToMetricRangePayload', () => {
it('returns empty result when all dayWiseBreakdown.breakdown are null', () => {
const data = {
billingPeriodStart: 1778763678,
billingPeriodEnd: 1781442078,
details: {
total: 0,
baseFee: 49,
billTotal: 49,
breakdown: [
{
type: 'Metrics',
unit: 'Million',
tiers: [],
dayWiseBreakdown: { type: '', breakdown: null },
},
{
type: 'Traces',
unit: 'GB',
tiers: [],
dayWiseBreakdown: { type: '', breakdown: null },
},
{
type: 'Logs',
unit: 'GB',
tiers: [],
dayWiseBreakdown: { type: '', breakdown: null },
},
],
},
};
const result = convertDataToMetricRangePayload(data);
expect(result.data.result).toHaveLength(0);
});
it('includes only series that have day-wise data', () => {
const data = {
details: {
breakdown: [
{
type: 'Metrics',
unit: 'Million',
dayWiseBreakdown: { breakdown: null },
},
{
type: 'Logs',
unit: 'GB',
dayWiseBreakdown: {
breakdown: [
{ timestamp: 1000, total: 5, quantity: 10, count: 0, size: 0 },
],
},
},
],
},
};
const result = convertDataToMetricRangePayload(data);
expect(result.data.result).toHaveLength(1);
expect(result.data.result[0].legend).toBe('Logs');
});
});
describe('calculateStartEndTime', () => {
it('returns min/max of all breakdown timestamps', () => {
const data = makeData([1000, 3000, 2000]);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 1000,
endTime: 3000,
});
});
it('includes billingPeriodStart and billingPeriodEnd in the range', () => {
const data = makeData([2000, 3000], 500, 4000);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 500,
endTime: 4000,
});
});
it('returns undefined when there are no timestamps and no billing period', () => {
expect(calculateStartEndTime({})).toStrictEqual({
startTime: undefined,
endTime: undefined,
});
});
it('returns undefined when breakdown is empty', () => {
const data = makeData([]);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: undefined,
endTime: undefined,
});
});
it('filters out non-finite billingPeriod values', () => {
const data = makeData([1000], NaN, Infinity);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 1000,
endTime: 1000,
});
});
it('works when details is missing', () => {
expect(
calculateStartEndTime({ billingPeriodStart: 100, billingPeriodEnd: 200 }),
).toStrictEqual({
startTime: 100,
endTime: 200,
});
});
});

View File

@@ -0,0 +1,71 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
const BILLING_SERIES_COLORS = [
Color.BG_FOREST_300,
Color.BG_ROBIN_500,
Color.BG_SAKURA_500,
];
export interface PrepareBillingBarConfigProps {
isDarkMode: boolean;
timezone?: Timezone;
minTimeScale?: number;
maxTimeScale?: number;
apiResponse?: MetricRangePayloadProps;
}
export function prepareBillingBarConfig({
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
apiResponse,
}: PrepareBillingBarConfigProps): UPlotConfigBuilder {
const builder = buildBaseConfig({
id: 'billing-usage-breakdown',
isDarkMode,
timezone,
panelType: PANEL_TYPES.BAR,
minTimeScale,
maxTimeScale,
});
const results = apiResponse?.data?.result;
if (!results?.length) {
return builder;
}
const labels = results.map((s) => s.legend || s.queryName || '');
const colorMapping = labels.reduce<Record<string, string>>(
(acc, label, index) => {
acc[label] = BILLING_SERIES_COLORS[index] ?? Color.BG_AMBER_500;
return acc;
},
{},
);
labels.forEach((label) => {
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
metric: {},
});
});
builder.setBands(getInitialStackedBands(results.length));
builder.setPadding([32, 32, 16, 16]);
builder.setFocus({ alpha: 0.3 });
return builder;
}

View File

@@ -1,7 +1,7 @@
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { isEmpty, isNull } from 'lodash-es';
import { unparse } from 'papaparse';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -29,23 +29,25 @@ export const convertDataToMetricRangePayload = (
return emptyStateData;
}
const payload = breakdown.map((info: any) => {
const metric = info.type;
const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort(
(a: any, b: any) => a.timestamp - b.timestamp,
);
const values = (sortedBreakdownData || []).map((categoryInfo: any) => [
categoryInfo.timestamp,
categoryInfo.total,
]);
const queryName = info.type;
const legend = info.type;
const { unit } = info;
const quantity = sortedBreakdownData.map(
(categoryInfo: any) => categoryInfo.quantity,
);
return { metric, values, queryName, legend, quantity, unit };
});
const payload = breakdown
.map((info: any) => {
const metric = info.type;
const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort(
(a: any, b: any) => a.timestamp - b.timestamp,
);
const values = (sortedBreakdownData || []).map((categoryInfo: any) => [
categoryInfo.timestamp,
categoryInfo.total,
]);
const queryName = info.type;
const legend = info.type;
const { unit } = info;
const quantity = sortedBreakdownData.map(
(categoryInfo: any) => categoryInfo.quantity,
);
return { metric, values, queryName, legend, quantity, unit };
})
.filter((series: any) => series.values.length > 0);
const sortedData = payload.sort((a: any, b: any) => {
const sumA = a.values.reduce((acc: any, val: any) => acc + val[1], 0);
@@ -120,11 +122,40 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
fileName: string;
} {
const graphCompatibleData = convertDataToMetricRangePayload(data);
const chartData = getUPlotChartData(graphCompatibleData);
const quantityMapArr = quantityDataArr(graphCompatibleData, chartData[0]);
const chartData = prepareChartData(graphCompatibleData);
const quantityMapArr = quantityDataArr(
graphCompatibleData,
chartData[0] as number[],
);
return {
csvData: unparse(generateCsvData(quantityMapArr)),
fileName: csvFileName(quantityMapArr),
};
}
export function calculateStartEndTime(
data: Partial<UsageResponsePayloadProps>,
): { startTime: number | undefined; endTime: number | undefined } {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
timestamps.push(entry.timestamp);
});
});
const billingTime: number[] = [
data?.billingPeriodStart,
data?.billingPeriodEnd,
].filter((t): t is number => typeof t === 'number' && Number.isFinite(t));
const allTimes = [...timestamps, ...billingTime];
if (allTimes.length === 0) {
return { startTime: undefined, endTime: undefined };
}
return {
startTime: Math.min(...allTimes),
endTime: Math.max(...allTimes),
};
}

View File

@@ -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,
queryKey: _accountServiceQueryKey,
data: accountServiceData,
isLoading: isAccountServiceLoading,
} = useGetAccountService(
{
cloudProvider: type,
id: cloudAccountId || '',
serviceId: serviceId || '',
},
{
query: {
enabled: !!serviceId && !!cloudAccountId,
select: (response): ServiceDetailsData => response.data,
},
},
);
const {
queryKey: _readOnlyServiceQueryKey,
data: readOnlyServiceData,
isLoading: isReadOnlyServiceLoading,
} = useGetService(
{
cloudProvider: type,
serviceId: serviceId || '',
},
{
...serviceQueryParams,
},
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,

View File

@@ -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(

View File

@@ -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<string, string[]> = {},

View File

@@ -18,6 +18,8 @@ interface TooltipHeaderProps {
showTooltipHeader: boolean;
isPinned: boolean;
activeItem: TooltipContentItem | null;
headerRowClassName?: string;
dateFormat?: string;
}
export default function TooltipHeader({
@@ -26,6 +28,8 @@ export default function TooltipHeader({
showTooltipHeader,
isPinned,
activeItem,
headerRowClassName,
dateFormat = DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
}: TooltipHeaderProps): JSX.Element {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
@@ -44,12 +48,13 @@ export default function TooltipHeader({
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
.format(dateFormat);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,
dateFormat,
]);
return (
@@ -58,7 +63,7 @@ export default function TooltipHeader({
data-testid="uplot-tooltip-header-container"
>
{showTooltipHeader && headerTitle && (
<div className={Styles.headerRow}>
<div className={cx(Styles.headerRow, headerRowClassName)}>
<span>{headerTitle}</span>
{isPinned && (
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">

View File

@@ -6,6 +6,10 @@ import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface Props {
@@ -17,9 +21,22 @@ interface Props {
* data. Currently unused on purpose.
*/
isVisible?: boolean;
/** Section actions — present only in editable sectioned mode. */
currentLayoutIndex?: number;
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function Panel({ panel, panelId, isVisible }: Props): JSX.Element {
function Panel({
panel,
panelId,
isVisible,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
@@ -48,7 +65,17 @@ function Panel({ panel, panelId, isVisible }: Props): JSX.Element {
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
<EllipsisVertical size={14} />
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={currentLayoutIndex}
sections={sections ?? []}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>

View File

@@ -0,0 +1,16 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import styles from './PanelActionsMenu.module.scss';
interface Props {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function PanelActionsMenu({
panelId,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onMovePanel) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
disabled: true,
});
} else {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
onMovePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
});
}
}
if (onDeletePanel) {
if (result.length > 0) {
result.push({ type: 'divider' });
}
result.push({
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
});
}
return result;
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default PanelActionsMenu;

View File

@@ -0,0 +1,22 @@
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.typeButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-100, #fff);
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--bg-robin-500);
}
}

View File

@@ -0,0 +1,82 @@
import { Modal } from 'antd';
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}
const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];
interface Props {
open: boolean;
onClose: () => void;
onSelect: (pluginKind: string) => void;
}
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
>
<div className={styles.grid}>
{PANEL_TYPES.map((type) => (
<button
key={type.pluginKind}
type="button"
className={styles.typeButton}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
>
{type.icon}
{type.label}
</button>
))}
</div>
</Modal>
);
}
export default PanelTypeSelectionModal;

View File

@@ -0,0 +1,76 @@
import { useCallback } from 'react';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addPanelToSectionOps,
createDefaultPanel,
panelRef,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface AddPanelArgs {
layoutIndex: number;
pluginKind: string;
}
/**
* Creates a new panel and places its item ref at the bottom of the target
* section, as one atomic patch. Structure-only: the panel is a valid minimal
* placeholder (its query is filled in once the panel editor lands).
*/
export function useAddPanelToSection({
sections,
}: Params): (args: AddPanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const target = sections.find((s) => s.layoutIndex === layoutIndex);
if (!target) {
return;
}
const panelId = uuid();
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
try {
await patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId,
panel: createDefaultPanel(pluginKind),
layoutIndex,
item: {
x: 0,
y: nextY,
width: 6,
height: 6,
content: { $ref: panelRef(panelId) },
},
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -0,0 +1,54 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface DeletePanelArgs {
panelId: string;
layoutIndex: number;
}
/**
* Removes a panel: drops its item ref from the section's items and deletes the
* panel from `spec.panels`, as one atomic patch.
*/
export function useDeletePanel({
sections,
}: Params): (args: DeletePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ panelId, layoutIndex }: DeletePanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const section = sections.find((s) => s.layoutIndex === layoutIndex);
if (!section) {
return;
}
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -0,0 +1,79 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
export interface MovePanelArgs {
panelId: string;
fromLayoutIndex: number;
toLayoutIndex: number;
}
interface Params {
sections: DashboardSection[];
}
/**
* Relocates a panel's item ref from one section to another. The panel itself
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
* the bottom of the target section. Persisted as one atomic patch.
*/
export function useMovePanelToSection({
sections,
}: Params): (args: MovePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({
panelId,
fromLayoutIndex,
toLayoutIndex,
}: MovePanelArgs): Promise<void> => {
if (!dashboardId || fromLayoutIndex === toLayoutIndex) {
return;
}
const source = sections.find((s) => s.layoutIndex === fromLayoutIndex);
const target = sections.find((s) => s.layoutIndex === toLayoutIndex);
if (!source || !target) {
return;
}
const moved = source.items.find((i) => i.id === panelId);
if (!moved) {
return;
}
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Place at a fresh row at the bottom of the target section.
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchDashboardV2(
{ id: dashboardId },
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
targetIndex: toLayoutIndex,
targetItems,
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -0,0 +1,17 @@
.addButton {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background: transparent;
border: 1px dashed var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
border-color: var(--bg-robin-500);
color: var(--bg-vanilla-100, #fff);
}
}

View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../../utils';
import { useAddSection } from '../hooks/useAddSection';
import { useFirstSectionMigration } from '../hooks/useFirstSectionMigration';
import FirstSectionMigrationModal from '../FirstSectionMigrationModal';
import styles from './AddSectionControl.module.scss';
const DEFAULT_SECTION_TITLE = 'New section';
interface Props {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
isSectioned: boolean;
}
function AddSectionControl({
sections,
layouts,
isSectioned,
}: Props): JSX.Element {
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
const { addSection } = useAddSection({ layouts });
const { migrate, isSaving } = useFirstSectionMigration({ sections });
// Free-flowing dashboard with existing panels → must migrate before sections
// can coexist (every panel must belong to a section once any exists).
const needsMigration =
!isSectioned && sections.some((s) => s.items.length > 0);
const handleClick = (): void => {
if (needsMigration) {
setIsMigrationOpen(true);
return;
}
void addSection(DEFAULT_SECTION_TITLE);
};
const handleConfirmMigration = async (): Promise<void> => {
await migrate(DEFAULT_SECTION_TITLE);
setIsMigrationOpen(false);
};
return (
<>
<button
type="button"
className={styles.addButton}
onClick={handleClick}
data-testid="add-section"
>
<Plus size={14} />
Add section
</button>
<FirstSectionMigrationModal
open={isMigrationOpen}
isSaving={isSaving}
onClose={(): void => setIsMigrationOpen(false)}
onConfirm={handleConfirmMigration}
/>
</>
);
}
export default AddSectionControl;

View File

@@ -0,0 +1,41 @@
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
interface Props {
open: boolean;
isSaving: boolean;
onClose: () => void;
onConfirm: () => void;
}
/**
* Shown when the user adds the first section to a free-flowing dashboard that
* already has panels. Confirms grouping the existing panels into a section
* before proceeding.
*/
function FirstSectionMigrationModal({
open,
isSaving,
onClose,
onConfirm,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Group panels into sections?"
onCancel={onClose}
onOk={onConfirm}
okText="Continue"
okButtonProps={{ disabled: isSaving, 'data-testid': 'confirm-migration' }}
destroyOnClose
>
<Typography.Text>
This dashboard&apos;s panels are currently free-flowing. Adding a section
will move the existing panels into their own section, and a new empty
section will be added below. You can rename sections afterwards.
</Typography.Text>
</Modal>
);
}
export default FirstSectionMigrationModal;

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface Props {
open: boolean;
initialValue: string;
isSaving: boolean;
onClose: () => void;
onSubmit: (title: string) => void;
}
function RenameSectionModal({
open,
initialValue,
isSaving,
onClose,
onSubmit,
}: Props): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.
useEffect(() => {
if (open) {
setValue(initialValue);
}
}, [open, initialValue]);
const submit = (): void => {
const trimmed = value.trim();
if (trimmed) {
onSubmit(trimmed);
}
};
return (
<Modal
open={open}
title="Rename section"
onCancel={onClose}
onOk={submit}
okText="Rename"
okButtonProps={{ disabled: isSaving || !value.trim() }}
destroyOnClose
>
<Input
testId="rename-section-input"
autoFocus
value={value}
maxLength={120}
placeholder="Section name"
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
</Modal>
);
}
export default RenameSectionModal;

View File

@@ -1,17 +1,45 @@
import { useRef, useState } from 'react';
import { Modal } from 'antd';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import RenameSectionModal from '../RenameSectionModal';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader from '../SectionHeader/SectionHeader';
import SectionHeader, {
type SectionDragHandle,
} from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
interface Props {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
}
function Section({ section }: Props): JSX.Element {
function Section({
section,
onAddPanel,
sections,
onMovePanel,
onDeletePanel,
dragHandle,
}: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
@@ -19,10 +47,48 @@ function Section({ section }: Props): JSX.Element {
rootMargin: '200px',
});
const [open, setOpen] = useState<boolean>(section.open);
const toggle = (): void => setOpen((prev) => !prev);
const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id });
const grid = <SectionGrid items={section.items} isVisible={isVisible} />;
const [isRenaming, setIsRenaming] = useState(false);
const { rename, isSaving } = useRenameSection({
layoutIndex: section.layoutIndex,
});
const handleRenameSubmit = async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
};
const [isAddingPanel, setIsAddingPanel] = useState(false);
const handleSelectPanelType = (pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
};
const { deleteSection } = useDeleteSection({ section });
const confirmDeleteSection = (): void => {
Modal.confirm({
title: `Delete section "${section.title ?? ''}"?`,
content: 'Panels in this section will be removed.',
okText: 'Delete',
okButtonProps: { danger: true },
centered: true,
onOk: () => deleteSection(),
});
};
const grid = (
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
);
if (!section.title) {
// Untitled section — just the grid (no header chrome), but still observed
@@ -51,8 +117,26 @@ function Section({ section }: Props): JSX.Element {
open={open}
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
onAddPanel={
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
}
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
/>
{open ? grid : null}
<RenameSectionModal
open={isRenaming}
initialValue={section.title}
isSaving={isSaving}
onClose={(): void => setIsRenaming(false)}
onSubmit={handleRenameSubmit}
/>
<PanelTypeSelectionModal
open={isAddingPanel}
onClose={(): void => setIsAddingPanel(false)}
onSelect={handleSelectPanelType}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -0,0 +1,68 @@
import { useMemo } from 'react';
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import styles from './SectionActionsMenu.module.scss';
interface Props {
sectionId: string;
onAddPanel?: () => void;
onRename?: () => void;
onDeleteSection?: () => void;
}
function SectionActionsMenu({
sectionId,
onAddPanel,
onRename,
onDeleteSection,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onAddPanel) {
result.push({
key: 'add-panel',
icon: <Plus size={14} />,
label: 'Add panel',
onClick: onAddPanel,
});
}
if (onRename) {
result.push({
key: 'rename',
icon: <PenLine size={14} />,
label: 'Rename section',
onClick: onRename,
});
}
if (onDeleteSection) {
result.push(
{ type: 'divider' },
{
key: 'delete-section',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete section',
onClick: onDeleteSection,
},
);
}
return result;
}, [onAddPanel, onRename, onDeleteSection]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Section actions"
data-testid={`dashboard-section-actions-${sectionId}`}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default SectionActionsMenu;

View File

@@ -0,0 +1,7 @@
.preview {
border: 1px solid var(--bg-robin-500);
border-radius: 4px;
background: var(--bg-ink-400, #0b0c0e);
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
cursor: grabbing;
}

View File

@@ -0,0 +1,32 @@
import type { DashboardSection } from '../../../utils';
import SectionHeader from '../SectionHeader/SectionHeader';
import styles from './SectionDragPreview.module.scss';
interface Props {
section: DashboardSection;
}
/**
* Lightweight preview rendered inside the DragOverlay while a section is being
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
* cheap and never triggers RGL width re-measurement.
*/
function SectionDragPreview({ section }: Props): JSX.Element {
const panelCount = section.items.length;
const title = `${section.title ?? ''} · ${panelCount} ${
panelCount === 1 ? 'panel' : 'panels'
}`;
return (
<div className={styles.preview}>
<SectionHeader
sectionId={`${section.id}-preview`}
title={title}
open={false}
onToggle={(): void => undefined}
/>
</div>
);
}
export default SectionDragPreview;

View File

@@ -2,18 +2,35 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import Panel from '../../Panel/Panel';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
items: DashboardSection['items'];
layoutIndex: number;
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
/** All sections + handlers — present only in editable sectioned mode (panel "Move to section" / delete). */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function SectionGrid({ items, isVisible }: Props): JSX.Element {
function SectionGrid({
items,
layoutIndex,
isVisible,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
@@ -26,6 +43,8 @@ function SectionGrid({ items, isVisible }: Props): JSX.Element {
[items],
);
const { handleLayoutChange } = usePersistLayout({ layoutIndex, items });
return (
<ResponsiveGridLayout
className={styles.grid}
@@ -34,13 +53,24 @@ function SectionGrid({ items, isVisible }: Props): JSX.Element {
autoSize
useCSSTransforms
layout={rglLayout}
isDraggable={false}
isResizable={false}
draggableHandle=".panel-drag-handle"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}
onResizeStop={handleLayoutChange}
margin={[8, 8]}
>
{items.map((item) => (
<div key={item.id}>
<Panel panel={item.panel} panelId={item.id} isVisible={isVisible} />
<Panel
panel={item.panel}
panelId={item.id}
isVisible={isVisible}
currentLayoutIndex={layoutIndex}
sections={isEditable ? sections : undefined}
onMovePanel={isEditable ? onMovePanel : undefined}
onDeletePanel={isEditable ? onDeletePanel : undefined}
/>
</div>
))}
</ResponsiveGridLayout>

View File

@@ -1,15 +1,29 @@
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import SectionActionsMenu from '../SectionActionsMenu/SectionActionsMenu';
import styles from './SectionHeader.module.scss';
export interface SectionDragHandle {
attributes: DraggableAttributes;
listeners: SyntheticListenerMap | undefined;
setActivatorNodeRef: (element: HTMLElement | null) => void;
}
interface Props {
sectionId: string;
title: string;
open: boolean;
onToggle: () => void;
repeatVariable?: string;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
onRename?: () => void;
onAddPanel?: () => void;
onDeleteSection?: () => void;
}
function SectionHeader({
@@ -18,9 +32,27 @@ function SectionHeader({
open,
onToggle,
repeatVariable,
dragHandle,
onRename,
onAddPanel,
onDeleteSection,
}: Props): JSX.Element {
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
{dragHandle ? (
<button
type="button"
className={styles.dragHandle}
ref={dragHandle.setActivatorNodeRef}
aria-label="Drag to reorder section"
data-testid={`dashboard-section-drag-${sectionId}`}
{...dragHandle.attributes}
{...dragHandle.listeners}
>
<GripVertical size={14} />
</button>
) : null}
<button
type="button"
className={styles.toggle}
@@ -35,6 +67,14 @@ function SectionHeader({
</Typography.Text>
) : null}
</button>
{hasActions ? (
<SectionActionsMenu
sectionId={sectionId}
onAddPanel={onAddPanel}
onRename={onRename}
onDeleteSection={onDeleteSection}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../utils';
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import Section from './Section/Section';
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
import SortableSection from './SortableSection';
interface Props {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
function SectionList({ sections, layouts }: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
} = useSectionDragReorder({ sections, layouts });
const onAddPanel = useAddPanelToSection({ sections });
const onMovePanel = useMovePanelToSection({ sections });
const onDeletePanel = useDeletePanel({ sections });
// Only titled sections participate in reordering; untitled (free-flow)
// blocks render in place without a drag handle.
const sortableIds = useMemo(
() => orderedSections.filter((s) => s.title).map((s) => s.id),
[orderedSections],
);
if (!isEditable) {
return (
<>
{sections.map((section) => (
<Section key={section.id} section={section} />
))}
</>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) =>
section.title ? (
<SortableSection
key={section.id}
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<Section
key={section.id}
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
),
)}
</SortableContext>
{/* dropAnimation disabled: optimistic reorder already places the section,
so animating the overlay back would cause a visible snap/shake. */}
<DragOverlay dropAnimation={null}>
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
</DragOverlay>
</DndContext>
);
}
export default SectionList;

View File

@@ -0,0 +1,59 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DashboardSection } from '../../utils';
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
import Section from './Section/Section';
interface Props {
section: DashboardSection;
sections: DashboardSection[];
onAddPanel: (args: AddPanelArgs) => void;
onMovePanel: (args: MovePanelArgs) => void;
onDeletePanel: (args: DeletePanelArgs) => void;
}
function SortableSection({
section,
sections,
onAddPanel,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
// dnd-kit drives the drag transform per-frame, so this must be an inline
// style — there is no static-stylesheet equivalent for a live transform.
// While dragging, the original is hidden (the DragOverlay renders the moving
// preview); keeping it in place preserves the gap and lets siblings animate.
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<Section
section={section}
sections={sections}
onAddPanel={onAddPanel}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
</div>
);
}
export default SortableSection;

View File

@@ -0,0 +1,59 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addSectionOp,
newGridLayout,
reorderLayoutsOp,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
interface Result {
addSection: (title: string) => Promise<void>;
isSaving: boolean;
}
/**
* Appends an empty titled section. When the dashboard has no layouts yet, the
* layouts array is created via a `replace` (an `add` to a missing/empty array
* pointer is unreliable); otherwise a new Grid is appended.
*/
export function useAddSection({ layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const addSection = useCallback(
async (title: string): Promise<void> => {
const trimmed = title.trim();
if (!dashboardId || !trimmed) {
return;
}
const op =
!layouts || layouts.length === 0
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[layouts, dashboardId, refetch, showErrorModal],
);
return { addSection, isSaving };
}

View File

@@ -0,0 +1,51 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, removeSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
section: DashboardSection;
}
interface Result {
deleteSection: () => Promise<void>;
isSaving: boolean;
}
/**
* Deletes a section: removes its Grid layout and deletes every panel it
* contained from `spec.panels` (orphan cleanup), as one atomic patch.
*/
export function useDeleteSection({ section }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const deleteSection = useCallback(async (): Promise<void> => {
if (!dashboardId) {
return;
}
const ops: DashboardtypesJSONPatchOperationDTO[] = section.items.map((i) =>
removePanelOp(i.id),
);
ops.push(removeSectionOp(section.layoutIndex));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [section, dashboardId, refetch, showErrorModal]);
return { deleteSection, isSaving };
}

View File

@@ -0,0 +1,64 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
interface Result {
migrate: (newSectionTitle: string) => Promise<void>;
isSaving: boolean;
}
/**
* Converts a free-flowing dashboard into a sectioned one: every existing
* untitled layout that holds panels is titled in place ("Section 1", "Section
* 2", …), then the brand-new section the user asked for is appended — all in one
* atomic patch. Used once the user confirms the migration prompt.
*/
export function useFirstSectionMigration({ sections }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const migrate = useCallback(
async (newSectionTitle: string): Promise<void> => {
const trimmed = newSectionTitle.trim();
if (!dashboardId || !trimmed) {
return;
}
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
let counter = 1;
sections.forEach((s) => {
if (!s.title && s.items.length > 0) {
ops.push(titleUntitledSectionOp(s.layoutIndex, `Section ${counter}`));
counter += 1;
}
});
ops.push(addSectionOp(trimmed));
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
return { migrate, isSaving };
}

View File

@@ -0,0 +1,97 @@
import { useCallback, useState } from 'react';
import type { Layout } from 'react-grid-layout';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { GridItem } from '../../../utils';
interface Params {
layoutIndex: number;
items: GridItem[];
}
interface Result {
handleLayoutChange: (rglLayout: Layout[]) => void;
isSaving: boolean;
}
/** Maps an RGL layout back onto the section's grid items, preserving panel refs. */
function mergeRglLayout(rglLayout: Layout[], items: GridItem[]): GridItem[] {
const byId = new Map(items.map((item) => [item.id, item]));
return rglLayout
.map((entry) => {
const existing = byId.get(entry.i);
if (!existing) {
return null;
}
return {
...existing,
x: entry.x,
y: entry.y,
width: entry.w,
height: entry.h,
};
})
.filter((item): item is GridItem => item !== null);
}
function hasGeometryChanged(next: GridItem[], prev: GridItem[]): boolean {
if (next.length !== prev.length) {
return true;
}
const prevById = new Map(prev.map((item) => [item.id, item]));
return next.some((item) => {
const before = prevById.get(item.id);
if (!before) {
return true;
}
return (
before.x !== item.x ||
before.y !== item.y ||
before.width !== item.width ||
before.height !== item.height
);
});
}
/**
* Persists panel geometry within a single section. Call the returned handler
* from RGL's `onDragStop`/`onResizeStop` (stop events only — not continuous
* `onLayoutChange`) to limit network churn.
*/
export function usePersistLayout({ layoutIndex, items }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const handleLayoutChange = useCallback(
async (rglLayout: Layout[]): Promise<void> => {
if (!dashboardId) {
return;
}
const nextItems = mergeRglLayout(rglLayout, items);
if (!hasGeometryChanged(nextItems, items)) {
return;
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[dashboardId, items, layoutIndex, refetch, showErrorModal],
);
return { handleLayoutChange, isSaving };
}

View File

@@ -0,0 +1,50 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { renameSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
interface Params {
layoutIndex: number;
}
interface Result {
rename: (title: string) => Promise<boolean>;
isSaving: boolean;
}
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
export function useRenameSection({ layoutIndex }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
const rename = useCallback(
async (title: string): Promise<boolean> => {
const trimmed = title.trim();
if (!dashboardId || !trimmed) {
return false;
}
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [
renameSectionOp(layoutIndex, trimmed),
]);
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, layoutIndex, refetch, showErrorModal],
);
return { rename, isSaving };
}

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type DragEndEvent,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { reorderLayoutsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
interface Result {
sensors: ReturnType<typeof useSensors>;
/** Display order — optimistically reordered on drop so the UI doesn't wait on refetch. */
orderedSections: DashboardSection[];
/** The section currently being dragged (for the DragOverlay preview), or null. */
activeSection: DashboardSection | null;
onDragStart: (event: DragStartEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
onDragCancel: () => void;
}
/**
* Owns section-reorder drag state. Reorders happen optimistically in local
* state (keyed by stable section id) and persist via a single
* `replace /spec/layouts` patch; the optimistic order is cleared once fresh
* server data arrives. Each section maps 1:1 to a Grid layout via `layoutIndex`,
* so the new layouts array is rebuilt by mapping the reordered sections back.
*/
export function useSectionDragReorder({ sections, layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
const { showErrorModal } = useErrorModal();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Server data is the source of truth — drop optimistic order whenever it changes.
useEffect(() => {
setLocalOrderIds(null);
}, [sections]);
const orderedSections = useMemo<DashboardSection[]>(() => {
if (!localOrderIds) {
return sections;
}
const byId = new Map(sections.map((s) => [s.id, s]));
const ordered = localOrderIds
.map((id) => byId.get(id))
.filter((s): s is DashboardSection => s !== undefined);
return ordered.length === sections.length ? ordered : sections;
}, [sections, localOrderIds]);
const onDragStart = useCallback((event: DragStartEvent): void => {
setActiveId(String(event.active.id));
}, []);
const onDragCancel = useCallback((): void => {
setActiveId(null);
}, []);
const onDragEnd = useCallback(
async (event: DragEndEvent): Promise<void> => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id || !dashboardId || !layouts) {
return;
}
const oldIndex = orderedSections.findIndex((s) => s.id === active.id);
const newIndex = orderedSections.findIndex((s) => s.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return;
}
const newOrdered = arrayMove(orderedSections, oldIndex, newIndex);
setLocalOrderIds(newOrdered.map((s) => s.id));
const newLayouts = newOrdered
.map((s) => layouts[s.layoutIndex])
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
try {
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
refetch();
} catch (error) {
setLocalOrderIds(null); // revert optimistic order on failure
showErrorModal(error as APIError);
}
},
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
);
const activeSection = useMemo(
() => orderedSections.find((s) => s.id === activeId) ?? null,
[orderedSections, activeId],
);
return {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
};
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import {
selectIsSectionOpen,
useDashboardStore,
} from '../../../store/useDashboardStore';
interface Params {
sectionId: string;
}
interface Result {
open: boolean;
toggle: () => void;
}
/**
* Owns a section's expand/collapse state. Collapse is a frontend-only, per-user
* preference (not in the dashboard spec): it lives in the persisted zustand
* store, keyed by dashboardId + section id, and survives reloads. Default open.
*/
export function useToggleSectionCollapse({ sectionId }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const open = useDashboardStore(selectIsSectionOpen(dashboardId, sectionId));
const toggleSectionCollapse = useDashboardStore(
(s) => s.toggleSectionCollapse,
);
const toggle = useCallback((): void => {
if (dashboardId) {
toggleSectionCollapse(dashboardId, sectionId);
}
}, [dashboardId, sectionId, toggleSectionCollapse]);
return { open, toggle };
}

View File

@@ -7,8 +7,11 @@ import type {
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../store/useDashboardStore';
import { layoutsToSections } from '../utils';
import AddSectionControl from './Section/AddSectionControl/AddSectionControl';
import Section from './Section/Section/Section';
import SectionList from './Section/SectionList';
import styles from './PanelsAndSectionsLayout.module.scss';
import 'react-grid-layout/css/styles.css';
@@ -20,6 +23,8 @@ interface Props {
}
function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const sections = useMemo(
() => layoutsToSections(layouts, panels),
[layouts, panels],
@@ -28,6 +33,11 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const isEmpty =
sections.length === 0 || sections.every((s) => s.items.length === 0);
// Sectioned mode = at least one titled layout. Sections then become a
// reorderable list; otherwise the dashboard is a single free-flowing grid
// with no section chrome or reordering.
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
const renderContent = (): ReactNode => {
if (isEmpty) {
return (
@@ -42,12 +52,27 @@ function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
);
}
if (isSectioned) {
return <SectionList sections={sections} layouts={layouts} />;
}
return sections.map((section) => (
<Section key={section.id} section={section} />
));
};
return <div className={styles.body}>{renderContent()}</div>;
return (
<div className={styles.body}>
{renderContent()}
{isEditable ? (
<AddSectionControl
sections={sections}
layouts={layouts}
isSectioned={isSectioned}
/>
) : null}
</div>
);
}
export default PanelsAndSectionsLayout;

View File

@@ -1,10 +1,13 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import DashboardDescription from './DashboardDescription';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
interface Props {
@@ -15,6 +18,17 @@ interface Props {
function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const isEditable = !dashboard.locked && editDashboard;
// Publish edit context to the store so hooks/components read it from there
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
const setEditContext = useDashboardStore((s) => s.setEditContext);
useEffect(() => {
setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch });
}, [dashboard.id, isEditable, refetch, setEditContext]);
const { spec } = dashboard;
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);

View File

@@ -0,0 +1,177 @@
import type {
DashboardGridItemDTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import type { GridItem } from './utils';
/**
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
* intentionally side-effect-free (no React, no network) so they can be unit
* tested and reused by the layout hooks. JSON pointers target the postable
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const PANEL_REF_PREFIX = '#/spec/panels/';
export function panelRef(panelId: string): string {
return `${PANEL_REF_PREFIX}${panelId}`;
}
/**
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
* requires exactly one query whose plugin kind is allowed for the panel;
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
* validated, so an empty builder query is the safe default. The real query is
* filled in once the panel editor lands.
*/
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
// The DTO types plugin/query kinds as large generated enum unions; the kind
// here is chosen dynamically by the user, so we build the structurally-valid
// shape and assert the type.
return {
kind: 'Panel',
spec: {
display: { name: 'New panel' },
plugin: { kind: pluginKind, spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
export function gridItemToDTO(item: GridItem): DashboardGridItemDTO {
return {
x: item.x,
y: item.y,
width: item.width,
height: item.height,
content: { $ref: panelRef(item.id) },
};
}
/** Replace the entire items array of one section (used on panel move/resize). */
export function replaceSectionItemsOp(
layoutIndex: number,
items: GridItem[],
): DashboardtypesJSONPatchOperationDTO {
return {
op: replace,
path: `/spec/layouts/${layoutIndex}/spec/items`,
value: items.map(gridItemToDTO),
};
}
/** Replace the whole layouts array (used on section reorder — avoids move-index ambiguity). */
export function reorderLayoutsOp(
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesJSONPatchOperationDTO {
return { op: replace, path: '/spec/layouts', value: layouts };
}
/** An empty titled Grid layout (one section). */
export function newGridLayout(title: string): DashboardtypesLayoutDTO {
return {
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
spec: { display: { title }, items: [] },
};
}
/** Append a new, empty titled Grid section. */
export function addSectionOp(
title: string,
): DashboardtypesJSONPatchOperationDTO {
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
}
interface AddPanelToSectionArgs {
panelId: string;
panel: DashboardtypesPanelDTO;
layoutIndex: number;
item: DashboardGridItemDTO;
}
/** Add a panel to `spec.panels` and an item ref into a section, as one atomic patch. */
export function addPanelToSectionOps({
panelId,
panel,
layoutIndex,
item,
}: AddPanelToSectionArgs): DashboardtypesJSONPatchOperationDTO[] {
return [
{ op: add, path: `/spec/panels/${panelId}`, value: panel },
{ op: add, path: `/spec/layouts/${layoutIndex}/spec/items/-`, value: item },
];
}
interface MovePanelArgs {
sourceIndex: number;
sourceItems: GridItem[];
targetIndex: number;
targetItems: GridItem[];
}
/** Move a panel's item ref from one section to another (panel stays in spec.panels). */
export function movePanelBetweenSectionsOps({
sourceIndex,
sourceItems,
targetIndex,
targetItems,
}: MovePanelArgs): DashboardtypesJSONPatchOperationDTO[] {
return [
replaceSectionItemsOp(sourceIndex, sourceItems),
replaceSectionItemsOp(targetIndex, targetItems),
];
}
/** Rename an existing section's title. */
export function renameSectionOp(
layoutIndex: number,
title: string,
): DashboardtypesJSONPatchOperationDTO {
return {
op: replace,
path: `/spec/layouts/${layoutIndex}/spec/display/title`,
value: title,
};
}
/**
* First-section migration: give an existing untitled (free-flowing) layout a
* title, turning it into a section in place while preserving its panels.
*/
export function titleUntitledSectionOp(
layoutIndex: number,
title: string,
): DashboardtypesJSONPatchOperationDTO {
return {
op: add,
path: `/spec/layouts/${layoutIndex}/spec/display`,
value: { title },
};
}
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
export function removeSectionOp(
layoutIndex: number,
): DashboardtypesJSONPatchOperationDTO {
return { op: remove, path: `/spec/layouts/${layoutIndex}` };
}
/** Remove a panel definition from `spec.panels`. */
export function removePanelOp(
panelId: string,
): DashboardtypesJSONPatchOperationDTO {
return { op: remove, path: `/spec/panels/${panelId}` };
}

View File

@@ -0,0 +1,35 @@
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/**
* Section collapse state — frontend-only and persisted to localStorage. Keyed by
* dashboardId → section stable id → open. An absent entry means "open" (the
* default). This is intentionally NOT server state: collapse is a per-user UI
* preference, so it lives here instead of in the dashboard spec.
*/
export interface CollapseSlice {
collapsed: Record<string, Record<string, boolean>>;
toggleSectionCollapse: (dashboardId: string, sectionId: string) => void;
}
export const createCollapseSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
CollapseSlice
> = (set, get) => ({
collapsed: {},
toggleSectionCollapse: (dashboardId, sectionId): void => {
const { collapsed } = get();
const current = collapsed[dashboardId]?.[sectionId];
// Absent → open by default, so the first toggle closes it.
const next = current === undefined ? false : !current;
set({
collapsed: {
...collapsed,
[dashboardId]: { ...collapsed[dashboardId], [sectionId]: next },
},
});
},
});

View File

@@ -0,0 +1,38 @@
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
/**
* Edit context shared across the V2 dashboard tree — the dashboard id, whether
* the user can edit, and the react-query refetch. Set once by DashboardContainer
* so hooks/components read it from the store instead of receiving it as props
* through every layer. Not persisted.
*/
export interface EditContextSlice {
dashboardId: string;
isEditable: boolean;
refetch: () => void;
setEditContext: (ctx: {
dashboardId: string;
isEditable: boolean;
refetch: () => void;
}) => void;
}
export const createEditContextSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
EditContextSlice
> = (set) => ({
dashboardId: '',
isEditable: false,
refetch: (): void => undefined,
setEditContext: (ctx): void => {
set({
dashboardId: ctx.dashboardId,
isEditable: ctx.isEditable,
refetch: ctx.refetch,
});
},
});

View File

@@ -0,0 +1,44 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
createEditContextSlice,
type EditContextSlice,
} from './slices/editContextSlice';
import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
export type DashboardStore = EditContextSlice & CollapseSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
* - collapse: per-section open state (frontend-only, persisted to localStorage).
*/
export const useDashboardStore = create<DashboardStore>()(
persist(
(...a) => ({
...createEditContextSlice(...a),
...createCollapseSlice(...a),
}),
{
name: '@signoz/dashboard-v2',
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
},
),
);
/** Selector: is a section open? Absent entry (or no dashboard) → open by default. */
export const selectIsSectionOpen =
(dashboardId: string, sectionId: string) =>
(state: DashboardStore): boolean => {
if (!dashboardId) {
return true;
}
const value = state.collapsed[dashboardId]?.[sectionId];
return value === undefined ? true : value;
};

View File

@@ -72,7 +72,6 @@ export interface DashboardSection {
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
layoutIndex: number;
title: string | undefined;
open: boolean;
items: GridItem[];
repeatVariable: string | undefined;
}
@@ -127,15 +126,11 @@ export function layoutsToSections(
.filter((it): it is GridItem => it !== null);
const title = spec?.display?.title;
// `open` defaults to true when no collapse field is set (the section
// is expanded by default).
const open = spec?.display?.collapse?.open !== false;
return {
id: getSectionStableId(items, idx),
layoutIndex: idx,
title,
open,
items,
repeatVariable: spec?.repeatVariable,
};

View File

@@ -300,7 +300,7 @@ export default function WorkspaceBlocked(): JSX.Element {
View Billing
</Button>
<RefreshPaymentStatus btnShape="round" />
<RefreshPaymentStatus />
</Flex>
)}

View File

@@ -143,7 +143,7 @@ function WorkspaceSuspended(): JSX.Element {
>
{t('continueMyJourney')}
</Button>
<RefreshPaymentStatus btnShape="round" />
<RefreshPaymentStatus />
</Flex>
</Row>
)}

7
go.mod
View File

@@ -43,7 +43,7 @@ require (
github.com/openfga/api/proto v0.0.0-20260319214821-f153694bfc20
github.com/openfga/language/pkg/go v0.2.1
github.com/opentracing/opentracing-go v1.2.0
github.com/perses/perses v0.53.1
github.com/perses/spec v0.1.2
github.com/pkg/errors v0.9.1
github.com/prometheus/alertmanager v0.31.1
github.com/prometheus/client_golang v1.23.2
@@ -138,9 +138,8 @@ require (
github.com/huandu/go-clone v1.7.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/perses/common v0.30.2 // indirect
github.com/nxadm/tail v1.4.11 // indirect
github.com/prometheus/client_golang/exp v0.0.0-20260325093428-d8591d0db856 // indirect
github.com/puzpuzpuz/xsync/v4 v4.4.0 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
@@ -151,8 +150,6 @@ require (
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zitadel/oidc/v3 v3.45.4 // indirect
github.com/zitadel/schema v1.3.2 // indirect
go.opentelemetry.io/collector/client v1.54.0 // indirect
go.opentelemetry.io/collector/config/configoptional v1.50.0 // indirect
go.opentelemetry.io/collector/config/configretry v1.50.0 // indirect

16
go.sum
View File

@@ -332,6 +332,7 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
@@ -832,8 +833,6 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -843,8 +842,6 @@ github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk=
github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M=
github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0=
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
@@ -907,10 +904,8 @@ github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCko
github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perses/common v0.30.2 h1:RAiVxUpX76lTCb4X7pfcXSvYdXQmZwKi4oDKAEO//u0=
github.com/perses/common v0.30.2/go.mod h1:DFtur1QPah2/ChXbKKhw7djYdwNgz27s5fPKpiK0Xao=
github.com/perses/perses v0.53.1 h1:9VY/6p9QWrZwPSV7qiwTMSOsgcB37Lb1AXKT0ORXc6I=
github.com/perses/perses v0.53.1/go.mod h1:ro8fsgBkHYOdrL/MV+fdP9mflKzYCy/+gcbxiaReI/A=
github.com/perses/spec v0.1.2 h1:yGoygcR3ZusuGDCmRMwsVXCMvMwi1qZndKV6NYNreEw=
github.com/perses/spec v0.1.2/go.mod h1:NoGI5jmGwRdkdPgyYSZJTBL4/Py+dqIPKS2QV8NOvGE=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
@@ -1171,10 +1166,6 @@ github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zitadel/oidc/v3 v3.45.4 h1:GKyWaPRVQ8sCu9XgJ3NgNGtG52FzwVJpzXjIUG2+YrI=
github.com/zitadel/oidc/v3 v3.45.4/go.mod h1:XALmFXS9/kSom9B6uWin1yJ2WTI/E4Ti5aXJdewAVEs=
github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU=
@@ -1619,6 +1610,7 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -12,7 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/spec/go/common"
"k8s.io/apimachinery/pkg/util/validation"
)

View File

@@ -9,8 +9,9 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/spec/go/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -43,7 +44,7 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
},
Queries: []Query{
{
Kind: "TimeSeriesQuery",
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindPromQL,

View File

@@ -8,12 +8,12 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
)
// DashboardSpec is the SigNoz dashboard v2 spec shape. It mirrors
// v1.DashboardSpec (Perses) field-for-field, except every common.Plugin
// dashboard.Spec (Perses) field-for-field, except every common.Plugin
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
type DashboardSpec struct {
@@ -24,7 +24,7 @@ type DashboardSpec struct {
Layouts []Layout `json:"layouts"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval,omitempty"`
Links []v1.Link `json:"links,omitempty"`
Links []dashboard.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════

View File

@@ -44,7 +44,7 @@ const basePostableJSON = `{
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
@@ -67,7 +67,7 @@ const basePostableJSON = `{
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "X",
"signal": "metrics",
@@ -184,7 +184,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
@@ -218,7 +218,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"spec": {
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
@@ -275,7 +275,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0",
"value": {
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B",
"signal": "metrics",
@@ -344,7 +344,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
@@ -508,7 +508,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
}]`).Apply(base)
@@ -534,11 +534,11 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "metrics",
"aggregations": [{"metricName": "signoz_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}},
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B", "signal": "metrics",
"aggregations": [{"metricName": "signoz_db_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}}

View File

@@ -133,6 +133,21 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}`,
wantContain: "NonExistentPanel",
},
{
name: "unknown panel envelope kind",
data: `{
"panels": {
"p1": {
"kind": "Row",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}
}
}
},
"layouts": []
}`,
wantContain: "unknown panel kind",
},
{
name: "unknown query plugin",
data: `{
@@ -142,7 +157,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {"kind": "FakeQueryPlugin", "spec": {}}
}
@@ -154,6 +169,48 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}`,
wantContain: "FakeQueryPlugin",
},
{
name: "unknown query envelope kind",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}
}
}]
}
}
},
"layouts": []
}`,
wantContain: "unknown request type",
},
{
name: "empty query envelope kind",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "",
"spec": {
"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}
}
}]
}
}
},
"layouts": []
}`,
wantContain: "unknown request type",
},
{
name: "unknown variable plugin",
data: `{
@@ -246,7 +303,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/PromQLQuery",
@@ -324,7 +381,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/PromQLQuery",
@@ -389,7 +446,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"spec": {}
},
"queries": [{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -620,8 +677,8 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}}},
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "B", "signal": "metrics"}}}}
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}}},
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "B", "signal": "metrics"}}}}
]
}
}
@@ -738,7 +795,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
@@ -786,7 +843,7 @@ func TestNumberPanelDefaults(t *testing.T) {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
@@ -847,7 +904,7 @@ func TestStorageRoundTrip(t *testing.T) {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
},
"p2": {
@@ -857,7 +914,7 @@ func TestStorageRoundTrip(t *testing.T) {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
@@ -1062,7 +1119,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
}}},
"layouts": []
}`)
@@ -1071,7 +1128,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {
"queries": [{"type": "` + subType + `", "spec": ` + subSpec + `}]
}}}}]
}}},

View File

@@ -10,8 +10,8 @@ import (
"strings"
"testing"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/perses/spec/go/dashboard"
"github.com/perses/spec/go/datasource"
"github.com/stretchr/testify/assert"
)
@@ -22,12 +22,12 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
ours reflect.Type
perses reflect.Type
}{
{"DashboardSpec", typeOf[DashboardSpec](), typeOf[v1.DashboardSpec]()},
{"Panel", typeOf[Panel](), typeOf[v1.Panel]()},
{"PanelSpec", typeOf[PanelSpec](), typeOf[v1.PanelSpec]()},
{"Query", typeOf[Query](), typeOf[v1.Query]()},
{"QuerySpec", typeOf[QuerySpec](), typeOf[v1.QuerySpec]()},
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[v1.DatasourceSpec]()},
{"DashboardSpec", typeOf[DashboardSpec](), typeOf[dashboard.Spec]()},
{"Panel", typeOf[Panel](), typeOf[dashboard.Panel]()},
{"PanelSpec", typeOf[PanelSpec](), typeOf[dashboard.PanelSpec]()},
{"Query", typeOf[Query](), typeOf[dashboard.Query]()},
{"QuerySpec", typeOf[QuerySpec](), typeOf[dashboard.QuerySpec]()},
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},

View File

@@ -1,14 +1,15 @@
package dashboardtypes
import (
"encoding/json"
"maps"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/perses/perses/pkg/model/api/v1/variable"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
"github.com/perses/spec/go/dashboard/variable"
"github.com/swaggest/jsonschema-go"
)
@@ -27,15 +28,36 @@ type DatasourceSpec struct {
// ══════════════════════════════════════════════
type Panel struct {
Kind string `json:"kind"`
Kind PanelKind `json:"kind"`
Spec PanelSpec `json:"spec"`
}
// PanelKind is the panel envelope discriminator. Perses leaves it a free
// string; SigNoz locks it to the single valid value.
type PanelKind string
const PanelKindPanel PanelKind = "Panel"
// Enum surfaces the allowed value in the generated OpenAPI schema.
func (PanelKind) Enum() []any { return []any{PanelKindPanel} }
func (k *PanelKind) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid panel kind")
}
if PanelKind(s) != PanelKindPanel {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown panel kind %q; allowed values: %s", s, allowedValuesForKind([]PanelKind{PanelKindPanel}))
}
*k = PanelKind(s)
return nil
}
type PanelSpec struct {
Display *v1.PanelDisplay `json:"display,omitempty"`
Plugin PanelPlugin `json:"plugin"`
Queries []Query `json:"queries,omitempty"`
Links []v1.Link `json:"links,omitempty"`
Display *dashboard.PanelDisplay `json:"display,omitempty"`
Plugin PanelPlugin `json:"plugin"`
Queries []Query `json:"queries,omitempty"`
Links []dashboard.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
@@ -43,8 +65,8 @@ type PanelSpec struct {
// ══════════════════════════════════════════════
type Query struct {
Kind string `json:"kind"`
Spec QuerySpec `json:"spec"`
Kind qb.RequestType `json:"kind"`
Spec QuerySpec `json:"spec"`
}
type QuerySpec struct {

View File

@@ -132,7 +132,7 @@
],
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -191,7 +191,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/CompositeQuery",
@@ -269,7 +269,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -334,7 +334,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -373,7 +373,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -418,7 +418,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -476,7 +476,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/ClickHouseSQL",
@@ -517,7 +517,7 @@
},
"queries": [
{
"kind": "LogQuery",
"kind": "raw",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -571,7 +571,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -610,7 +610,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/CompositeQuery",
@@ -681,7 +681,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/CompositeQuery",
@@ -722,7 +722,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/PromQLQuery",

View File

@@ -30,7 +30,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
@@ -85,7 +85,7 @@
},
"queries": [
{
"kind": "TimeSeriesQuery",
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",

View File

@@ -1,11 +1,32 @@
package querybuildertypesv5
import "github.com/SigNoz/signoz/pkg/valuer"
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type RequestType struct {
valuer.String
}
// UnmarshalJSON rejects values that are not a known request type.
func (r *RequestType) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: must be a string")
}
v := RequestType{valuer.NewString(s)}
switch v {
case RequestTypeScalar, RequestTypeTimeSeries, RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeDistribution:
*r = v
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`")
}
}
var (
RequestTypeUnknown = RequestType{valuer.NewString("")}
// Scalar result(s), example: number panel, and table panel.

View File

@@ -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