mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-01 12:20:38 +01:00
Compare commits
18 Commits
nv/layout-
...
nv/v2-publ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276165fb72 | ||
|
|
ff22facdd6 | ||
|
|
72a6ca6516 | ||
|
|
bbd5cc380e | ||
|
|
6cd9b5bbd6 | ||
|
|
13f6c232a1 | ||
|
|
dac1489294 | ||
|
|
1d98e9ebf6 | ||
|
|
15e99e43ff | ||
|
|
8c766f8c10 | ||
|
|
99b32f00b9 | ||
|
|
76f8646c69 | ||
|
|
28c00e298a | ||
|
|
4592b12256 | ||
|
|
b990d40c5f | ||
|
|
95a0d7c035 | ||
|
|
e678728c61 | ||
|
|
42d3e7e0e4 |
@@ -177,11 +177,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return nil, err
|
||||
}
|
||||
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider(defStore)
|
||||
gcpCloudProviderModule := implcloudprovider.NewGCPCloudProvider(defStore)
|
||||
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
|
||||
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
|
||||
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
|
||||
cloudintegrationtypes.CloudProviderTypeGCP: gcpCloudProviderModule,
|
||||
}
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
|
||||
@@ -1024,8 +1024,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesAgentReport:
|
||||
nullable: true
|
||||
@@ -1171,8 +1169,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionArtifact'
|
||||
type: object
|
||||
CloudintegrationtypesCredentials:
|
||||
properties:
|
||||
@@ -1203,46 +1199,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
CloudintegrationtypesGCPAccountConfig:
|
||||
properties:
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionArtifact:
|
||||
type: object
|
||||
CloudintegrationtypesGCPIntegrationConfig:
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceConfig:
|
||||
properties:
|
||||
logs:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceLogsConfig'
|
||||
metrics:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceMetricsConfig'
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceLogsConfig:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceMetricsConfig:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGettableAccountWithConnectionArtifact:
|
||||
properties:
|
||||
connectionArtifact:
|
||||
@@ -1375,8 +1331,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesPostableAgentCheckIn:
|
||||
properties:
|
||||
@@ -1401,8 +1355,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
@@ -1447,8 +1399,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceConfig'
|
||||
type: object
|
||||
CloudintegrationtypesServiceDashboard:
|
||||
properties:
|
||||
@@ -1491,7 +1441,6 @@ components:
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
- cloudsql
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -1553,8 +1502,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAzureAccountConfig:
|
||||
properties:
|
||||
@@ -1565,22 +1512,6 @@ components:
|
||||
required:
|
||||
- resourceGroups
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableGCPAccountConfig:
|
||||
properties:
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableService:
|
||||
properties:
|
||||
config:
|
||||
@@ -2932,6 +2863,13 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettablePublicDashboardDataV2:
|
||||
properties:
|
||||
dashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -17649,6 +17587,138 @@ paths:
|
||||
summary: Update my organization
|
||||
tags:
|
||||
- orgs
|
||||
/api/v2/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the sanitized v2-shape dashboard data for
|
||||
public access. Each panel query is reduced to a safe field subset, so filters
|
||||
and raw query strings are not exposed.
|
||||
operationId: GetPublicDashboardDataV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardDataV2'
|
||||
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:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get public dashboard data (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/public/dashboards/{id}/panels/{key}/query_range:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns query range results for a panel of a v2-shape
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: key
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
|
||||
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:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get query range result (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/readyz:
|
||||
get:
|
||||
operationId: Readyz
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
|
||||
return &gcpcloudprovider{
|
||||
serviceDefinitions: defStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// for manual flow we don't have any integration config to return, so returning empty config for now.
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeGCP, serviceID)
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
querier querier.Querier
|
||||
licensing licensing.Licensing
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
@@ -41,6 +42,7 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
|
||||
settings: scopedProviderSettings,
|
||||
querier: querier,
|
||||
licensing: licensing,
|
||||
tagModule: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +134,49 @@ func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.U
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, storableDashboard.OrgID, coretypes.KindDashboard, storableDashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storableDashboard.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "dashboard",
|
||||
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRangeV2",
|
||||
})
|
||||
|
||||
dashboard, err := module.GetDashboardByPublicIDV2(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicDashboard, err := module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange(startTimeRaw, endTimeRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query, err := dashboard.GetPanelQuery(startTime, endTime, panelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
|
||||
@@ -61,7 +61,5 @@
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
}
|
||||
|
||||
@@ -86,7 +86,5 @@
|
||||
"ROLE_EDIT": "SigNoz | Edit Role",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ import type {
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
@@ -1799,6 +1803,217 @@ export const useLockDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const getPublicDashboardDataV2 = (
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardDataV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryKey = ({
|
||||
id,
|
||||
}: GetPublicDashboardDataV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetPublicDashboardDataV2QueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
> = ({ signal }) => getPublicDashboardDataV2({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
>;
|
||||
export type GetPublicDashboardDataV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardDataV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardDataV2QueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardDataV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardDataV2QueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(id && key),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
>;
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
|
||||
* @summary List dashboards for the current user (v2)
|
||||
|
||||
@@ -2630,25 +2630,9 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPAccountConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAccountDTO {
|
||||
@@ -2756,29 +2740,9 @@ export interface CloudintegrationtypesAzureServiceConfigDTO {
|
||||
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceLogsConfigDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceMetricsConfigDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceConfigDTO {
|
||||
logs?: CloudintegrationtypesGCPServiceLogsConfigDTO;
|
||||
metrics?: CloudintegrationtypesGCPServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSServiceConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureServiceConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPServiceConfigDTO;
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesServiceIDDTO {
|
||||
@@ -2809,7 +2773,6 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
cosmosdb = 'cosmosdb',
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
cloudsql = 'cloudsql',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -2874,14 +2837,9 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
|
||||
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
|
||||
gcp?: CloudintegrationtypesGCPConnectionArtifactDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesCredentialsDTO {
|
||||
@@ -2914,10 +2872,6 @@ export interface CloudintegrationtypesDataCollectedDTO {
|
||||
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
|
||||
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
|
||||
/**
|
||||
@@ -3009,7 +2963,6 @@ export type CloudintegrationtypesIntegrationConfigDTO =
|
||||
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPIntegrationConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
@@ -3072,7 +3025,6 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountDTO {
|
||||
@@ -3202,25 +3154,9 @@ export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
projectIds: string[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesUpdatableGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountDTO {
|
||||
@@ -4951,6 +4887,11 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
dashboard?: DashboardtypesGettableDashboardV2DTO;
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -11171,6 +11112,29 @@ export type GetMyOrganization200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetPublicDashboardDataV2200 = {
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Readyz200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -680,13 +680,6 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Datetime', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('formats datetime units', () => {
|
||||
expect(formatUniversalUnit(900, UniversalYAxisUnit.DATETIME_FROM_NOW)).toBe(
|
||||
'56 years ago',
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
.filtersBar {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filtersBarLeft {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filtersBarSearch {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.filtersBarSource {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.pageError {
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
background: color-mix(in srgb, var(--accent-cherry) 8%, transparent);
|
||||
color: var(--accent-cherry);
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
|
||||
color: var(--text-cherry-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
@@ -1,164 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus, Search, X } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTableParams } from 'components/TanStackTableView';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import {
|
||||
LIMIT_KEY,
|
||||
PAGE_KEY,
|
||||
PAGE_SIZE,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
SEARCH_KEY,
|
||||
SOURCE_FILTER_OPTIONS,
|
||||
SOURCE_FILTER_TO_IS_OVERRIDE,
|
||||
SOURCE_KEY,
|
||||
type SourceFilter,
|
||||
} from '../constants';
|
||||
import type { PricingRule } from '../types';
|
||||
import DeleteConfirmDialog from './components/DeleteConfirmDialog';
|
||||
import ModelCostDrawer, {
|
||||
useModelCostDrawer,
|
||||
} from './components/ModelCostDrawer';
|
||||
import ModelCostsTable from './components/ModelCostsTable';
|
||||
import { useModelCostDelete } from './hooks/useModelCostDelete';
|
||||
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
|
||||
import styles from './ModelCostTabPanel.module.scss';
|
||||
import ModelCostsTable from './components/ModelCostsTable';
|
||||
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// "Model costs" tab: the priced-model listing, search + source filter, the add/
|
||||
// edit drawer, and pagination. Page and page size live in the URL (shareable/
|
||||
// reload-safe) and are owned by TanStackTable via enableQueryParams — this tab
|
||||
// reads them back through the same useTableParams hook so the two stay in lockstep.
|
||||
function ModelCostTabPanel(): JSX.Element {
|
||||
const { page, limit, setPage } = useTableParams(
|
||||
const { page, limit } = useTableParams(
|
||||
{ page: PAGE_KEY, limit: LIMIT_KEY },
|
||||
{ page: 1, limit: PAGE_SIZE },
|
||||
);
|
||||
|
||||
const [search, setSearch] = useQueryState(
|
||||
SEARCH_KEY,
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const [source, setSource] = useQueryState(
|
||||
SOURCE_KEY,
|
||||
parseAsStringEnum<SourceFilter>(
|
||||
SOURCE_FILTER_OPTIONS.map((option) => option.value),
|
||||
).withDefault('all'),
|
||||
);
|
||||
|
||||
const handleSearchChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
): void => {
|
||||
void setSearch(event.target.value || null);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const clearSearch = (): void => {
|
||||
void setSearch(null);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSourceChange = (value: string | string[]): void => {
|
||||
void setSource(value as SourceFilter);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const isOverride = SOURCE_FILTER_TO_IS_OVERRIDE[source];
|
||||
|
||||
// Search + source filters are intentionally omitted for now — the list API
|
||||
// doesn't honour them yet. They'll be reintroduced here once it does.
|
||||
const listParams: ListLLMPricingRulesParams = {
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
...(debouncedSearch ? { q: debouncedSearch } : {}),
|
||||
...(isOverride !== undefined ? { isOverride } : {}),
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams, {
|
||||
query: {
|
||||
enabled: search === debouncedSearch,
|
||||
},
|
||||
});
|
||||
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [canManagePricing] = useComponentPermission(
|
||||
['manage_llm_pricing'],
|
||||
user.role,
|
||||
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
|
||||
() => data?.data?.items || [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
const drawer = useModelCostDrawer();
|
||||
const deletion = useModelCostDelete();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.filtersBar}>
|
||||
<div className={styles.filtersBarLeft}>
|
||||
<Input
|
||||
className={styles.filtersBarSearch}
|
||||
placeholder="Search by model or provider"
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
prefix={<Search size={14} />}
|
||||
suffix={
|
||||
search ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<X size={14} />}
|
||||
onClick={clearSearch}
|
||||
aria-label="Clear search"
|
||||
testId="model-cost-search-clear"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
testId="model-cost-search"
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.filtersBarSource}
|
||||
items={SOURCE_FILTER_OPTIONS}
|
||||
value={source}
|
||||
onChange={handleSourceChange}
|
||||
testId="source-filter"
|
||||
/>
|
||||
</div>
|
||||
{canManagePricing && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => drawer.openForAdd()}
|
||||
testId="add-model-cost-btn"
|
||||
>
|
||||
Add model cost
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load pricing rules. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
|
||||
<ModelCostsTable
|
||||
rules={rules}
|
||||
isLoading={isLoading}
|
||||
total={total}
|
||||
selectedRuleId={drawer.selectedRuleId}
|
||||
canManage={canManagePricing}
|
||||
onEdit={drawer.openForEdit}
|
||||
onDelete={deletion.requestDelete}
|
||||
selectedRuleId={null}
|
||||
canManage={false}
|
||||
onEdit={(): void => undefined}
|
||||
onDelete={(): void => undefined}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
@@ -166,29 +54,6 @@ function ModelCostTabPanel(): JSX.Element {
|
||||
All prices per 1M tokens (USD)
|
||||
</Typography.Text>
|
||||
</footer>
|
||||
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
initialDraft={drawer.initialDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={drawer.save}
|
||||
isSaving={drawer.isSaving}
|
||||
saveError={drawer.saveError}
|
||||
canManage={canManagePricing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deletion.pendingDelete && (
|
||||
<DeleteConfirmDialog
|
||||
open
|
||||
modelName={deletion.pendingDelete.modelName}
|
||||
isDeleting={deletion.isDeleting}
|
||||
onConfirm={deletion.confirmDelete}
|
||||
onCancel={deletion.cancelDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { AlertDialog } from '@signozhq/ui/alert-dialog';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean;
|
||||
modelName: string;
|
||||
isDeleting: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Confirmation step before deleting a model cost — deletion is irreversible, so
|
||||
// the destructive action is gated behind an explicit confirm. AlertDialog blocks
|
||||
// outside-click dismissal and hides the close button to force an explicit choice.
|
||||
function DeleteConfirmDialog({
|
||||
open,
|
||||
modelName,
|
||||
isDeleting,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteConfirmDialogProps): JSX.Element {
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
width="narrow"
|
||||
title="Delete Model Cost Data "
|
||||
titleIcon={<Trash2 size={16} />}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
prefix={<X size={12} />}
|
||||
testId="drawer-delete-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={onConfirm}
|
||||
prefix={<Trash2 size={12} />}
|
||||
testId="drawer-delete-confirm-btn"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Are you sure you want to delete <strong>{modelName}</strong>? Once deleted,
|
||||
this action cannot be undone.
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteConfirmDialog;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './DeleteConfirmDialog';
|
||||
@@ -1,58 +0,0 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.required {
|
||||
composes: required from './shared.module.scss';
|
||||
}
|
||||
|
||||
.modelCostDrawer {
|
||||
// Uniform horizontal padding across header / body / footer. The header and
|
||||
// footer read these dialog vars; the body (rendered in drawer-description)
|
||||
// is set directly below.
|
||||
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
|
||||
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
|
||||
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
|
||||
// The drawer body — children render inside [data-slot='drawer-description']
|
||||
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-10) var(--spacing-12);
|
||||
}
|
||||
|
||||
[data-slot='select-content'] {
|
||||
width: var(--radix-select-trigger-width);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
// Horizontal padding is provided by the drawer-footer slot var above.
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import PatternEditor from './components/PatternEditor';
|
||||
import PricingFields from './components/PricingFields';
|
||||
import SourceSelector from './components/SourceSelector';
|
||||
import { PROVIDER_OPTIONS } from '../../../constants';
|
||||
import styles from './ModelCostDrawer.module.scss';
|
||||
import {
|
||||
validateModelName,
|
||||
validatePricing,
|
||||
validateProvider,
|
||||
} from '../../../utils';
|
||||
import type { DrawerDraft, DrawerMode } from '../../../types';
|
||||
|
||||
interface ModelCostDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
initialDraft: DrawerDraft;
|
||||
onClose: () => void;
|
||||
onSave: (draft: DrawerDraft) => void;
|
||||
isSaving: boolean;
|
||||
saveError: string | null;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
function ModelCostDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
initialDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
saveError,
|
||||
canManage,
|
||||
}: ModelCostDrawerProps): JSX.Element {
|
||||
// Default mode validates on submit, then re-validates on change — so we don't
|
||||
// flag empty fields before the user has tried to save, but errors clear live
|
||||
// once they start fixing them.
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isDirty },
|
||||
} = useForm<DrawerDraft>({
|
||||
defaultValues: initialDraft,
|
||||
});
|
||||
|
||||
const isOverride = watch('isOverride');
|
||||
|
||||
// Metadata (model id / provider / patterns / source) is editable by any
|
||||
// manager. Pricing fields are editable only once the user picks "User
|
||||
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
|
||||
// Admin-only, so non-managers can't edit anything.
|
||||
const metadataReadOnly = !canManage;
|
||||
const pricingReadOnly = !canManage || !isOverride;
|
||||
|
||||
// Non-managers can only view (write APIs are Admin-only), so the drawer is a
|
||||
// read-only "View" rather than "Edit"/"Add".
|
||||
let drawerTitle = 'Add model cost';
|
||||
if (!canManage) {
|
||||
drawerTitle = 'View model cost';
|
||||
} else if (mode === 'edit') {
|
||||
drawerTitle = 'Edit model cost';
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="drawer-cancel-btn"
|
||||
>
|
||||
{canManage ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(onSave)}
|
||||
disabled={!isDirty}
|
||||
loading={isSaving}
|
||||
testId="drawer-save-btn"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
width="base"
|
||||
className={styles.modelCostDrawer}
|
||||
footer={footer}
|
||||
title={drawerTitle}
|
||||
drawerHeaderProps={{ className: styles.title }}
|
||||
>
|
||||
<div className={styles.drawerSection}>
|
||||
<label htmlFor="billing-model-id">
|
||||
Billing model ID{' '}
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<Controller
|
||||
name="modelName"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string => validateModelName(value, mode),
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="billing-model-id"
|
||||
placeholder="e.g. openai:gpt-4o"
|
||||
required
|
||||
value={field.value}
|
||||
disabled={mode === 'edit' || metadataReadOnly}
|
||||
aria-invalid={!!fieldState.error}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="drawer-model-id-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text as="p" size="small" color="danger" role="alert">
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.drawerSection}>
|
||||
<label htmlFor="provider-select">Provider</label>
|
||||
<Controller
|
||||
name="provider"
|
||||
control={control}
|
||||
rules={{ validate: validateProvider }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<SelectSimple
|
||||
id="provider-select"
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
items={PROVIDER_OPTIONS}
|
||||
disabled={mode === 'edit' || metadataReadOnly}
|
||||
className={styles.fullWidth}
|
||||
withPortal={false}
|
||||
testId="drawer-provider-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text size="small" color="danger" role="alert">
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="patterns"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<PatternEditor
|
||||
patterns={field.value}
|
||||
isReadOnly={metadataReadOnly}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Source is auto vs. override — a choice only a manager can make, so
|
||||
there's nothing to show a read-only viewer. */}
|
||||
{canManage && (
|
||||
<Controller
|
||||
name="isOverride"
|
||||
control={control}
|
||||
// Pricing requirements depend on this toggle, so re-validate pricing
|
||||
// whenever the source changes (clears/sets the pricing error).
|
||||
rules={{ deps: ['pricing'] }}
|
||||
render={({ field }): JSX.Element => (
|
||||
<SourceSelector
|
||||
isOverride={field.value}
|
||||
isReadOnly={metadataReadOnly}
|
||||
disableAuto={mode === 'add' || !initialDraft.sourceId}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="pricing"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value, values): true | string =>
|
||||
validatePricing(value, values.isOverride),
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<PricingFields
|
||||
pricing={field.value}
|
||||
isReadOnly={pricingReadOnly}
|
||||
onChange={(patch): void => field.onChange({ ...field.value, ...patch })}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text as="p" size="small" color="danger" role="alert">
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{saveError && (
|
||||
<Typography.Text as="p" size="small" color="danger" role="alert">
|
||||
{saveError}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCostDrawer;
|
||||
@@ -1,69 +0,0 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pricingField {
|
||||
composes: pricingField from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.cacheModeField {
|
||||
margin-top: var(--spacing-5);
|
||||
}
|
||||
|
||||
.extraBucketsSection {
|
||||
margin-top: var(--spacing-7);
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.extraBucketsSectionHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bucketRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
input {
|
||||
flex: 1 auto auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bucketRowName {
|
||||
flex: 0 0 110px;
|
||||
}
|
||||
|
||||
.bucketAddBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bucketPicker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-6);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.bucketPickerTitle {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.bucketPickerChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { CACHE_BUCKETS, CACHE_MODE_OPTIONS } from '../../../../../constants';
|
||||
import styles from './ExtraPricingBuckets.module.scss';
|
||||
import { parsePricingAmount } from '../../../../../utils';
|
||||
import type { CacheBucketKey, DrawerDraft } from '../../../../../types';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
type Pricing = DrawerDraft['pricing'];
|
||||
|
||||
interface ExtraPricingBucketsProps {
|
||||
pricing: Pricing;
|
||||
isReadOnly: boolean;
|
||||
onChange: (patch: Partial<Pricing>) => void;
|
||||
}
|
||||
|
||||
function ExtraPricingBuckets({
|
||||
pricing,
|
||||
isReadOnly,
|
||||
onChange,
|
||||
}: ExtraPricingBucketsProps): JSX.Element {
|
||||
const [isExtraPricingBucketOpen, setIsExtraPricingBucketOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
// Track which buckets are shown separately from their value, so a freshly
|
||||
// added bucket can start blank (value null) instead of being seeded to 0.
|
||||
// Seeded from buckets that already carry a value (edit mode).
|
||||
const [addedKeys, setAddedKeys] = useState<Set<CacheBucketKey>>(
|
||||
() =>
|
||||
new Set(
|
||||
CACHE_BUCKETS.filter((b) => pricing[b.key] !== null).map((b) => b.key),
|
||||
),
|
||||
);
|
||||
|
||||
const addedBuckets = CACHE_BUCKETS.filter((b) => addedKeys.has(b.key));
|
||||
const availableBuckets = CACHE_BUCKETS.filter((b) => !addedKeys.has(b.key));
|
||||
const patchBucket = (key: CacheBucketKey, value: number | null): void => {
|
||||
const patch: Partial<Pricing> = { [key]: value };
|
||||
onChange(patch);
|
||||
};
|
||||
|
||||
const addBucket = (key: CacheBucketKey): void => {
|
||||
// Leave the value null so the field renders blank until the user types.
|
||||
setAddedKeys((prev) => new Set(prev).add(key));
|
||||
// Close the picker once nothing is left to add.
|
||||
if (availableBuckets.length <= 1) {
|
||||
setIsExtraPricingBucketOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeBucket = (key: CacheBucketKey): void => {
|
||||
setAddedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
patchBucket(key, null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
|
||||
<div className={styles.extraBucketsSectionHead}>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
Extra pricing buckets
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
optional
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{addedBuckets.map((bucket) => (
|
||||
<div className={styles.bucketRow} key={bucket.key}>
|
||||
<Typography.Text as="span" className={styles.bucketRowName}>
|
||||
{bucket.label}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={pricing[bucket.key] ?? ''}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
// Clearing the field is allowed — the row stays mounted because
|
||||
// presence is tracked in `addedKeys`, not the value. Removal is
|
||||
// explicit via the trash button.
|
||||
patchBucket(bucket.key, parsePricingAmount(e.target.value))
|
||||
}
|
||||
testId={`drawer-${bucket.testId}-cost`}
|
||||
/>
|
||||
<Tooltip title="Pricing per 1M tokens" placement="left">
|
||||
<Typography.Text size="xs" color="muted">
|
||||
1M
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
onClick={(): void => removeBucket(bucket.key)}
|
||||
aria-label={`Remove ${bucket.label}`}
|
||||
data-testid={`drawer-remove-${bucket.testId}`}
|
||||
prefix={<Trash2 size={14} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{addedBuckets.length > 0 && (
|
||||
<div className={cx(styles.pricingField, styles.cacheModeField)}>
|
||||
<label htmlFor="cache-mode">Cache mode</label>
|
||||
<SelectSimple
|
||||
id="cache-mode"
|
||||
value={pricing.cacheMode}
|
||||
items={CACHE_MODE_OPTIONS}
|
||||
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
|
||||
disabled={isReadOnly}
|
||||
className={styles.fullWidth}
|
||||
withPortal={false}
|
||||
testId="drawer-cache-mode"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReadOnly && !isExtraPricingBucketOpen && availableBuckets.length > 0 && (
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
className={styles.bucketAddBtn}
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => setIsExtraPricingBucketOpen(true)}
|
||||
testId="drawer-add-bucket-btn"
|
||||
>
|
||||
Add pricing bucket
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && isExtraPricingBucketOpen && (
|
||||
<div className={styles.bucketPicker} data-testid="drawer-bucket-picker">
|
||||
<div className={styles.bucketPickerTitle}>Add a pricing bucket</div>
|
||||
<div className={styles.bucketPickerChips}>
|
||||
{availableBuckets.map((bucket) => (
|
||||
<Button
|
||||
key={bucket.key}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={(): void => addBucket(bucket.key)}
|
||||
testId={`drawer-add-bucket-${bucket.testId}`}
|
||||
>
|
||||
{bucket.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsExtraPricingBucketOpen(false)}
|
||||
testId="drawer-add-bucket-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExtraPricingBuckets;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './ExtraPricingBuckets';
|
||||
@@ -1,49 +0,0 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.help {
|
||||
composes: help from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.patternBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.patternChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-3);
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.patternChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.patternChipRemove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
}
|
||||
|
||||
.patternAdd {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import styles from './PatternEditor.module.scss';
|
||||
|
||||
interface PatternEditorProps {
|
||||
patterns: string[];
|
||||
isReadOnly: boolean;
|
||||
onChange: (patterns: string[]) => void;
|
||||
}
|
||||
|
||||
// Model-name prefix patterns as removable chips + an add input.
|
||||
function PatternEditor({
|
||||
patterns,
|
||||
isReadOnly,
|
||||
onChange,
|
||||
}: PatternEditorProps): JSX.Element {
|
||||
const [patternInput, setPatternInput] = useState<string>('');
|
||||
|
||||
const addPattern = (): void => {
|
||||
const next = patternInput.trim();
|
||||
if (!next || patterns.includes(next)) {
|
||||
setPatternInput('');
|
||||
return;
|
||||
}
|
||||
onChange([...patterns, next]);
|
||||
setPatternInput('');
|
||||
};
|
||||
|
||||
const removePattern = (pattern: string): void => {
|
||||
onChange(patterns.filter((p) => p !== pattern));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.drawerSection}>
|
||||
<Typography.Text as="span">
|
||||
Model name patterns{' '}
|
||||
<Typography.Text as="span" color="muted">
|
||||
(prefix match)
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
<div className={styles.patternBox}>
|
||||
<div className={styles.patternChips}>
|
||||
{patterns.map((pattern) => (
|
||||
<Badge
|
||||
key={pattern}
|
||||
color="vanilla"
|
||||
variant="outline"
|
||||
className={styles.patternChip}
|
||||
>
|
||||
{pattern}*
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove pattern ${pattern}`}
|
||||
className={styles.patternChipRemove}
|
||||
onClick={(): void => removePattern(pattern)}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className={styles.patternAdd}>
|
||||
<Input
|
||||
placeholder="Add pattern…"
|
||||
value={patternInput}
|
||||
onChange={(e): void => setPatternInput(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addPattern();
|
||||
}
|
||||
}}
|
||||
testId="drawer-pattern-input"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={addPattern}
|
||||
testId="drawer-pattern-add-btn"
|
||||
>
|
||||
+ Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
Each pattern uses <strong>prefix matching</strong> against{' '}
|
||||
<code>gen_ai.request.model</code>.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PatternEditor;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './PatternEditor';
|
||||
@@ -1,31 +0,0 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSurface {
|
||||
composes: drawerSurface from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSurfaceHead {
|
||||
composes: drawerSurfaceHead from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.managedLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.pricingField {
|
||||
composes: pricingField from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.required {
|
||||
composes: required from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.pricingGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ExtraPricingBuckets from '../ExtraPricingBuckets';
|
||||
import styles from './PricingFields.module.scss';
|
||||
import { parsePricingAmount } from '../../../../../utils';
|
||||
import type { DrawerDraft } from '../../../../../types';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
type Pricing = DrawerDraft['pricing'];
|
||||
|
||||
interface PricingFieldsProps {
|
||||
pricing: Pricing;
|
||||
isReadOnly: boolean;
|
||||
onChange: (patch: Partial<Pricing>) => void;
|
||||
}
|
||||
|
||||
function PricingFields({
|
||||
pricing,
|
||||
isReadOnly,
|
||||
onChange,
|
||||
}: PricingFieldsProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<Typography.Text size="base" weight="bold">
|
||||
Pricing (per 1M tokens, USD)
|
||||
</Typography.Text>
|
||||
|
||||
{isReadOnly && (
|
||||
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
|
||||
<Lock size={12} />
|
||||
|
||||
<Typography.Text color="muted">Read-only</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.pricingGrid}>
|
||||
<div className={styles.pricingField}>
|
||||
<label htmlFor="input-cost">
|
||||
Input cost{' '}
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="input-cost"
|
||||
type="number"
|
||||
step={0.01}
|
||||
required
|
||||
value={pricing.input ?? ''}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
onChange({ input: parsePricingAmount(e.target.value) })
|
||||
}
|
||||
testId="drawer-input-cost"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.pricingField}>
|
||||
<label htmlFor="output-cost">
|
||||
Output cost{' '}
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="output-cost"
|
||||
type="number"
|
||||
step={0.01}
|
||||
required
|
||||
value={pricing.output ?? ''}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e): void =>
|
||||
onChange({ output: parsePricingAmount(e.target.value) })
|
||||
}
|
||||
testId="drawer-output-cost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExtraPricingBuckets
|
||||
pricing={pricing}
|
||||
isReadOnly={isReadOnly}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingFields;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './PricingFields';
|
||||
@@ -1,115 +0,0 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSurface {
|
||||
composes: drawerSurface from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSurfaceHead {
|
||||
composes: drawerSurfaceHead from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.managedLabel {
|
||||
composes: managedLabel from '../../shared.module.scss';
|
||||
}
|
||||
|
||||
.sourceRadioGroup {
|
||||
--radio-group-item-border-color: var(--l2-border);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
.sourceRadio {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid transparent;
|
||||
background: var(--l3-background);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
// Include padding + border in the 100% width so the card fits inside
|
||||
// the SOURCE surface instead of overflowing its right edge.
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
|
||||
// The radio button itself: keep it fixed-size and aligned with the title
|
||||
// baseline (margin-top compensates for align-items: flex-start vs the
|
||||
// title's line-box).
|
||||
> button[role='radio'] {
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
// The library wraps children in a <label>. Make it grow into the
|
||||
// remaining width and reset the .drawerSection label typography leak
|
||||
// (set earlier in this file) so the title/desc divs use their own styles.
|
||||
> label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
|
||||
// Use :has() to highlight the wrapper card when its inner button is checked.
|
||||
&.sourceRadioAuto:has(button[data-state='checked']) {
|
||||
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
&.sourceRadioOverride:has(button[data-state='checked']) {
|
||||
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sourceRadioTitle {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.sourceRadioDesc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.resetConfirm {
|
||||
margin-top: var(--spacing-6);
|
||||
padding: var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: color-mix(in srgb, var(--accent-primary) 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, transparent);
|
||||
|
||||
p {
|
||||
margin: 0 0 var(--spacing-5);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.resetConfirmActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './SourceSelector.module.scss';
|
||||
|
||||
interface SourceSelectorProps {
|
||||
isOverride: boolean;
|
||||
isReadOnly: boolean;
|
||||
disableAuto?: boolean;
|
||||
onChange: (isOverride: boolean) => void;
|
||||
}
|
||||
|
||||
// Auto-populated vs user-override selector, with a confirm step before
|
||||
// discarding custom values back to defaults.
|
||||
function SourceSelector({
|
||||
isOverride,
|
||||
isReadOnly,
|
||||
disableAuto = false,
|
||||
onChange,
|
||||
}: SourceSelectorProps): JSX.Element {
|
||||
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
|
||||
|
||||
const handleSourceChange = (value: 'auto' | 'override'): void => {
|
||||
if (value === 'auto' && isOverride) {
|
||||
setShowResetConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (value === 'override' && !isOverride) {
|
||||
onChange(true);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReset = (): void => {
|
||||
onChange(false);
|
||||
setShowResetConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Source
|
||||
</Typography.Text>
|
||||
|
||||
{isReadOnly && (
|
||||
<span className={styles.managedLabel} data-testid="drawer-managed-label">
|
||||
<Lock size={12} />
|
||||
Managed by SigNoz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={isOverride ? 'override' : 'auto'}
|
||||
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
|
||||
className={styles.sourceRadioGroup}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="auto"
|
||||
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
|
||||
testId="drawer-source-auto"
|
||||
disabled={disableAuto}
|
||||
>
|
||||
<div className={styles.sourceRadioTitle}>Auto-populated</div>
|
||||
<div className={styles.sourceRadioDesc}>
|
||||
{disableAuto
|
||||
? 'Available once SigNoz has default pricing for this model.'
|
||||
: 'Default pricing from SigNoz.'}
|
||||
</div>
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
value="override"
|
||||
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
|
||||
testId="drawer-source-override"
|
||||
>
|
||||
<div className={styles.sourceRadioTitle}>User override</div>
|
||||
<div className={styles.sourceRadioDesc}>
|
||||
Custom pricing. Takes precedence.
|
||||
</div>
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
{showResetConfirm && (
|
||||
<div className={styles.resetConfirm} aria-label="Reset to default pricing">
|
||||
<p>
|
||||
Reset to default pricing? Custom values will be discarded. It might take
|
||||
24 hours for changes to take effect.
|
||||
</p>
|
||||
<div className={styles.resetConfirmActions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowResetConfirm(false)}
|
||||
testId="drawer-reset-keep-btn"
|
||||
>
|
||||
Keep
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={confirmReset}
|
||||
testId="drawer-reset-confirm-btn"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourceSelector;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './SourceSelector';
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { EMPTY_DRAFT } from '../../../../constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
|
||||
import { buildRulePayload, draftFromRule } from '../../../../utils';
|
||||
|
||||
interface UseModelCostDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: DrawerMode;
|
||||
initialDraft: DrawerDraft;
|
||||
openForAdd: (prefillModelName?: string) => void;
|
||||
openForEdit: (rule: PricingRule) => void;
|
||||
close: () => void;
|
||||
save: (draft: DrawerDraft) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
saveError: string | null;
|
||||
selectedRuleId: string | null;
|
||||
}
|
||||
|
||||
export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<DrawerMode>('add');
|
||||
const [initialDraft, setInitialDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
|
||||
useCreateOrUpdateLLMPricingRules();
|
||||
|
||||
const invalidateList = useCallback(async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setInitialDraft({
|
||||
...EMPTY_DRAFT,
|
||||
modelName: '',
|
||||
patterns: [],
|
||||
});
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((rule: PricingRule): void => {
|
||||
setMode('edit');
|
||||
setInitialDraft(draftFromRule(rule));
|
||||
setSelectedRuleId(rule.id);
|
||||
setSaveError(null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(
|
||||
async (draft: DrawerDraft): Promise<void> => {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await createOrUpdate({
|
||||
data: { rules: [buildRulePayload(draft)] },
|
||||
});
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
}
|
||||
},
|
||||
[createOrUpdate, invalidateList, mode],
|
||||
);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
mode,
|
||||
initialDraft,
|
||||
openForAdd,
|
||||
openForEdit,
|
||||
close,
|
||||
save,
|
||||
isSaving,
|
||||
saveError,
|
||||
selectedRuleId,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './ModelCostDrawer';
|
||||
export { useModelCostDrawer } from './hooks/useModelCostDrawer';
|
||||
@@ -1,59 +0,0 @@
|
||||
/* Shared drawer selectors used by 2+ of the model-cost drawer components. */
|
||||
/* Components pull these in via CSS-modules `composes` from their own module so */
|
||||
/* the authored class names in the TSX stay identical. */
|
||||
/* NOTE: this file is a `composes` target, so it is parsed as plain CSS (no SCSS */
|
||||
/* preprocessing). Keep it flat — no nesting, no slash-slash comments. */
|
||||
|
||||
.drawerSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.drawerSection .help,
|
||||
.help {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help code {
|
||||
padding: 1px var(--spacing-2);
|
||||
border-radius: 3px;
|
||||
background: var(--l3-background);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.drawerSurface {
|
||||
padding: var(--spacing-7);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.drawerSurfaceHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-5);
|
||||
}
|
||||
|
||||
.managedLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
|
||||
.pricingField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.pricingField input {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -15,6 +15,6 @@
|
||||
justify-content: center;
|
||||
margin-top: var(--spacing-8);
|
||||
min-height: 400px;
|
||||
color: var(--l3-foreground);
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getListLLMPricingRulesQueryKey,
|
||||
useDeleteLLMPricingRule,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import type { PricingRule } from '../../types';
|
||||
|
||||
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
|
||||
// and the model name to show in the confirmation copy.
|
||||
type PendingDelete = Pick<PricingRule, 'id' | 'modelName'>;
|
||||
|
||||
interface UseModelCostDeleteResult {
|
||||
requestDelete: (rule: PendingDelete) => void;
|
||||
confirmDelete: () => Promise<void>;
|
||||
cancelDelete: () => void;
|
||||
pendingDelete: PendingDelete | null;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
// Owns the confirm-then-delete flow for a pricing rule, independent of the
|
||||
// add/edit drawer — delete is triggered from the table row menu, so this state
|
||||
// lives at the panel level rather than inside useModelCostDrawer.
|
||||
export function useModelCostDelete(): UseModelCostDeleteResult {
|
||||
const queryClient = useQueryClient();
|
||||
// The rule queued for deletion. Non-null drives the confirm dialog open.
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
|
||||
|
||||
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
|
||||
useDeleteLLMPricingRule();
|
||||
|
||||
const requestDelete = useCallback((rule: PendingDelete): void => {
|
||||
setPendingDelete({ id: rule.id, modelName: rule.modelName });
|
||||
}, []);
|
||||
|
||||
const cancelDelete = useCallback((): void => {
|
||||
setPendingDelete(null);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback(async (): Promise<void> => {
|
||||
if (!pendingDelete) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteRuleApi({ pathParams: { id: pendingDelete.id } });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
setPendingDelete(null);
|
||||
toast.success('Model cost deleted');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
toast.error(message);
|
||||
}
|
||||
}, [deleteRuleApi, pendingDelete, queryClient]);
|
||||
|
||||
return {
|
||||
requestDelete,
|
||||
confirmDelete,
|
||||
cancelDelete,
|
||||
pendingDelete,
|
||||
isDeleting,
|
||||
};
|
||||
}
|
||||
@@ -1,68 +1,6 @@
|
||||
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { CacheBucketDef, DrawerDraft } from './types';
|
||||
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
export const PAGE_KEY = 'page';
|
||||
export const LIMIT_KEY = 'limit';
|
||||
export const SEARCH_KEY = 'search';
|
||||
export const SEARCH_DEBOUNCE_MS = 300;
|
||||
export const SOURCE_KEY = 'source';
|
||||
|
||||
export type SourceFilter = 'all' | 'override' | 'auto';
|
||||
export const SOURCE_FILTER_OPTIONS: { value: SourceFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All sources' },
|
||||
{ value: 'override', label: 'User override' },
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
];
|
||||
|
||||
export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
|
||||
SourceFilter,
|
||||
boolean | undefined
|
||||
> = {
|
||||
all: undefined,
|
||||
override: true,
|
||||
auto: false,
|
||||
};
|
||||
|
||||
// Match the page size so the skeleton reserves the same number of rows the
|
||||
// loaded page renders — otherwise the table height jumps on load.
|
||||
export const SKELETON_ROW_COUNT = PAGE_SIZE;
|
||||
|
||||
export const PROVIDER_OPTIONS = [
|
||||
{ value: 'OpenAI', label: 'OpenAI' },
|
||||
{ value: 'Anthropic', label: 'Anthropic' },
|
||||
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
|
||||
{ value: 'Google', label: 'Google' },
|
||||
{ value: 'Self-hosted', label: 'Self-hosted' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const CACHE_MODE_OPTIONS = [
|
||||
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
|
||||
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
|
||||
// https://app.notion.com/p/signoz/LLM-Tokens-Cost-Calculation-330fcc6bcd19805283ccc841d596358e?source=copy_link#33efcc6bcd1980e6a187e442c6ba5996
|
||||
{ value: CacheModeDTO.unknown, label: 'Unknown' },
|
||||
];
|
||||
|
||||
export const CACHE_BUCKETS: CacheBucketDef[] = [
|
||||
{ key: 'cacheRead', label: 'cache_read', testId: 'cache-read' },
|
||||
{ key: 'cacheWrite', label: 'cache_write', testId: 'cache-write' },
|
||||
];
|
||||
|
||||
export const EMPTY_DRAFT: DrawerDraft = {
|
||||
id: null,
|
||||
sourceId: null,
|
||||
modelName: '',
|
||||
provider: 'OpenAI',
|
||||
patterns: [],
|
||||
isOverride: true,
|
||||
pricing: {
|
||||
input: null,
|
||||
output: null,
|
||||
cacheMode: CacheModeDTO.unknown,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,39 +1,4 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
type LlmpricingruletypesLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
|
||||
|
||||
export interface ExtraBucket {
|
||||
key: string;
|
||||
pricePerMillion: number;
|
||||
}
|
||||
|
||||
export type DrawerMode = 'add' | 'edit';
|
||||
|
||||
// Optional pricing buckets the user can add/remove. Keyed by the matching
|
||||
// DrawerDraft['pricing'] field.
|
||||
export type CacheBucketKey = 'cacheRead' | 'cacheWrite';
|
||||
|
||||
export interface CacheBucketDef {
|
||||
key: CacheBucketKey;
|
||||
label: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export interface DrawerDraft {
|
||||
id: string | null;
|
||||
sourceId: string | null;
|
||||
modelName: string;
|
||||
provider: string;
|
||||
patterns: string[];
|
||||
isOverride: boolean;
|
||||
pricing: {
|
||||
input: number | null;
|
||||
output: number | null;
|
||||
cacheMode: CacheModeDTO;
|
||||
cacheRead: number | null;
|
||||
cacheWrite: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type LlmpricingruletypesLLMPricingCacheCostsDTO,
|
||||
type LlmpricingruletypesLLMRulePricingDTO,
|
||||
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import type {
|
||||
DrawerDraft,
|
||||
DrawerMode,
|
||||
ExtraBucket,
|
||||
PricingRule,
|
||||
} from './types';
|
||||
import type { ExtraBucket } from './types';
|
||||
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -24,19 +13,6 @@ const getRelativeTime = (
|
||||
return parsed?.isValid() ? parsed.fromNow() : '—';
|
||||
};
|
||||
|
||||
const hasCacheValue = (value: number | null | undefined): value is number =>
|
||||
typeof value === 'number' && value > 0;
|
||||
|
||||
// ─── Input helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export const parsePricingAmount = (raw: string): number | null => {
|
||||
if (raw.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
// ─── Display helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
@@ -47,117 +23,38 @@ export const formatPricePerMillion = (value: number | undefined): string => {
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
|
||||
export const getExtraBuckets = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): ExtraBucket[] => {
|
||||
const cache = rule.pricing?.cache;
|
||||
if (!cache) {
|
||||
return [];
|
||||
}
|
||||
const buckets: ExtraBucket[] = [];
|
||||
if (hasCacheValue(cache.read)) {
|
||||
if (typeof cache.read === 'number' && cache.read > 0) {
|
||||
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
|
||||
}
|
||||
if (hasCacheValue(cache.write)) {
|
||||
if (typeof cache.write === 'number' && cache.write > 0) {
|
||||
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
|
||||
rule.isOverride ? 'User override' : 'Auto';
|
||||
export const getSourceLabel = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
|
||||
|
||||
export const getRelativeLastSeen = (rule: PricingRule): string =>
|
||||
getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
|
||||
export const getRelativeLastSeen = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
|
||||
|
||||
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
|
||||
// are lower-cased so the id is consistently normalised (providers/models can
|
||||
// arrive with mixed casing).
|
||||
export const getCanonicalId = (rule: PricingRule): string => {
|
||||
export const getCanonicalId = (
|
||||
rule: LlmpricingruletypesLLMPricingRuleDTO,
|
||||
): string => {
|
||||
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
|
||||
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
|
||||
return `${provider}:${model}`;
|
||||
};
|
||||
|
||||
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
|
||||
|
||||
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
|
||||
id: rule.id,
|
||||
sourceId: rule.sourceId ?? null,
|
||||
modelName: rule.modelName,
|
||||
provider: rule.provider,
|
||||
patterns: rule.modelPattern || [],
|
||||
isOverride: !!rule.isOverride,
|
||||
pricing: {
|
||||
input: rule.pricing?.input ?? 0,
|
||||
output: rule.pricing?.output ?? 0,
|
||||
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
|
||||
cacheRead: rule.pricing?.cache?.read ?? null,
|
||||
cacheWrite: rule.pricing?.cache?.write ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const buildCacheCosts = (
|
||||
pricing: DrawerDraft['pricing'],
|
||||
): LlmpricingruletypesLLMPricingCacheCostsDTO | undefined => {
|
||||
const { cacheMode, cacheRead, cacheWrite } = pricing;
|
||||
if (!hasCacheValue(cacheRead) && !hasCacheValue(cacheWrite)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
mode: cacheMode,
|
||||
...(hasCacheValue(cacheRead) && { read: cacheRead }),
|
||||
...(hasCacheValue(cacheWrite) && { write: cacheWrite }),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPricingPayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesLLMRulePricingDTO => {
|
||||
const cache = buildCacheCosts(draft.pricing);
|
||||
return {
|
||||
input: draft.pricing.input ?? 0,
|
||||
output: draft.pricing.output ?? 0,
|
||||
...(cache && { cache }),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildRulePayload = (
|
||||
draft: DrawerDraft,
|
||||
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
|
||||
id: draft.id || undefined,
|
||||
sourceId: draft.sourceId || undefined,
|
||||
modelName: draft.modelName.trim(),
|
||||
provider: draft.provider.trim(),
|
||||
modelPattern: draft.patterns,
|
||||
isOverride: draft.isOverride,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: buildPricingPayload(draft),
|
||||
});
|
||||
|
||||
export const validateModelName = (
|
||||
modelName: string,
|
||||
mode: DrawerMode,
|
||||
): true | string =>
|
||||
mode === 'add' && !modelName.trim() ? 'Billing model ID is required.' : true;
|
||||
|
||||
export const validateProvider = (provider: string): true | string =>
|
||||
provider.trim() ? true : 'Provider is required.';
|
||||
|
||||
export const validatePricing = (
|
||||
pricing: DrawerDraft['pricing'],
|
||||
isOverride: boolean,
|
||||
): true | string => {
|
||||
if (!isOverride) {
|
||||
return true;
|
||||
}
|
||||
if (pricing.input === null || pricing.input <= 0) {
|
||||
return 'Input cost must be greater than 0.';
|
||||
}
|
||||
if (pricing.output === null || pricing.output <= 0) {
|
||||
return 'Output cost must be greater than 0.';
|
||||
}
|
||||
if ((pricing.cacheRead ?? 0) < 0 || (pricing.cacheWrite ?? 0) < 0) {
|
||||
return 'Cache costs must be non-negative.';
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
.members-settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.members-settings {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Group } from '@visx/group';
|
||||
import { Pie } from '@visx/shape';
|
||||
@@ -8,10 +8,12 @@ import { themeColors } from 'constants/theme';
|
||||
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { isNaN } from 'lodash-es';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
|
||||
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
||||
import { preparePieChartData } from './preparePieChartData';
|
||||
import { lightenColor, tooltipStyles } from './utils';
|
||||
|
||||
import './PiePanelWrapper.styles.scss';
|
||||
@@ -42,15 +44,37 @@ function PiePanelWrapper({
|
||||
detectBounds: true,
|
||||
});
|
||||
|
||||
const panelData = queryResponse.data?.payload?.data?.result || [];
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const pieChartData = useMemo(
|
||||
() =>
|
||||
preparePieChartData(queryResponse.data?.payload, {
|
||||
customLegendColors: widget?.customLegendColors,
|
||||
colorMap: isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
}),
|
||||
[queryResponse.data?.payload, widget?.customLegendColors, isDarkMode],
|
||||
let pieChartData: {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: any;
|
||||
}[] = [].concat(
|
||||
...(panelData
|
||||
.map((d) => {
|
||||
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
|
||||
return {
|
||||
label,
|
||||
value: d?.values?.[0]?.[1],
|
||||
record: d,
|
||||
color:
|
||||
widget?.customLegendColors?.[label] ||
|
||||
generateColor(
|
||||
label,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((d) => d !== undefined) as never[]),
|
||||
);
|
||||
|
||||
pieChartData = pieChartData.filter(
|
||||
(arc) =>
|
||||
arc.value && !isNaN(parseFloat(arc.value)) && parseFloat(arc.value) > 0,
|
||||
);
|
||||
|
||||
let size = 0;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { preparePieChartData } from '../preparePieChartData';
|
||||
|
||||
const options = { colorMap: themeColors.chartcolors };
|
||||
|
||||
/**
|
||||
* Mirrors a query-range payload: the (possibly collapsed) time-series `result`
|
||||
* plus the scalar table nested under `newResult` (as getQueryResults produces it).
|
||||
*/
|
||||
function makePayload(
|
||||
result: QueryData[],
|
||||
tables: QueryDataV3[],
|
||||
): MetricRangePayloadProps {
|
||||
return {
|
||||
data: {
|
||||
result,
|
||||
resultType: 'scalar',
|
||||
newResult: { data: { result: tables, resultType: 'scalar' } },
|
||||
},
|
||||
} as MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
function tableEntry(
|
||||
columns: NonNullable<QueryDataV3['table']>['columns'],
|
||||
rows: NonNullable<QueryDataV3['table']>['rows'],
|
||||
overrides: Partial<QueryDataV3> = {},
|
||||
): QueryDataV3 {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: { columns, rows },
|
||||
...overrides,
|
||||
} as QueryDataV3;
|
||||
}
|
||||
|
||||
describe('preparePieChartData', () => {
|
||||
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
|
||||
// SELECT count() AS col1, sum(value) AS col2 — the backend collapses the
|
||||
// time-series result onto col1; the full data lives in the scalar table.
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
values: [[0, '23399927']],
|
||||
} as QueryData,
|
||||
],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
],
|
||||
[{ data: { col1: 23399927, col2: 588691297 } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices).toHaveLength(2);
|
||||
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
|
||||
['col1', '23399927'],
|
||||
['col2', '588691297'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefixes the group when multiple value columns are grouped', () => {
|
||||
const payload = makePayload(
|
||||
[],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
],
|
||||
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => s.label)).toStrictEqual([
|
||||
'prod · col1',
|
||||
'prod · col2',
|
||||
]);
|
||||
expect(slices[0].record.metric).toStrictEqual({ env: 'prod' });
|
||||
});
|
||||
|
||||
it('drops non-positive and non-numeric values', () => {
|
||||
const payload = makePayload(
|
||||
[],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
|
||||
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
|
||||
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
|
||||
],
|
||||
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
|
||||
});
|
||||
|
||||
it('keeps the series path for a single value column (grouped panel)', () => {
|
||||
// One value column → the time-series result is authoritative (one slice per
|
||||
// group), so existing behaviour is preserved.
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: { 'service.name': 'adservice' },
|
||||
queryName: 'A',
|
||||
legend: 'adservice',
|
||||
values: [[0, '100']],
|
||||
} as QueryData,
|
||||
{
|
||||
metric: { 'service.name': 'cartservice' },
|
||||
queryName: 'A',
|
||||
legend: 'cartservice',
|
||||
values: [[0, '200']],
|
||||
} as QueryData,
|
||||
],
|
||||
[
|
||||
tableEntry(
|
||||
[
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
[
|
||||
{ data: { 'service.name': 'adservice', A: 100 } },
|
||||
{ data: { 'service.name': 'cartservice', A: 200 } },
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
|
||||
['adservice', '100'],
|
||||
['cartservice', '200'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the legacy series result when there is no scalar table', () => {
|
||||
const payload = makePayload(
|
||||
[
|
||||
{
|
||||
metric: { 'service.name': 'adservice' },
|
||||
queryName: 'A',
|
||||
legend: '{{service.name}}',
|
||||
values: [[1000, '42']],
|
||||
} as QueryData,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const slices = preparePieChartData(payload, options);
|
||||
|
||||
expect(slices).toHaveLength(1);
|
||||
expect(slices[0].value).toBe('42');
|
||||
});
|
||||
|
||||
it('returns no slices for an empty payload', () => {
|
||||
expect(preparePieChartData(undefined, options)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { isNaN } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export interface PieChartSlice {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: {
|
||||
queryName: string;
|
||||
legend?: string;
|
||||
/** Group-by labels, used for drilldown; absent when the slice has no group. */
|
||||
metric?: QueryData['metric'];
|
||||
};
|
||||
}
|
||||
|
||||
interface PreparePieChartDataOptions {
|
||||
customLegendColors?: Record<string, string>;
|
||||
colorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
const colorFor = (
|
||||
label: string,
|
||||
{ customLegendColors, colorMap }: PreparePieChartDataOptions,
|
||||
): string => customLegendColors?.[label] || generateColor(label, colorMap);
|
||||
|
||||
const isPositive = (value: string): boolean =>
|
||||
!!value && !isNaN(parseFloat(value)) && parseFloat(value) > 0;
|
||||
|
||||
/**
|
||||
* Time-series result: one slice per series, value = first datapoint. This is the
|
||||
* original pie behaviour — kept verbatim (same label/value/colour/record) so
|
||||
* single-value and grouped panels are unaffected.
|
||||
*/
|
||||
function slicesFromSeries(
|
||||
result: QueryData[],
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
return result
|
||||
.filter((d) => d?.values?.[0]?.[1] !== undefined)
|
||||
.map((d) => {
|
||||
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
|
||||
return {
|
||||
label,
|
||||
value: d.values[0][1],
|
||||
color: colorFor(label, options),
|
||||
record: d,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* V5 scalar table: one slice per (row × value column). With more than one value
|
||||
* column the column name keeps the slices distinct, so a ClickHouse query like
|
||||
* `count() AS col1, sum() AS col2` renders a slice per column instead of
|
||||
* collapsing onto the first; group-by columns become the slice label.
|
||||
*/
|
||||
function slicesFromTables(
|
||||
tables: QueryDataV3[],
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
const slices: PieChartSlice[] = [];
|
||||
|
||||
tables.forEach((entry) => {
|
||||
const { table } = entry;
|
||||
if (!table?.columns?.length || !table?.rows?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueColumns = table.columns.filter((column) => column.isValueColumn);
|
||||
if (valueColumns.length === 0) {
|
||||
return;
|
||||
}
|
||||
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
|
||||
const hasMultipleValueColumns = valueColumns.length > 1;
|
||||
|
||||
table.rows.forEach((row) => {
|
||||
const groupLabel = labelColumns
|
||||
.map((column) => row.data[column.id || column.name])
|
||||
.filter((part) => part != null)
|
||||
.map(String)
|
||||
.join(', ');
|
||||
// Drilldown filters by group-by labels; leave it undefined when there
|
||||
// are none (e.g. a ClickHouse query) so no filterless menu is offered.
|
||||
const metric = labelColumns.length
|
||||
? labelColumns.reduce<Record<string, string>>((acc, column) => {
|
||||
acc[column.name] = String(row.data[column.id || column.name]);
|
||||
return acc;
|
||||
}, {})
|
||||
: undefined;
|
||||
|
||||
valueColumns.forEach((column) => {
|
||||
let label: string;
|
||||
if (hasMultipleValueColumns) {
|
||||
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
|
||||
} else {
|
||||
label = groupLabel || entry.legend || entry.queryName || '';
|
||||
}
|
||||
|
||||
slices.push({
|
||||
label,
|
||||
value: String(row.data[column.id || column.name]),
|
||||
color: colorFor(label, options),
|
||||
record: { queryName: entry.queryName, legend: entry.legend, metric },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return slices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds pie slices from a query-range payload, dropping non-positive/non-numeric
|
||||
* values.
|
||||
*
|
||||
* A scalar response with several value columns (e.g. a ClickHouse
|
||||
* `count() AS col1, sum() AS col2`) collapses to a single series in
|
||||
* `data.result` — only the first value column survives. The full data is kept in
|
||||
* the scalar table under `newResult`, so in that case slices are built from the
|
||||
* table (one per value column). Otherwise the legacy time-series result is used,
|
||||
* preserving existing behaviour for single-value and grouped panels.
|
||||
*/
|
||||
export function preparePieChartData(
|
||||
payload: MetricRangePayloadProps | undefined,
|
||||
options: PreparePieChartDataOptions,
|
||||
): PieChartSlice[] {
|
||||
const tables = (payload?.data?.newResult?.data?.result || []).filter(
|
||||
(entry) => entry?.table?.rows?.length,
|
||||
);
|
||||
const hasMultipleValueColumns = tables.some(
|
||||
(entry) =>
|
||||
(entry.table?.columns || []).filter((column) => column.isValueColumn)
|
||||
.length > 1,
|
||||
);
|
||||
|
||||
const slices = hasMultipleValueColumns
|
||||
? slicesFromTables(tables, options)
|
||||
: slicesFromSeries(payload?.data?.result || [], options);
|
||||
|
||||
return slices.filter((slice) => isPositive(slice.value));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
.rolesListingTable {
|
||||
margin-top: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
|
||||
.rolesSettingsContent {
|
||||
padding: 0 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.rolesSettingsToolbar {
|
||||
|
||||
@@ -20,8 +20,7 @@ export type ComponentTypes =
|
||||
| 'add_panel'
|
||||
| 'page_pipelines'
|
||||
| 'edit_locked_dashboard'
|
||||
| 'add_panel_locked_dashboard'
|
||||
| 'manage_llm_pricing';
|
||||
| 'add_panel_locked_dashboard';
|
||||
|
||||
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
current_org_settings: ['ADMIN'],
|
||||
@@ -43,7 +42,6 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
page_pipelines: ['ADMIN', 'EDITOR'],
|
||||
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
|
||||
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
|
||||
manage_llm_pricing: ['ADMIN'],
|
||||
};
|
||||
|
||||
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
|
||||
@@ -421,5 +421,61 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/public/dashboards/{id}", handler.New(provider.authzMiddleware.CheckWithoutClaims(
|
||||
provider.dashboardHandler.GetPublicDataV2,
|
||||
authtypes.Relation{Verb: coretypes.VerbRead},
|
||||
coretypes.ResourceMetaResourcePublicDashboard,
|
||||
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
|
||||
}, []string{}), handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboardDataV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get public dashboard data (v2)",
|
||||
Description: "This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettablePublicDashboardDataV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/public/dashboards/{id}/panels/{key}/query_range", handler.New(provider.authzMiddleware.CheckWithoutClaims(
|
||||
provider.dashboardHandler.GetPublicWidgetQueryRangeV2,
|
||||
authtypes.Relation{Verb: coretypes.VerbRead},
|
||||
coretypes.ResourceMetaResourcePublicDashboard,
|
||||
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
|
||||
}, []string{}), handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboardPanelQueryRangeV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get query range result (v2)",
|
||||
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(querybuildertypesv5.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-1,.cls-2,.cls-3{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_SQL_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="4.67 10.44 4.67 13.45 12 17.35 12 14.34 4.67 10.44"/><polygon class="cls-1" points="4.67 15.09 4.67 18.1 12 22 12 18.99 4.67 15.09"/><polygon class="cls-2" points="12 17.35 19.33 13.45 19.33 10.44 12 14.34 12 17.35"/><polygon class="cls-2" points="12 22 19.33 18.1 19.33 15.09 12 18.99 12 22"/><polygon class="cls-3" points="19.33 8.91 19.33 5.9 12 2 12 5.01 19.33 8.91"/><polygon class="cls-2" points="12 2 4.67 5.9 4.67 8.91 12 5.01 12 2"/><polygon class="cls-1" points="4.67 5.87 4.67 8.89 12 12.79 12 9.77 4.67 5.87"/><polygon class="cls-2" points="12 12.79 19.33 8.89 19.33 5.87 12 9.77 12 12.79"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 933 B |
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"id": "cloudsql",
|
||||
"title": "GCP Cloud SQL",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Cloud SQL Overview",
|
||||
"description": "Overview of GCP Cloud SQL metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
### Monitor GCP Cloud SQL with SigNoz
|
||||
|
||||
Collect key GCP Cloud SQL metrics and view them with an out of the box dashboard.
|
||||
@@ -481,7 +481,6 @@ func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// TODO: Rename AgentCheckIn to just CheckIn.
|
||||
func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -81,6 +81,12 @@ type Module interface {
|
||||
|
||||
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
|
||||
|
||||
// get the v2 dashboard data by public dashboard id
|
||||
GetDashboardByPublicIDV2(context.Context, valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// gets the query results by panel key and public shared id for a v2 dashboard
|
||||
GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error)
|
||||
|
||||
CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error)
|
||||
|
||||
ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error)
|
||||
@@ -101,6 +107,10 @@ type Handler interface {
|
||||
|
||||
GetPublicWidgetQueryRange(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublicDataV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublicWidgetQueryRangeV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdatePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeletePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -247,6 +247,14 @@ func (module *module) GetPublicWidgetQueryRange(context.Context, valuer.UUID, ui
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(_ context.Context, _ valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRangeV2(context.Context, valuer.UUID, string, string, string) (*qbtypes.QueryRangeResponse, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetPublicDashboardSelectorsAndOrg(_ context.Context, _ valuer.UUID, _ []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
return nil, valuer.UUID{}, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
@@ -376,3 +376,53 @@ func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublicDataV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.GetDashboardByPublicIDV2(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard, err := handler.module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypes.NewPublicDashboardDataFromDashboardV2(dashboard, publicDashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
panelKey, ok := mux.Vars(r)["key"]
|
||||
if !ok || panelKey == "" {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "panel key is missing from the path"))
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeResults)
|
||||
}
|
||||
|
||||
@@ -31,13 +31,11 @@ type AgentReport struct {
|
||||
type AccountConfig struct {
|
||||
AWS *AWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
|
||||
Azure *AzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
|
||||
GCP *GCPAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type UpdatableAccountConfig struct {
|
||||
AWS *UpdatableAWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
|
||||
Azure *UpdatableAzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
|
||||
GCP *UpdatableGCPAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type PostableAccount struct {
|
||||
@@ -50,7 +48,6 @@ type PostableAccountConfig struct {
|
||||
AgentVersion string
|
||||
AWS *AWSPostableAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
|
||||
Azure *AzurePostableAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
|
||||
GCP *GCPPostableAccountConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type Credentials struct {
|
||||
@@ -69,7 +66,6 @@ type ConnectionArtifact struct {
|
||||
// required till new providers are added
|
||||
AWS *AWSConnectionArtifact `json:"aws,omitempty" required:"false" nullable:"false"`
|
||||
Azure *AzureConnectionArtifact `json:"azure,omitempty" required:"false" nullable:"false"`
|
||||
GCP *GCPConnectionArtifact `json:"gcp,omitempty" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type GetConnectionArtifactRequest = PostableAccount
|
||||
@@ -215,30 +211,6 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
|
||||
}
|
||||
|
||||
return &AccountConfig{Azure: &AzureAccountConfig{DeploymentRegion: config.Azure.DeploymentRegion, ResourceGroups: config.Azure.ResourceGroups}}, nil
|
||||
case CloudProviderTypeGCP:
|
||||
if config.GCP == nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
|
||||
}
|
||||
|
||||
if config.GCP.DeploymentProjectID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
|
||||
}
|
||||
|
||||
if err := validateGCPRegion(config.GCP.DeploymentRegion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(config.GCP.ProjectIDs) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
|
||||
}
|
||||
|
||||
return &AccountConfig{
|
||||
GCP: &GCPAccountConfig{
|
||||
DeploymentProjectID: config.GCP.DeploymentProjectID,
|
||||
ProjectIDs: config.GCP.ProjectIDs,
|
||||
DeploymentRegion: config.GCP.DeploymentRegion,
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -272,30 +244,6 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
|
||||
}
|
||||
|
||||
return &AccountConfig{Azure: &AzureAccountConfig{ResourceGroups: config.Config.Azure.ResourceGroups}}, nil
|
||||
case CloudProviderTypeGCP:
|
||||
if config.Config.GCP == nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
|
||||
}
|
||||
|
||||
if err := validateGCPRegion(config.Config.GCP.DeploymentRegion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(config.Config.GCP.ProjectIDs) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
|
||||
}
|
||||
|
||||
if config.Config.GCP.DeploymentProjectID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
|
||||
}
|
||||
|
||||
return &AccountConfig{
|
||||
GCP: &GCPAccountConfig{
|
||||
DeploymentProjectID: config.Config.GCP.DeploymentProjectID,
|
||||
ProjectIDs: config.Config.GCP.ProjectIDs,
|
||||
DeploymentRegion: config.Config.GCP.DeploymentRegion,
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -384,16 +332,15 @@ func (config *PostableAccountConfig) SetAgentVersion(agentVersion string) {
|
||||
// thats why not naming it MarshalJSON(), as it will interfere with default JSON marshalling of AccountConfig struct.
|
||||
// NOTE: this entertains first non-null provider's config.
|
||||
func (config *AccountConfig) ToJSON() ([]byte, error) {
|
||||
switch {
|
||||
case config.AWS != nil:
|
||||
if config.AWS != nil {
|
||||
return json.Marshal(config.AWS)
|
||||
case config.Azure != nil:
|
||||
return json.Marshal(config.Azure)
|
||||
case config.GCP != nil:
|
||||
return json.Marshal(config.GCP)
|
||||
default:
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
|
||||
}
|
||||
|
||||
if config.Azure != nil {
|
||||
return json.Marshal(config.Azure)
|
||||
}
|
||||
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
|
||||
}
|
||||
|
||||
func NewIngestionKeyName(provider CloudProviderType) string {
|
||||
|
||||
@@ -50,7 +50,6 @@ type IntegrationConfig struct {
|
||||
type ProviderIntegrationConfig struct {
|
||||
AWS *AWSIntegrationConfig `json:"aws,omitempty" required:"false" nullable:"false"`
|
||||
Azure *AzureIntegrationConfig `json:"azure,omitempty" required:"false" nullable:"false"`
|
||||
GCP *GCPIntegrationConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
// NewGettableAgentCheckIn constructs a backward-compatible response from an AgentCheckInResponse.
|
||||
|
||||
@@ -63,7 +63,6 @@ type StorableCloudIntegrationService struct {
|
||||
type StorableServiceConfig struct {
|
||||
AWS *StorableAWSServiceConfig
|
||||
Azure *StorableAzureServiceConfig
|
||||
GCP *StorableGCPServiceConfig
|
||||
}
|
||||
|
||||
type StorableAWSServiceConfig struct {
|
||||
@@ -93,15 +92,6 @@ type StorableAzureMetricsServiceConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type StorableGCPServiceConfig struct {
|
||||
Logs *StorableGCPServiceLogsConfig `json:"logs,omitempty"`
|
||||
Metrics *StorableGCPServiceMetricsConfig `json:"metrics,omitempty"`
|
||||
}
|
||||
|
||||
type StorableGCPServiceLogsConfig = GCPServiceLogsConfig
|
||||
|
||||
type StorableGCPServiceMetricsConfig = GCPServiceMetricsConfig
|
||||
|
||||
// Scan scans value from DB.
|
||||
func (r *StorableAgentReport) Scan(src any) error {
|
||||
var data []byte
|
||||
@@ -235,30 +225,6 @@ func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, s
|
||||
}
|
||||
|
||||
return &StorableServiceConfig{Azure: storableAzureServiceConfig}, nil
|
||||
case CloudProviderTypeGCP:
|
||||
storableGCPServiceConfig := new(StorableGCPServiceConfig)
|
||||
|
||||
if supportedSignals.Logs {
|
||||
if serviceConfig.GCP.Logs == nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "logs config is required for GCP service: %s", serviceID.StringValue())
|
||||
}
|
||||
|
||||
storableGCPServiceConfig.Logs = &StorableGCPServiceLogsConfig{
|
||||
Enabled: serviceConfig.GCP.Logs.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
if supportedSignals.Metrics {
|
||||
if serviceConfig.GCP.Metrics == nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "metrics config is required for GCP service: %s", serviceID.StringValue())
|
||||
}
|
||||
|
||||
storableGCPServiceConfig.Metrics = &StorableGCPServiceMetricsConfig{
|
||||
Enabled: serviceConfig.GCP.Metrics.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
return &StorableServiceConfig{GCP: storableGCPServiceConfig}, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -280,13 +246,6 @@ func newStorableServiceConfigFromJSON(provider CloudProviderType, jsonStr string
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse Azure service config JSON")
|
||||
}
|
||||
return &StorableServiceConfig{Azure: azureConfig}, nil
|
||||
case CloudProviderTypeGCP:
|
||||
gcpConfig := new(StorableGCPServiceConfig)
|
||||
err := json.Unmarshal([]byte(jsonStr), gcpConfig)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse GCP service config JSON")
|
||||
}
|
||||
return &StorableServiceConfig{GCP: gcpConfig}, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -307,13 +266,6 @@ func (config *StorableServiceConfig) toJSON(provider CloudProviderType) ([]byte,
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize Azure service config to JSON")
|
||||
}
|
||||
|
||||
return jsonBytes, nil
|
||||
case CloudProviderTypeGCP:
|
||||
jsonBytes, err := json.Marshal(config.GCP)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize GCP service config to JSON")
|
||||
}
|
||||
|
||||
return jsonBytes, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
|
||||
@@ -11,7 +11,6 @@ var (
|
||||
// cloud providers.
|
||||
CloudProviderTypeAWS = CloudProviderType{valuer.NewString("aws")}
|
||||
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
|
||||
CloudProviderTypeGCP = CloudProviderType{valuer.NewString("gcp")}
|
||||
|
||||
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
|
||||
)
|
||||
@@ -22,8 +21,6 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
|
||||
return CloudProviderTypeAWS, nil
|
||||
case CloudProviderTypeAzure.StringValue():
|
||||
return CloudProviderTypeAzure, nil
|
||||
case CloudProviderTypeGCP.StringValue():
|
||||
return CloudProviderTypeGCP, nil
|
||||
default:
|
||||
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
type GCPAccountConfig struct {
|
||||
// Project ID where central pub/sub for logs exist
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// Project ID where otel collector will be deployed
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// List of project IDs to monitor
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPPostableAccountConfig = GCPAccountConfig
|
||||
|
||||
type UpdatableGCPAccountConfig struct {
|
||||
// Project ID where central pub/sub for logs exist
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// Compute service region where otel collector will be deployed
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// List of project IDs to monitor
|
||||
ProjectIDs []string `json:"projectIds" required:"true"`
|
||||
}
|
||||
|
||||
type GCPConnectionArtifact struct{}
|
||||
|
||||
type GCPIntegrationConfig struct{}
|
||||
|
||||
type GCPTelemetryCollectionStrategy struct{}
|
||||
|
||||
type GCPServiceConfig struct {
|
||||
Logs *GCPServiceLogsConfig `json:"logs,omitempty" required:"false"`
|
||||
Metrics *GCPServiceMetricsConfig `json:"metrics,omitempty" required:"false"`
|
||||
}
|
||||
|
||||
type GCPServiceLogsConfig struct {
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
}
|
||||
|
||||
type GCPServiceMetricsConfig struct {
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
}
|
||||
@@ -102,51 +102,6 @@ var (
|
||||
AzureRegionWestUS = CloudProviderRegion{valuer.NewString("westus")} // West US.
|
||||
AzureRegionWestUS2 = CloudProviderRegion{valuer.NewString("westus2")} // West US 2.
|
||||
AzureRegionWestUS3 = CloudProviderRegion{valuer.NewString("westus3")} // West US 3.
|
||||
|
||||
// GCP regions.
|
||||
GCPRegionAfricaSouth1 = CloudProviderRegion{valuer.NewString("africa-south1")} // Johannesburg, South Africa. Africa.
|
||||
GCPRegionAsiaEast1 = CloudProviderRegion{valuer.NewString("asia-east1")} // Changhua County, Taiwan. APAC.
|
||||
GCPRegionAsiaEast2 = CloudProviderRegion{valuer.NewString("asia-east2")} // Hong Kong. APAC.
|
||||
GCPRegionAsiaNortheast1 = CloudProviderRegion{valuer.NewString("asia-northeast1")} // Tokyo, Japan. APAC.
|
||||
GCPRegionAsiaNortheast2 = CloudProviderRegion{valuer.NewString("asia-northeast2")} // Osaka, Japan. APAC.
|
||||
GCPRegionAsiaNortheast3 = CloudProviderRegion{valuer.NewString("asia-northeast3")} // Seoul, South Korea. APAC.
|
||||
GCPRegionAsiaSouth1 = CloudProviderRegion{valuer.NewString("asia-south1")} // Mumbai, India. APAC.
|
||||
GCPRegionAsiaSouth2 = CloudProviderRegion{valuer.NewString("asia-south2")} // Delhi, India. APAC.
|
||||
GCPRegionAsiaSoutheast1 = CloudProviderRegion{valuer.NewString("asia-southeast1")} // Jurong West, Singapore. APAC.
|
||||
GCPRegionAsiaSoutheast2 = CloudProviderRegion{valuer.NewString("asia-southeast2")} // Jakarta, Indonesia. APAC.
|
||||
GCPRegionAsiaSoutheast3 = CloudProviderRegion{valuer.NewString("asia-southeast3")} // Bangkok, Thailand. APAC.
|
||||
GCPRegionAustraliaSoutheast1 = CloudProviderRegion{valuer.NewString("australia-southeast1")} // Sydney, Australia. APAC.
|
||||
GCPRegionAustraliaSoutheast2 = CloudProviderRegion{valuer.NewString("australia-southeast2")} // Melbourne, Australia. APAC.
|
||||
GCPRegionEuropeCentral2 = CloudProviderRegion{valuer.NewString("europe-central2")} // Warsaw, Poland. Europe.
|
||||
GCPRegionEuropeNorth1 = CloudProviderRegion{valuer.NewString("europe-north1")} // Hamina, Finland. Europe.
|
||||
GCPRegionEuropeNorth2 = CloudProviderRegion{valuer.NewString("europe-north2")} // Stockholm, Sweden. Europe.
|
||||
GCPRegionEuropeSouthwest1 = CloudProviderRegion{valuer.NewString("europe-southwest1")} // Madrid, Spain. Europe.
|
||||
GCPRegionEuropeWest1 = CloudProviderRegion{valuer.NewString("europe-west1")} // St. Ghislain, Belgium. Europe.
|
||||
GCPRegionEuropeWest2 = CloudProviderRegion{valuer.NewString("europe-west2")} // London, England. Europe.
|
||||
GCPRegionEuropeWest3 = CloudProviderRegion{valuer.NewString("europe-west3")} // Frankfurt, Germany. Europe.
|
||||
GCPRegionEuropeWest4 = CloudProviderRegion{valuer.NewString("europe-west4")} // Eemshaven, Netherlands. Europe.
|
||||
GCPRegionEuropeWest6 = CloudProviderRegion{valuer.NewString("europe-west6")} // Zurich, Switzerland. Europe.
|
||||
GCPRegionEuropeWest8 = CloudProviderRegion{valuer.NewString("europe-west8")} // Milan, Italy. Europe.
|
||||
GCPRegionEuropeWest9 = CloudProviderRegion{valuer.NewString("europe-west9")} // Paris, France. Europe.
|
||||
GCPRegionEuropeWest10 = CloudProviderRegion{valuer.NewString("europe-west10")} // Berlin, Germany. Europe.
|
||||
GCPRegionEuropeWest12 = CloudProviderRegion{valuer.NewString("europe-west12")} // Turin, Italy. Europe.
|
||||
GCPRegionMECentral1 = CloudProviderRegion{valuer.NewString("me-central1")} // Doha, Qatar. Middle East.
|
||||
GCPRegionMECentral2 = CloudProviderRegion{valuer.NewString("me-central2")} // Dammam, Saudi Arabia. Middle East.
|
||||
GCPRegionMEWest1 = CloudProviderRegion{valuer.NewString("me-west1")} // Tel Aviv, Israel. Middle East.
|
||||
GCPRegionNorthamericaNortheast1 = CloudProviderRegion{valuer.NewString("northamerica-northeast1")} // Montréal, Québec, Canada. North America.
|
||||
GCPRegionNorthamericaNortheast2 = CloudProviderRegion{valuer.NewString("northamerica-northeast2")} // Toronto, Ontario, Canada. North America.
|
||||
GCPRegionNorthamericaSouth1 = CloudProviderRegion{valuer.NewString("northamerica-south1")} // Querétaro, Mexico. North America.
|
||||
GCPRegionSouthamericaEast1 = CloudProviderRegion{valuer.NewString("southamerica-east1")} // Osasco, São Paulo, Brazil. South America.
|
||||
GCPRegionSouthamericaWest1 = CloudProviderRegion{valuer.NewString("southamerica-west1")} // Santiago, Chile. South America.
|
||||
GCPRegionUSCentral1 = CloudProviderRegion{valuer.NewString("us-central1")} // Council Bluffs, Iowa. North America.
|
||||
GCPRegionUSEast1 = CloudProviderRegion{valuer.NewString("us-east1")} // Moncks Corner, South Carolina. North America.
|
||||
GCPRegionUSEast4 = CloudProviderRegion{valuer.NewString("us-east4")} // Ashburn, Virginia. North America.
|
||||
GCPRegionUSEast5 = CloudProviderRegion{valuer.NewString("us-east5")} // Columbus, Ohio. North America.
|
||||
GCPRegionUSSouth1 = CloudProviderRegion{valuer.NewString("us-south1")} // Dallas, Texas. North America.
|
||||
GCPRegionUSWest1 = CloudProviderRegion{valuer.NewString("us-west1")} // The Dalles, Oregon. North America.
|
||||
GCPRegionUSWest2 = CloudProviderRegion{valuer.NewString("us-west2")} // Los Angeles, California. North America.
|
||||
GCPRegionUSWest3 = CloudProviderRegion{valuer.NewString("us-west3")} // Salt Lake City, Utah. North America.
|
||||
GCPRegionUSWest4 = CloudProviderRegion{valuer.NewString("us-west4")} // Las Vegas, Nevada. North America.
|
||||
)
|
||||
|
||||
func Enum() []any {
|
||||
@@ -172,18 +127,6 @@ func Enum() []any {
|
||||
AzureRegionSwedenCentral, AzureRegionSwitzerlandNorth, AzureRegionSwitzerlandWest,
|
||||
AzureRegionUAECentral, AzureRegionUAENorth, AzureRegionUKSouth, AzureRegionUKWest,
|
||||
AzureRegionWestCentralUS, AzureRegionWestEurope, AzureRegionWestIndia, AzureRegionWestUS, AzureRegionWestUS2, AzureRegionWestUS3,
|
||||
// GCP regions.
|
||||
GCPRegionAfricaSouth1, GCPRegionAsiaEast1, GCPRegionAsiaEast2, GCPRegionAsiaNortheast1, GCPRegionAsiaNortheast2, GCPRegionAsiaNortheast3,
|
||||
GCPRegionAsiaSouth1, GCPRegionAsiaSouth2, GCPRegionAsiaSoutheast1, GCPRegionAsiaSoutheast2, GCPRegionAsiaSoutheast3,
|
||||
GCPRegionAustraliaSoutheast1, GCPRegionAustraliaSoutheast2,
|
||||
GCPRegionEuropeCentral2, GCPRegionEuropeNorth1, GCPRegionEuropeNorth2, GCPRegionEuropeSouthwest1,
|
||||
GCPRegionEuropeWest1, GCPRegionEuropeWest2, GCPRegionEuropeWest3, GCPRegionEuropeWest4, GCPRegionEuropeWest6,
|
||||
GCPRegionEuropeWest8, GCPRegionEuropeWest9, GCPRegionEuropeWest10, GCPRegionEuropeWest12,
|
||||
GCPRegionMECentral1, GCPRegionMECentral2, GCPRegionMEWest1,
|
||||
GCPRegionNorthamericaNortheast1, GCPRegionNorthamericaNortheast2, GCPRegionNorthamericaSouth1,
|
||||
GCPRegionSouthamericaEast1, GCPRegionSouthamericaWest1,
|
||||
GCPRegionUSCentral1, GCPRegionUSEast1, GCPRegionUSEast4, GCPRegionUSEast5, GCPRegionUSSouth1,
|
||||
GCPRegionUSWest1, GCPRegionUSWest2, GCPRegionUSWest3, GCPRegionUSWest4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,19 +154,6 @@ var SupportedRegions = map[CloudProviderType][]CloudProviderRegion{
|
||||
AzureRegionUAECentral, AzureRegionUAENorth, AzureRegionUKSouth, AzureRegionUKWest,
|
||||
AzureRegionWestCentralUS, AzureRegionWestEurope, AzureRegionWestIndia, AzureRegionWestUS, AzureRegionWestUS2, AzureRegionWestUS3,
|
||||
},
|
||||
CloudProviderTypeGCP: {
|
||||
GCPRegionAfricaSouth1, GCPRegionAsiaEast1, GCPRegionAsiaEast2, GCPRegionAsiaNortheast1, GCPRegionAsiaNortheast2, GCPRegionAsiaNortheast3,
|
||||
GCPRegionAsiaSouth1, GCPRegionAsiaSouth2, GCPRegionAsiaSoutheast1, GCPRegionAsiaSoutheast2, GCPRegionAsiaSoutheast3,
|
||||
GCPRegionAustraliaSoutheast1, GCPRegionAustraliaSoutheast2,
|
||||
GCPRegionEuropeCentral2, GCPRegionEuropeNorth1, GCPRegionEuropeNorth2, GCPRegionEuropeSouthwest1,
|
||||
GCPRegionEuropeWest1, GCPRegionEuropeWest2, GCPRegionEuropeWest3, GCPRegionEuropeWest4, GCPRegionEuropeWest6,
|
||||
GCPRegionEuropeWest8, GCPRegionEuropeWest9, GCPRegionEuropeWest10, GCPRegionEuropeWest12,
|
||||
GCPRegionMECentral1, GCPRegionMECentral2, GCPRegionMEWest1,
|
||||
GCPRegionNorthamericaNortheast1, GCPRegionNorthamericaNortheast2, GCPRegionNorthamericaSouth1,
|
||||
GCPRegionSouthamericaEast1, GCPRegionSouthamericaWest1,
|
||||
GCPRegionUSCentral1, GCPRegionUSEast1, GCPRegionUSEast4, GCPRegionUSEast5, GCPRegionUSSouth1,
|
||||
GCPRegionUSWest1, GCPRegionUSWest2, GCPRegionUSWest3, GCPRegionUSWest4,
|
||||
},
|
||||
}
|
||||
|
||||
func validateAWSRegion(region string) error {
|
||||
@@ -245,13 +175,3 @@ func validateAzureRegion(region string) error {
|
||||
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid Azure region: %s", region)
|
||||
}
|
||||
|
||||
func validateGCPRegion(region string) error {
|
||||
for _, r := range SupportedRegions[CloudProviderTypeGCP] {
|
||||
if r.StringValue() == region {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid GCP region: %s", region)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ type CloudIntegrationService struct {
|
||||
type ServiceConfig struct {
|
||||
AWS *AWSServiceConfig `json:"aws,omitempty" required:"false" nullable:"false"`
|
||||
Azure *AzureServiceConfig `json:"azure,omitempty" required:"false" nullable:"false"`
|
||||
GCP *GCPServiceConfig `json:"gcp,omitempty" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
|
||||
@@ -97,7 +96,6 @@ type DataCollected struct {
|
||||
type TelemetryCollectionStrategy struct {
|
||||
AWS *AWSTelemetryCollectionStrategy `json:"aws,omitempty" required:"false" nullable:"false"`
|
||||
Azure *AzureTelemetryCollectionStrategy `json:"azure,omitempty" required:"false" nullable:"false"`
|
||||
GCP *GCPTelemetryCollectionStrategy `json:"gcp,omitempty" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
// Assets represents the collection of dashboards.
|
||||
@@ -147,10 +145,6 @@ func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.U
|
||||
if config.Azure == nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "Azure config is required for Azure service")
|
||||
}
|
||||
case CloudProviderTypeGCP:
|
||||
if config.GCP == nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config is required for GCP service")
|
||||
}
|
||||
}
|
||||
|
||||
return &CloudIntegrationService{
|
||||
@@ -267,22 +261,6 @@ func NewServiceConfigFromJSON(provider CloudProviderType, jsonString string) (*S
|
||||
}
|
||||
|
||||
return &ServiceConfig{Azure: azureServiceConfig}, nil
|
||||
case CloudProviderTypeGCP:
|
||||
gcpServiceConfig := new(GCPServiceConfig)
|
||||
|
||||
if storableServiceConfig.GCP.Logs != nil {
|
||||
gcpServiceConfig.Logs = &GCPServiceLogsConfig{
|
||||
Enabled: storableServiceConfig.GCP.Logs.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
if storableServiceConfig.GCP.Metrics != nil {
|
||||
gcpServiceConfig.Metrics = &GCPServiceMetricsConfig{
|
||||
Enabled: storableServiceConfig.GCP.Metrics.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
return &ServiceConfig{GCP: gcpServiceConfig}, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -307,10 +285,6 @@ func (service *CloudIntegrationService) Update(provider CloudProviderType, servi
|
||||
if config.Azure == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "Azure config is required for Azure service")
|
||||
}
|
||||
case CloudProviderTypeGCP:
|
||||
if config.GCP == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "GCP config is required for GCP service")
|
||||
}
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -332,10 +306,6 @@ func (config *ServiceConfig) IsServiceEnabled(provider CloudProviderType) bool {
|
||||
logsEnabled := config.Azure.Logs != nil && config.Azure.Logs.Enabled
|
||||
metricsEnabled := config.Azure.Metrics != nil && config.Azure.Metrics.Enabled
|
||||
return logsEnabled || metricsEnabled
|
||||
case CloudProviderTypeGCP:
|
||||
logsEnabled := config.GCP.Logs != nil && config.GCP.Logs.Enabled
|
||||
metricsEnabled := config.GCP.Metrics != nil && config.GCP.Metrics.Enabled
|
||||
return logsEnabled || metricsEnabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -349,8 +319,6 @@ func (config *ServiceConfig) IsMetricsEnabled(provider CloudProviderType) bool {
|
||||
return config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
|
||||
case CloudProviderTypeAzure:
|
||||
return config.Azure.Metrics != nil && config.Azure.Metrics.Enabled
|
||||
case CloudProviderTypeGCP:
|
||||
return config.GCP.Metrics != nil && config.GCP.Metrics.Enabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -363,8 +331,6 @@ func (config *ServiceConfig) IsLogsEnabled(provider CloudProviderType) bool {
|
||||
return config.AWS.Logs != nil && config.AWS.Logs.Enabled
|
||||
case CloudProviderTypeAzure:
|
||||
return config.Azure.Logs != nil && config.Azure.Logs.Enabled
|
||||
case CloudProviderTypeGCP:
|
||||
return config.GCP.Logs != nil && config.GCP.Logs.Enabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -39,9 +39,6 @@ var (
|
||||
AzureServiceCosmosDB = ServiceID{valuer.NewString("cosmosdb")}
|
||||
AzureServiceCassandraDB = ServiceID{valuer.NewString("cassandradb")}
|
||||
AzureServiceRedis = ServiceID{valuer.NewString("redis")}
|
||||
|
||||
// GCP services.
|
||||
GCPServiceCloudSQL = ServiceID{valuer.NewString("cloudsql")}
|
||||
)
|
||||
|
||||
func (ServiceID) Enum() []any {
|
||||
@@ -73,7 +70,6 @@ func (ServiceID) Enum() []any {
|
||||
AzureServiceCosmosDB,
|
||||
AzureServiceCassandraDB,
|
||||
AzureServiceRedis,
|
||||
GCPServiceCloudSQL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +106,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
|
||||
AzureServiceCassandraDB,
|
||||
AzureServiceRedis,
|
||||
},
|
||||
CloudProviderTypeGCP: {
|
||||
GCPServiceCloudSQL,
|
||||
},
|
||||
}
|
||||
|
||||
func NewServiceID(provider CloudProviderType, service string) (ServiceID, error) {
|
||||
|
||||
@@ -94,8 +94,7 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
}
|
||||
allowed := allowedQueryKinds[panelKind]
|
||||
for qi, q := range panel.Spec.Queries {
|
||||
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
|
||||
if err := validateQueryAllowedForPanel(q.Spec.Plugin, allowed, panelKind, queryPath); err != nil {
|
||||
if err := d.validateQuery(qi, q, panelKind, path, allowed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -103,6 +102,17 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardSpec) validateQuery(qi int, q Query, panelKind PanelPluginKind, path string, allowed []QueryPluginKind) error {
|
||||
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
|
||||
if err := validateQueryAllowedForPanel(q.Spec.Plugin, allowed, panelKind, queryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateQueryContent(q, queryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
|
||||
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
|
||||
qb.QueryTypeBuilder: QueryKindBuilder,
|
||||
@@ -138,33 +148,32 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxLayoutsPerDashboard = 500
|
||||
|
||||
// validateLayouts validates the dashboard's layouts: bounded section count,
|
||||
// per-item geometry, resolvable panel references, and no panel placed twice.
|
||||
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
|
||||
// runs here so its errors can name the layout by index.
|
||||
func (d *DashboardSpec) validateLayouts() error {
|
||||
if len(d.Layouts) > maxLayoutsPerDashboard {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
|
||||
// validateQueryContent runs the query-builder type's own validation on the panel's
|
||||
// query - whatever its kind - by assembling the same v5 composite query the panel would
|
||||
// execute and delegating to it. The rules (aggregations, group by, order by, promql /
|
||||
// clickhouse / formula / trace-operator specifics, ...) live on querybuildertypesv5; this
|
||||
// only delegates, scoped to the query's request type, so a dashboard cannot persist a
|
||||
// query the querier would later reject. Request-type options keep e.g. a list (raw) panel
|
||||
// from being forced to carry an aggregation.
|
||||
func validateQueryContent(q Query, path string) error {
|
||||
composite, err := q.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
|
||||
}
|
||||
if err := composite.Validate(qb.GetValidationOptions(q.Kind)...); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Could enforce this but skipping for now: panels in no grid item (orphans)
|
||||
// are allowed.
|
||||
|
||||
// The frontend keys each grid item by its panel id, so placing one panel in
|
||||
// two grid items collides; reject duplicate references dashboard-wide. Maps
|
||||
// each referenced panel key to the path of the item that first placed it.
|
||||
referencedPanels := make(map[string]string, len(d.Panels))
|
||||
// validateLayouts rejects grid items referencing a panel that doesn't exist.
|
||||
func (d *DashboardSpec) validateLayouts() error {
|
||||
for li, layout := range d.Layouts {
|
||||
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
|
||||
if !ok {
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
if err := validateGridLayoutGeometry(grid, li); err != nil {
|
||||
return err
|
||||
}
|
||||
for ii, item := range grid.Items {
|
||||
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
|
||||
if item.Content == nil {
|
||||
@@ -177,10 +186,6 @@ func (d *DashboardSpec) validateLayouts() error {
|
||||
if _, ok := d.Panels[key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
|
||||
}
|
||||
if firstPath, dup := referencedPanels[key]; dup {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: panel %q is already placed by %s", path, key, firstPath)
|
||||
}
|
||||
referencedPanels[key] = path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -299,22 +299,19 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
// Layout edits
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("move panel by editing layout y coordinate", func(t *testing.T) {
|
||||
// p2 fills the right half of row 0, so p1 can only move to a fresh row
|
||||
// without tripping overlap validation.
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/y", "value": 6}]`).Apply(base)
|
||||
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
// The first item used to live at y=0, now lives at y=6.
|
||||
assert.Contains(t, raw, `"x":0,"y":6,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
|
||||
// The first item used to live at x=0, now lives at x=6.
|
||||
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
|
||||
})
|
||||
|
||||
t.Run("resize panel by editing layout width", func(t *testing.T) {
|
||||
// p2 sits at x=6, so p1 (at x=0) can only shrink; widening it would overlap.
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 3}]`).Apply(base)
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"width":3`)
|
||||
assert.Contains(t, raw, `"width":12`)
|
||||
})
|
||||
|
||||
t.Run("rename layout row title", func(t *testing.T) {
|
||||
@@ -324,12 +321,11 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("append layout item", func(t *testing.T) {
|
||||
// Appending needs a not-yet-placed panel, so add one in the same patch;
|
||||
// re-placing p1 or p2 would be a duplicate reference.
|
||||
out, err := decode(t, `[
|
||||
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
|
||||
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
|
||||
]`).Apply(base)
|
||||
out, err := decode(t, `[{
|
||||
"op": "add",
|
||||
"path": "/spec/layouts/0/spec/items/-",
|
||||
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
// Item count went 2 → 3.
|
||||
raw := jsonOf(t, out)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
@@ -1391,11 +1390,23 @@ func TestSpanGaps(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
// A panel's query carries the request type implied by the panel kind: list panels
|
||||
// are raw (no aggregation), table-like panels are scalar, the rest time series.
|
||||
requestKind := func(panelKind string) string {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
}
|
||||
}
|
||||
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
|
||||
return []byte(`{
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"plugin": {"kind": "` + panelKind + `", "spec": {}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
|
||||
"queries": [{"kind": "` + requestKind(panelKind) + `", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
|
||||
}}},
|
||||
"layouts": []
|
||||
}`)
|
||||
@@ -1404,7 +1415,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
return []byte(`{
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"plugin": {"kind": "` + panelKind + `", "spec": {}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {
|
||||
"queries": [{"kind": "` + requestKind(panelKind) + `", "spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {
|
||||
"queries": [{"type": "` + subType + `", "spec": ` + subSpec + `}]
|
||||
}}}}]
|
||||
}}},
|
||||
@@ -1444,122 +1455,41 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGridGeometry(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
items []dashboard.GridItem
|
||||
expectErrContain string
|
||||
}{
|
||||
{
|
||||
scenario: "valid side-by-side items",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 6, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "valid full-width item",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 12, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "stacked items do not overlap",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 0, Y: 6, Width: 6, Height: 6}},
|
||||
expectErrContain: "",
|
||||
},
|
||||
{
|
||||
scenario: "zero width",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 0, Height: 6}},
|
||||
expectErrContain: "width must be at least 1",
|
||||
},
|
||||
{
|
||||
scenario: "zero height",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 0}},
|
||||
expectErrContain: "height must be at least 1",
|
||||
},
|
||||
{
|
||||
scenario: "negative x",
|
||||
items: []dashboard.GridItem{{X: -1, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "x must not be negative",
|
||||
},
|
||||
{
|
||||
scenario: "negative y",
|
||||
items: []dashboard.GridItem{{X: 0, Y: -1, Width: 6, Height: 6}},
|
||||
expectErrContain: "y must not be negative",
|
||||
},
|
||||
{
|
||||
scenario: "width wider than grid",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 13, Height: 6}},
|
||||
expectErrContain: "width (13) exceeds grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "x at grid width",
|
||||
items: []dashboard.GridItem{{X: 12, Y: 0, Width: 1, Height: 6}},
|
||||
expectErrContain: "x (12) must be less than grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "x plus width overflows grid",
|
||||
items: []dashboard.GridItem{{X: 8, Y: 0, Width: 6, Height: 6}},
|
||||
expectErrContain: "x (8) + width (6) exceeds grid width 12",
|
||||
},
|
||||
{
|
||||
scenario: "overlapping items",
|
||||
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 3, Y: 3, Width: 6, Height: 6}},
|
||||
expectErrContain: "items[0] and items[1] overlap",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: test.items}, 0)
|
||||
if test.expectErrContain == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), test.expectErrContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGridItemLimit(t *testing.T) {
|
||||
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, maxItemsPerGridLayout+1)}, 0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "maximum is")
|
||||
}
|
||||
|
||||
// Both panel refs are valid, so this errors only if geometry validation runs on
|
||||
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
|
||||
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
|
||||
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
|
||||
},
|
||||
"layouts": [{"kind": "Grid", "spec": {"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
|
||||
]}}]
|
||||
// TestCommaSeparatedAggregationRejectedOnWrite asserts the write path (unmarshalDashboard
|
||||
// runs DashboardSpec.Validate) rejects an aggregation that packs several comma-separated
|
||||
// calls into one expression, while accepting a single call and a properly pre-split list.
|
||||
func TestCommaSeparatedAggregationRejectedOnWrite(t *testing.T) {
|
||||
buildDashboardWithLogsAggregation := func(aggregationsJSON string) []byte {
|
||||
return []byte(`{
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "logs", "aggregations": ` + aggregationsJSON + `
|
||||
}}}}]
|
||||
}}},
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "overlap")
|
||||
}
|
||||
}
|
||||
|
||||
// The frontend keys each grid item by its panel id, so the same panel placed by
|
||||
// two grid items crashes the section; the backend rejects it dashboard-wide. The
|
||||
// two items are side by side so they clear the overlap check first.
|
||||
func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
|
||||
},
|
||||
"layouts": [{"kind": "Grid", "spec": {"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
|
||||
]}}]
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "already placed")
|
||||
// Both offending grid items are named.
|
||||
require.Contains(t, err.Error(), "spec.layouts[0].spec.items[0].content")
|
||||
require.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
|
||||
t.Run("comma-separated expression is rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(buildDashboardWithLogsAggregation(`[{"expression": "count(), sum(bytes)"}]`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "single function call")
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
|
||||
t.Run("single-call expression is accepted", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(buildDashboardWithLogsAggregation(`[{"expression": "count()"}]`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("pre-split aggregations are accepted", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(buildDashboardWithLogsAggregation(`[{"expression": "count()"}, {"expression": "sum(bytes)"}]`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("comma inside function args is not mistaken for multiple calls", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(buildDashboardWithLogsAggregation(`[{"expression": "countIf(day > 10, status)"}]`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
@@ -125,6 +126,34 @@ func (QueryPlugin) JSONSchemaOneOf() []any {
|
||||
}
|
||||
}
|
||||
|
||||
func (plugin QueryPlugin) buildV5CompositeQueryFromPlugin() (qb.CompositeQuery, error) {
|
||||
switch spec := plugin.Spec.(type) {
|
||||
case *qb.CompositeQuery:
|
||||
if spec == nil {
|
||||
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "composite query is empty")
|
||||
}
|
||||
return *spec, nil
|
||||
case *BuilderQuerySpec:
|
||||
if spec == nil {
|
||||
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "builder query is empty")
|
||||
}
|
||||
return wrapEnvelope(qb.QueryTypeBuilder, spec.Spec), nil
|
||||
case *qb.PromQuery:
|
||||
return wrapEnvelope(qb.QueryTypePromQL, *spec), nil
|
||||
case *qb.ClickHouseQuery:
|
||||
return wrapEnvelope(qb.QueryTypeClickHouseSQL, *spec), nil
|
||||
case *qb.QueryBuilderFormula:
|
||||
return wrapEnvelope(qb.QueryTypeFormula, *spec), nil
|
||||
case *qb.QueryBuilderTraceOperator:
|
||||
return wrapEnvelope(qb.QueryTypeTraceOperator, *spec), nil
|
||||
}
|
||||
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "unsupported query kind %q", plugin.Kind)
|
||||
}
|
||||
|
||||
func wrapEnvelope(queryType qb.QueryType, spec any) qb.CompositeQuery {
|
||||
return qb.CompositeQuery{Queries: []qb.QueryEnvelope{{Type: queryType, Spec: spec}}}
|
||||
}
|
||||
|
||||
type QueryPluginVariant[S any] struct {
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
|
||||
181
pkg/types/dashboardtypes/perses_public_dashboard.go
Normal file
181
pkg/types/dashboardtypes/perses_public_dashboard.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Gettable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// GettablePublicDashboardDataV2 is the anonymous-facing payload of a v2 dashboard.
|
||||
type GettablePublicDashboardDataV2 struct {
|
||||
Dashboard *GettableDashboardV2 `json:"dashboard"`
|
||||
PublicDashboard *GettablePublicDasbhboard `json:"publicDashboard"`
|
||||
}
|
||||
|
||||
// NewPublicDashboardDataFromDashboardV2 builds the anonymous v2 payload: panel queries
|
||||
// are redacted, and only the body fields v1 exposed (name, metadata, tags, spec) are set.
|
||||
func NewPublicDashboardDataFromDashboardV2(dashboard *DashboardV2, publicDashboard *PublicDashboard) *GettablePublicDashboardDataV2 {
|
||||
spec := dashboard.Spec
|
||||
redactPanelQueries(&spec)
|
||||
|
||||
return &GettablePublicDashboardDataV2{
|
||||
Dashboard: &GettableDashboardV2{
|
||||
DashboardV2MetadataBase: dashboard.DashboardV2MetadataBase,
|
||||
Name: dashboard.Name,
|
||||
Tags: tagtypes.NewGettableTagsFromTags(dashboard.Tags),
|
||||
Spec: spec,
|
||||
},
|
||||
PublicDashboard: &GettablePublicDasbhboard{
|
||||
TimeRangeEnabled: publicDashboard.TimeRangeEnabled,
|
||||
DefaultTimeRange: publicDashboard.DefaultTimeRange,
|
||||
PublicPath: publicDashboard.PublicPath(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Redaction
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func redactPanelQueries(spec *DashboardSpec) {
|
||||
panels := make(map[string]*Panel, len(spec.Panels))
|
||||
for key, panel := range spec.Panels {
|
||||
if panel == nil {
|
||||
panels[key] = nil
|
||||
continue
|
||||
}
|
||||
redacted := *panel
|
||||
queries := make([]Query, len(redacted.Spec.Queries))
|
||||
for i, query := range redacted.Spec.Queries {
|
||||
query.Spec.Plugin.Spec = redactQuery(query.Spec.Plugin.Spec)
|
||||
queries[i] = query
|
||||
}
|
||||
redacted.Spec.Queries = queries
|
||||
panels[key] = &redacted
|
||||
}
|
||||
spec.Panels = panels
|
||||
}
|
||||
|
||||
func redactQuery(spec any) any {
|
||||
switch s := spec.(type) {
|
||||
case *qb.CompositeQuery:
|
||||
if s == nil {
|
||||
return spec
|
||||
}
|
||||
queries := make([]qb.QueryEnvelope, len(s.Queries))
|
||||
for i, envelope := range s.Queries {
|
||||
envelope.Spec = redactLeafQuery(envelope.Spec)
|
||||
queries[i] = envelope
|
||||
}
|
||||
return &qb.CompositeQuery{Queries: queries}
|
||||
case *BuilderQuerySpec:
|
||||
if s == nil {
|
||||
return spec
|
||||
}
|
||||
return &BuilderQuerySpec{Spec: redactLeafQuery(s.Spec)}
|
||||
case *qb.PromQuery:
|
||||
return redactQueryPtr(s)
|
||||
case *qb.ClickHouseQuery:
|
||||
return redactQueryPtr(s)
|
||||
case *qb.QueryBuilderFormula:
|
||||
return redactQueryPtr(s)
|
||||
case *qb.QueryBuilderTraceOperator:
|
||||
return redactQueryPtr(s)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
func redactQueryPtr[T any](s *T) any {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
redacted := redactLeafQuery(*s).(T)
|
||||
return &redacted
|
||||
}
|
||||
|
||||
func redactLeafQuery(spec any) any {
|
||||
switch s := spec.(type) {
|
||||
case qb.QueryBuilderQuery[qb.LogAggregation]:
|
||||
return redactBuilderQuery(s)
|
||||
case qb.QueryBuilderQuery[qb.MetricAggregation]:
|
||||
return redactBuilderQuery(s)
|
||||
case qb.QueryBuilderQuery[qb.TraceAggregation]:
|
||||
return redactBuilderQuery(s)
|
||||
case qb.PromQuery:
|
||||
return qb.PromQuery{Name: s.Name, Legend: s.Legend}
|
||||
case qb.ClickHouseQuery:
|
||||
return qb.ClickHouseQuery{Name: s.Name, Legend: s.Legend}
|
||||
case qb.QueryBuilderFormula:
|
||||
return qb.QueryBuilderFormula{Name: s.Name, Expression: s.Expression, Legend: s.Legend}
|
||||
case qb.QueryBuilderTraceOperator:
|
||||
return qb.QueryBuilderTraceOperator{
|
||||
Name: s.Name,
|
||||
Expression: s.Expression,
|
||||
Aggregations: s.Aggregations,
|
||||
GroupBy: s.GroupBy,
|
||||
Legend: s.Legend,
|
||||
}
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
func redactBuilderQuery[T any](q qb.QueryBuilderQuery[T]) qb.QueryBuilderQuery[T] {
|
||||
return qb.QueryBuilderQuery[T]{
|
||||
Name: q.Name,
|
||||
Signal: q.Signal,
|
||||
Source: q.Source,
|
||||
Aggregations: q.Aggregations,
|
||||
GroupBy: q.GroupBy,
|
||||
Legend: q.Legend,
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Panel query
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string) (*qb.QueryRangeRequest, error) {
|
||||
panel, ok := d.Spec.Panels[panelKey]
|
||||
if !ok || panel == nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidInput, "panel with key %q doesn't exist", panelKey)
|
||||
}
|
||||
// Validator guarantees exactly one query per panel.
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "panel %q must have exactly one query", panelKey)
|
||||
}
|
||||
|
||||
query := panel.Spec.Queries[0]
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fillGaps lives on the panel visualization; only timeseries and bar chart carry it.
|
||||
fillGaps := false
|
||||
switch panelSpec := panel.Spec.Plugin.Spec.(type) {
|
||||
case *TimeSeriesPanelSpec:
|
||||
if panelSpec != nil {
|
||||
fillGaps = panelSpec.Visualization.FillSpans
|
||||
}
|
||||
case *BarChartPanelSpec:
|
||||
if panelSpec != nil {
|
||||
fillGaps = panelSpec.Visualization.FillSpans
|
||||
}
|
||||
}
|
||||
|
||||
return &qb.QueryRangeRequest{
|
||||
SchemaVersion: "v1",
|
||||
Start: startTime,
|
||||
End: endTime,
|
||||
RequestType: query.Kind,
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
t.Run("returns error when the panel does not exist", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := dashboard.GetPanelQuery(1, 2, "wrongPanelKey")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
|
||||
t.Run("returns error when the panel is nil", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
|
||||
t.Run("returns error unless the panel has exactly one query", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
queries []Query
|
||||
}{
|
||||
{description: "zero queries", queries: nil},
|
||||
{description: "two queries", queries: []Query{{}, {}}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{Queries: tc.queries},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("builds a single-envelope request for a builder query", func(t *testing.T) {
|
||||
builder := qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: builder},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(100, 200, "panel-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "v1", req.SchemaVersion)
|
||||
assert.Equal(t, uint64(100), req.Start)
|
||||
assert.Equal(t, uint64(200), req.End)
|
||||
assert.Equal(t, qb.RequestTypeTimeSeries, req.RequestType)
|
||||
require.Len(t, req.CompositeQuery.Queries, 1)
|
||||
assert.Equal(t, qb.QueryTypeBuilder, req.CompositeQuery.Queries[0].Type)
|
||||
assert.Equal(t, builder, req.CompositeQuery.Queries[0].Spec)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.False(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
|
||||
t.Run("uses a composite query as-is", func(t *testing.T) {
|
||||
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
|
||||
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
{Type: qb.QueryTypePromQL, Spec: qb.PromQuery{Name: "B"}},
|
||||
}}
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindComposite,
|
||||
Spec: composite,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, composite.Queries, req.CompositeQuery.Queries)
|
||||
})
|
||||
|
||||
t.Run("wraps a leaf query in a single typed envelope", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
plugin QueryPlugin
|
||||
expectedType qb.QueryType
|
||||
}{
|
||||
{
|
||||
description: "promql",
|
||||
plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &qb.PromQuery{Name: "A", Query: "up"}},
|
||||
expectedType: qb.QueryTypePromQL,
|
||||
},
|
||||
{
|
||||
description: "clickhouse",
|
||||
plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &qb.ClickHouseQuery{Name: "A", Query: "SELECT 1"}},
|
||||
expectedType: qb.QueryTypeClickHouseSQL,
|
||||
},
|
||||
{
|
||||
description: "formula",
|
||||
plugin: QueryPlugin{Kind: QueryKindFormula, Spec: &qb.QueryBuilderFormula{Name: "F1", Expression: "A / B"}},
|
||||
expectedType: qb.QueryTypeFormula,
|
||||
},
|
||||
{
|
||||
description: "trace operator",
|
||||
plugin: QueryPlugin{Kind: QueryKindTraceOperator, Spec: &qb.QueryBuilderTraceOperator{Name: "T1", Expression: "A => B"}},
|
||||
expectedType: qb.QueryTypeTraceOperator,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{Plugin: tc.plugin},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, req.CompositeQuery.Queries, 1)
|
||||
assert.Equal(t, tc.expectedType, req.CompositeQuery.Queries[0].Type)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
panelPlugin PanelPlugin
|
||||
expectedFillGaps bool
|
||||
}{
|
||||
{
|
||||
description: "timeseries with fillSpans enabled",
|
||||
panelPlugin: PanelPlugin{Kind: PanelKindTimeSeries, Spec: &TimeSeriesPanelSpec{Visualization: TimeSeriesVisualization{FillSpans: true}}},
|
||||
expectedFillGaps: true,
|
||||
},
|
||||
{
|
||||
description: "timeseries with fillSpans disabled",
|
||||
panelPlugin: PanelPlugin{Kind: PanelKindTimeSeries, Spec: &TimeSeriesPanelSpec{Visualization: TimeSeriesVisualization{FillSpans: false}}},
|
||||
expectedFillGaps: false,
|
||||
},
|
||||
{
|
||||
description: "bar chart with fillSpans enabled",
|
||||
panelPlugin: PanelPlugin{Kind: PanelKindBarChart, Spec: &BarChartPanelSpec{Visualization: BarChartVisualization{FillSpans: true}}},
|
||||
expectedFillGaps: true,
|
||||
},
|
||||
{
|
||||
description: "table panel has no fillSpans",
|
||||
panelPlugin: PanelPlugin{Kind: PanelKindTable, Spec: &TablePanelSpec{}},
|
||||
expectedFillGaps: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: tc.panelPlugin,
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.Equal(t, tc.expectedFillGaps, req.FormatOptions.FillGaps)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error for an unsupported plugin spec", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: "not-a-query",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRedactLeafQuery(t *testing.T) {
|
||||
t.Run("builder query drops filter, having, limit and keeps display fields", func(t *testing.T) {
|
||||
input := qb.QueryBuilderQuery[qb.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qb.MetricAggregation{{MetricName: "system_cpu_usage"}},
|
||||
GroupBy: []qb.GroupByKey{{}},
|
||||
Legend: "cpu",
|
||||
Disabled: true,
|
||||
StepInterval: qb.Step{Duration: time.Minute},
|
||||
Filter: &qb.Filter{Expression: "service.name = 'checkout'"},
|
||||
Having: &qb.Having{},
|
||||
Limit: 100,
|
||||
}
|
||||
|
||||
redacted, ok := redactLeafQuery(input).(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok)
|
||||
|
||||
// dropped (not in the v1 whitelist)
|
||||
assert.Nil(t, redacted.Filter)
|
||||
assert.Nil(t, redacted.Having)
|
||||
assert.Zero(t, redacted.Limit)
|
||||
assert.Zero(t, redacted.StepInterval)
|
||||
assert.False(t, redacted.Disabled)
|
||||
|
||||
// kept (display)
|
||||
assert.Equal(t, "A", redacted.Name)
|
||||
assert.Equal(t, telemetrytypes.SignalMetrics, redacted.Signal)
|
||||
assert.Equal(t, "cpu", redacted.Legend)
|
||||
require.Len(t, redacted.Aggregations, 1)
|
||||
assert.Equal(t, "system_cpu_usage", redacted.Aggregations[0].MetricName)
|
||||
assert.Len(t, redacted.GroupBy, 1)
|
||||
})
|
||||
|
||||
t.Run("promql query drops the raw query, step and disabled", func(t *testing.T) {
|
||||
redacted, ok := redactLeafQuery(qb.PromQuery{
|
||||
Name: "A",
|
||||
Query: "sum(rate(http_requests_total[5m]))",
|
||||
Step: qb.Step{Duration: time.Minute},
|
||||
Disabled: true,
|
||||
Legend: "rps",
|
||||
}).(qb.PromQuery)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Empty(t, redacted.Query)
|
||||
assert.Zero(t, redacted.Step)
|
||||
assert.False(t, redacted.Disabled)
|
||||
assert.Equal(t, "A", redacted.Name)
|
||||
assert.Equal(t, "rps", redacted.Legend)
|
||||
})
|
||||
|
||||
t.Run("clickhouse query drops the raw query string", func(t *testing.T) {
|
||||
redacted, ok := redactLeafQuery(qb.ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT * FROM signoz_logs WHERE user = 'admin'",
|
||||
Legend: "logs",
|
||||
}).(qb.ClickHouseQuery)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Empty(t, redacted.Query)
|
||||
assert.Equal(t, "A", redacted.Name)
|
||||
assert.Equal(t, "logs", redacted.Legend)
|
||||
})
|
||||
|
||||
t.Run("formula keeps its expression but drops limit and having", func(t *testing.T) {
|
||||
redacted, ok := redactLeafQuery(qb.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A / B",
|
||||
Legend: "ratio",
|
||||
Limit: 50,
|
||||
Having: &qb.Having{},
|
||||
}).(qb.QueryBuilderFormula)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, "A / B", redacted.Expression)
|
||||
assert.Equal(t, "F1", redacted.Name)
|
||||
assert.Equal(t, "ratio", redacted.Legend)
|
||||
assert.Zero(t, redacted.Limit)
|
||||
assert.Nil(t, redacted.Having)
|
||||
})
|
||||
|
||||
t.Run("trace operator drops filter but keeps expression and aggregations", func(t *testing.T) {
|
||||
redacted, ok := redactLeafQuery(qb.QueryBuilderTraceOperator{
|
||||
Name: "T1",
|
||||
Expression: "A => B",
|
||||
Aggregations: []qb.TraceAggregation{{}},
|
||||
Legend: "spans",
|
||||
Filter: &qb.Filter{Expression: "http.status_code = 500"},
|
||||
ReturnSpansFrom: "A",
|
||||
Limit: 10,
|
||||
}).(qb.QueryBuilderTraceOperator)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Nil(t, redacted.Filter)
|
||||
assert.Empty(t, redacted.ReturnSpansFrom)
|
||||
assert.Zero(t, redacted.Limit)
|
||||
assert.Equal(t, "A => B", redacted.Expression)
|
||||
assert.Equal(t, "T1", redacted.Name)
|
||||
assert.Len(t, redacted.Aggregations, 1)
|
||||
})
|
||||
|
||||
t.Run("unknown value is returned unchanged", func(t *testing.T) {
|
||||
assert.Equal(t, "passthrough", redactLeafQuery("passthrough"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRedactQueryPluginWrappers(t *testing.T) {
|
||||
t.Run("builder plugin pointer is redacted and stays a pointer", func(t *testing.T) {
|
||||
plugin := &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.LogAggregation]{
|
||||
Name: "A",
|
||||
Filter: &qb.Filter{Expression: "body contains 'secret'"},
|
||||
}}
|
||||
|
||||
result, ok := redactQuery(plugin).(*BuilderQuerySpec)
|
||||
require.True(t, ok)
|
||||
|
||||
builder, ok := result.Spec.(qb.QueryBuilderQuery[qb.LogAggregation])
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, builder.Filter)
|
||||
assert.Equal(t, "A", builder.Name)
|
||||
})
|
||||
|
||||
t.Run("composite plugin redacts every sub-query envelope", func(t *testing.T) {
|
||||
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
|
||||
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A", Filter: &qb.Filter{Expression: "x = 1"}}},
|
||||
{Type: qb.QueryTypePromQL, Spec: qb.PromQuery{Name: "B", Query: "up"}},
|
||||
}}
|
||||
|
||||
result, ok := redactQuery(composite).(*qb.CompositeQuery)
|
||||
require.True(t, ok)
|
||||
require.Len(t, result.Queries, 2)
|
||||
|
||||
builder, ok := result.Queries[0].Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, builder.Filter)
|
||||
|
||||
prom, ok := result.Queries[1].Spec.(qb.PromQuery)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, prom.Query)
|
||||
})
|
||||
|
||||
t.Run("promql plugin pointer drops the raw query", func(t *testing.T) {
|
||||
result, ok := redactQuery(&qb.PromQuery{Name: "A", Query: "up"}).(*qb.PromQuery)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, result.Query)
|
||||
assert.Equal(t, "A", result.Name)
|
||||
})
|
||||
|
||||
t.Run("nil composite pointer is returned unchanged without panicking", func(t *testing.T) {
|
||||
var nilComposite *qb.CompositeQuery
|
||||
assert.Equal(t, nilComposite, redactQuery(nilComposite))
|
||||
})
|
||||
|
||||
t.Run("unknown plugin spec is returned unchanged", func(t *testing.T) {
|
||||
assert.Equal(t, 42, redactQuery(42))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRedactPanelQueries(t *testing.T) {
|
||||
t.Run("redacts panel queries without mutating the source spec", func(t *testing.T) {
|
||||
sourcePlugin := &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{
|
||||
Name: "A",
|
||||
Filter: &qb.Filter{Expression: "service.name = 'payments'"},
|
||||
}}
|
||||
sourcePanel := &Panel{Spec: PanelSpec{
|
||||
Queries: []Query{{Spec: QuerySpec{Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: sourcePlugin}}}},
|
||||
}}
|
||||
spec := DashboardSpec{Panels: map[string]*Panel{"panel-1": sourcePanel}}
|
||||
|
||||
redactPanelQueries(&spec)
|
||||
|
||||
// redacted output has no filter
|
||||
redactedPanel := spec.Panels["panel-1"]
|
||||
require.NotNil(t, redactedPanel)
|
||||
redactedBuilder := redactedPanel.Spec.Queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec).Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
assert.Nil(t, redactedBuilder.Filter)
|
||||
|
||||
// source is untouched: original plugin still carries the filter
|
||||
sourceBuilder := sourcePlugin.Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.NotNil(t, sourceBuilder.Filter)
|
||||
assert.Equal(t, "service.name = 'payments'", sourceBuilder.Filter.Expression)
|
||||
assert.NotSame(t, sourcePanel, redactedPanel)
|
||||
})
|
||||
|
||||
t.Run("preserves nil panels without panicking", func(t *testing.T) {
|
||||
spec := DashboardSpec{Panels: map[string]*Panel{"panel-1": nil}}
|
||||
|
||||
redactPanelQueries(&spec)
|
||||
|
||||
panel, ok := spec.Panels["panel-1"]
|
||||
assert.True(t, ok)
|
||||
assert.Nil(t, panel)
|
||||
})
|
||||
}
|
||||
@@ -322,55 +322,6 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
gridColumnCount = 12
|
||||
maxItemsPerGridLayout = 100
|
||||
)
|
||||
|
||||
// validateGridLayoutGeometry checks a single grid layout's item geometry (size,
|
||||
// position, and intra-section overlap), which Perses does not. It reads only the
|
||||
// layout's own items; layoutIndex is supplied by the caller (validateLayouts)
|
||||
// solely to name the layout in error paths.
|
||||
func validateGridLayoutGeometry(spec *dashboard.GridLayoutSpec, layoutIndex int) error {
|
||||
if len(spec.Items) > maxItemsPerGridLayout {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items: has %d items; maximum is %d", layoutIndex, len(spec.Items), maxItemsPerGridLayout)
|
||||
}
|
||||
for i, item := range spec.Items {
|
||||
// The width/x bounds keep x+width small enough not to overflow.
|
||||
switch {
|
||||
case item.Width < 1:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width must be at least 1, got %d", layoutIndex, i, item.Width)
|
||||
case item.Height < 1:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: height must be at least 1, got %d", layoutIndex, i, item.Height)
|
||||
case item.X < 0:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x must not be negative, got %d", layoutIndex, i, item.X)
|
||||
case item.Y < 0:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: y must not be negative, got %d", layoutIndex, i, item.Y)
|
||||
case item.Width > gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width (%d) exceeds grid width %d", layoutIndex, i, item.Width, gridColumnCount)
|
||||
case item.X >= gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) must be less than grid width %d", layoutIndex, i, item.X, gridColumnCount)
|
||||
case item.X+item.Width > gridColumnCount:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) + width (%d) exceeds grid width %d", layoutIndex, i, item.X, item.Width, gridColumnCount)
|
||||
}
|
||||
// Could cap y/height but skipping for now: the grid grows vertically
|
||||
// without limit (frontend autoSize), so "too big" has no natural bound.
|
||||
}
|
||||
// Two items overlap iff their rectangles intersect on both axes.
|
||||
overlap := func(a, b dashboard.GridItem) bool {
|
||||
return a.X < b.X+b.Width && b.X < a.X+a.Width &&
|
||||
a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
|
||||
}
|
||||
for i := 0; i < len(spec.Items); i++ {
|
||||
for j := i + 1; j < len(spec.Items); j++ {
|
||||
if overlap(spec.Items[i], spec.Items[j]) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d] and items[%d] overlap", layoutIndex, i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Layout) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},
|
||||
|
||||
@@ -2,6 +2,7 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -212,6 +213,30 @@ func (typ *PublicDashboard) PublicPath() string {
|
||||
return "/public/dashboard/" + typ.ID.StringValue()
|
||||
}
|
||||
|
||||
// ResolveTimeRange returns the [start, end] window in epoch millis for a public
|
||||
// widget/panel query: the caller-supplied range when the dashboard allows it,
|
||||
// otherwise now minus the configured default range.
|
||||
func (typ *PublicDashboard) ResolveTimeRange(startTimeRaw, endTimeRaw string) (uint64, uint64, error) {
|
||||
if typ.TimeRangeEnabled {
|
||||
startTime, err := strconv.ParseUint(startTimeRaw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, errors.New(errors.TypeInvalidInput, ErrCodePublicDashboardInvalidInput, "invalid startTime")
|
||||
}
|
||||
endTime, err := strconv.ParseUint(endTimeRaw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, errors.New(errors.TypeInvalidInput, ErrCodePublicDashboardInvalidInput, "invalid endTime")
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
timeRange, err := time.ParseDuration(typ.DefaultTimeRange)
|
||||
if err != nil {
|
||||
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "stored defaultTimeRange %q is not a valid duration", typ.DefaultTimeRange)
|
||||
}
|
||||
now := time.Now()
|
||||
return uint64(now.Add(-timeRange).UnixMilli()), uint64(now.UnixMilli()), nil
|
||||
}
|
||||
|
||||
func (typ *PostablePublicDashboard) UnmarshalJSON(data []byte) error {
|
||||
type alias PostablePublicDashboard
|
||||
var temp alias
|
||||
|
||||
117
pkg/types/dashboardtypes/public_dashboard_test.go
Normal file
117
pkg/types/dashboardtypes/public_dashboard_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublicDashboardResolveTimeRange(t *testing.T) {
|
||||
t.Run("returns the explicit range when time range is enabled", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
startTimeRaw string
|
||||
endTimeRaw string
|
||||
expectedStart uint64
|
||||
expectedEnd uint64
|
||||
}{
|
||||
{
|
||||
description: "valid epoch millis",
|
||||
startTimeRaw: "1700000000000",
|
||||
endTimeRaw: "1700000600000",
|
||||
expectedStart: 1700000000000,
|
||||
expectedEnd: 1700000600000,
|
||||
},
|
||||
{
|
||||
description: "zero start is allowed",
|
||||
startTimeRaw: "0",
|
||||
endTimeRaw: "1700000600000",
|
||||
expectedStart: 0,
|
||||
expectedEnd: 1700000600000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: true}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange(tc.startTimeRaw, tc.endTimeRaw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedStart, startTime)
|
||||
assert.Equal(t, tc.expectedEnd, endTime)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects an invalid explicit range when time range is enabled", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
startTimeRaw string
|
||||
endTimeRaw string
|
||||
}{
|
||||
{description: "non-numeric startTime", startTimeRaw: "abc", endTimeRaw: "1700000600000"},
|
||||
{description: "empty startTime", startTimeRaw: "", endTimeRaw: "1700000600000"},
|
||||
{description: "negative startTime", startTimeRaw: "-1", endTimeRaw: "1700000600000"},
|
||||
{description: "non-numeric endTime", startTimeRaw: "1700000000000", endTimeRaw: "xyz"},
|
||||
{description: "empty endTime", startTimeRaw: "1700000000000", endTimeRaw: ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: true}
|
||||
|
||||
_, _, err := publicDashboard.ResolveTimeRange(tc.startTimeRaw, tc.endTimeRaw)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("derives the range from now and the default when time range is disabled", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
defaultTimeRange string
|
||||
expectedWidthMS uint64
|
||||
}{
|
||||
{description: "one hour", defaultTimeRange: "1h", expectedWidthMS: uint64(time.Hour.Milliseconds())},
|
||||
{description: "thirty minutes", defaultTimeRange: "30m", expectedWidthMS: uint64((30 * time.Minute).Milliseconds())},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: tc.defaultTimeRange}
|
||||
|
||||
before := uint64(time.Now().UnixMilli())
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange("ignored", "ignored")
|
||||
after := uint64(time.Now().UnixMilli())
|
||||
|
||||
require.NoError(t, err)
|
||||
// end is "now"; both bounds share the same instant, so the width is exact.
|
||||
assert.GreaterOrEqual(t, endTime, before)
|
||||
assert.LessOrEqual(t, endTime, after)
|
||||
assert.Equal(t, tc.expectedWidthMS, endTime-startTime)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores caller-supplied bounds when time range is disabled", func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: "1h"}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange("123", "456")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, uint64(123), startTime)
|
||||
assert.NotEqual(t, uint64(456), endTime)
|
||||
assert.Equal(t, uint64(time.Hour.Milliseconds()), endTime-startTime)
|
||||
})
|
||||
|
||||
t.Run("returns an internal error for an unparseable stored default range", func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: "not-a-duration"}
|
||||
|
||||
_, _, err := publicDashboard.ResolveTimeRange("", "")
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInternal))
|
||||
})
|
||||
}
|
||||
@@ -652,6 +652,7 @@
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "T1",
|
||||
"expression": "A => B",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()",
|
||||
|
||||
@@ -651,3 +651,45 @@ func TestQueryBuilderQuery_UnmarshalJSON(t *testing.T) {
|
||||
assert.Equal(t, telemetrytypes.FieldContextSpan, query.Order[0].Key.FieldContext)
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryBuilderQueryValidateAggregationExpressions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
aggregations []LogAggregation
|
||||
expectRejected bool
|
||||
}{
|
||||
{
|
||||
description: "single call is accepted",
|
||||
aggregations: []LogAggregation{{Expression: "count()"}},
|
||||
expectRejected: false,
|
||||
},
|
||||
{
|
||||
description: "comma separated calls are rejected",
|
||||
aggregations: []LogAggregation{{Expression: "count(), sum(bytes)"}},
|
||||
expectRejected: true,
|
||||
},
|
||||
{
|
||||
description: "comma inside function args is a single call",
|
||||
aggregations: []LogAggregation{{Expression: "countIf(day > 10, status)"}},
|
||||
expectRejected: false,
|
||||
},
|
||||
{
|
||||
description: "no aggregations is accepted",
|
||||
aggregations: nil,
|
||||
expectRejected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
query := QueryBuilderQuery[LogAggregation]{Aggregations: testCase.aggregations}
|
||||
err := query.ValidateAggregationExpressions()
|
||||
if testCase.expectRejected {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "single function call")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -255,6 +256,10 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
|
||||
// TODO: add url with docs
|
||||
}
|
||||
|
||||
if err := q.ValidateAggregationExpressions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for duplicate aliases
|
||||
aliases := make(map[string]bool)
|
||||
for i, agg := range q.Aggregations {
|
||||
@@ -341,6 +346,48 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAggregationExpressions checks only that no logs/traces aggregation packs
|
||||
// multiple comma-separated function calls into a single expression. Unlike Validate,
|
||||
// it imposes no request-type rules and does not require aggregations to be present,
|
||||
// so write-time callers (e.g. dashboard validation) can enforce just this invariant
|
||||
// without rejecting otherwise-valid queries (e.g. a list panel with no aggregation).
|
||||
func (q QueryBuilderQuery[T]) ValidateAggregationExpressions() error {
|
||||
for _, agg := range q.Aggregations {
|
||||
switch v := any(agg).(type) {
|
||||
case TraceAggregation:
|
||||
if err := validateSingleExpressionAggregation(v.Expression); err != nil {
|
||||
return err
|
||||
}
|
||||
case LogAggregation:
|
||||
if err := validateSingleExpressionAggregation(v.Expression); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSingleExpressionAggregation rejects a logs/traces aggregation whose
|
||||
// expression contains more than one function call. The frontend stores multiple
|
||||
// aggregations as a single comma-separated string and splits them before querying;
|
||||
// anything persisted server-side must already be split into one call per entry.
|
||||
func validateSingleExpressionAggregation(expression string) error {
|
||||
// aggregationCallRegexp matches a single function-style aggregation call such as
|
||||
// count() or sum(field). It is used to detect expressions that pack several
|
||||
// comma-separated calls into one aggregation (e.g. "count(), sum(field)"), which
|
||||
// must instead be provided as separate aggregation entries.
|
||||
var aggregationCallRegexp = regexp.MustCompile(`[a-zA-Z0-9_]+\([^)]*\)`)
|
||||
|
||||
if len(aggregationCallRegexp.FindAllString(expression, -1)) > 1 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"aggregation expression %q must contain a single function call; provide multiple aggregations as separate entries",
|
||||
expression,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m MetricAggregation) ValidateForType() error {
|
||||
if m.SpaceAggregation.IsPercentile() && !m.Type.IsPercentileSpaceAggregationAllowed() {
|
||||
return errors.Newf(
|
||||
|
||||
@@ -143,7 +143,7 @@ def test_get_credentials_unsupported_provider(
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/unknown/credentials"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/credentials"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
@@ -56,14 +56,14 @@ def test_create_account_unsupported_provider(
|
||||
) -> None:
|
||||
"""Test that creating an account with an unsupported cloud provider returns 400."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
cloud_provider = "unknown"
|
||||
cloud_provider = "gcp"
|
||||
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(endpoint),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"config": {"unknown": {"deploymentRegion": "us-central1", "regions": ["us-central1"]}},
|
||||
"config": {"gcp": {"deploymentRegion": "us-central1", "regions": ["us-central1"]}},
|
||||
"credentials": {
|
||||
"sigNozApiURL": "https://test.signoz.cloud",
|
||||
"sigNozApiKey": "test-key",
|
||||
|
||||
@@ -341,7 +341,7 @@ def test_list_services_unsupported_provider(
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/unknown/services"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/services"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
@@ -173,125 +173,6 @@ def test_create_rejects_too_many_tags(
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_invalid_grid_layout(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def panel(name: str) -> dict:
|
||||
return {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": name},
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Two grid items reference valid, distinct panels but share cells, so the
|
||||
# overlap is the only violation.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-overlap",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Overlap"},
|
||||
"panels": {"p1": panel("P1"), "p2": panel("P2")},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "overlap" in response.json()["error"]["message"]
|
||||
|
||||
# One panel placed by two grid items (side by side, so they clear the overlap
|
||||
# check first). The frontend keys grid items by panel id, so this is rejected.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-multiref",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Multiref"},
|
||||
"panels": {"p1": panel("P1")},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "already placed" in response.json()["error"]["message"]
|
||||
|
||||
# More grid items than allowed. The item-count check runs before the
|
||||
# panel-ref check, so content-less items suffice here.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-too-many-items",
|
||||
"spec": {
|
||||
"display": {"name": "Rejects Too Many"},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "maximum" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
@@ -1357,3 +1238,72 @@ def test_dashboard_v2_get_by_metric_name(
|
||||
assert sorted(by_dashboard[d1_id]) == ["D1 builder target"]
|
||||
assert sorted(by_dashboard[d2_id]) == ["D2 clickhouse target", "D2 promql target"]
|
||||
assert sorted(by_dashboard[d3_id]) == ["D3 promql target"]
|
||||
|
||||
|
||||
# ─── aggregation expression validation ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_dashboard_v2_rejects_comma_separated_aggregation(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""The querier never splits aggregation expressions, so each aggregation entry must
|
||||
be a single function call. A comma-separated expression ("count(), sum(...)") is
|
||||
rejected at create time; the pre-split form of the same panel is accepted."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def make_dashboard(aggregations: list[dict]) -> dict:
|
||||
return {
|
||||
"schemaVersion": "v6",
|
||||
"name": f"agg-{uuid.uuid4().hex[:8]}",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"display": {"name": "Aggregation"},
|
||||
"panels": {
|
||||
"p-agg": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "agg"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {"name": "A", "signal": "logs", "aggregations": aggregations},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# a single comma-separated expression is rejected
|
||||
rejected = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=make_dashboard([{"expression": "count(), sum(latency_ms)"}]),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert rejected.status_code == HTTPStatus.BAD_REQUEST, rejected.text
|
||||
assert "single function call" in rejected.text, rejected.text
|
||||
|
||||
# the pre-split form of the same panel is accepted
|
||||
accepted = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=make_dashboard([{"expression": "count()"}, {"expression": "sum(latency_ms)"}]),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert accepted.status_code == HTTPStatus.CREATED, accepted.text
|
||||
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{accepted.json()['data']['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
233
tests/integration/tests/dashboard/05_v2_public_dashboard.py
Normal file
233
tests/integration/tests/dashboard/05_v2_public_dashboard.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker
|
||||
|
||||
V2_BASE_URL = "/api/v2/dashboards"
|
||||
PANEL_KEY = "24e2697b"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Public dashboards are a licensed feature, so a license must be present.
|
||||
"""
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_public_dashboard_v2(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Insert metric data so the panel query resolves to a result.
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name="system.cpu.time",
|
||||
labels={"service.name": "sampleapp"},
|
||||
timestamp=now - timedelta(minutes=minutes),
|
||||
value=value,
|
||||
temporality="Cumulative",
|
||||
)
|
||||
for minutes, value in ((5, 100.0), (3, 200.0), (1, 300.0))
|
||||
]
|
||||
)
|
||||
|
||||
# Create a v2 dashboard with one panel whose builder query carries a filter,
|
||||
# so we can assert the filter is redacted from the anonymous payload.
|
||||
create_response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "v2-public-sample",
|
||||
"tags": [{"key": "team", "value": "pulse"}],
|
||||
"spec": {
|
||||
"display": {"name": "Sample Dashboard", "description": "Used for integration tests"},
|
||||
"duration": "1h",
|
||||
"variables": [],
|
||||
"panels": {
|
||||
PANEL_KEY: {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "total"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"visualization": {"fillSpans": True}}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "system.cpu.time",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate",
|
||||
}
|
||||
],
|
||||
"filter": {"expression": "service.name = 'sampleapp'"},
|
||||
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {"$ref": f"#/spec/panels/{PANEL_KEY}"},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert create_response.status_code == HTTPStatus.CREATED, create_response.text
|
||||
dashboard_id = create_response.json()["data"]["id"]
|
||||
|
||||
# Enable public sharing (the public-config endpoint is shape-agnostic, still v1).
|
||||
public_response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert public_response.status_code == HTTPStatus.CREATED, public_response.text
|
||||
|
||||
config_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert config_response.status_code == HTTPStatus.OK, config_response.text
|
||||
public_id = config_response.json()["data"]["publicPath"].split("/public/dashboard/")[-1]
|
||||
|
||||
# ── anonymous public data (no Authorization header) ──────────────────────
|
||||
data_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}"),
|
||||
timeout=5,
|
||||
)
|
||||
assert data_response.status_code == HTTPStatus.OK, data_response.text
|
||||
body = data_response.json()
|
||||
assert body["status"] == "success"
|
||||
|
||||
dashboard = body["data"]["dashboard"]
|
||||
assert dashboard["schemaVersion"] == "v6"
|
||||
assert dashboard["spec"]["display"]["name"] == "Sample Dashboard"
|
||||
assert {"key": "team", "value": "pulse"} in dashboard["tags"]
|
||||
|
||||
# Identity/audit fields are not exposed to anonymous viewers.
|
||||
assert dashboard["createdBy"] == ""
|
||||
assert dashboard["updatedBy"] == ""
|
||||
|
||||
# The public config is echoed back.
|
||||
assert body["data"]["publicDashboard"]["timeRangeEnabled"] is True
|
||||
|
||||
# The builder query is redacted: the filter is gone, display fields remain.
|
||||
builder = dashboard["spec"]["panels"][PANEL_KEY]["spec"]["queries"][0]["spec"]["plugin"]["spec"]
|
||||
assert "filter" not in builder
|
||||
assert builder["name"] == "A"
|
||||
assert builder["signal"] == "metrics"
|
||||
assert builder["aggregations"][0]["metricName"] == "system.cpu.time"
|
||||
assert builder["groupBy"][0]["name"] == "service.name"
|
||||
|
||||
# ── anonymous panel query range ──────────────────────────────────────────
|
||||
start_time = int((now - timedelta(minutes=15)).timestamp() * 1000)
|
||||
end_time = int(now.timestamp() * 1000)
|
||||
|
||||
query_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}/panels/{PANEL_KEY}/query_range"),
|
||||
params={"startTime": start_time, "endTime": end_time},
|
||||
timeout=10,
|
||||
)
|
||||
assert query_response.status_code == HTTPStatus.OK, query_response.text
|
||||
query_body = query_response.json()
|
||||
assert query_body["status"] == "success"
|
||||
|
||||
# The inserted metric is returned as a time series for query "A".
|
||||
result = query_body["data"]
|
||||
assert result["type"] == "time_series"
|
||||
results = result["data"]["results"]
|
||||
result_a = next((r for r in results if r.get("queryName") == "A"), None)
|
||||
assert result_a is not None, results
|
||||
series = result_a["aggregations"][0]["series"]
|
||||
assert len(series) >= 1, result_a
|
||||
assert len(series[0]["values"]) >= 1, series[0]
|
||||
|
||||
# With timeRangeEnabled, the bounds are required.
|
||||
missing_range = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}/panels/{PANEL_KEY}/query_range"),
|
||||
timeout=5,
|
||||
)
|
||||
assert missing_range.status_code == HTTPStatus.BAD_REQUEST, missing_range.text
|
||||
|
||||
# An unknown panel key is rejected.
|
||||
unknown_panel = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}/panels/does-not-exist/query_range"),
|
||||
params={"startTime": start_time, "endTime": end_time},
|
||||
timeout=5,
|
||||
)
|
||||
assert unknown_panel.status_code == HTTPStatus.BAD_REQUEST, unknown_panel.text
|
||||
|
||||
# A bogus public id is rejected before any data is served.
|
||||
bogus = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/not-a-real-id/panels/{PANEL_KEY}/query_range"),
|
||||
params={"startTime": start_time, "endTime": end_time},
|
||||
timeout=5,
|
||||
)
|
||||
assert bogus.status_code >= HTTPStatus.BAD_REQUEST
|
||||
|
||||
# ── deleting the dashboard removes public access ─────────────────────────
|
||||
# DeleteV2 drops the public-config row in the same transaction, so the public
|
||||
# id no longer resolves to a dashboard.
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{dashboard_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT, delete_response.text
|
||||
|
||||
deleted_data = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}"),
|
||||
timeout=5,
|
||||
)
|
||||
assert deleted_data.status_code == HTTPStatus.NOT_FOUND, deleted_data.text
|
||||
|
||||
deleted_query = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}/panels/{PANEL_KEY}/query_range"),
|
||||
params={"startTime": start_time, "endTime": end_time},
|
||||
timeout=5,
|
||||
)
|
||||
assert deleted_query.status_code == HTTPStatus.NOT_FOUND, deleted_query.text
|
||||
Reference in New Issue
Block a user