Compare commits

..

1 Commits

Author SHA1 Message Date
swapnil-signoz
cc99247ad3 feat: adding support for Azure Container Apps 2026-06-04 13:02:12 +05:30
79 changed files with 1911 additions and 3803 deletions

4
.github/CODEOWNERS vendored
View File

@@ -188,7 +188,3 @@ go.mod @therealpandey
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

@@ -69,8 +69,6 @@ jobs:
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
with:

View File

@@ -70,8 +70,6 @@ jobs:
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
with:

View File

@@ -35,8 +35,6 @@ jobs:
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
echo 'VITE_ENVIRONMENT="production"' >> .env
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
- name: node-setup
uses: actions/setup-node@v5
with:

View File

@@ -870,6 +870,14 @@ components:
- timestampMillis
- data
type: object
CloudintegrationtypesAssets:
properties:
dashboards:
items:
$ref: '#/components/schemas/CloudintegrationtypesDashboard'
nullable: true
type: array
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
deploymentRegion:
@@ -1017,6 +1025,17 @@ components:
- ingestionUrl
- ingestionKey
type: object
CloudintegrationtypesDashboard:
properties:
definition:
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
description:
type: string
id:
type: string
title:
type: string
type: object
CloudintegrationtypesDataCollected:
properties:
logs:
@@ -1190,7 +1209,7 @@ components:
CloudintegrationtypesService:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesServiceAssets'
$ref: '#/components/schemas/CloudintegrationtypesAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
@@ -1203,6 +1222,8 @@ components:
type: string
supportedSignals:
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
title:
type: string
required:
@@ -1213,17 +1234,9 @@ components:
- assets
- supportedSignals
- dataCollected
- telemetryCollectionStrategy
- cloudIntegrationService
type: object
CloudintegrationtypesServiceAssets:
properties:
dashboards:
items:
$ref: '#/components/schemas/CloudintegrationtypesServiceDashboard'
type: array
required:
- dashboards
type: object
CloudintegrationtypesServiceConfig:
properties:
aws:
@@ -1231,18 +1244,6 @@ components:
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
type: object
CloudintegrationtypesServiceDashboard:
properties:
description:
type: string
integrationDashboard:
$ref: '#/components/schemas/CloudintegrationtypesStorableIntegrationDashboard'
title:
type: string
required:
- title
- description
type: object
CloudintegrationtypesServiceID:
enum:
- alb
@@ -1260,6 +1261,7 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- containerapp
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -1277,30 +1279,6 @@ components:
- icon
- enabled
type: object
CloudintegrationtypesStorableIntegrationDashboard:
properties:
createdAt:
format: date-time
type: string
dashboardId:
type: string
id:
type: string
provider:
type: string
slug:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- dashboardId
- provider
- slug
- createdAt
- updatedAt
type: object
CloudintegrationtypesSupportedSignals:
properties:
logs:
@@ -1308,6 +1286,13 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesTelemetryCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
@@ -2661,30 +2646,6 @@ components:
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
type: object
DashboardtypesJSONPatchOperation:
properties:
from:
description: Source JSON Pointer for move/copy ops; ignored for other ops.
type: string
op:
$ref: '#/components/schemas/DashboardtypesPatchOp'
path:
description: JSON Pointer (RFC 6901) into the dashboard's postable shape
— e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0,
/tags/-.
type: string
value:
description: 'Value to add/replace/test against. The expected type depends
on the path. Common shapes (see referenced schemas for the exact field
set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N
(or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable;
/spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a
TagtypesPostableTag; /spec/display/name and other leaf string fields take
a string. Required for add/replace/test; ignored for remove/move/copy.'
required:
- op
- path
type: object
DashboardtypesLayout:
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
@@ -2900,20 +2861,6 @@ components:
$ref: '#/components/schemas/DashboardtypesQuery'
type: array
type: object
DashboardtypesPatchOp:
enum:
- add
- remove
- replace
- move
- copy
- test
type: string
DashboardtypesPatchableDashboardV2:
items:
$ref: '#/components/schemas/DashboardtypesJSONPatchOperation'
nullable: true
type: array
DashboardtypesPieChartPanelSpec:
properties:
formatting:
@@ -3201,27 +3148,6 @@ components:
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesUpdatableDashboardV2:
properties:
image:
type: string
name:
type: string
schemaVersion:
type: string
spec:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
nullable: true
type: array
required:
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesUpdatablePublicDashboard:
properties:
defaultTimeRange:
@@ -8103,64 +8029,6 @@ paths:
summary: Update account
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services:
get:
deprecated: false
description: This endpoint lists the services metadata for the specified account
and cloud provider
operationId: ListAccountServicesMetadata
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableServicesMetadata'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List account services metadata
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
put:
deprecated: false
@@ -12957,12 +12825,6 @@ paths:
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -13015,12 +12877,6 @@ paths:
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -13033,12 +12889,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -13053,262 +12903,6 @@ paths:
summary: Get dashboard (v2)
tags:
- dashboard
patch:
deprecated: false
description: 'This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard.
The patch is applied against the postable view of the dashboard (metadata,
data, tags), so individual panels, queries, variables, layouts, or tags can
be updated without re-sending the rest of the dashboard. Apply is lenient:
`remove` on a missing path is a no-op (idempotent) and `add` creates any missing
parent objects, rather than failing as strict RFC 6902 would. The resulting
dashboard is still validated. Locked dashboards are rejected.'
operationId: PatchDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPatchableDashboardV2'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Patch dashboard (v2)
tags:
- dashboard
put:
deprecated: false
description: This endpoint updates a v2-shape dashboard's metadata, data, and
tag set. Locked dashboards are rejected.
operationId: UpdateDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesUpdatableDashboardV2'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Update dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/lock:
delete:
deprecated: false
description: This endpoint unlocks a v2-shape dashboard. Only the dashboard's
creator or an org admin may lock or unlock.
operationId: UnlockDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Unlock dashboard (v2)
tags:
- dashboard
put:
deprecated: false
description: This endpoint locks a v2-shape dashboard. Only the dashboard's
creator or an org admin may lock or unlock.
operationId: LockDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -355,32 +355,26 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
var integrationService *cloudintegrationtypes.CloudIntegrationService
if cloudIntegrationID.IsZero() {
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
if !cloudIntegrationID.IsZero() {
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService != nil {
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
if err != nil {
return nil, err
}
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
}
if err := module.enrichDashboardIDs(ctx, orgID, provider, serviceID, serviceDefinition); err != nil {
return nil, err
}
}
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService == nil {
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
}
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
if err != nil {
return nil, err
}
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
integrationDashboards, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewService(provider, serviceDefinition, integrationService, integrationDashboards), nil
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
}
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
@@ -589,3 +583,20 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
}
return nil
}
// enrichDashboardIDs replaces the raw dashboard name in each Dashboard.ID with the provisioned UUID.
// TODO: remove this hack and send idiomatic response to client.
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
for i, d := range serviceDefinition.Assets.Dashboards {
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
continue
}
return err
}
serviceDefinition.Assets.Dashboards[i].ID = row.DashboardID
}
return nil
}

View File

@@ -221,18 +221,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -351,18 +351,19 @@ function App(): JSX.Element {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
environment: process.env.ENVIRONMENT,
release: process.env.VERSION,
environment: 'production',
integrations: [
// Kept for the `transaction` tag used in routing, even though
// tracing is disabled. Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: [],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
beforeSend(event) {

View File

@@ -36,8 +36,6 @@ import type {
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccountServicesMetadata200,
ListAccountServicesMetadataPathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
@@ -633,116 +631,6 @@ export const useUpdateAccount = <
> => {
return useMutation(getUpdateAccountMutationOptions(options));
};
/**
* This endpoint lists the services metadata for the specified account and cloud provider
* @summary List account services metadata
*/
export const listAccountServicesMetadata = (
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListAccountServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
method: 'GET',
signal,
});
};
export const getListAccountServicesMetadataQueryKey = ({
cloudProvider,
id,
}: ListAccountServicesMetadataPathParameters) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
] as const;
};
export const getListAccountServicesMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListAccountServicesMetadataQueryKey({ cloudProvider, id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listAccountServicesMetadata>>
> = ({ signal }) => listAccountServicesMetadata({ cloudProvider, id }, signal);
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && id),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListAccountServicesMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof listAccountServicesMetadata>>
>;
export type ListAccountServicesMetadataQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List account services metadata
*/
export function useListAccountServicesMetadata<
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListAccountServicesMetadataQueryOptions(
{ cloudProvider, id },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List account services metadata
*/
export const invalidateListAccountServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListAccountServicesMetadataQueryKey({ cloudProvider, id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service

View File

@@ -21,10 +21,8 @@ import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
@@ -35,13 +33,7 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -824,360 +816,3 @@ export const invalidateGetDashboardV2 = async (
return queryClient;
};
/**
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.
* @summary Patch dashboard (v2)
*/
export const patchDashboardV2 = (
{ id }: PatchDashboardV2PathParameters,
dashboardtypesPatchableDashboardV2DTONull?: BodyType<DashboardtypesPatchableDashboardV2DTO | null> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PatchDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPatchableDashboardV2DTONull,
signal,
});
};
export const getPatchDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
> => {
const mutationKey = ['patchDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchDashboardV2>>,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof patchDashboardV2>>
>;
export type PatchDashboardV2MutationBody =
| BodyType<DashboardtypesPatchableDashboardV2DTO | null>
| undefined;
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Patch dashboard (v2)
*/
export const usePatchDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
> => {
return useMutation(getPatchDashboardV2MutationOptions(options));
};
/**
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
* @summary Update dashboard (v2)
*/
export const updateDashboardV2 = (
{ id }: UpdateDashboardV2PathParameters,
dashboardtypesUpdatableDashboardV2DTO?: BodyType<DashboardtypesUpdatableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesUpdatableDashboardV2DTO,
signal,
});
};
export const getUpdateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDashboardV2>>,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardV2>>
>;
export type UpdateDashboardV2MutationBody =
| BodyType<DashboardtypesUpdatableDashboardV2DTO>
| undefined;
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard (v2)
*/
export const useUpdateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardV2MutationOptions(options));
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)
*/
export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
});
};
export const getUnlockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unlockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof unlockDashboardV2>>,
{ pathParams: UnlockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unlockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnlockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unlockDashboardV2>>
>;
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unlock dashboard (v2)
*/
export const useUnlockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnlockDashboardV2MutationOptions(options));
};
/**
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Lock dashboard (v2)
*/
export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
});
};
export const getLockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['lockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof lockDashboardV2>>,
{ pathParams: LockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return lockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type LockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof lockDashboardV2>>
>;
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Lock dashboard (v2)
*/
export const useLockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};

View File

@@ -2457,6 +2457,33 @@ export interface CloudintegrationtypesAccountDTO {
updatedAt?: string;
}
export interface DashboardtypesStorableDashboardDataDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesDashboardDTO {
definition?: DashboardtypesStorableDashboardDataDTO;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id?: string;
/**
* @type string
*/
title?: string;
}
export interface CloudintegrationtypesAssetsDTO {
/**
* @type array,null
*/
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
}
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
/**
* @type string
@@ -2547,6 +2574,7 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
containerapp = 'containerapp',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -2839,54 +2867,6 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
providerAccountId?: string;
}
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
dashboardId: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
provider: string;
/**
* @type string
*/
slug: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface CloudintegrationtypesServiceDashboardDTO {
/**
* @type string
*/
description: string;
integrationDashboard?: CloudintegrationtypesStorableIntegrationDashboardDTO;
/**
* @type string
*/
title: string;
}
export interface CloudintegrationtypesServiceAssetsDTO {
/**
* @type array
*/
dashboards: CloudintegrationtypesServiceDashboardDTO[];
}
export interface CloudintegrationtypesSupportedSignalsDTO {
/**
* @type boolean
@@ -2898,8 +2878,13 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
metrics?: boolean;
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesServiceAssetsDTO;
assets: CloudintegrationtypesAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO | null;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
@@ -2915,6 +2900,7 @@ export interface CloudintegrationtypesServiceDTO {
*/
overview: string;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
/**
* @type string
*/
@@ -3720,10 +3706,6 @@ export interface DashboardtypesCustomVariableSpecDTO {
customValue: string;
}
export interface DashboardtypesStorableDashboardDataDTO {
[key: string]: unknown;
}
export enum DashboardtypesSourceDTO {
user = 'user',
system = 'system',
@@ -4672,32 +4654,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
replace = 'replace',
move = 'move',
copy = 'copy',
test = 'test',
}
export interface DashboardtypesJSONPatchOperationDTO {
/**
* @type string
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
*/
from?: string;
op: DashboardtypesPatchOpDTO;
/**
* @type string
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-.
*/
path: string;
/**
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
*/
value?: unknown;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4707,13 +4663,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
}
/**
* @nullable
*/
export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -4757,26 +4706,6 @@ export enum DashboardtypesQueryPluginKindDTO {
'signoz/ClickHouseSQL' = 'signoz/ClickHouseSQL',
'signoz/TraceOperator' = 'signoz/TraceOperator',
}
export interface DashboardtypesUpdatableDashboardV2DTO {
/**
* @type string
*/
image?: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
schemaVersion: string;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
}
export interface DashboardtypesUpdatablePublicDashboardDTO {
/**
* @type string
@@ -8695,18 +8624,6 @@ export type UpdateAccountPathParameters = {
cloudProvider: string;
id: string;
};
export type ListAccountServicesMetadataPathParameters = {
cloudProvider: string;
id: string;
};
export type ListAccountServicesMetadata200 = {
data: CloudintegrationtypesGettableServicesMetadataDTO;
/**
* @type string
*/
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
id: string;
@@ -9560,34 +9477,6 @@ export type GetDashboardV2200 = {
status: string;
};
export type PatchDashboardV2PathParameters = {
id: string;
};
export type PatchDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UpdateDashboardV2PathParameters = {
id: string;
};
export type UpdateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UnlockDashboardV2PathParameters = {
id: string;
};
export type LockDashboardV2PathParameters = {
id: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -1,14 +1,15 @@
import { getWaterfallV4 } from 'api/generated/services/tracedetail';
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV4PayloadProps,
GetTraceV4SuccessResponse,
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
const getTraceV4 = async (
props: GetTraceV4PayloadProps,
): Promise<SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse> => {
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
@@ -18,37 +19,31 @@ const getTraceV4 = async (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// Backend only uses the uncollapsedSpans list (unlike V2 which also interprets
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const response = await getWaterfallV4(
{ traceID: props.traceId },
{
selectedSpanId: props.selectedSpanId,
uncollapsedSpans,
limit: 10000,
},
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
limit: 10000,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
);
// Generated client unwraps the axios response; .data is the waterfall payload.
// Wire spans carry time_unix; SpanV3's timestamp + 'service.name' are derived below.
type WireSpan = Omit<SpanV3, 'timestamp' | 'service.name'> & {
time_unix: number;
};
const rawPayload = response.data as unknown as Omit<
GetTraceV4SuccessResponse,
'spans'
> & { spans: WireSpan[] | null };
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
}));
// API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
// Convert by using the first span's timestamp as the base if there's a mismatch.
let { startTimestampMillis, endTimestampMillis } = rawPayload;
@@ -75,4 +70,4 @@ const getTraceV4 = async (
};
};
export default getTraceV4;
export default getTraceV3;

View File

@@ -33,8 +33,7 @@ export const REACT_QUERY_KEY = {
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -8,16 +8,16 @@ import { Tabs } from '@signozhq/ui/tabs';
import { Skeleton } from 'antd';
import logEvent from 'api/common/logEvent';
import {
getListAccountServicesMetadataQueryKey,
getListServicesMetadataQueryKey,
invalidateGetService,
invalidateListAccountServicesMetadata,
invalidateListServicesMetadata,
useGetService,
useUpdateService,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesServiceConfigDTO,
CloudintegrationtypesServiceDTO,
ListAccountServicesMetadata200,
ListServicesMetadata200,
} from 'api/generated/services/sigNoz.schemas';
import CloudServiceDataCollected from 'components/CloudIntegrations/CloudServiceDataCollected/CloudServiceDataCollected';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
@@ -240,12 +240,16 @@ function ServiceDetails({
// instead of waiting for the refetch to complete.
reset(nextFormValues);
const servicesListQueryKey = getListAccountServicesMetadataQueryKey({
cloudProvider: type,
id: cloudAccountId,
});
const servicesListQueryKey = getListServicesMetadataQueryKey(
{
cloudProvider: type,
},
{
cloud_integration_id: cloudAccountId,
},
);
queryClient.setQueryData<ListAccountServicesMetadata200 | undefined>(
queryClient.setQueryData<ListServicesMetadata200 | undefined>(
servicesListQueryKey,
(prev) => {
if (!prev?.data?.services?.length) {
@@ -279,10 +283,15 @@ function ServiceDetails({
},
);
invalidateListAccountServicesMetadata(queryClient, {
cloudProvider: type,
id: cloudAccountId,
});
invalidateListServicesMetadata(
queryClient,
{
cloudProvider: type,
},
{
cloud_integration_id: cloudAccountId,
},
);
logEvent(`${type} Integration: Service settings saved`, {
cloudAccountId,

View File

@@ -55,6 +55,7 @@ const buildServiceDetailsResponse = (
},
},
},
telemetryCollectionStrategy: { aws: {} },
},
});

View File

@@ -1,68 +0,0 @@
import type { KeyboardEvent, MouseEvent } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { CloudintegrationtypesServiceDashboardDTO } from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { openInNewTab } from 'utils/navigation';
const DISABLED_TOOLTIP =
'Enable metrics collection for this service to view this dashboard.';
function DashboardCard({
dashboard,
isInteractive,
}: {
dashboard: CloudintegrationtypesServiceDashboardDTO;
isInteractive: boolean;
}): JSX.Element {
const dashboardId = dashboard.integrationDashboard?.dashboardId;
const isClickable = Boolean(dashboardId) && isInteractive;
const dashboardUrl = dashboardId ? `/dashboard/${dashboardId}` : '';
const { safeNavigate } = useSafeNavigate();
const interactiveProps = isClickable
? {
role: 'button',
tabIndex: 0,
onClick: (event: MouseEvent<HTMLDivElement>): void => {
if (event.metaKey || event.ctrlKey) {
openInNewTab(dashboardUrl);
return;
}
safeNavigate(dashboardUrl);
},
onKeyDown: (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
safeNavigate(dashboardUrl);
}
},
}
: {};
const card = (
<div
className={`aws-service-dashboard-item ${
isClickable
? 'aws-service-dashboard-item-clickable'
: 'aws-service-dashboard-item-disabled'
} `}
{...interactiveProps}
>
<div className="aws-service-dashboard-item-content">
<div className="aws-service-dashboard-item-title">{dashboard.title}</div>
<div className="aws-service-dashboard-item-description">
{dashboard.description}
</div>
</div>
</div>
);
if (!dashboardId) {
return <TooltipSimple title={DISABLED_TOOLTIP}>{card}</TooltipSimple>;
}
return card;
}
export default DashboardCard;

View File

@@ -53,11 +53,6 @@
}
}
&.aws-service-dashboard-item-disabled {
cursor: not-allowed;
opacity: 0.6;
}
.aws-service-dashboard-item-content {
display: flex;
flex-direction: column;

View File

@@ -1,9 +1,11 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
CloudintegrationtypesServiceDashboardDTO,
CloudintegrationtypesDashboardDTO,
CloudintegrationtypesServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { withBasePath } from 'utils/basePath';
import DashboardCard from './DashboardCard';
import './ServiceDashboards.styles.scss';
function ServiceDashboards({
@@ -14,6 +16,7 @@ function ServiceDashboards({
isInteractive?: boolean;
}): JSX.Element {
const dashboards = service?.assets?.dashboards || [];
const { safeNavigate } = useSafeNavigate();
if (!dashboards.length) {
return <></>;
}
@@ -22,20 +25,68 @@ function ServiceDashboards({
<div className="aws-service-dashboards">
<div className="aws-service-dashboards-title">Dashboards</div>
<div className="aws-service-dashboards-items">
{dashboards.map(
(dashboard: CloudintegrationtypesServiceDashboardDTO, index: number) => {
const key =
dashboard.integrationDashboard?.dashboardId ||
`${dashboard.title}-${index}`;
return (
<DashboardCard
key={key}
dashboard={dashboard}
isInteractive={isInteractive}
/>
);
},
)}
{dashboards.map((dashboard: CloudintegrationtypesDashboardDTO) => {
if (!dashboard.id) {
return null;
}
const dashboardUrl = `/dashboard/${dashboard.id}`;
return (
<div
key={dashboard.id}
className={`aws-service-dashboard-item ${
isInteractive ? 'aws-service-dashboard-item-clickable' : ''
}`}
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : -1}
onClick={(event): void => {
if (!isInteractive) {
return;
}
if (event.metaKey || event.ctrlKey) {
window.open(
withBasePath(dashboardUrl),
'_blank',
'noopener,noreferrer',
);
return;
}
safeNavigate(dashboardUrl);
}}
onAuxClick={(event): void => {
if (!isInteractive) {
return;
}
if (event.button === 1) {
window.open(
withBasePath(dashboardUrl),
'_blank',
'noopener,noreferrer',
);
}
}}
onKeyDown={(event): void => {
if (!isInteractive) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
safeNavigate(dashboardUrl);
}
}}
>
<div className="aws-service-dashboard-item-content">
<div className="aws-service-dashboard-item-title">
{dashboard.title}
</div>
<div className="aws-service-dashboard-item-description">
{dashboard.description}
</div>
</div>
</div>
);
})}
</div>
</div>
);

View File

@@ -1,11 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Skeleton } from 'antd';
import {
useListAccounts,
useListAccountServicesMetadata,
useListServicesMetadata,
} from 'api/generated/services/cloudintegration';
import { useListServicesMetadata } from 'api/generated/services/cloudintegration';
import type { CloudintegrationtypesServiceMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { IntegrationType } from 'container/Integrations/types';
@@ -24,33 +20,17 @@ function ServicesList({
}: ServicesListProps): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const isAccountConnected = Boolean(cloudAccountId);
const { data: listAccountsResponse, isLoading: isAccountsLoading } =
useListAccounts({ cloudProvider: type });
const hasConnectedAccounts =
(listAccountsResponse?.data?.accounts?.length ?? 0) > 0;
const hasValidCloudAccountId = Boolean(cloudAccountId);
const serviceQueryParams = hasValidCloudAccountId
? { cloud_integration_id: cloudAccountId }
: undefined;
const { data: accountServicesMetadata, isLoading: isAccountServicesLoading } =
useListAccountServicesMetadata(
{ cloudProvider: type, id: cloudAccountId },
{ query: { enabled: isAccountConnected } },
);
const {
data: providerServicesMetadata,
isLoading: isProviderServicesLoading,
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
});
const servicesMetadata = hasConnectedAccounts
? accountServicesMetadata
: providerServicesMetadata;
const isLoading =
isAccountsLoading ||
(hasConnectedAccounts
? isAccountServicesLoading || !isAccountConnected
: isProviderServicesLoading);
const { data: servicesMetadata, isLoading } = useListServicesMetadata(
{
cloudProvider: type,
},
serviceQueryParams,
);
const awsServices = useMemo(
() => servicesMetadata?.data?.services ?? [],

View File

@@ -10,7 +10,7 @@ import {
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
@@ -42,6 +42,7 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { isEmpty, isUndefined } from 'lodash-es';
import LiveLogs from 'pages/LiveLogs';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
@@ -76,6 +77,7 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const dispatch = useDispatch();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -88,9 +90,10 @@ function LogsExplorerViewsContainer({
DEFAULT_PER_PAGE_VALUE,
);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const currentMinTimeRef = useRef<number>(minTime);
@@ -326,6 +329,16 @@ function LogsExplorerViewsContainer({
currentMinTimeRef.current !== minTime ||
orderByChanged
) {
// Recalculate global time when query changes i.e. stage and run query clicked
if (
!!requestData?.id &&
stagedQuery?.id &&
requestData?.id !== stagedQuery?.id &&
selectedTime !== 'custom'
) {
dispatch(UpdateTimeInterval(selectedTime));
}
const newRequestData = getRequestData(stagedQuery, {
filters: listQuery?.filters || initialFilters,
filter: listQuery?.filter || { expression: '' },
@@ -347,6 +360,8 @@ function LogsExplorerViewsContainer({
minTime,
activeLogId,
selectedPanelType,
dispatch,
selectedTime,
maxTime,
orderBy,
]);

View File

@@ -108,21 +108,13 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => {
const { useQueryBuilder } = jest.requireActual(
'hooks/queryBuilder/useQueryBuilder',
);
const { useSyncTimeOnStagedQueryChange } = jest.requireActual(
'hooks/queryBuilder/useSyncTimeOnStagedQueryChange',
);
return function MockDateTimeSelection(): JSX.Element {
const { stagedQuery } = useQueryBuilder();
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
return <div>MockDateTimeSelection</div>;
};
});
jest.mock(
'container/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock(
'container/LogsExplorerChart',
() =>

View File

@@ -233,10 +233,8 @@
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
padding: 0px;
gap: 0;
margin: 4px;
.qb-tag-text {
.ant-typography {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px !important;
@@ -246,7 +244,7 @@
padding: 2px 6px;
}
> button {
.close-icon {
display: flex;
align-items: center;
justify-content: center;
@@ -261,26 +259,26 @@
&.resource {
border: 1px solid color-mix(in srgb, var(--bg-aqua-400) 13%, transparent);
.qb-tag-text {
.ant-typography {
color: var(--bg-aqua-400);
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
font-size: 14px;
}
> button {
.close-icon {
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
}
}
&.tag {
border: 1px solid color-mix(in srgb, var(--bg-sienna-400) 20%, transparent);
.qb-tag-text {
.ant-typography {
color: var(--bg-sienna-400);
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
font-size: 14px;
}
> button {
.close-icon {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
}
}
@@ -288,13 +286,13 @@
&.scope {
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
.qb-tag-text {
.ant-typography {
color: var(--bg-robin-400);
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
font-size: 14px;
}
> button {
.close-icon {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
}
}

View File

@@ -966,7 +966,6 @@ function QueryBuilderSearchV2(
>
<Tooltip title={chipValue}>
<TypographyText
className="qb-tag-text"
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {

View File

@@ -19,7 +19,6 @@ import {
useIsGlobalTimeQueryRefreshing,
} from 'store/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSyncTimeOnStagedQueryChange } from 'hooks/queryBuilder/useSyncTimeOnStagedQueryChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
@@ -188,8 +187,6 @@ function DateTimeSelection({
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
const getInputLabel = (
startTime?: Dayjs,
endTime?: Dayjs,

View File

@@ -1,139 +0,0 @@
import { renderHook } from '@testing-library/react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { useSyncTimeOnStagedQueryChange } from '../useSyncTimeOnStagedQueryChange';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
useDispatch: (): jest.Mock => mockDispatch,
useSelector: jest.fn(),
}));
jest.mock('store/actions', () => ({
UpdateTimeInterval: jest.fn((time: string) => ({
type: 'UPDATE_TIME_INTERVAL_THUNK',
payload: time,
})),
}));
const mockedUseSelector = useSelector as jest.Mock;
const mockedUpdateTimeInterval = UpdateTimeInterval as unknown as jest.Mock;
const setSelectedTime = (value: string): void => {
mockedUseSelector.mockImplementation(
(selector: (state: { globalTime: { selectedTime: string } }) => unknown) =>
selector({ globalTime: { selectedTime: value } }),
);
};
describe('useSyncTimeOnStagedQueryChange', () => {
beforeEach(() => {
jest.clearAllMocks();
setSelectedTime('1h');
});
it('does not dispatch on initial mount when stagedQueryId is undefined', () => {
renderHook(() => useSyncTimeOnStagedQueryChange(undefined));
expect(mockDispatch).not.toHaveBeenCalled();
});
it('does not dispatch on initial mount when stagedQueryId is already defined', () => {
renderHook(() => useSyncTimeOnStagedQueryChange('initial-id'));
expect(mockDispatch).not.toHaveBeenCalled();
});
it('does not dispatch when stagedQueryId transitions from undefined to defined (first staged query arriving)', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: undefined as string | undefined } },
);
rerender({ id: 'first-id' });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('dispatches UpdateTimeInterval with current selectedTime when stagedQueryId changes', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
expect(mockDispatch).not.toHaveBeenCalled();
rerender({ id: 'second-id' });
expect(mockedUpdateTimeInterval).toHaveBeenCalledTimes(1);
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('1h');
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'UPDATE_TIME_INTERVAL_THUNK',
payload: '1h',
});
});
it('does not dispatch when selectedTime is "custom" even if stagedQueryId changes', () => {
setSelectedTime('custom');
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
rerender({ id: 'second-id' });
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockedUpdateTimeInterval).not.toHaveBeenCalled();
});
it('does not dispatch when only selectedTime changes (stagedQueryId stable)', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'stable-id' as string | undefined } },
);
setSelectedTime('5m');
rerender({ id: 'stable-id' });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('dispatches once per distinct stagedQueryId change', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'a' as string | undefined } },
);
rerender({ id: 'b' });
rerender({ id: 'c' });
rerender({ id: 'c' }); // no change — should not dispatch again
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
it('does not dispatch when stagedQueryId transitions from defined to undefined', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
rerender({ id: undefined });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('uses the latest selectedTime at the moment of stagedQueryId change', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'a' as string | undefined } },
);
setSelectedTime('15m');
rerender({ id: 'b' });
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('15m');
});
});

View File

@@ -1,36 +0,0 @@
import { useEffect, useRef } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
// Push fresh min/max back into Redux whenever the staged query changes for a
// relative time interval. The data hooks that read minTime/maxTime from Redux
// otherwise keep refetching with the originally frozen window and the time
// picker displays a stale absolute range.
// ref - SigNoz/signoz#8277
export function useSyncTimeOnStagedQueryChange(
stagedQueryId: string | undefined,
): void {
const dispatch = useDispatch();
const selectedTime = useSelector<AppState, GlobalReducer['selectedTime']>(
(state) => state.globalTime.selectedTime,
);
const prevStagedQueryIdRef = useRef<string | undefined>();
useEffect(() => {
const prevId = prevStagedQueryIdRef.current;
const currentId = stagedQueryId;
prevStagedQueryIdRef.current = currentId;
if (
prevId !== undefined &&
currentId !== undefined &&
prevId !== currentId &&
selectedTime !== 'custom'
) {
dispatch(UpdateTimeInterval(selectedTime));
}
}, [stagedQueryId, selectedTime, dispatch]);
}

View File

@@ -1,66 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getTraceAggregations } from 'api/generated/services/tracedetail';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import useGetTraceAggregations from '../useGetTraceAggregations';
jest.mock('api/generated/services/tracedetail', () => ({
__esModule: true,
getTraceAggregations: jest
.fn()
.mockResolvedValue({ status: 'success', data: { aggregations: [] } }),
}));
const mockApi = getTraceAggregations as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
const aggregations = [
{ field: { name: 'service.name' }, aggregation: 'execution_time_percentage' },
] as never;
describe('useGetTraceAggregations', () => {
beforeEach(() => mockApi.mockClear());
it('fetches when enabled with a traceId and aggregations', async () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: true }),
{ wrapper },
);
await waitFor(() => expect(mockApi).toHaveBeenCalledTimes(1));
expect(mockApi).toHaveBeenCalledWith({ traceID: 't1' }, { aggregations });
});
it('does not fetch when disabled', () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: false }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
it('does not fetch without a traceId', () => {
renderHook(
() => useGetTraceAggregations({ traceId: '', aggregations, enabled: true }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
it('does not fetch with no aggregations requested', () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations: [], enabled: true }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
});

View File

@@ -1,34 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import {
GetTraceAggregations200,
SpantypesSpanAggregationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getTraceAggregations } from 'api/generated/services/tracedetail';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
interface UseGetTraceAggregationsProps {
traceId: string;
aggregations: SpantypesSpanAggregationDTO[];
enabled: boolean;
}
type UseGetTraceAggregations = UseQueryResult<GetTraceAggregations200>;
/**
* Fetches trace aggregations on demand — gate via `enabled` so the request
* fires only when the Analytics panel is open. The query key includes the
* requested fields, so changing the color-by field refetches.
*/
const useGetTraceAggregations = ({
traceId,
aggregations,
enabled,
}: UseGetTraceAggregationsProps): UseGetTraceAggregations =>
useQuery({
queryFn: () => getTraceAggregations({ traceID: traceId }, { aggregations }),
queryKey: [REACT_QUERY_KEY.GET_TRACE_AGGREGATIONS, traceId, aggregations],
enabled: enabled && !!traceId && aggregations.length > 0,
refetchOnWindowFocus: false,
});
export default useGetTraceAggregations;

View File

@@ -1,29 +1,30 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceV4 from 'api/trace/getTraceV4';
import getTraceV3 from 'api/trace/getTraceV3';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV4PayloadProps,
GetTraceV4SuccessResponse,
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
} from 'types/api/trace/getTraceV3';
const useGetTraceV4 = (props: GetTraceV4PayloadProps): UseTraceV4 =>
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
useQuery({
queryFn: () => getTraceV4(props),
queryFn: () => getTraceV3(props),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V4_WATERFALL,
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
props.aggregations,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseTraceV4 = UseQueryResult<
SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse,
type UseTraceV3 = UseQueryResult<
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceV4;
export default useGetTraceV3;

View File

@@ -33,15 +33,6 @@
scrollbar-width: none;
}
.state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 120px;
padding: 24px 12px;
}
.list {
display: grid;
grid-template-columns: auto auto 1fr;

View File

@@ -1,28 +1,21 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import cx from 'classnames';
import { DetailsHeader } from 'components/DetailsPanel';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import {
SpantypesSpanAggregationDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { useTraceStore } from '../../stores/traceStore';
import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../../utils/aggregations';
import AnalyticsTabContent, { AnalyticsRow } from './AnalyticsTabContent';
import styles from './AnalyticsPanel.module.scss';
@@ -42,31 +35,10 @@ function AnalyticsPanel({
onClose,
onTabChange,
}: AnalyticsPanelProps): JSX.Element | null {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const colorByField = useTraceStore((s) => s.colorByField);
const colorByFieldName = colorByField.name;
const aggregations = useTraceStore((s) => s.aggregations);
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
const isDarkMode = useIsDarkMode();
// Fetch exec-time % + span count for the current color-by field only, and
// only while the panel is open. Changing the field refetches via the key.
const aggregationsRequest = useMemo<SpantypesSpanAggregationDTO[]>(() => {
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
// the literal-union vs enum nominal types differ
const field = colorByField as unknown as TelemetrytypesTelemetryFieldKeyDTO;
return [
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
];
}, [colorByField]);
const { data, isLoading, isError } = useGetTraceAggregations({
traceId: traceId || '',
aggregations: aggregationsRequest,
enabled: isOpen,
});
const aggregations = data?.data.aggregations;
const execTimePct = useMemo(
() =>
findAggregationMap(
@@ -83,39 +55,38 @@ function AnalyticsPanel({
[aggregations, colorByFieldName],
);
const execTimeRows = useMemo<AnalyticsRow[]>(() => {
const execTimeRows = useMemo(() => {
if (!execTimePct) {
return [];
}
return Object.entries(execTimePct)
.sort(([, a], [, b]) => b - a)
.map(([group, percentage]) => {
const pair = generateColorPair(group);
return {
group,
percentage,
color: isDarkMode ? pair.color : pair.colorDark,
widthPct: Math.min(percentage, 100),
label: `${percentage.toFixed(2)}%`,
};
});
})
.sort((a, b) => b.percentage - a.percentage);
}, [execTimePct, isDarkMode]);
const spanCountRows = useMemo<AnalyticsRow[]>(() => {
const spanCountRows = useMemo(() => {
if (!spanCounts) {
return [];
}
const max = Math.max(...Object.values(spanCounts), 1);
return Object.entries(spanCounts)
.sort(([, a], [, b]) => b - a)
.map(([group, count]) => {
const pair = generateColorPair(group);
return {
group,
count,
max,
color: isDarkMode ? pair.color : pair.colorDark,
widthPct: (count / max) * 100,
label: String(count),
};
});
})
.sort((a, b) => b.count - a.count);
}, [spanCounts, isDarkMode]);
if (!isOpen) {
@@ -161,23 +132,65 @@ function AnalyticsPanel({
<div className={styles.tabsScroll}>
<TabsContent value="exec-time">
<AnalyticsTabContent
isLoading={isLoading}
isError={isError}
fieldName={colorByFieldName}
rows={execTimeRows}
valueVariant="wide"
/>
<div className={styles.list}>
{execTimeRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
className={styles.dot}
style={{ backgroundColor: row.color }}
/>
<span key={`${row.group}-name`} className={styles.serviceName}>
{row.group}
</span>
<div key={`${row.group}-bar`} className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${Math.min(row.percentage, 100)}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className={cx(styles.value, styles.valueWide)}>
{row.percentage.toFixed(2)}%
</span>
</div>
</>
))}
</div>
</TabsContent>
<TabsContent value="spans">
<AnalyticsTabContent
isLoading={isLoading}
isError={isError}
fieldName={colorByFieldName}
rows={spanCountRows}
valueVariant="narrow"
/>
<div className={styles.list}>
{spanCountRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
className={styles.dot}
style={{ backgroundColor: row.color }}
/>
<span key={`${row.group}-name`} className={styles.serviceName}>
{row.group}
</span>
<div key={`${row.group}-bar`} className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${(row.count / row.max) * 100}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className={cx(styles.value, styles.valueNarrow)}>
{row.count}
</span>
</div>
</>
))}
</div>
</TabsContent>
</div>
</TabsRoot>

View File

@@ -1,84 +0,0 @@
import { Fragment } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import styles from './AnalyticsPanel.module.scss';
export interface AnalyticsRow {
group: string;
color: string;
widthPct: number;
label: string;
}
interface AnalyticsTabContentProps {
isLoading: boolean;
isError: boolean;
fieldName: string;
rows: AnalyticsRow[];
valueVariant: 'wide' | 'narrow';
}
// Loading / error / empty render in place of the rows so the tabs stay visible.
function AnalyticsTabContent({
isLoading,
isError,
fieldName,
rows,
valueVariant,
}: AnalyticsTabContentProps): JSX.Element {
if (isLoading) {
return (
<div className={styles.state}>
<Spinner height="auto" />
</div>
);
}
if (isError) {
return (
<div className={styles.state}>
<Typography.Text>Couldn&apos;t load analytics</Typography.Text>
</div>
);
}
if (rows.length === 0) {
return (
<div className={styles.state}>
<Typography.Text>No data for {fieldName}</Typography.Text>
</div>
);
}
return (
<div className={styles.list}>
{rows.map((row) => (
<Fragment key={row.group}>
<div className={styles.dot} style={{ backgroundColor: row.color }} />
<span className={styles.serviceName}>{row.group}</span>
<div className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${row.widthPct}%`,
backgroundColor: row.color,
}}
/>
</div>
<span
className={cx(
styles.value,
valueVariant === 'wide' ? styles.valueWide : styles.valueNarrow,
)}
>
{row.label}
</span>
</div>
</Fragment>
))}
</div>
);
}
export default AnalyticsTabContent;

View File

@@ -1,144 +0,0 @@
import { screen } from '@testing-library/react';
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
import { render } from 'tests/test-utils';
import { DEFAULT_COLOR_BY_FIELD } from '../../../constants';
import { useTraceStore } from '../../../stores/traceStore';
import AnalyticsPanel from '../AnalyticsPanel';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { id: string } => ({ id: 'trace-123' }),
}));
jest.mock('hooks/trace/useGetTraceAggregations', () => ({
__esModule: true,
default: jest.fn(),
}));
// Isolate the panel's own logic from the floating-panel chrome.
jest.mock('periscope/components/FloatingPanel', () => ({
__esModule: true,
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('components/DetailsPanel', () => ({
__esModule: true,
DetailsHeader: (): JSX.Element => <div data-testid="details-header" />,
}));
jest.mock('components/Spinner', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="spinner" />,
}));
const mockHook = useGetTraceAggregations as jest.Mock;
const noop = (): void => undefined;
const renderPanel = (isOpen = true): ReturnType<typeof render> =>
render(<AnalyticsPanel isOpen={isOpen} onClose={noop} onTabChange={noop} />);
const aggregationsResponse = {
status: 'success',
data: {
aggregations: [
{
field: { name: 'service.name' },
aggregation: 'execution_time_percentage',
value: { api: 80, db: 20 },
},
{
field: { name: 'service.name' },
aggregation: 'span_count',
value: { api: 5, db: 2 },
},
],
},
};
describe('AnalyticsPanel', () => {
beforeEach(() => {
mockHook.mockReset();
useTraceStore.setState({ colorByField: DEFAULT_COLOR_BY_FIELD });
});
it('renders nothing when closed and does not enable the fetch', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
const { container } = renderPanel(false);
expect(container).toBeEmptyDOMElement();
expect(mockHook).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
it('requests both aggregations for the current color-by field when open', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderPanel();
expect(mockHook).toHaveBeenCalledWith(
expect.objectContaining({
traceId: 'trace-123',
enabled: true,
aggregations: [
{
field: DEFAULT_COLOR_BY_FIELD,
aggregation: 'execution_time_percentage',
},
{ field: DEFAULT_COLOR_BY_FIELD, aggregation: 'span_count' },
],
}),
);
});
it('shows the loading state with the tabs still visible', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderPanel();
expect(screen.getByTestId('spinner')).toBeInTheDocument();
// tabs stay visible while loading
expect(screen.getByText('% exec time')).toBeInTheDocument();
expect(screen.getByText('Spans')).toBeInTheDocument();
});
it('shows an error state when the request fails', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderPanel();
expect(screen.getByText(/couldn't load analytics/i)).toBeInTheDocument();
});
it('renders rows for the current field on success', () => {
mockHook.mockReturnValue({
data: aggregationsResponse,
isLoading: false,
isError: false,
});
renderPanel();
expect(screen.getByText('api')).toBeInTheDocument();
expect(screen.getByText('80.00%')).toBeInTheDocument();
});
it('shows an empty state when the field has no data', () => {
mockHook.mockReturnValue({
data: { status: 'success', data: { aggregations: [] } },
isLoading: false,
isError: false,
});
renderPanel();
expect(screen.getByText(/no data for service.name/i)).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,3 @@
// Applied to both the menu content and the submenu content (each renders in
// its own portal with a default z-index of 50) so both stack above
// FloatingPanel (z-index 999).
.traceOptionsDropdown {
z-index: 1100;
}

View File

@@ -1,16 +1,7 @@
import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button } from '@signozhq/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Settings2 } from '@signozhq/icons';
import { useTraceStore } from '../stores/traceStore';
@@ -23,9 +14,6 @@ interface TraceOptionsMenuProps {
onOpenPreviewFields: () => void;
}
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
// because the simple preset offers no way to style the submenu content,
// which renders in its own portal and needs a z-index above FloatingPanel.
function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
@@ -37,52 +25,78 @@ function TraceOptionsMenu({
(s) => s.availableColorByOptions,
);
const handleColorByChange = (name: string): void => {
const next = availableColorByOptions.find((o) => o.field.name === name);
if (next) {
setColorByField(next.field);
const menuItems: MenuItem[] = useMemo(() => {
const items: MenuItem[] = [
{
key: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
onClick: onToggleTraceDetails,
},
{
key: 'preview-fields',
label: 'Preview fields',
onClick: onOpenPreviewFields,
},
];
// Only show the "Colour by" submenu if there's an actual choice to make.
if (availableColorByOptions.length > 1) {
items.push({
key: 'colour-by',
label: 'Colour by',
children: [
{
type: 'group',
label: 'COLOUR BY',
children: [
{
type: 'radio-group',
value: colorByField.name,
onChange: (name: string): void => {
const next = availableColorByOptions.find(
(o) => o.field.name === name,
);
if (next) {
setColorByField(next.field);
}
},
children: availableColorByOptions.map((opt) => ({
type: 'radio',
key: opt.field.name,
label: opt.label,
value: opt.field.name,
})),
},
],
},
],
});
}
};
return items;
}, [
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
colorByField.name,
setColorByField,
availableColorByOptions,
]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Trace options"
prefix={<Settings2 size={14} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={styles.traceOptionsDropdown}>
<DropdownMenuItem clickable onSelect={onToggleTraceDetails}>
{showTraceDetails ? 'Hide trace details' : 'Show trace details'}
</DropdownMenuItem>
<DropdownMenuItem clickable onSelect={onOpenPreviewFields}>
Preview fields
</DropdownMenuItem>
{/* Only show the "Colour by" submenu if there's an actual choice to make. */}
{availableColorByOptions.length > 1 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Colour by</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={styles.traceOptionsDropdown}>
<DropdownMenuLabel>COLOUR BY</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={colorByField.name}
onValueChange={handleColorByChange}
>
{availableColorByOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.field.name} value={opt.field.name}>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
<Dropdown
menu={{ items: menuItems }}
align="start"
className={styles.traceOptionsDropdown}
>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Trace options"
prefix={<Settings2 size={14} />}
/>
</Dropdown>
);
}

View File

@@ -1,9 +1,6 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import {
computeVisualLayout,
WIDE_GROUP_THRESHOLD,
} from '../computeVisualLayout';
import { computeVisualLayout } from '../computeVisualLayout';
function makeSpan(
overrides: Partial<FlamegraphSpan> & {
@@ -475,177 +472,4 @@ describe('computeVisualLayout', () => {
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
});
// --- Wide-group fast path (> WIDE_GROUP_THRESHOLD siblings) ---
// Past the threshold the layout switches to exact overlap-only packing to
// avoid the O(N^2) connector-avoidance spiral. These lock in correctness and
// the no-overlap invariant at scale.
function noRowHasOverlap(
layout: ReturnType<typeof computeVisualLayout>,
): void {
for (const row of layout.visualRows) {
const sorted = [...row].sort((a, b) => a.timestamp - b.timestamp);
for (let i = 1; i < sorted.length; i++) {
const prevEnd = sorted[i - 1].timestamp + sorted[i - 1].durationNano / 1e6;
expect(sorted[i].timestamp).toBeGreaterThanOrEqual(prevEnd);
}
}
}
it('should pack thousands of sequential leaf siblings into 1 row (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
// 2000 strictly sequential (non-overlapping) children
for (let i = 0; i < 2000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * 10,
durationNano: 5e6, // 5ms, ends before next starts
}),
);
}
const layout = computeVisualLayout([[root], kids]);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.totalVisualRows).toBe(2); // all siblings share row 1
for (const k of kids) {
expect(layout.spanToVisualRow[k.spanId]).toBe(1);
}
noRowHasOverlap(layout);
});
it('should pack thousands of fully-overlapping leaf siblings without violations (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
// 1000 children all spanning the same window → each needs its own row
for (let i = 0; i < 1000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6,
}),
);
}
const layout = computeVisualLayout([[root], kids]);
expect(layout.totalVisualRows).toBe(1001); // root + 1000 stacked rows
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1001);
noRowHasOverlap(layout);
});
it('should keep non-leaf subtrees adjacent within a wide mixed group (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
for (let i = 0; i < 1000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * 10,
durationNano: 5e6,
}),
);
}
// One of the wide siblings has a child of its own
const grandchild = makeSpan({
spanId: 'gc',
parentSpanId: 'k500',
timestamp: 5000,
durationNano: 2e6,
});
const layout = computeVisualLayout([[root], kids, [grandchild]]);
const parentRow = layout.spanToVisualRow['k500'];
const gcRow = layout.spanToVisualRow['gc'];
expect(gcRow - parentRow).toBe(1); // subtree adjacency preserved
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1002);
noRowHasOverlap(layout);
});
// --- Regression guards for the wide-trace layout spiral ---
// The pre-fix algorithm's connector-avoidance checks formed a positive-
// feedback loop on wide SCATTERED groups (short spans spread across the
// parent window with modest concurrency): each pushed-down child stamped
// connector points on intermediate rows, pushing later children even
// higher. Sequential and fully-overlapping groups (tests above) do NOT
// trigger it — these two shapes do, and encode its failure signatures.
function makeScatteredStar(
childCount: number,
spacingMs: number,
durationMs: number,
): FlamegraphSpan[][] {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: (childCount * spacingMs + durationMs) * 1e6,
});
const kids: FlamegraphSpan[] = [];
for (let i = 0; i < childCount; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * spacingMs,
durationNano: durationMs * 1e6,
}),
);
}
return [[root], kids];
}
it('should not spiral row count on a scattered group just above the threshold', () => {
// Children of 50ms each, starting every 10ms → max temporal concurrency
// is 6 (each span overlaps the next 5). The old algorithm spiraled this
// shape to ~1 row per child; overlap-only packing needs ~7 including the
// root. Sized just above WIDE_GROUP_THRESHOLD so a regression fails fast
// (sub-second) rather than burning CI time.
const count = WIDE_GROUP_THRESHOLD + 88;
const layout = computeVisualLayout(makeScatteredStar(count, 10, 50));
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(count + 1);
// Generous slack over the optimal ~7 — but orders of magnitude below the
// one-row-per-child the spiral produced.
expect(layout.totalVisualRows).toBeLessThan(30);
noRowHasOverlap(layout);
});
it('should be faster through the fast path despite one extra span', () => {
// Identical scattered shape on both sides of the gate: THRESHOLD children
// run the original connector-avoidance path, THRESHOLD + 1 run the
// overlap-only fast path. With one MORE span to place, the fast path can
// only win because the algorithm is cheaper — a self-calibrating
// comparison (same machine, same process) with no absolute time budget.
// On this shape the avoidance path spirals (~tens of ms) while the fast
// path stays ~1ms, so the margin is well past timer noise. Also guards
// the gate itself: if everything routed to one path, one extra span can
// never be faster.
const avoidancePathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD, 10, 50);
const fastPathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD + 1, 10, 50);
// Warm-up runs so JIT compilation doesn't skew either side.
computeVisualLayout(avoidancePathInput);
computeVisualLayout(fastPathInput);
const timeOf = (input: FlamegraphSpan[][]): number => {
const start = performance.now();
computeVisualLayout(input);
return performance.now() - start;
};
// Best-of-3 per side to shave off GC pauses and scheduler noise.
const RUNS = [0, 1, 2];
const avoidanceMs = Math.min(...RUNS.map(() => timeOf(avoidancePathInput)));
const fastMs = Math.min(...RUNS.map(() => timeOf(fastPathInput)));
expect(fastMs).toBeLessThan(avoidanceMs);
});
});

View File

@@ -18,82 +18,6 @@ export interface VisualLayout {
totalVisualRows: number;
}
// Above this many siblings under one parent, the connector-avoidance refinement
// (Checks 2 & 3) is both visually meaningless — the row is already a dense wall —
// and quadratic: every child deposits a connector point on each intermediate row,
// which pushes later children even higher, which deposits more points. That
// feedback loop inflates a layout needing ~50 rows to thousands and never
// finishes on wide traces. Past the threshold we pack by overlap only.
// Exported so the regression tests stay anchored to the real gate value.
export const WIDE_GROUP_THRESHOLD = 512;
/**
* Segment tree over rows that answers "lowest row index >= `from` whose smallest
* span start-time is >= `end`" in O(log rows). Used to place a large group of
* leaf siblings by overlap only: because siblings are processed in descending
* start order, every already-placed span on a row starts at or after the current
* one, so [start, end] overlaps a row iff some span there starts before `end` —
* i.e. the row is free iff its minimum start >= end. Each node stores the max of
* its subtree's per-row minimum starts so a free row can be found by descent.
*/
class LowestFreeRow {
private readonly size: number;
private readonly tree: Float64Array;
constructor(rows: number) {
let size = 1;
while (size < rows) {
size *= 2;
}
this.size = size;
this.tree = new Float64Array(size * 2).fill(Infinity);
}
place(row: number, start: number): void {
let i = row + this.size;
// A row's key is the minimum start among its spans. Children are processed
// in descending start order so a leaf's start is the new minimum, but a
// non-leaf subtree's descendant can land on a row out of order — take min.
if (start >= this.tree[i]) {
return;
}
this.tree[i] = start;
for (i >>= 1; i >= 1; i >>= 1) {
const next = Math.max(this.tree[2 * i], this.tree[2 * i + 1]);
if (this.tree[i] === next) {
break;
}
this.tree[i] = next;
}
}
lowestFrom(from: number, end: number): number {
return this.descend(1, 0, this.size - 1, from, end);
}
private descend(
node: number,
lo: number,
hi: number,
from: number,
end: number,
): number {
if (hi < from || this.tree[node] < end) {
return -1;
}
if (lo === hi) {
return lo;
}
const mid = (lo + hi) >> 1;
const left = this.descend(2 * node, lo, mid, from, end);
if (left !== -1) {
return left;
}
return this.descend(2 * node + 1, mid + 1, hi, from, end);
}
}
/**
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
*
@@ -290,53 +214,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
arr.push(point);
}
// Fast path for a parent with a very large group of children: pack by overlap
// only (descending greedy), skipping the quadratic connector-avoidance that
// spirals at this scale. Leaf children — the bulk of a wide trace — are placed
// in O(log rows) via the segment tree; the rare non-leaf subtree falls back to
// findPlacement against the shared interval map. Both structures are kept in
// sync so each placement sees all prior occupancy. Same ShapeEntry[] contract.
function computeWideShape(
rootSpan: FlamegraphSpan,
children: FlamegraphSpan[],
): ShapeEntry[] {
const shape: ShapeEntry[] = [{ span: rootSpan, relativeRow: 0 }];
const localIntervals = new Map<number, Array<[number, number]>>();
// Children occupy relative rows 1..children.length in the worst case.
const finder = new LowestFreeRow(children.length + 2);
const occupy = (row: number, span: FlamegraphSpan): void => {
const s = span.timestamp;
const e = span.timestamp + span.durationNano / 1e6;
shape.push({ span, relativeRow: row });
addIntervalTo(localIntervals, row, s, e);
finder.place(row, s);
};
for (const child of children) {
if (childrenMap.has(child.spanId)) {
// Non-leaf: place its whole subtree shape as a unit via findPlacement.
const childShape = computeSubtreeShape(child);
const offset = findPlacement(childShape, 1, localIntervals);
for (const entry of childShape) {
occupy(entry.relativeRow + offset, entry.span);
}
} else {
const end = child.timestamp + child.durationNano / 1e6;
occupy(finder.lowestFrom(1, end), child);
}
}
return shape;
}
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
const children = childrenMap.get(rootSpan.spanId);
if (children && children.length > WIDE_GROUP_THRESHOLD) {
return computeWideShape(rootSpan, children);
}
const localIntervals = new Map<number, Array<[number, number]>>();
const localConnectorPoints = new Map<number, number[]>();
const shape: ShapeEntry[] = [];
@@ -347,6 +225,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
shape.push({ span: rootSpan, relativeRow: 0 });
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
const children = childrenMap.get(rootSpan.spanId);
if (children) {
for (const child of children) {
const childShape = computeSubtreeShape(child);

View File

@@ -94,7 +94,7 @@ export function useVisualLayoutWorker(spans: FlamegraphSpan[][]): {
cleanup();
};
// Timeout: if worker doesn't respond in 15s, terminate and error
// Timeout: if worker doesn't respond in 30s, terminate and error
const WORKER_TIMEOUT_MS = 15000;
const timeoutId = setTimeout(() => {
if (requestIdRef.current === currentId && isComputingRef.current) {

View File

@@ -119,7 +119,6 @@
gap: 12px;
margin-bottom: 14px;
align-items: center;
background: var(--l3-background);
}
.searchInput {
@@ -127,13 +126,16 @@
padding: 6px 8px;
background: var(--l3-background);
height: 18px;
margin-inline-end: 6px;
:global(.ant-input-prefix) {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
svg {
opacity: 0.4;
}
}
&,
input {
font-size: 14px;
line-height: 18px;

View File

@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV4SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
import { GetTraceV3SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceWaterfallStates } from './constants';
import Error from './TraceWaterfallStates/Error/Error';
@@ -22,7 +22,7 @@ interface ITraceWaterfallProps {
localUncollapsedNodes: Set<string>;
setLocalUncollapsedNodes: Dispatch<SetStateAction<Set<string>>>;
traceData:
| SuccessResponse<GetTraceV4SuccessResponse, unknown>
| SuccessResponse<GetTraceV3SuccessResponse, unknown>
| ErrorResponse
| undefined;
isFetchingTraceData: boolean;

View File

@@ -1,34 +0,0 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAvailableColorByFieldNames } from '../utils';
const span = (partial: Partial<SpanV3>): SpanV3 =>
({ level: 1, resource: {}, attributes: {}, ...partial }) as SpanV3;
describe('getAvailableColorByFieldNames', () => {
it('returns [] for an empty span set', () => {
expect(getAvailableColorByFieldNames([])).toStrictEqual([]);
});
it('offers a field if any span carries it, in option order', () => {
const spans = [
span({ resource: { 'service.name': 'api' } }),
// k8s.node.name lives on a non-root span — still offered
span({ resource: { 'k8s.node.name': 'node-1' } }),
];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual([
'service.name',
'k8s.node.name',
]);
});
it('reads from attributes when the key is not on resource', () => {
const spans = [span({ attributes: { 'host.name': 'box-1' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('does not offer fields no span carries', () => {
const spans = [span({ resource: { 'service.name': 'api' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);
});
});

View File

@@ -8,17 +8,23 @@ import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import useGetTraceV3 from 'hooks/trace/useGetTraceV3';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import NoData from 'pages/TraceDetailV2/NoData/NoData';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import {
SpanV3,
TraceDetailV3URLProps,
WaterfallAggregationRequest,
} from 'types/api/trace/getTraceV3';
import { COLOR_BY_FIELDS } from './constants';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { AGGREGATIONS } from './utils/aggregations';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
@@ -28,7 +34,6 @@ import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from './utils';
import cx from 'classnames';
@@ -98,6 +103,17 @@ function TraceDetailsV3(): JSX.Element {
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
// upfront so a future color-by-field switch doesn't need to refetch.
const waterfallAggregationsRequest = useMemo<WaterfallAggregationRequest[]>(
() =>
COLOR_BY_FIELDS.flatMap((field) => [
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
]),
[],
);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
@@ -105,6 +121,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
});
const queryParams = fullDataLoadedRef.current
@@ -113,17 +130,19 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
} = useGetTraceV3({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
aggregations: queryParams.aggregations,
});
const allSpans = traceData?.payload?.spans || [];
@@ -131,13 +150,6 @@ function TraceDetailsV3(): JSX.Element {
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
@@ -145,6 +157,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
}
@@ -369,7 +382,7 @@ function TraceDetailsV3(): JSX.Element {
);
return (
<TraceStoreSync availableColorByFields={availableColorByFields}>
<TraceStoreSync aggregations={traceData?.payload?.aggregations}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}

View File

@@ -2,20 +2,21 @@ import { ReactNode, useEffect } from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { useAppContext } from 'providers/App/App';
import { WaterfallAggregationResponse } from 'types/api/trace/getTraceV3';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreAggregations,
setTraceStoreCallbacks,
setTraceStoreUserPreferences,
} from './traceStore';
interface TraceStoreSyncProps {
availableColorByFields: string[] | undefined;
aggregations: WaterfallAggregationResponse[] | undefined;
children: ReactNode;
}
/**
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
* Bridges React-managed inputs (the `aggregations` prop, `userPreferences`
* from AppContext, and the user-pref mutation hook) into the Zustand store.
*
* Renders nothing until `userPreferences` resolves so the flamegraph never
@@ -24,15 +25,15 @@ interface TraceStoreSyncProps {
* is logged in, so this gate is usually already settled by mount time.
*/
function TraceStoreSync({
availableColorByFields,
aggregations,
children,
}: TraceStoreSyncProps): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
setTraceStoreAggregations(aggregations);
}, [aggregations]);
useEffect(() => {
setTraceStoreUserPreferences(userPreferences ?? null);

View File

@@ -1,71 +0,0 @@
import { USER_PREFERENCES } from 'constants/userPreferences';
import { UserPreference } from 'types/api/preferences/preference';
import { COLOR_BY_OPTIONS, DEFAULT_COLOR_BY_FIELD } from '../../constants';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreUserPreferences,
useTraceStore,
} from '../traceStore';
const colorByPref = (fieldName: string): UserPreference[] => [
{
name: USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
value: fieldName,
} as UserPreference,
];
const optionNames = (): string[] =>
useTraceStore.getState().availableColorByOptions.map((o) => o.field.name);
describe('traceStore color-by gating', () => {
beforeEach(() => {
useTraceStore.setState({
availableColorByFieldNames: undefined,
userPreferences: null,
colorByField: DEFAULT_COLOR_BY_FIELD,
availableColorByOptions: COLOR_BY_OPTIONS.filter(
(o) => o.field.name === DEFAULT_COLOR_BY_FIELD.name,
),
});
});
it('offers only the default field before spans load', () => {
expect(optionNames()).toStrictEqual([DEFAULT_COLOR_BY_FIELD.name]);
expect(useTraceStore.getState().colorByField).toStrictEqual(
DEFAULT_COLOR_BY_FIELD,
);
});
it('offers the default plus any field present on loaded spans', () => {
setTraceStoreAvailableColorByFields(['host.name']);
expect(optionNames()).toStrictEqual([
DEFAULT_COLOR_BY_FIELD.name,
'host.name',
]);
});
it('honors the persisted color-by field when it is available', () => {
setTraceStoreAvailableColorByFields(['host.name']);
setTraceStoreUserPreferences(colorByPref('host.name'));
expect(useTraceStore.getState().colorByField.name).toBe('host.name');
});
it('falls back to the default when the persisted field is not available', () => {
setTraceStoreUserPreferences(colorByPref('host.name'));
setTraceStoreAvailableColorByFields(['k8s.node.name']);
expect(useTraceStore.getState().colorByField.name).toBe(
DEFAULT_COLOR_BY_FIELD.name,
);
expect(optionNames()).toStrictEqual([
DEFAULT_COLOR_BY_FIELD.name,
'k8s.node.name',
]);
});
it('trusts the persisted field while spans are still loading', () => {
// availableColorByFieldNames stays undefined (loading) — do not flip to default
setTraceStoreUserPreferences(colorByPref('host.name'));
expect(useTraceStore.getState().colorByField.name).toBe('host.name');
});
});

View File

@@ -1,6 +1,7 @@
import { USER_PREFERENCES } from 'constants/userPreferences';
import { UserPreference } from 'types/api/preferences/preference';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { WaterfallAggregationResponse } from 'types/api/trace/getTraceV3';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { create } from 'zustand';
@@ -10,6 +11,10 @@ import {
ColorByOption,
DEFAULT_COLOR_BY_FIELD,
} from '../constants';
import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../utils/aggregations';
import { toTelemetryFieldKey } from '../utils/previewFields';
interface MutateOptions {
@@ -25,9 +30,7 @@ type MutateUserPreference = (
interface TraceStoreState {
// --- Inputs synced from React layer via TraceStoreSync ---
// Fields present on loaded spans; gates color-by options. `undefined` while
// loading so we keep trusting the persisted field.
availableColorByFieldNames: string[] | undefined;
aggregations: WaterfallAggregationResponse[] | undefined;
userPreferences: UserPreference[] | null;
updateUserPreferenceInContext: UpdateUserPreferenceInContext | null;
mutateUserPreference: MutateUserPreference | null;
@@ -38,7 +41,9 @@ interface TraceStoreState {
previewFields: TelemetryFieldKey[];
// --- Setters used only by TraceStoreSync ---
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
setAggregations: (
aggregations: WaterfallAggregationResponse[] | undefined,
) => void;
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
setCallbacks: (callbacks: {
updateUserPreferenceInContext: UpdateUserPreferenceInContext;
@@ -66,18 +71,23 @@ function getPersistedColorByField(
/**
* Re-derives `colorByField` + `availableColorByOptions` from the two inputs.
* Preserves the "trust persisted while spans load" rule so the flamegraph
* doesn't repaint when the waterfall response arrives.
* Preserves the "trust persisted while aggregations load" rule so the
* flamegraph doesn't repaint when the aggregations response arrives.
*/
function deriveColorState(
availableColorByFieldNames: string[] | undefined,
aggregations: WaterfallAggregationResponse[] | undefined,
userPreferences: UserPreference[] | null,
): Pick<TraceStoreState, 'colorByField' | 'availableColorByOptions'> {
const isFieldAvailable = (fieldName: string): boolean => {
if (fieldName === DEFAULT_COLOR_BY_FIELD.name) {
return true;
}
return !!availableColorByFieldNames?.includes(fieldName);
const map = findAggregationMap(
aggregations,
AGGREGATIONS.EXEC_TIME_PCT,
fieldName,
);
return !!map && Object.keys(map).length > 0;
};
const availableColorByOptions = COLOR_BY_OPTIONS.filter((opt) =>
@@ -85,10 +95,10 @@ function deriveColorState(
);
const persistedColorByField = getPersistedColorByField(userPreferences);
// While loading, trust persisted — don't flip to default prematurely.
// While aggregations are loading, trust persisted — don't flip to default
// just because we haven't confirmed availability yet.
const colorByField =
availableColorByFieldNames === undefined ||
isFieldAvailable(persistedColorByField.name)
aggregations === undefined || isFieldAvailable(persistedColorByField.name)
? persistedColorByField
: DEFAULT_COLOR_BY_FIELD;
@@ -124,7 +134,7 @@ function derivePreviewFields(
}
export const useTraceStore = create<TraceStoreState>()((set, get) => ({
availableColorByFieldNames: undefined,
aggregations: undefined,
userPreferences: null,
updateUserPreferenceInContext: null,
mutateUserPreference: null,
@@ -135,19 +145,19 @@ export const useTraceStore = create<TraceStoreState>()((set, get) => ({
),
previewFields: [],
setAvailableColorByFields: (availableColorByFieldNames): void => {
setAggregations: (aggregations): void => {
const { userPreferences } = get();
set({
availableColorByFieldNames,
...deriveColorState(availableColorByFieldNames, userPreferences),
aggregations,
...deriveColorState(aggregations, userPreferences),
});
},
setUserPreferences: (userPreferences): void => {
const { availableColorByFieldNames } = get();
const { aggregations } = get();
set({
userPreferences,
...deriveColorState(availableColorByFieldNames, userPreferences),
...deriveColorState(aggregations, userPreferences),
previewFields: derivePreviewFields(userPreferences),
});
},
@@ -225,9 +235,9 @@ export const useTraceStore = create<TraceStoreState>()((set, get) => ({
},
}));
export const setTraceStoreAvailableColorByFields = (
fieldNames: string[] | undefined,
): void => useTraceStore.getState().setAvailableColorByFields(fieldNames);
export const setTraceStoreAggregations = (
aggregations: WaterfallAggregationResponse[] | undefined,
): void => useTraceStore.getState().setAggregations(aggregations);
export const setTraceStoreUserPreferences = (
userPreferences: UserPreference[] | null,

View File

@@ -1,6 +1,5 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { COLOR_BY_OPTIONS } from './constants';
import {
ColorPair,
generateColorPair,
@@ -110,14 +109,3 @@ export function resolveSpanColor(
}
return generateColorPair(getSpanGroupValue(span, colorByFieldName));
}
/**
* Color-by fields present on any of the given spans — replaces the old
* server-side aggregation gating. `service.name` is always offered by the store
* regardless of this list.
*/
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
return COLOR_BY_OPTIONS.filter((opt) =>
spans.some((s) => getSpanAttribute(s, opt.field.name)),
).map((opt) => opt.field.name);
}

View File

@@ -1,19 +1,19 @@
import {
SpantypesSpanAggregationResultDTO,
SpantypesSpanAggregationTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
WaterfallAggregationResponse,
WaterfallAggregationType,
} from 'types/api/trace/getTraceV3';
export const AGGREGATIONS = {
EXEC_TIME_PCT: SpantypesSpanAggregationTypeDTO.execution_time_percentage,
SPAN_COUNT: SpantypesSpanAggregationTypeDTO.span_count,
DURATION: SpantypesSpanAggregationTypeDTO.duration,
} as const;
EXEC_TIME_PCT: 'execution_time_percentage',
SPAN_COUNT: 'span_count',
DURATION: 'duration',
} as const satisfies Record<string, WaterfallAggregationType>;
export function getAggregationMap(
aggregations: SpantypesSpanAggregationResultDTO[] | undefined,
type: SpantypesSpanAggregationTypeDTO,
aggregations: WaterfallAggregationResponse[] | undefined,
type: WaterfallAggregationType,
fieldName: string,
): Record<string, number> | null | undefined {
): Record<string, number> | undefined {
return aggregations?.find(
(a) => a.aggregation === type && a.field.name === fieldName,
)?.value;

View File

@@ -53,20 +53,20 @@ export default function WorkspaceBlocked(): JSX.Element {
const { t } = useTranslation(['workspaceLocked']);
useEffect((): void => {
void logEvent('Workspace Blocked: Screen Viewed', {});
logEvent('Workspace Blocked: Screen Viewed', {});
}, []);
const handleContactUsClick = (): void => {
void logEvent('Workspace Blocked: Contact Us Clicked', {});
logEvent('Workspace Blocked: Contact Us Clicked', {});
};
const handleTabClick = (key: string): void => {
void logEvent('Workspace Blocked: Screen Tabs Clicked', { tabKey: key });
logEvent('Workspace Blocked: Screen Tabs Clicked', { tabKey: key });
};
const handleCollapseChange = (key: string | string[]): void => {
const lastKey = Array.isArray(key) ? key.slice(-1)[0] : key;
void logEvent('Workspace Blocked: Screen Tab FAQ Item Clicked', {
logEvent('Workspace Blocked: Screen Tab FAQ Item Clicked', {
panelKey: lastKey,
});
};
@@ -109,7 +109,7 @@ export default function WorkspaceBlocked(): JSX.Element {
);
const handleUpdateCreditCard = useCallback(async () => {
void logEvent('Workspace Blocked: User Clicked Update Credit Card', {});
logEvent('Workspace Blocked: User Clicked Update Credit Card', {});
updateCreditCard({
url: getBaseUrl(),
@@ -117,7 +117,7 @@ export default function WorkspaceBlocked(): JSX.Element {
}, [updateCreditCard]);
const handleExtendTrial = (): void => {
void logEvent('Workspace Blocked: User Clicked Extend Trial', {});
logEvent('Workspace Blocked: User Clicked Extend Trial', {});
notifications.info({
message: t('extendTrial'),
@@ -133,7 +133,7 @@ export default function WorkspaceBlocked(): JSX.Element {
};
const handleViewBilling = (e?: React.MouseEvent): void => {
void logEvent('Workspace Blocked: User Clicked View Billing', {});
logEvent('Workspace Blocked: User Clicked View Billing', {});
safeNavigate(ROUTES.BILLING, { newTab: !!e && isModifierKeyPressed(e) });
};

View File

@@ -6,12 +6,14 @@ import { Typography } from '@signozhq/ui/typography';
import manageCreditCardApi from 'api/v1/portal/create';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDateWithMinutes } from 'utils/timeUtils';
import featureGraphicCorrelationUrl from '@/assets/Images/feature-graphic-correlation.svg';
@@ -107,6 +109,15 @@ function WorkspaceSuspended(): JSX.Element {
</Typography.Title>
<Typography.Text className="workspace-suspended__details">
{t('actionDescription')}
<br />
{t('yourDataIsSafe')}{' '}
<span className="workspace-suspended__details__highlight">
{getFormattedDateWithMinutes(
dayjs(activeLicense?.event_queue?.scheduled_at).unix() ||
Date.now(),
)}
</span>{' '}
{t('actNow')}
</Typography.Text>
</Space>
</Col>

View File

@@ -1,9 +1,26 @@
export interface GetTraceV4PayloadProps {
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export type WaterfallAggregationType =
| 'span_count'
| 'execution_time_percentage'
| 'duration';
export interface WaterfallAggregationRequest {
field: TelemetryFieldKey;
aggregation: WaterfallAggregationType;
}
export interface WaterfallAggregationResponse extends WaterfallAggregationRequest {
value: Record<string, number>;
}
export interface GetTraceV3PayloadProps {
traceId: string;
selectedSpanId: string;
uncollapsedSpans: string[];
isSelectedSpanIDUnCollapsed: boolean;
limit?: number; // Optional limit for number of spans to fetch, default can be set in API
aggregations?: WaterfallAggregationRequest[];
}
export interface TraceDetailV3URLProps {
@@ -71,7 +88,7 @@ export interface SpanV3 {
trace_state: string;
}
export interface GetTraceV4SuccessResponse {
export interface GetTraceV3SuccessResponse {
spans: SpanV3[];
hasMissingSpans: boolean;
uncollapsedSpans: string[];
@@ -81,4 +98,5 @@ export interface GetTraceV4SuccessResponse {
totalErrorSpansCount: number;
rootServiceName: string;
rootServiceEntryPoint: string;
aggregations?: WaterfallAggregationResponse[];
}

View File

@@ -24,8 +24,6 @@ interface ImportMetaEnv {
readonly VITE_TUNNEL_URL: string;
readonly VITE_TUNNEL_DOMAIN: string;
readonly VITE_DOCS_BASE_URL: string;
readonly VITE_ENVIRONMENT: string;
readonly VITE_VERSION: string;
}
interface ImportMeta {

View File

@@ -82,20 +82,11 @@ export default defineConfig(({ mode }): UserConfig => {
];
if (env.VITE_SENTRY_AUTH_TOKEN) {
// Refuse to upload sourcemaps without an explicit version.
if (!env.VITE_VERSION) {
throw new Error(
'VITE_VERSION must be set to upload sourcemaps to Sentry; refusing to upload without a matching release.',
);
}
plugins.push(
sentryVitePlugin({
authToken: env.VITE_SENTRY_AUTH_TOKEN,
org: env.VITE_SENTRY_ORG,
project: env.VITE_SENTRY_PROJECT_ID,
// Pin the sourcemap-upload release to the same value injected as
// process.env.VERSION so uploaded sourcemaps resolve. Ref: platform-pod#2393
release: { name: env.VITE_VERSION },
}),
);
}
@@ -164,8 +155,6 @@ export default defineConfig(({ mode }): UserConfig => {
'process.env.TUNNEL_URL': JSON.stringify(env.VITE_TUNNEL_URL),
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
'process.env.ENVIRONMENT': JSON.stringify(env.VITE_ENVIRONMENT),
'process.env.VERSION': JSON.stringify(env.VITE_VERSION),
},
// In production, use relative paths so assets work with any base path injected by the backend.
// In dev, use the configured base path for proper HMR and routing.

1
go.mod
View File

@@ -18,7 +18,6 @@ require (
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/dustin/go-humanize v1.0.1
github.com/emersion/go-smtp v0.24.0
github.com/evanphx/json-patch/v5 v5.9.11
github.com/gin-gonic/gin v1.11.0
github.com/go-co-op/gocron v1.30.1
github.com/go-openapi/runtime v0.29.2

2
go.sum
View File

@@ -311,8 +311,6 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=

View File

@@ -151,26 +151,6 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services", handler.New(
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListAccountServicesMetadata),
handler.OpenAPIDef{
ID: "ListAccountServicesMetadata",
Tags: []string{"cloudintegration"},
Summary: "List account services metadata",
Description: "This endpoint lists the services metadata for the specified account and cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableServicesMetadata),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetService),
handler.OpenAPIDef{

View File

@@ -24,10 +24,9 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
// TODO: add http.StatusConflict once the dashboard name unique index is added.
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -42,87 +41,13 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
ID: "UpdateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update dashboard (v2)",
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdatableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
ID: "PatchDashboardV2",
Tags: []string{"dashboard"},
Summary: "Patch dashboard (v2)",
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
Request: new(dashboardtypes.PatchableDashboardV2),
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
// but our OpenAPI generator only reflects schemas for content types it
// understands (application/json, form-urlencoded, multipart) — anything
// else degrades to `type: string`. Declaring application/json here keeps
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Lock dashboard (v2)",
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
ID: "UnlockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unlock dashboard (v2)",
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -75,7 +75,6 @@ type Handler interface {
UpdateAccount(http.ResponseWriter, *http.Request)
DisconnectAccount(http.ResponseWriter, *http.Request)
ListServicesMetadata(http.ResponseWriter, *http.Request)
ListAccountServicesMetadata(http.ResponseWriter, *http.Request)
GetService(http.ResponseWriter, *http.Request)
UpdateService(http.ResponseWriter, *http.Request)
AgentCheckIn(http.ResponseWriter, *http.Request)

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="b27f1ad0-7d11-4247-9da3-91bce6211f32" x1="8.798" y1="8.703" x2="14.683" y2="8.703" gradientUnits="userSpaceOnUse"><stop offset="0.001" stop-color="#773adc"/><stop offset="1" stop-color="#552f99"/></linearGradient><linearGradient id="b2f92112-4ca9-4b17-a019-c9f26c1a4a8f" x1="5.764" y1="3.777" x2="5.764" y2="13.78" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a67af4"/><stop offset="0.999" stop-color="#773adc"/></linearGradient></defs><g id="b8a0486a-5501-4d92-b540-a766c4b3b548"><g><g><g><path d="M16.932,11.578a8.448,8.448,0,0,1-7.95,5.59,8.15,8.15,0,0,1-2.33-.33,2.133,2.133,0,0,0,.18-.83c.01,0,.03.01.04.01a7.422,7.422,0,0,0,2.11.3,7.646,7.646,0,0,0,6.85-4.28l.01-.01Z" fill="#32bedd"/><path d="M3.582,14.068a2.025,2.025,0,0,0-.64.56,8.6,8.6,0,0,1-1.67-2.44l1.04.23v.26a.6.6,0,0,0,.47.59l.14.03a6.136,6.136,0,0,0,.62.73Z" fill="#32bedd"/><path d="M12.352.958a2.28,2.28,0,0,0-.27.81c-.02-.01-.05-.02-.07-.03a7.479,7.479,0,0,0-3.03-.63,7.643,7.643,0,0,0-5.9,2.8l-.29.06a.6.6,0,0,0-.48.58v.46l-1.02.19A8.454,8.454,0,0,1,8.982.268,8.6,8.6,0,0,1,12.352.958Z" fill="#32bedd"/><path d="M16.872,5.7l-1.09-.38a6.6,6.6,0,0,0-.72-1.16c-.02-.03-.04-.05-.05-.07a2.083,2.083,0,0,0,.72-.45A7.81,7.81,0,0,1,16.872,5.7Z" fill="#32bedd"/><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="#fff"/><g><g id="e918f286-5032-4942-ad29-ea17e6f1cc90"><path d="M1.1,5.668l1.21-.23v6.55l-1.23-.27-.99-.22a.111.111,0,0,1-.09-.12v-5.4a.12.12,0,0,1,.09-.12Z" fill="#a67af4"/></g><g><g id="a47a99dd-4d47-4c70-8c42-c5ac274ce496"><g><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="url(#b27f1ad0-7d11-4247-9da3-91bce6211f32)"/><path d="M8.586,3.3,2.878,4.378a.177.177,0,0,0-.14.175V12.68a.177.177,0,0,0,.137.174L8.581,14.1a.176.176,0,0,0,.21-.174V3.478A.175.175,0,0,0,8.619,3.3Z" fill="url(#b2f92112-4ca9-4b17-a019-c9f26c1a4a8f)"/></g></g><polygon points="5.948 4.921 5.948 12.483 7.934 12.814 7.934 4.564 5.948 4.921" fill="#b796f9" opacity="0.5"/><polygon points="3.509 5.329 3.509 11.954 5.238 12.317 5.238 5.031 3.509 5.329" fill="#b796f9" opacity="0.5"/></g></g></g><path d="M16,2.048a1.755,1.755,0,1,1-1.76-1.76A1.756,1.756,0,0,1,16,2.048Z" fill="#32bedd"/><circle cx="4.65" cy="15.973" r="1.759" fill="#32bedd"/></g><path d="M18,6.689v3.844a.222.222,0,0,1-.133.2l-.766.316-3.07,1.268-.011,0a.126.126,0,0,1-.038,0,.1.1,0,0,1-.1-.1V5.234a.1.1,0,0,1,.054-.088l0,0,.019,0a.031.031,0,0,1,.019,0,.055.055,0,0,1,.034.008l.011,0,.012,0L17.05,6.2l.8.282A.213.213,0,0,1,18,6.689Z" fill="#773adc"/><path d="M13.959,5.14l-3.8.715a.118.118,0,0,0-.093.117v5.409a.118.118,0,0,0,.091.116l3.8.831a.115.115,0,0,0,.137-.09.109.109,0,0,0,0-.026V5.256a.117.117,0,0,0-.115-.118A.082.082,0,0,0,13.959,5.14Z" fill="#a67af4"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,263 @@
{
"id": "containerapp",
"title": "Container App",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_rxbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.App",
"resourceType": "containerApps",
"metrics": {},
"logs": {
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Container App Overview",
"description": "Overview of Container App metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,7 @@
### Monitor Container Apps with SigNoz
Collect key Container App metrics and view them with an out of the box dashboard.
To collect logs, you need to make sure that you have chosen "Azure Monitor" as the logging option for Container's App Environment.
Note: This integration ingests logs for only "ContainerAppConsoleLogs" and "ContainerAppSystemLogs" diagnostic settings categories.

View File

@@ -276,44 +276,6 @@ func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Req
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableServicesMetadata(services))
}
func (handler *handler) ListAccountServicesMetadata(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
accountID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
// check if integration account exists and is not removed.
_, err = handler.module.GetConnectedAccount(ctx, valuer.MustNewUUID(claims.OrgID), accountID, provider)
if err != nil {
render.Error(rw, err)
return
}
services, err := handler.module.ListServicesMetadata(ctx, valuer.MustNewUUID(claims.OrgID), provider, accountID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableServicesMetadata(services))
}
func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -478,3 +440,4 @@ func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableAgentCheckIn(provider, resp))
}

View File

@@ -60,12 +60,6 @@ type Module interface {
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
}
type Handler interface {
@@ -95,12 +89,4 @@ type Handler interface {
CreateV2(http.ResponseWriter, *http.Request)
GetV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
UnlockV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
}

View File

@@ -153,7 +153,7 @@ func (store *store) ListPublic(ctx context.Context, orgID valuer.UUID) ([]*dashb
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
BunDB().
NewUpdate().
Model(storableDashboard).
WherePK().

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
@@ -73,135 +72,3 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, false)
}
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
isAdmin := false
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
orgID,
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdatableDashboardV2{}
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PatchableDashboardV2{}
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}

View File

@@ -55,97 +55,3 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
if err != nil {
return err
}
err = existing.Update(updatable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
updateable, err := patch.Apply(existing)
if err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
if err != nil {
return err
}
err = existing.Update(*updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
}

View File

@@ -86,12 +86,11 @@ func New(
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
// Normalize Start/End to ms. UnmarshalJSON covers HTTP requests; callers
// that build the request programmatically skip it, so this is the catch-all
// (idempotent for the already-normalized path).
if err := req.Normalize(); err != nil {
return nil, err
}
// Coerce the window to epoch milliseconds up front so every downstream
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
// safely assume ms regardless of the resolution the caller sent.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
tmplVars := req.Variables
if tmplVars == nil {
@@ -428,12 +427,10 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Catch-all normalization for programmatic callers (see QueryRange). End is
// 0 here for the open-ended stream, which Normalize leaves untouched.
if err := req.Normalize(); err != nil {
client.Error <- err
return
}
// Coerce the window to epoch milliseconds up front (End may be 0 for the
// open-ended stream, which ToMilliSecs leaves untouched).
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
event := &qbtypes.QBEvent{
Version: "v5",

View File

@@ -33,6 +33,28 @@ func ToNanoSecs(epoch uint64) uint64 {
return temp * uint64(math.Pow(10, float64(19-count)))
}
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
func ToMilliSecs(epoch uint64) uint64 {
if epoch == 0 {
return 0
}
temp := epoch
count := 0
for epoch != 0 {
epoch /= 10
count++
}
const msDigits = 13
if count < msDigits {
return temp * uint64(math.Pow(10, float64(msDigits-count)))
}
return temp / uint64(math.Pow(10, float64(count-msDigits)))
}
// TODO(srikanthccv): should these be rounded to nearest multiple of 60 instead of 5 if step > 60?
// That would make graph look nice but "nice" but should be less important than the usefulness.
func RecommendedStepInterval(start, end uint64) uint64 {

View File

@@ -60,3 +60,51 @@ func TestToNanoSecs(t *testing.T) {
})
}
}
func TestToMilliSecs(t *testing.T) {
tests := []struct {
name string
epoch uint64
expected uint64
}{
{
name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC",
epoch: 1672531200, // seconds
expected: 1672531200000, // * 10^3
},
{
name: "13-digit Unix timestamp (milliseconds) - already ms",
epoch: 1672531200000,
expected: 1672531200000, // unchanged
},
{
name: "16-digit Unix timestamp (microseconds)",
epoch: 1672531200000000, // microseconds
expected: 1672531200000, // / 10^3
},
{
name: "19-digit Unix timestamp (nanoseconds)",
epoch: 1672531200000000000, // nanoseconds
expected: 1672531200000, // / 10^6
},
{
name: "Unix epoch start - zero is unchanged",
epoch: 0,
expected: 0,
},
{
name: "Recent timestamp in seconds - 2024-05-25 12:00:00 UTC",
epoch: 1716638400,
expected: 1716638400000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToMilliSecs(tt.epoch)
if result != tt.expected {
t.Errorf("ToMilliSecs(%d) = %d, want %d", tt.epoch, result, tt.expected)
}
})
}
}

View File

@@ -18,16 +18,14 @@ var (
type StorableIntegrationDashboard struct {
bun.BaseModel `bun:"table:integration_dashboard"`
ID string `json:"id" bun:"id,pk,type:text" required:"true"`
DashboardID string `json:"dashboardId" bun:"dashboard_id,type:text" required:"true"`
Provider IntegrationDashboardProviderType `json:"provider" bun:"provider,type:text" required:"true"`
Slug string `json:"slug" bun:"slug,type:text" required:"true"`
CreatedAt time.Time `json:"createdAt" bun:"created_at" required:"true"`
UpdatedAt time.Time `json:"updatedAt" bun:"updated_at" required:"true"`
ID string `bun:"id,pk,type:text"`
DashboardID string `bun:"dashboard_id,type:text"`
Provider IntegrationDashboardProviderType `bun:"provider,type:text"`
Slug string `bun:"slug,type:text"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
}
type IntegrationDashboard = StorableIntegrationDashboard
func NewStorableIntegrationDashboard(dashboardID string, provider IntegrationDashboardProviderType, slug string) *StorableIntegrationDashboard {
now := time.Now()
return &StorableIntegrationDashboard{

View File

@@ -50,18 +50,10 @@ type ListServicesMetadataParams struct {
// Service represents a cloud integration service with its definition,
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
type Service struct {
ServiceDefinitionMetadata
Overview string `json:"overview" required:"true"` // markdown
ServiceAssets ServiceAssets `json:"assets" required:"true"`
SupportedSignals SupportedSignals `json:"supportedSignals" required:"true"`
DataCollected DataCollected `json:"dataCollected" required:"true"`
ServiceDefinition
CloudIntegrationService *CloudIntegrationService `json:"cloudIntegrationService" required:"true" nullable:"true"`
}
type ServiceAssets struct {
Dashboards []*ServiceDashboard `json:"dashboards" required:"true" nullable:"false"`
}
type GetServiceParams struct {
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
}
@@ -129,12 +121,6 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
type ServiceDashboard struct {
Title string `json:"title" required:"true"`
Description string `json:"description" required:"true"`
IntegrationDashboard *IntegrationDashboard `json:"integrationDashboard,omitempty" required:"false"`
}
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, provider CloudProviderType, config *ServiceConfig) (*CloudIntegrationService, error) {
switch provider {
case CloudProviderTypeAWS:
@@ -178,41 +164,11 @@ func NewServiceMetadata(definition ServiceDefinition, enabled bool) *ServiceMeta
}
}
func NewService(provider CloudProviderType, def *ServiceDefinition, integrationService *CloudIntegrationService, integrationDashboards []*StorableIntegrationDashboard) *Service {
service := &Service{
ServiceDefinitionMetadata: def.ServiceDefinitionMetadata,
Overview: def.Overview,
SupportedSignals: def.SupportedSignals,
DataCollected: def.DataCollected,
CloudIntegrationService: integrationService,
ServiceAssets: ServiceAssets{Dashboards: make([]*ServiceDashboard, 0, len(def.Assets.Dashboards))},
func NewService(def ServiceDefinition, storableService *CloudIntegrationService) *Service {
return &Service{
ServiceDefinition: def,
CloudIntegrationService: storableService,
}
integrationDashboardsMap := make(map[string]*IntegrationDashboard)
for _, d := range integrationDashboards {
integrationDashboardsMap[d.Slug] = d
}
for _, d := range def.Assets.Dashboards {
dashboard := &ServiceDashboard{
Title: d.Title,
Description: d.Description,
}
if integrationService != nil {
slug := CloudIntegrationDashboardSlug(provider, integrationService.Type, d.ID)
if integrationDashboard, exists := integrationDashboardsMap[slug]; exists {
if integrationDashboard != nil {
dashboard.IntegrationDashboard = integrationDashboard
}
}
}
service.ServiceAssets.Dashboards = append(service.ServiceAssets.Dashboards, dashboard)
}
return service
}
func NewGettableServicesMetadata(services []*ServiceMetadata) *GettableServicesMetadata {

View File

@@ -27,6 +27,7 @@ var (
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
AzureServiceContainerApp = ServiceID{valuer.NewString("containerapp")}
)
func (ServiceID) Enum() []any {
@@ -46,6 +47,7 @@ func (ServiceID) Enum() []any {
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceContainerApp,
}
}
@@ -69,6 +71,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceContainerApp,
},
}

View File

@@ -21,7 +21,6 @@ var (
ErrCodeDashboardInvalidWidgetQuery = errors.MustNewCode("dashboard_invalid_widget_query")
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
)
type StorableDashboard struct {

View File

@@ -62,54 +62,6 @@ type DashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d *DashboardV2) CanUpdate() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
return nil
}
func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
return err
}
if updatable.Name != d.Name {
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
}
d.DashboardV2MetadataBase = updatable.DashboardV2MetadataBase
d.Tags = resolvedTags
d.Spec = updatable.Spec
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
}
if d.Source == SourceSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be locked or unlocked")
}
if d.CreatedBy != updatedBy && !isAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
return nil
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image,omitempty"`
@@ -174,7 +126,7 @@ func (p *PostableDashboardV2) Validate() error {
if err := p.validateName(); err != nil {
return err
}
if err := validateDashboardTags(p.Tags); err != nil {
if err := p.validateTags(); err != nil {
return err
}
return p.Spec.Validate()
@@ -241,11 +193,11 @@ func generateDashboardName(displayName string) string {
return prefix + "-" + string(suffix)
}
func validateDashboardTags(tags []tagtypes.PostableTag) error {
if len(tags) > MaxTagsPerDashboard {
func (p *PostableDashboardV2) validateTags() error {
if len(p.Tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
for _, tag := range tags {
for _, tag := range p.Tags {
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key)
}
@@ -311,57 +263,6 @@ func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardDat
type StorableDashboardV2Metadata = DashboardV2MetadataBase
// ════════════════════════════════════════════════════════════════════════
// Updatable
// ════════════════════════════════════════════════════════════════════════
type UpdatableDashboardV2 struct {
DashboardV2MetadataBase
Name string `json:"name" required:"true"`
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias UpdatableDashboardV2
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*u = UpdatableDashboardV2(tmp)
if u.Spec.Display == nil {
u.Spec.Display = &common.Display{}
}
if u.Spec.Display.Name == "" {
u.Spec.Display.Name = u.Name
}
return u.Validate()
}
func (u *UpdatableDashboardV2) Validate() error {
if u.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "schemaVersion must be %q, got %q", SchemaVersion, u.SchemaVersion)
}
if err := validateDashboardName(u.Name); err != nil {
return err
}
if err := validateDashboardTags(u.Tags); err != nil {
return err
}
return u.Spec.Validate()
}
func (d DashboardV2) toUpdatableDashboardV2() UpdatableDashboardV2 {
return UpdatableDashboardV2{
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
Name: d.Name,
Tags: tagtypes.NewPostableTagsFromTags(d.Tags),
Spec: d.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Convertors
// ════════════════════════════════════════════════════════════════════════

View File

@@ -1,83 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/swaggest/jsonschema-go"
)
// PatchableDashboardV2 is an RFC 6902 patch request.
type PatchableDashboardV2 struct {
// Ops shapes the OpenAPI schema; tagged so swaggest reflects it.
Ops []JSONPatchOperation `json:"ops"`
// patch holds the decoded payload, set by UnmarshalJSON.
patch jsonpatch.Patch
}
// PrepareJSONSchema collapses the struct's object schema into the bare ops array.
func (PatchableDashboardV2) PrepareJSONSchema(s *jsonschema.Schema) error {
// Called on several passes; only the one with built properties carries `ops`.
ops, ok := s.Properties["ops"]
if !ok || ops.TypeObject == nil {
return nil
}
*s = *ops.TypeObject
return nil
}
type JSONPatchOperation struct {
Op PatchOp `json:"op" required:"true"`
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-."`
// `value` is required for add/replace/test.
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
// `from` is required for move/copy.
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
}
// PatchOp covers the six RFC 6902 JSON Patch verbs.
type PatchOp struct{ valuer.String }
var (
PatchOpAdd = PatchOp{valuer.NewString("add")}
PatchOpRemove = PatchOp{valuer.NewString("remove")}
PatchOpReplace = PatchOp{valuer.NewString("replace")}
PatchOpMove = PatchOp{valuer.NewString("move")}
PatchOpCopy = PatchOp{valuer.NewString("copy")}
PatchOpTest = PatchOp{valuer.NewString("test")}
)
func (PatchOp) Enum() []any {
return []any{PatchOpAdd, PatchOpRemove, PatchOpReplace, PatchOpMove, PatchOpCopy, PatchOpTest}
}
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
patch, err := jsonpatch.DecodePatch(data)
if err != nil {
return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error())
}
if err := json.Unmarshal(data, &p.Ops); err != nil {
return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error())
}
p.patch = patch
return nil
}
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV2, error) {
existingAsUpdatable := existing.toUpdatableDashboardV2()
raw, err := json.Marshal(existingAsUpdatable)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
}
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
if err != nil {
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
}
out := &UpdatableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {
return nil, err
}
return out, nil
}

View File

@@ -1,569 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// basePostableJSON is the postable shape of a small but realistic v2
// dashboard used as the base document for patch tests. Each panel carries
// one builder query in the same shape production dashboards use
// (aggregations, filter, groupBy populated), and the dashboard has one
// variable — the variable is not patched in any test here, that's
// covered in a separate variable-focused suite.
const basePostableJSON = `{
"schemaVersion": "v6",
"name": "service-overview",
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
"spec": {
"display": {"name": "Service overview"},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}],
"filter": {"expression": "service.name IN $service"},
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}]
}}}
}
]
}
},
"p2": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "X",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_latency_count",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {"title": "Row 1"},
"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/p2"}}
]
}
}
],
"duration": "1h"
}
}`
func TestPatchableDashboardV2_Apply(t *testing.T) {
// Apply doesn't mutate the input *DashboardV2 — it marshals it to
// JSON, applies the patch, and unmarshals the result into a fresh
// struct. Sharing one base across subtests is safe.
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
testOrgID := valuer.GenerateUUID()
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
base.Tags = []*tagtypes.Tag{
{Key: "team", Value: "alpha"},
{Key: "env", Value: "prod"},
}
decode := func(t *testing.T, body string) PatchableDashboardV2 {
t.Helper()
var patch PatchableDashboardV2
require.NoError(t, json.Unmarshal([]byte(body), &patch))
return patch
}
// jsonOf marshals the patched dashboard back to JSON so subtests can
// assert on field values without reaching into the typed plugin specs.
jsonOf := func(t *testing.T, out *UpdatableDashboardV2) string {
t.Helper()
raw, err := json.Marshal(out)
require.NoError(t, err)
return string(raw)
}
// ─────────────────────────────────────────────────────────────────
// Successful patches
// ─────────────────────────────────────────────────────────────────
t.Run("no-op preserves all fields", func(t *testing.T) {
out, err := decode(t, `[]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.DashboardV2MetadataBase, out.DashboardV2MetadataBase)
assert.Equal(t, tagtypes.NewPostableTagsFromTags(base.Tags), out.Tags)
assert.Equal(t, base.Spec.Display.Name, out.Spec.Display.Name)
require.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels))
for k, panel := range base.Spec.Panels {
require.Contains(t, out.Spec.Panels, k)
assert.Equal(t, panel.Spec.Plugin.Kind, out.Spec.Panels[k].Spec.Plugin.Kind)
}
assert.Len(t, out.Tags, len(base.Tags))
assert.Len(t, out.Spec.Variables, len(base.Spec.Variables))
assert.Len(t, out.Spec.Layouts, len(base.Spec.Layouts))
})
t.Run("add metadata image", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/image", "value": "https://example.com/img.png"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", out.Image)
assert.Equal(t, SchemaVersion, out.SchemaVersion, "schemaVersion preserved")
})
t.Run("replace display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": "Renamed"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Renamed", out.Spec.Display.Name)
})
// Per RFC 6902 § 4.1, `add` on an existing object member replaces the
// existing value rather than erroring — same effect as `replace`.
t.Run("add overwrites existing display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/spec/display/name", "value": "Overwritten"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Overwritten", out.Spec.Display.Name)
})
t.Run("add data refreshInterval", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/spec/refreshInterval", "value": "30s"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "30s", string(out.Spec.RefreshInterval))
})
t.Run("add panel leaves others untouched", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 3)
assert.Contains(t, out.Spec.Panels, "p3")
// Plugin specs round-trip through MarshalJSON which resolves defaults
// (e.g. timePreference → "global_time"), so compare the serialized
// shape rather than the in-memory structs to skip that normalization.
for _, id := range []string{"p1", "p2"} {
wantJSON, err := json.Marshal(base.Spec.Panels[id])
require.NoError(t, err)
gotJSON, err := json.Marshal(out.Spec.Panels[id])
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(gotJSON), "panel %s untouched", id)
}
})
t.Run("replace single panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, PanelPluginKind("signoz/BarChartPanel"), out.Spec.Panels["p2"].Spec.Plugin.Kind)
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Spec.Panels["p1"].Spec.Plugin.Kind, "p1 untouched")
})
// Removing a panel realistically also drops its layout item — exercise
// the multi-op shape the UI sends.
t.Run("remove panel and its layout item", func(t *testing.T) {
out, err := decode(t, `[
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 1)
assert.Contains(t, out.Spec.Panels, "p1")
assert.NotContains(t, out.Spec.Panels, "p2")
raw := jsonOf(t, out)
assert.NotContains(t, raw, `"$ref":"#/spec/panels/p2"`)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p1"`)
})
// The headline use case: edit a single field of a single query inside
// one panel without re-sending any other part of the dashboard.
t.Run("rename single query inside panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0/spec/plugin/spec/name",
"value": "renamed"
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
assert.Contains(t, jsonOf(t, out), `"name":"renamed"`)
})
// Replace a query at a specific index — swaps query "A" out for "B"
// without re-sending the rest of the panel.
t.Run("replace query at index", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0",
"value": {
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_db_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"name":"B"`)
assert.NotContains(t, raw, `"name":"A"`)
})
// ─────────────────────────────────────────────────────────────────
// Layout edits
// ─────────────────────────────────────────────────────────────────
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 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) {
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":12`)
})
t.Run("rename layout row title", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/display/title", "value": "Latency"}]`).Apply(base)
require.NoError(t, err)
assert.Contains(t, jsonOf(t, out), `"title":"Latency"`)
})
t.Run("append layout item", func(t *testing.T) {
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)
assert.Equal(t, 3, strings.Count(raw, `"$ref":"#/spec/panels/`))
})
// Composing add-panel + add-layout-item is the realistic shape of the
// "add a new chart to my dashboard" UI flow — exercise it end-to-end.
t.Run("add panel and corresponding layout item", func(t *testing.T) {
out, err := decode(t, `[
{
"op": "add",
"path": "/spec/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"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)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 3)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
})
t.Run("append tag", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 3)
assert.Equal(t, "env", out.Tags[2].Key)
assert.Equal(t, "staging", out.Tags[2].Value)
})
t.Run("append tag when none exist", func(t *testing.T) {
noTagsBase := &DashboardV2{
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
Name: base.Name,
Tags: nil,
Spec: base.Spec,
}
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "team", "value": "new"}}]`).Apply(noTagsBase)
require.NoError(t, err)
require.Len(t, out.Tags, 1)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "new", out.Tags[0].Value)
})
t.Run("replace tag value", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/value", "value": "beta"}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 2)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "beta", out.Tags[0].Value)
assert.Equal(t, "env", out.Tags[1].Key, "tag at index 1 untouched")
assert.Equal(t, "prod", out.Tags[1].Value, "tag at index 1 untouched")
for _, tag := range out.Tags {
assert.NotEqual(t, "alpha", tag.Value, "old tag value must be gone")
}
})
t.Run("multiple ops applied in order", func(t *testing.T) {
out, err := decode(t, `[
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Multi-step", out.Spec.Display.Name)
assert.Len(t, out.Spec.Panels, 1)
assert.Len(t, out.Tags, 3)
})
// `test` is an RFC 6902 precondition op: aborts the patch if the value
// at the path doesn't equal the supplied value. Used for optimistic
// concurrency. Here it matches, so the subsequent ops apply.
t.Run("test op passes", func(t *testing.T) {
out, err := decode(t, `[
{"op": "test", "path": "/spec/display/name", "value": "Service overview"},
{"op": "replace", "path": "/spec/display/name", "value": "Confirmed"}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Confirmed", out.Spec.Display.Name)
})
// ─────────────────────────────────────────────────────────────────
// Failure cases
// ─────────────────────────────────────────────────────────────────
t.Run("decode rejects non-array body", func(t *testing.T) {
var patch PatchableDashboardV2
err := json.Unmarshal([]byte(`{"op": "replace"}`), &patch)
require.Error(t, err)
})
t.Run("decode rejects malformed JSON", func(t *testing.T) {
var patch PatchableDashboardV2
// Outer json.Unmarshal rejects non-JSON before PatchableDashboardV2's
// UnmarshalJSON runs, so the error is a stdlib SyntaxError rather
// than the InvalidInput-classified wrap.
err := json.Unmarshal([]byte(`not json`), &patch)
require.Error(t, err)
})
// `test` precondition fails — the whole patch is rejected, including
// the subsequent replace.
t.Run("test op failure rejected", func(t *testing.T) {
_, err := decode(t, `[
{"op": "test", "path": "/spec/display/name", "value": "Wrong"},
{"op": "replace", "path": "/spec/display/name", "value": "Should not apply"}
]`).Apply(base)
require.Error(t, err)
})
// Lenient apply (AllowMissingPathOnRemove): removing a path that doesn't
// exist is a no-op rather than an error, so removes are idempotent.
t.Run("remove at missing path is a no-op", func(t *testing.T) {
out, err := decode(t, `[{"op": "remove", "path": "/spec/panels/does-not-exist"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels), "existing panels untouched")
})
t.Run("remove schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "remove", "path": "/schemaVersion"}]`).Apply(base)
require.Error(t, err)
})
t.Run("wrong schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "replace", "path": "/schemaVersion", "value": "v5"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), SchemaVersion)
})
t.Run("empty display name defaults to dashboard name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": ""}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.Name, out.Spec.Display.Name, "empty display.name should default from name")
})
t.Run("unknown top-level field rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "add", "path": "/bogus", "value": 42}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "bogus")
})
t.Run("invalid panel kind rejected", func(t *testing.T) {
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "NotAPanel")
})
t.Run("query kind incompatible with panel rejected", func(t *testing.T) {
// PromQLQuery is not allowed on ListPanel — verify the cross-check
// in Validate still runs after a patch.
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
}]`).Apply(base)
require.Error(t, err)
})
t.Run("removing the only query rejected", func(t *testing.T) {
// Validate requires exactly one query per panel — leaving zero is rejected.
_, err := decode(t, `[{"op": "remove", "path": "/spec/panels/p2/spec/queries/0"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("two direct queries rejected", func(t *testing.T) {
// Validate requires exactly one query per panel. To display multiple
// data sources in one panel, wrap them in a CompositeQuery (see the
// "replace query with composite" subtest below).
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "metrics",
"aggregations": [{"metricName": "signoz_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}},
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B", "signal": "metrics",
"aggregations": [{"metricName": "signoz_db_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}}
]
}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("too many tags rejected", func(t *testing.T) {
// Base already has 2 tags; add 9 more to exceed MaxTagsPerDashboard (10).
_, err := decode(t, `[
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "1"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "2"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "3"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "4"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "5"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "6"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "7"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "8"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "9"}}
]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "at most")
})
}

View File

@@ -12,13 +12,6 @@ import (
"github.com/swaggest/jsonschema-go"
)
const (
// minEpochMs and maxEpochMs bound a plausible ms timestamp to
// 1990-01-01 .. 2100-01-01, used to reject malformed Start/End values.
minEpochMs uint64 = 631_152_000_000
maxEpochMs uint64 = 4_102_444_800_000
)
type QueryEnvelope struct {
// Type is the type of the query.
Type QueryType `json:"type"` // "builder_query" | "builder_formula" | "builder_sub_query" | "builder_join" | "promql" | "clickhouse_sql"
@@ -556,23 +549,7 @@ func (r *QueryRangeRequest) SkipFillGaps(name string) bool {
return false
}
// Normalize coerces Start and End to epoch milliseconds, inferring the source
// resolution (s/ms/µs/ns) from each value's magnitude, and rejects non-zero
// values outside the plausible 1990-2100 range. Lets downstream consumers
// assume ms regardless of what the caller sent.
func (r *QueryRangeRequest) Normalize() error {
start, err := toMilliSecs(r.Start)
if err != nil {
return err
}
end, err := toMilliSecs(r.End)
if err != nil {
return err
}
r.Start, r.End = start, end
return nil
}
// UnmarshalJSON implements custom JSON unmarshaling to disallow unknown fields.
func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Define a type alias to avoid infinite recursion
type Alias QueryRangeRequest
@@ -632,11 +609,6 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Copy the decoded values back to the original struct
*r = QueryRangeRequest(temp)
// Coerce Start/End to ms (and validate) at decode time for HTTP requests.
if err := r.Normalize(); err != nil {
return err
}
return nil
}
@@ -690,24 +662,3 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
return canDefaultZero
}
// toMilliSecs scales an epoch to milliseconds based on its magnitude: seconds are
// scaled up, micro/nanoseconds down, milliseconds left as-is. Zero is returned
// unchanged. A non-zero result outside 1990-2100 is rejected as malformed.
func toMilliSecs(epoch uint64) (uint64, error) {
var ms uint64
switch {
case epoch < 1e12: // seconds
ms = epoch * 1_000
case epoch < 1e15: // milliseconds
ms = epoch
case epoch < 1e18: // microseconds
ms = epoch / 1_000
default: // nanoseconds
ms = epoch / 1_000_000
}
if epoch != 0 && (ms < minEpochMs || ms > maxEpochMs) {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "timestamp %d is outside the supported range (1990-2100)", epoch)
}
return ms, nil
}

View File

@@ -1903,70 +1903,3 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestQueryRangeRequest_Normalize(t *testing.T) {
tests := []struct {
name string
start uint64
end uint64
wantStart uint64
wantEnd uint64
wantErr bool
}{
{
name: "seconds are scaled up to ms",
start: 1672531200, // 2023-01-01 in seconds
end: 1716638400, // 2024-05-25 in seconds
wantStart: 1672531200000, // * 10^3
wantEnd: 1716638400000,
},
{
name: "milliseconds pass through unchanged",
start: 1672531200000,
end: 1716638400000,
wantStart: 1672531200000,
wantEnd: 1716638400000,
},
{
name: "microseconds are scaled down to ms",
start: 1672531200000000, // µs
end: 1716638400000000,
wantStart: 1672531200000, // / 10^3
wantEnd: 1716638400000,
},
{
name: "nanoseconds are scaled down to ms",
start: 1672531200000000000, // ns
end: 1716638400000000000,
wantStart: 1672531200000, // / 10^6
wantEnd: 1716638400000,
},
{
name: "zero end (open-ended stream) is left untouched",
start: 1672531200000,
end: 0,
wantStart: 1672531200000,
wantEnd: 0,
},
{
name: "out-of-range timestamp is rejected",
start: 5_000_000_000_000, // ~year 2128 in ms, beyond the 2100 bound
end: 5_000_000_000_000,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &QueryRangeRequest{Start: tt.start, End: tt.end}
err := r.Normalize()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantStart, r.Start)
assert.Equal(t, tt.wantEnd, r.End)
})
}
}

View File

@@ -86,49 +86,6 @@ def test_list_services_with_account(
assert svc["enabled"] is False, f"Service {svc['id']} should be disabled before any config is set"
EC2_SERVICE_ID = "ec2"
def test_list_account_services(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""ListAccountServicesMetadata reflects enabled state after enabling a service."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{EC2_SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable ec2 failed: {put_response.status_code}: {put_response.text}"
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert list_response.status_code == HTTPStatus.OK, f"Expected 200, got {list_response.status_code}"
data = list_response.json()["data"]
assert "services" in data, "Response should contain 'services' field"
assert isinstance(data["services"], list), "services should be a list"
assert len(data["services"]) > 0, "services list should be non-empty"
ec2_service = next((s for s in data["services"] if s["id"] == EC2_SERVICE_ID), None)
assert ec2_service is not None, f"EC2 service '{EC2_SERVICE_ID}' not found in services list"
assert ec2_service["enabled"] is True, f"EC2 service should be enabled, got: {ec2_service['enabled']}"
def test_get_service_details_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument