mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 09:30:31 +01:00
Compare commits
11 Commits
issue-5340
...
trace-deta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e84ccdf91 | ||
|
|
0a5f3c3b39 | ||
|
|
38d6f35e72 | ||
|
|
db1ed748a7 | ||
|
|
9c92335548 | ||
|
|
a609a4044c | ||
|
|
f78d98ea71 | ||
|
|
f60e5039be | ||
|
|
a483ef81a4 | ||
|
|
b9c107a851 | ||
|
|
5f6cc4c297 |
17
.github/workflows/goci.yaml
vendored
17
.github/workflows/goci.yaml
vendored
@@ -140,3 +140,20 @@ jobs:
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)
|
||||
transaction-groups:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in transaction groups schema. Run go run cmd/enterprise/*.go generate config transaction-groups locally and commit."; exit 1)
|
||||
|
||||
@@ -6,12 +6,15 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
const webSettingsSchemaPath = "docs/config/web-settings.json"
|
||||
const webSettingsSchemaPath = "frontend/src/schemas/generated/webSettings.schema.json"
|
||||
|
||||
const transactionGroupsSchemaPath = "frontend/src/schemas/generated/transactionGroups.schema.json"
|
||||
|
||||
func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
configCmd := &cobra.Command{
|
||||
@@ -27,6 +30,14 @@ func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
},
|
||||
})
|
||||
|
||||
configCmd.AddCommand(&cobra.Command{
|
||||
Use: "transaction-groups",
|
||||
Short: "Generate JSON Schema for transaction groups",
|
||||
RunE: func(currCmd *cobra.Command, args []string) error {
|
||||
return generateTransactionGroups()
|
||||
},
|
||||
})
|
||||
|
||||
parentCmd.AddCommand(configCmd)
|
||||
}
|
||||
|
||||
@@ -52,6 +63,7 @@ func generateWebSettings() error {
|
||||
return err
|
||||
}
|
||||
|
||||
schema.WithTitle("WebSettings")
|
||||
data, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -59,3 +71,31 @@ func generateWebSettings() error {
|
||||
|
||||
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func generateTransactionGroups() error {
|
||||
falseVal := false
|
||||
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
|
||||
|
||||
reflector := jsonschema.Reflector{}
|
||||
reflector.DefaultOptions = append(reflector.DefaultOptions,
|
||||
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
|
||||
if params.Value.Kind() == reflect.Struct {
|
||||
params.Schema.AdditionalProperties = &noAdditional
|
||||
}
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
|
||||
schema, err := reflector.Reflect(authtypes.TransactionGroups{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schema.WithTitle("TransactionGroups")
|
||||
data, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(transactionGroupsSchemaPath, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
@@ -651,8 +651,6 @@ components:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesPostableRotateToken:
|
||||
properties:
|
||||
@@ -2407,6 +2405,46 @@ components:
|
||||
to_user:
|
||||
type: string
|
||||
type: object
|
||||
CoretypesKind:
|
||||
enum:
|
||||
- anonymous
|
||||
- organization
|
||||
- role
|
||||
- serviceaccount
|
||||
- user
|
||||
- notification-channel
|
||||
- route-policy
|
||||
- apdex-setting
|
||||
- auth-domain
|
||||
- session
|
||||
- cloud-integration
|
||||
- cloud-integration-service
|
||||
- integration
|
||||
- dashboard
|
||||
- public-dashboard
|
||||
- ingestion-key
|
||||
- ingestion-limit
|
||||
- pipeline
|
||||
- user-preference
|
||||
- org-preference
|
||||
- quick-filter
|
||||
- ttl-setting
|
||||
- rule
|
||||
- planned-maintenance
|
||||
- saved-view
|
||||
- trace-funnel
|
||||
- factor-password
|
||||
- factor-api-key
|
||||
- license
|
||||
- subscription
|
||||
- logs
|
||||
- traces
|
||||
- metrics
|
||||
- audit-logs
|
||||
- meter-metrics
|
||||
- logs-field
|
||||
- traces-field
|
||||
type: string
|
||||
CoretypesObject:
|
||||
properties:
|
||||
resource:
|
||||
@@ -2448,7 +2486,7 @@ components:
|
||||
CoretypesResourceRef:
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
$ref: '#/components/schemas/CoretypesKind'
|
||||
type:
|
||||
$ref: '#/components/schemas/CoretypesType'
|
||||
required:
|
||||
@@ -15662,16 +15700,20 @@ paths:
|
||||
summary: List metric names
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/alerts:
|
||||
/api/v2/metrics/alerts:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns associated alerts for a specified metric
|
||||
operationId: GetMetricAlerts
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
@@ -15726,28 +15768,36 @@ paths:
|
||||
summary: Get metric alerts
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/attributes:
|
||||
/api/v2/metrics/attributes:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns attribute keys and their unique values for
|
||||
a specified metric
|
||||
operationId: GetMetricAttributes
|
||||
parameters:
|
||||
- in: query
|
||||
name: start
|
||||
schema:
|
||||
nullable: true
|
||||
type: integer
|
||||
- in: query
|
||||
name: end
|
||||
schema:
|
||||
nullable: true
|
||||
type: integer
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
- description: Start of the time range as a Unix timestamp in milliseconds.
|
||||
in: query
|
||||
name: start
|
||||
schema:
|
||||
description: Start of the time range as a Unix timestamp in milliseconds.
|
||||
nullable: true
|
||||
type: integer
|
||||
- description: End of the time range as a Unix timestamp in milliseconds.
|
||||
in: query
|
||||
name: end
|
||||
schema:
|
||||
description: End of the time range as a Unix timestamp in milliseconds.
|
||||
nullable: true
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -15801,16 +15851,20 @@ paths:
|
||||
summary: Get metric attributes
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/dashboards:
|
||||
/api/v2/metrics/dashboards:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns associated dashboards for a specified metric
|
||||
operationId: GetMetricDashboards
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
@@ -15865,17 +15919,21 @@ paths:
|
||||
summary: Get metric dashboards
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/highlights:
|
||||
/api/v2/metrics/highlights:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns highlights like number of datapoints, totaltimeseries,
|
||||
active time series, last received time for a specified metric
|
||||
operationId: GetMetricHighlights
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
@@ -15930,17 +15988,79 @@ paths:
|
||||
summary: Get metric highlights
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/metadata:
|
||||
/api/v2/metrics/inspect:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns raw time series data points for a metric within a time
|
||||
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
|
||||
operationId: InspectMetrics
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Inspect raw metric data points
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/metadata:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns metadata information like metric description,
|
||||
unit, type, temporality, monotonicity for a specified metric
|
||||
operationId: GetMetricMetadata
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
@@ -16000,12 +16120,6 @@ paths:
|
||||
description: This endpoint helps to update metadata information like metric
|
||||
description, unit, type, temporality, monotonicity for a specified metric
|
||||
operationId: UpdateMetricMetadata
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -16046,64 +16160,6 @@ paths:
|
||||
summary: Update metric metadata
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/inspect:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns raw time series data points for a metric within a time
|
||||
range (max 30 minutes). Each series includes labels and timestamp/value pairs.
|
||||
operationId: InspectMetrics
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesInspectMetricsResponse'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Inspect raw metric data points
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/onboarding:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"test": "jest",
|
||||
"test:changedsince": "jest --changedSince=main --coverage --silent",
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
|
||||
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
|
||||
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
|
||||
@@ -19,16 +19,15 @@ import type {
|
||||
|
||||
import type {
|
||||
GetMetricAlerts200,
|
||||
GetMetricAlertsPathParameters,
|
||||
GetMetricAlertsParams,
|
||||
GetMetricAttributes200,
|
||||
GetMetricAttributesParams,
|
||||
GetMetricAttributesPathParameters,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsPathParameters,
|
||||
GetMetricDashboardsParams,
|
||||
GetMetricHighlights200,
|
||||
GetMetricHighlightsPathParameters,
|
||||
GetMetricHighlightsParams,
|
||||
GetMetricMetadata200,
|
||||
GetMetricMetadataPathParameters,
|
||||
GetMetricMetadataParams,
|
||||
GetMetricsOnboardingStatus200,
|
||||
GetMetricsStats200,
|
||||
GetMetricsTreemap200,
|
||||
@@ -40,7 +39,6 @@ import type {
|
||||
MetricsexplorertypesTreemapRequestDTO,
|
||||
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateMetricMetadataPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -146,27 +144,26 @@ export const invalidateListMetrics = async (
|
||||
* @summary Get metric alerts
|
||||
*/
|
||||
export const getMetricAlerts = (
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
params: GetMetricAlertsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricAlerts200>({
|
||||
url: `/api/v2/metrics/${metricName}/alerts`,
|
||||
url: `/api/v2/metrics/alerts`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricAlertsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricAlertsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/alerts`] as const;
|
||||
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
|
||||
return [`/api/v2/metrics/alerts`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricAlertsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
params: GetMetricAlertsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
@@ -177,19 +174,13 @@ export const getGetMetricAlertsQueryOptions = <
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
|
||||
signal,
|
||||
}) => getMetricAlerts({ metricName }, signal);
|
||||
}) => getMetricAlerts(params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -209,7 +200,7 @@ export function useGetMetricAlerts<
|
||||
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
params: GetMetricAlertsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
@@ -218,7 +209,7 @@ export function useGetMetricAlerts<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
|
||||
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -232,11 +223,11 @@ export function useGetMetricAlerts<
|
||||
*/
|
||||
export const invalidateGetMetricAlerts = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
params: GetMetricAlertsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricAlertsQueryKey({ metricName }) },
|
||||
{ queryKey: getGetMetricAlertsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -248,12 +239,11 @@ export const invalidateGetMetricAlerts = async (
|
||||
* @summary Get metric attributes
|
||||
*/
|
||||
export const getMetricAttributes = (
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
params: GetMetricAttributesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricAttributes200>({
|
||||
url: `/api/v2/metrics/${metricName}/attributes`,
|
||||
url: `/api/v2/metrics/attributes`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
@@ -261,21 +251,16 @@ export const getMetricAttributes = (
|
||||
};
|
||||
|
||||
export const getGetMetricAttributesQueryKey = (
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/metrics/${metricName}/attributes`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
return [`/api/v2/metrics/attributes`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricAttributesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
params: GetMetricAttributesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
@@ -287,19 +272,13 @@ export const getGetMetricAttributesQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetMetricAttributesQueryKey({ metricName }, params);
|
||||
queryOptions?.queryKey ?? getGetMetricAttributesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>
|
||||
> = ({ signal }) => getMetricAttributes({ metricName }, params, signal);
|
||||
> = ({ signal }) => getMetricAttributes(params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -319,8 +298,7 @@ export function useGetMetricAttributes<
|
||||
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
params: GetMetricAttributesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAttributes>>,
|
||||
@@ -329,11 +307,7 @@ export function useGetMetricAttributes<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricAttributesQueryOptions(
|
||||
{ metricName },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
const queryOptions = getGetMetricAttributesQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -347,12 +321,11 @@ export function useGetMetricAttributes<
|
||||
*/
|
||||
export const invalidateGetMetricAttributes = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricAttributesPathParameters,
|
||||
params?: GetMetricAttributesParams,
|
||||
params: GetMetricAttributesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricAttributesQueryKey({ metricName }, params) },
|
||||
{ queryKey: getGetMetricAttributesQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -364,27 +337,28 @@ export const invalidateGetMetricAttributes = async (
|
||||
* @summary Get metric dashboards
|
||||
*/
|
||||
export const getMetricDashboards = (
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
params: GetMetricDashboardsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricDashboards200>({
|
||||
url: `/api/v2/metrics/${metricName}/dashboards`,
|
||||
url: `/api/v2/metrics/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricDashboardsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/dashboards`] as const;
|
||||
export const getGetMetricDashboardsQueryKey = (
|
||||
params?: GetMetricDashboardsParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/dashboards`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
@@ -396,18 +370,13 @@ export const getGetMetricDashboardsQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>
|
||||
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
|
||||
> = ({ signal }) => getMetricDashboards(params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -427,7 +396,7 @@ export function useGetMetricDashboards<
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
@@ -436,10 +405,7 @@ export function useGetMetricDashboards<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -453,11 +419,11 @@ export function useGetMetricDashboards<
|
||||
*/
|
||||
export const invalidateGetMetricDashboards = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
params: GetMetricDashboardsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
|
||||
{ queryKey: getGetMetricDashboardsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -469,27 +435,28 @@ export const invalidateGetMetricDashboards = async (
|
||||
* @summary Get metric highlights
|
||||
*/
|
||||
export const getMetricHighlights = (
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
params: GetMetricHighlightsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricHighlights200>({
|
||||
url: `/api/v2/metrics/${metricName}/highlights`,
|
||||
url: `/api/v2/metrics/highlights`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricHighlightsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricHighlightsPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/highlights`] as const;
|
||||
export const getGetMetricHighlightsQueryKey = (
|
||||
params?: GetMetricHighlightsParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/highlights`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricHighlightsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
params: GetMetricHighlightsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
@@ -501,18 +468,13 @@ export const getGetMetricHighlightsQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
|
||||
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>
|
||||
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
|
||||
> = ({ signal }) => getMetricHighlights(params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -532,7 +494,7 @@ export function useGetMetricHighlights<
|
||||
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
params: GetMetricHighlightsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
@@ -541,10 +503,7 @@ export function useGetMetricHighlights<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricHighlightsQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -558,219 +517,17 @@ export function useGetMetricHighlights<
|
||||
*/
|
||||
export const invalidateGetMetricHighlights = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
params: GetMetricHighlightsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricHighlightsQueryKey({ metricName }) },
|
||||
{ queryKey: getGetMetricHighlightsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
export const getMetricMetadata = (
|
||||
{ metricName }: GetMetricMetadataPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricMetadata200>({
|
||||
url: `/api/v2/metrics/${metricName}/metadata`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricMetadataQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricMetadataPathParameters) => {
|
||||
return [`/api/v2/metrics/${metricName}/metadata`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricMetadataQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>
|
||||
> = ({ signal }) => getMetricMetadata({ metricName }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricMetadataQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>
|
||||
>;
|
||||
export type GetMetricMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
|
||||
export function useGetMetricMetadata<
|
||||
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ metricName }: GetMetricMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricMetadataQueryOptions({ metricName }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
export const invalidateGetMetricMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ metricName }: GetMetricMetadataPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricMetadataQueryKey({ metricName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint helps to update metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
|
||||
* @summary Update metric metadata
|
||||
*/
|
||||
export const updateMetricMetadata = (
|
||||
{ metricName }: UpdateMetricMetadataPathParameters,
|
||||
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metrics/${metricName}/metadata`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMetricMetadataMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMetricMetadata'];
|
||||
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 updateMetricMetadata>>,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateMetricMetadata(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMetricMetadataMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>
|
||||
>;
|
||||
export type UpdateMetricMetadataMutationBody =
|
||||
| BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>
|
||||
| undefined;
|
||||
export type UpdateMetricMetadataMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update metric metadata
|
||||
*/
|
||||
export const useUpdateMetricMetadata = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricMetadataPathParameters;
|
||||
data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateMetricMetadataMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns raw time series data points for a metric within a time range (max 30 minutes). Each series includes labels and timestamp/value pairs.
|
||||
* @summary Inspect raw metric data points
|
||||
@@ -854,6 +611,188 @@ export const useInspectMetrics = <
|
||||
> => {
|
||||
return useMutation(getInspectMetricsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
export const getMetricMetadata = (
|
||||
params: GetMetricMetadataParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricMetadata200>({
|
||||
url: `/api/v2/metrics/metadata`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricMetadataQueryKey = (
|
||||
params?: GetMetricMetadataParams,
|
||||
) => {
|
||||
return [`/api/v2/metrics/metadata`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricMetadataQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>
|
||||
> = ({ signal }) => getMetricMetadata(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricMetadataQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>
|
||||
>;
|
||||
export type GetMetricMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
|
||||
export function useGetMetricMetadata<
|
||||
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricMetadataQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get metric metadata
|
||||
*/
|
||||
export const invalidateGetMetricMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricMetadataParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricMetadataQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint helps to update metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
|
||||
* @summary Update metric metadata
|
||||
*/
|
||||
export const updateMetricMetadata = (
|
||||
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metrics/metadata`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMetricMetadataMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMetricMetadata'];
|
||||
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 updateMetricMetadata>>,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateMetricMetadata(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMetricMetadataMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>
|
||||
>;
|
||||
export type UpdateMetricMetadataMutationBody =
|
||||
| BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>
|
||||
| undefined;
|
||||
export type UpdateMetricMetadataMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update metric metadata
|
||||
*/
|
||||
export const useUpdateMetricMetadata = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMetricMetadata>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateMetricMetadataMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Lightweight endpoint that checks if any non-SigNoz metrics have been ingested, used for onboarding status detection
|
||||
* @summary Check if non-SigNoz metrics have been received
|
||||
|
||||
@@ -2094,6 +2094,45 @@ export interface AuthtypesGettableTokenDTO {
|
||||
tokenType?: string;
|
||||
}
|
||||
|
||||
export enum CoretypesKindDTO {
|
||||
anonymous = 'anonymous',
|
||||
organization = 'organization',
|
||||
role = 'role',
|
||||
serviceaccount = 'serviceaccount',
|
||||
user = 'user',
|
||||
'notification-channel' = 'notification-channel',
|
||||
'route-policy' = 'route-policy',
|
||||
'apdex-setting' = 'apdex-setting',
|
||||
'auth-domain' = 'auth-domain',
|
||||
session = 'session',
|
||||
'cloud-integration' = 'cloud-integration',
|
||||
'cloud-integration-service' = 'cloud-integration-service',
|
||||
integration = 'integration',
|
||||
dashboard = 'dashboard',
|
||||
'public-dashboard' = 'public-dashboard',
|
||||
'ingestion-key' = 'ingestion-key',
|
||||
'ingestion-limit' = 'ingestion-limit',
|
||||
pipeline = 'pipeline',
|
||||
'user-preference' = 'user-preference',
|
||||
'org-preference' = 'org-preference',
|
||||
'quick-filter' = 'quick-filter',
|
||||
'ttl-setting' = 'ttl-setting',
|
||||
rule = 'rule',
|
||||
'planned-maintenance' = 'planned-maintenance',
|
||||
'saved-view' = 'saved-view',
|
||||
'trace-funnel' = 'trace-funnel',
|
||||
'factor-password' = 'factor-password',
|
||||
'factor-api-key' = 'factor-api-key',
|
||||
license = 'license',
|
||||
subscription = 'subscription',
|
||||
logs = 'logs',
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
'audit-logs' = 'audit-logs',
|
||||
'meter-metrics' = 'meter-metrics',
|
||||
'logs-field' = 'logs-field',
|
||||
'traces-field' = 'traces-field',
|
||||
}
|
||||
export enum CoretypesTypeDTO {
|
||||
user = 'user',
|
||||
serviceaccount = 'serviceaccount',
|
||||
@@ -2104,10 +2143,7 @@ export enum CoretypesTypeDTO {
|
||||
telemetryresource = 'telemetryresource',
|
||||
}
|
||||
export interface CoretypesResourceRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind: string;
|
||||
kind: CoretypesKindDTO;
|
||||
type: CoretypesTypeDTO;
|
||||
}
|
||||
|
||||
@@ -2243,12 +2279,12 @@ export interface AuthtypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
transactionGroups?: AuthtypesTransactionGroupsDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableRotateTokenDTO {
|
||||
@@ -10334,9 +10370,14 @@ export type ListMetrics200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricAlertsPathParameters = {
|
||||
export type GetMetricAlertsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricAlerts200 = {
|
||||
data: MetricsexplorertypesMetricAlertsResponseDTO;
|
||||
/**
|
||||
@@ -10345,18 +10386,20 @@ export type GetMetricAlerts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricAttributesPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type GetMetricAttributesParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
/**
|
||||
* @type integer,null
|
||||
* @description undefined
|
||||
* @description Start of the time range as a Unix timestamp in milliseconds.
|
||||
*/
|
||||
start?: number | null;
|
||||
/**
|
||||
* @type integer,null
|
||||
* @description undefined
|
||||
* @description End of the time range as a Unix timestamp in milliseconds.
|
||||
*/
|
||||
end?: number | null;
|
||||
};
|
||||
@@ -10369,9 +10412,14 @@ export type GetMetricAttributes200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsPathParameters = {
|
||||
export type GetMetricDashboardsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboards200 = {
|
||||
data: MetricsexplorertypesMetricDashboardsResponseDTO;
|
||||
/**
|
||||
@@ -10380,9 +10428,14 @@ export type GetMetricDashboards200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricHighlightsPathParameters = {
|
||||
export type GetMetricHighlightsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricHighlights200 = {
|
||||
data: MetricsexplorertypesMetricHighlightsResponseDTO;
|
||||
/**
|
||||
@@ -10391,22 +10444,24 @@ export type GetMetricHighlights200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricMetadataPathParameters = {
|
||||
metricName: string;
|
||||
};
|
||||
export type GetMetricMetadata200 = {
|
||||
data: MetricsexplorertypesMetricMetadataDTO;
|
||||
export type InspectMetrics200 = {
|
||||
data: MetricsexplorertypesInspectMetricsResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateMetricMetadataPathParameters = {
|
||||
export type GetMetricMetadataParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
export type InspectMetrics200 = {
|
||||
data: MetricsexplorertypesInspectMetricsResponseDTO;
|
||||
|
||||
export type GetMetricMetadata200 = {
|
||||
data: MetricsexplorertypesMetricMetadataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -191,9 +191,6 @@ function TimeSeries({
|
||||
if (metrics[0] && yAxisUnit) {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName: metricNames[0],
|
||||
},
|
||||
data: buildUpdateMetricYAxisUnitPayload(
|
||||
metricNames[0],
|
||||
metrics[0],
|
||||
|
||||
@@ -48,18 +48,14 @@ function AllAttributes({
|
||||
isLoading: isLoadingAttributes,
|
||||
isError: isErrorAttributes,
|
||||
refetch: refetchAttributes,
|
||||
} = useGetMetricAttributes(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
start: minTime ? Math.floor(minTime / 1000000) : undefined,
|
||||
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
|
||||
},
|
||||
);
|
||||
} = useGetMetricAttributes({
|
||||
metricName,
|
||||
start: minTime ? Math.floor(minTime / 1000000) : undefined,
|
||||
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
|
||||
});
|
||||
|
||||
const attributes = useMemo(
|
||||
() => attributesData?.data?.attributes ?? [],
|
||||
() => attributesData?.data.attributes ?? [],
|
||||
[attributesData],
|
||||
);
|
||||
|
||||
|
||||
@@ -237,9 +237,6 @@ function Metadata({
|
||||
const handleSave = useCallback(() => {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
pathParams: {
|
||||
metricName,
|
||||
},
|
||||
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ function MetricDetails({
|
||||
);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse?.data) {
|
||||
if (!metricMetadataResponse) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } =
|
||||
|
||||
@@ -195,14 +195,12 @@ describe('Metadata', () => {
|
||||
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
metricName: MOCK_METRIC_NAME,
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
unit: 'By',
|
||||
isMonotonic: true,
|
||||
}),
|
||||
pathParams: {
|
||||
metricName: MOCK_METRIC_NAME,
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CoretypesKindDTO,
|
||||
CoretypesObjectGroupDTO,
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -56,7 +57,7 @@ const baseAuthzResources: AuthzResources = {
|
||||
|
||||
// API payload resource refs — only kind+type, no allowedVerbs (matches CoretypesResourceRefDTO shape)
|
||||
const dashboardResourceRef = {
|
||||
kind: 'dashboard',
|
||||
kind: 'dashboard' as CoretypesKindDTO,
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
const alertResourceRef = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import type {
|
||||
CoretypesKindDTO,
|
||||
CoretypesObjectGroupDTO,
|
||||
CoretypesResourceRefDTO,
|
||||
CoretypesTypeDTO,
|
||||
@@ -147,7 +148,7 @@ export function buildPatchPayload({
|
||||
continue;
|
||||
}
|
||||
const resourceDef: CoretypesResourceRefDTO = {
|
||||
kind: found.kind,
|
||||
kind: found.kind as CoretypesKindDTO,
|
||||
type: found.type as CoretypesTypeDTO,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from 'pages/TracesFunnels/FunnelContext';
|
||||
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import './AddSpanToFunnelModal.styles.scss';
|
||||
@@ -71,7 +72,9 @@ function FunnelDetailsView({
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
// Dead V2 code (removed in the trace-v2 cleanup sweep); FunnelConfiguration
|
||||
// now takes SpanV3, so bridge the V2 Span here to keep the build green.
|
||||
span={span as unknown as SpanV3}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
|
||||
@@ -2,13 +2,13 @@ import {
|
||||
AuthtypesTransactionDTO,
|
||||
CoretypesTypeDTO,
|
||||
AuthtypesRelationDTO,
|
||||
CoretypesKindDTO,
|
||||
} from '../../api/generated/services/sigNoz.schemas';
|
||||
import permissionsType from './permissions.type';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
ResourceName,
|
||||
ResourcesForRelation,
|
||||
ResourceType,
|
||||
} from './types';
|
||||
@@ -87,7 +87,7 @@ export function permissionToTransactionDto(
|
||||
relation: relation as AuthtypesRelationDTO,
|
||||
object: {
|
||||
resource: {
|
||||
kind: resourceName as ResourceName,
|
||||
kind: resourceName as CoretypesKindDTO,
|
||||
type: type as CoretypesTypeDTO,
|
||||
},
|
||||
selector: selector || '*',
|
||||
|
||||
117
frontend/src/hooks/useInlineOverflowCount.ts
Normal file
117
frontend/src/hooks/useInlineOverflowCount.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface UseInlineOverflowCountOptions {
|
||||
itemCount: number;
|
||||
/** Horizontal gap between items, in px. */
|
||||
gap?: number;
|
||||
/** Width kept free at the end of the line for a trailing "+N" trigger, in px. */
|
||||
reserveWidth?: number;
|
||||
/** Pause measuring (e.g. while expanded) without unmounting. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseInlineOverflowCountResult {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
visibleCount: number;
|
||||
overflowCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how many of a container's children (each marked
|
||||
* `data-overflow-item="true"`) fit on a single line, reserving `reserveWidth`
|
||||
* for a trailing "+N" trigger. Item widths are cached, so children hidden with
|
||||
* `display: none` still count toward the fit; measuring pauses while `enabled`
|
||||
* is false.
|
||||
*/
|
||||
export function useInlineOverflowCount({
|
||||
itemCount,
|
||||
gap = 8,
|
||||
reserveWidth = 0,
|
||||
enabled = true,
|
||||
}: UseInlineOverflowCountOptions): UseInlineOverflowCountResult {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(itemCount);
|
||||
const itemWidthsRef = useRef<number[]>([]);
|
||||
const enabledRef = useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
|
||||
useEffect(() => {
|
||||
itemWidthsRef.current = [];
|
||||
setVisibleCount(itemCount);
|
||||
}, [itemCount]);
|
||||
|
||||
const measure = useCallback((): void => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !enabledRef.current) {
|
||||
return;
|
||||
}
|
||||
const itemElements = Array.from(container.children).filter(
|
||||
(itemElement): itemElement is HTMLElement =>
|
||||
itemElement instanceof HTMLElement &&
|
||||
itemElement.dataset.overflowItem === 'true',
|
||||
);
|
||||
if (itemElements.length === 0) {
|
||||
setVisibleCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
itemElements.forEach((itemElement, index) => {
|
||||
if (itemElement.offsetWidth > 0) {
|
||||
itemWidthsRef.current[index] = itemElement.offsetWidth;
|
||||
}
|
||||
});
|
||||
const cachedWidths: number[] = [];
|
||||
for (let index = 0; index < itemElements.length; index += 1) {
|
||||
const cachedWidth = itemWidthsRef.current[index];
|
||||
if (cachedWidth == null) {
|
||||
// Width not cached yet — reveal everything for one frame so it gets
|
||||
// measured, then the next pass collapses accurately.
|
||||
setVisibleCount(itemElements.length);
|
||||
return;
|
||||
}
|
||||
cachedWidths.push(cachedWidth);
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const totalWidth = cachedWidths.reduce(
|
||||
(runningTotal, itemWidth, index) =>
|
||||
runningTotal + itemWidth + (index > 0 ? gap : 0),
|
||||
0,
|
||||
);
|
||||
if (totalWidth <= containerWidth) {
|
||||
setVisibleCount(itemElements.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableWidth = containerWidth - reserveWidth;
|
||||
let usedWidth = 0;
|
||||
let fitCount = 0;
|
||||
for (let index = 0; index < cachedWidths.length; index += 1) {
|
||||
const itemWidthWithGap = cachedWidths[index] + (index > 0 ? gap : 0);
|
||||
if (usedWidth + itemWidthWithGap > availableWidth && fitCount > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += itemWidthWithGap;
|
||||
fitCount += 1;
|
||||
}
|
||||
setVisibleCount(Math.max(1, fitCount));
|
||||
}, [gap, reserveWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
observer.observe(container);
|
||||
Array.from(container.children).forEach((child) => observer.observe(child));
|
||||
measure();
|
||||
return (): void => observer.disconnect();
|
||||
}, [measure, itemCount, enabled]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
visibleCount,
|
||||
overflowCount: Math.max(0, itemCount - visibleCount),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
.dashboardActionsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboardActionsSecondary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Braces,
|
||||
ClipboardCopy,
|
||||
Configure,
|
||||
Ellipsis,
|
||||
Copy,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
Grid3X3,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
SquareStack,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
@@ -55,14 +65,31 @@ function DashboardActions({
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const { user } = useAppContext();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
|
||||
const [isCloning, setIsCloning] = useState<boolean>(false);
|
||||
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
|
||||
const { addSection, isSaving: isAddingSection } = useAddSection({
|
||||
layouts: dashboard.spec.layouts,
|
||||
});
|
||||
|
||||
const handleCreateSection = useCallback(
|
||||
async (title: string): Promise<void> => {
|
||||
await addSection(title);
|
||||
setIsNewSectionOpen(false);
|
||||
},
|
||||
[addSection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
toast.error(t('something_went_wrong', { ns: 'common' }));
|
||||
@@ -89,6 +116,24 @@ function DashboardActions({
|
||||
URL.revokeObjectURL(url);
|
||||
}, [dashboardDataJSON, title]);
|
||||
|
||||
const handleClone = useCallback(async (): Promise<void> => {
|
||||
if (!dashboard.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsCloning(true);
|
||||
const response = await cloneDashboardV2({ id: dashboard.id });
|
||||
toast.success('Dashboard cloned');
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
}, [dashboard.id, safeNavigate, showErrorModal]);
|
||||
|
||||
const handleConfirmDelete = useCallback((): void => {
|
||||
deleteDashboardMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
@@ -99,17 +144,24 @@ function DashboardActions({
|
||||
}, [deleteDashboardMutation]);
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const editGroup: MenuItem[] = [];
|
||||
const dashboardGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
editGroup.push({
|
||||
dashboardGroup.push({
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
icon: <PenLine size={14} />,
|
||||
onClick: onOpenRename,
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
});
|
||||
if (isAuthor || user.role === USER_ROLES.ADMIN) {
|
||||
editGroup.push({
|
||||
dashboardGroup.push({
|
||||
key: 'lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
icon: <LockKeyhole size={14} />,
|
||||
@@ -117,14 +169,14 @@ function DashboardActions({
|
||||
onClick: onLockToggle,
|
||||
});
|
||||
}
|
||||
editGroup.push({
|
||||
dashboardGroup.push({
|
||||
key: 'fullscreen',
|
||||
label: 'Full screen',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: handle.enter,
|
||||
});
|
||||
|
||||
const exportGroup: MenuItem[] = [
|
||||
const dataGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'export',
|
||||
label: 'Export JSON',
|
||||
@@ -139,7 +191,35 @@ function DashboardActions({
|
||||
},
|
||||
];
|
||||
|
||||
const dangerGroup: MenuItem[] = [
|
||||
const layoutGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
layoutGroup.push({
|
||||
key: 'new-section',
|
||||
label: 'New section',
|
||||
icon: <SquareStack size={14} />,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
});
|
||||
}
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
children: dashboardGroup,
|
||||
},
|
||||
{ type: 'group', key: 'group-data', label: 'Data', children: dataGroup },
|
||||
];
|
||||
if (layoutGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'group',
|
||||
key: 'group-layout',
|
||||
label: 'Layout',
|
||||
children: layoutGroup,
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete dashboard',
|
||||
@@ -147,74 +227,85 @@ function DashboardActions({
|
||||
danger: true,
|
||||
onClick: (): void => setIsDeleteOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
return [editGroup, exportGroup, dangerGroup]
|
||||
.filter((group) => group.length > 0)
|
||||
.flatMap((group, index) =>
|
||||
index > 0 ? [{ type: 'divider' } as MenuItem, ...group] : group,
|
||||
);
|
||||
);
|
||||
return items;
|
||||
}, [
|
||||
isDashboardLocked,
|
||||
canEdit,
|
||||
isCloning,
|
||||
isAuthor,
|
||||
user.role,
|
||||
isDashboardLocked,
|
||||
dashboard.createdBy,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handle.enter,
|
||||
exportJSON,
|
||||
setCopy,
|
||||
dashboardDataJSON,
|
||||
canEdit,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<div className={styles.dashboardActionsSecondary}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Grid3X3 size="md" />}
|
||||
testId="options"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Ellipsis size="md" />}
|
||||
testId="options"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Braces size="md" />}
|
||||
testId="edit-json"
|
||||
onClick={(): void => setIsJsonEditorOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Edit as JSON
|
||||
</Button>
|
||||
{!isDashboardLocked && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
onClose={(): void => setIsJsonEditorOpen(false)}
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
title={`Delete dashboard"?`}
|
||||
@@ -223,6 +314,15 @@ function DashboardActions({
|
||||
onConfirm={handleConfirmDelete}
|
||||
onClose={(): void => setIsDeleteOpen(false)}
|
||||
/>
|
||||
<SectionTitleModal
|
||||
open={isNewSectionOpen}
|
||||
heading="New section"
|
||||
okText="Create section"
|
||||
initialValue=""
|
||||
isSaving={isAddingSection}
|
||||
onClose={(): void => setIsNewSectionOpen(false)}
|
||||
onSubmit={handleCreateSection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
.dashboardInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 40%;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardTitleContainer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboardImage {
|
||||
@@ -21,9 +11,8 @@
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: fit-content;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
@@ -37,6 +26,19 @@
|
||||
cursor: text !important;
|
||||
}
|
||||
|
||||
.descriptionIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
|
||||
.dashboardTitleEditor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -54,8 +56,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Flexes into the remaining space and clips so the ResizeObserver can measure
|
||||
how many tags fit before collapsing the rest into a `+N` badge. */
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
|
||||
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -9,6 +9,7 @@ import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardInfoProps {
|
||||
@@ -45,6 +46,11 @@ function DashboardInfo({
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
|
||||
const { containerRef, visibleCount } = useVisibleTagCount(tags);
|
||||
const needsOverflow = tags.length > visibleCount;
|
||||
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
|
||||
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -56,83 +62,106 @@ function DashboardInfo({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<div className={styles.dashboardTitleContainer}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasTags && (
|
||||
<div className={styles.dashboardTags}>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<Typography.Text color="muted">{description}</Typography.Text>
|
||||
<TooltipSimple title={description}>
|
||||
<SolidInfoCircle
|
||||
className={styles.descriptionIcon}
|
||||
size={14}
|
||||
data-testid="dashboard-description-info"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{hasTags && (
|
||||
<>
|
||||
<span className={styles.divider} />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.dashboardTags}
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<Badge
|
||||
color="warning"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
+{remainingTags.length}
|
||||
</Badge>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BADGE_GAP,
|
||||
estimateBadgeWidth,
|
||||
OVERFLOW_BADGE_WIDTH,
|
||||
} from 'components/Alerts/LabelColumn/utils';
|
||||
|
||||
interface Result {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
visibleCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how many tags fit in the container and returns the visible count,
|
||||
* reserving room for the `+N` overflow badge. Reuses the badge-width estimation
|
||||
* from the alerts LabelColumn so dashboards and alerts overflow identically.
|
||||
*/
|
||||
export function useVisibleTagCount(tags: string[]): Result {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(tags.length);
|
||||
|
||||
const calculateVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
for (const tag of tags) {
|
||||
const badgeWidth = estimateBadgeWidth(tag) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count += 1;
|
||||
}
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[tags],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setVisibleCount(calculateVisible(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
if (container.clientWidth > 0) {
|
||||
setVisibleCount(calculateVisible(container.clientWidth));
|
||||
}
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateVisible]);
|
||||
|
||||
return { containerRef, visibleCount };
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
color: var(--l2-foreground);
|
||||
background-color: var(--l1-background);
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 2px 0px var(--l2-border);
|
||||
box-shadow:
|
||||
0 1px 0 0 var(--l2-border),
|
||||
0 6px 12px -10px var(--l2-border);
|
||||
}
|
||||
|
||||
.dashboardPageToolbarSubContainer {
|
||||
@@ -16,5 +18,22 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbarRow2 {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
clear: both;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
.timeCluster {
|
||||
float: right;
|
||||
margin: 0 0 0 16px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
.root {
|
||||
:global(.ant-drawer-wrapper-body) {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
:global(.ant-drawer-header) {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
:global(.ant-drawer-title) {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-drawer-body) {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.ant-drawer-footer) {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.validation {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.validationValid {
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
|
||||
.validationInvalid {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { KeyboardEvent, useCallback } from 'react';
|
||||
import MEditor from '@monaco-editor/react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { Drawer } from 'antd';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
|
||||
import styles from './JsonEditorDrawer.module.scss';
|
||||
import JsonEditorToolbar from './JsonEditorToolbar';
|
||||
import { useJsonEditor } from './useJsonEditor';
|
||||
|
||||
interface JsonEditorDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function JsonEditorDrawer({
|
||||
dashboard,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
|
||||
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
|
||||
|
||||
const onCopy = useCallback((): void => {
|
||||
copyToClipboard(draft);
|
||||
toast.success('JSON copied to clipboard');
|
||||
}, [copyToClipboard, draft]);
|
||||
|
||||
const onDownload = useCallback((): void => {
|
||||
const blob = new Blob([draft], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${dashboard.name || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [draft, dashboard.name]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void apply();
|
||||
}
|
||||
},
|
||||
[apply],
|
||||
);
|
||||
|
||||
const applyDisabled = !isDirty || !validity.valid || isSaving;
|
||||
const validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Dashboard JSON"
|
||||
placement="right"
|
||||
width={660}
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
rootClassName={styles.root}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="json-editor-cancel"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="json-editor-apply"
|
||||
disabled={applyDisabled}
|
||||
onClick={(): void => void apply()}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<JsonEditorToolbar
|
||||
isDirty={isDirty}
|
||||
onFormat={format}
|
||||
onCopy={onCopy}
|
||||
onDownload={onDownload}
|
||||
onReset={reset}
|
||||
/>
|
||||
<div className={styles.editor}>
|
||||
<MEditor
|
||||
language="json"
|
||||
height="100%"
|
||||
value={draft}
|
||||
onChange={(value): void => setDraft(value ?? '')}
|
||||
options={{
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: 'Space Mono',
|
||||
}}
|
||||
theme="vs-dark"
|
||||
onMount={(editor, monaco): void => {
|
||||
defineJsonEditorTheme(monaco, editor.getContainerDomNode());
|
||||
monaco.editor.setTheme(JSON_EDITOR_THEME);
|
||||
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditorDrawer;
|
||||
@@ -0,0 +1,12 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { AlignLeft, Copy, Download, RotateCcw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './JsonEditorToolbar.module.scss';
|
||||
|
||||
interface JsonEditorToolbarProps {
|
||||
isDirty: boolean;
|
||||
onFormat: () => void;
|
||||
onCopy: () => void;
|
||||
onDownload: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function JsonEditorToolbar({
|
||||
isDirty,
|
||||
onFormat,
|
||||
onCopy,
|
||||
onDownload,
|
||||
onReset,
|
||||
}: JsonEditorToolbarProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<AlignLeft size={14} />}
|
||||
testId="json-editor-format"
|
||||
onClick={onFormat}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Copy size={14} />}
|
||||
testId="json-editor-copy"
|
||||
onClick={onCopy}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Download size={14} />}
|
||||
testId="json-editor-download"
|
||||
onClick={onDownload}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<RotateCcw size={14} />}
|
||||
testId="json-editor-reset"
|
||||
disabled={!isDirty}
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditorToolbar;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer';
|
||||
import { useJsonEditor } from '../useJsonEditor';
|
||||
|
||||
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next?: string) => void;
|
||||
}): JSX.Element => (
|
||||
<textarea
|
||||
aria-label="json-editor"
|
||||
data-testid="monaco"
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [{}, jest.fn()],
|
||||
}));
|
||||
|
||||
const mockUseJsonEditor = useJsonEditor as jest.Mock;
|
||||
|
||||
const dashboard = {
|
||||
id: 'dash-1',
|
||||
name: 'My dashboard',
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
function hookValue(
|
||||
overrides: Partial<ReturnType<typeof useJsonEditor>> = {},
|
||||
): ReturnType<typeof useJsonEditor> {
|
||||
return {
|
||||
draft: '{\n "a": 1\n}',
|
||||
setDraft: jest.fn(),
|
||||
validity: { valid: true, lineCount: 3 },
|
||||
isDirty: true,
|
||||
isSaving: false,
|
||||
format: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
apply: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('JsonEditorDrawer', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the toolbar, editor and footer actions when open', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue());
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('json-editor-format')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('json-editor-copy')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('json-editor-download')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('json-editor-reset')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('json-editor-apply')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('monaco')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a valid status with the line count', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ validity: { valid: true, lineCount: 12 } }),
|
||||
);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
|
||||
'Valid JSON · 12 lines',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the error line and message when invalid', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({
|
||||
validity: {
|
||||
valid: false,
|
||||
lineCount: 4,
|
||||
errorLine: 3,
|
||||
message: 'Unexpected token',
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
|
||||
'Line 3 · Unexpected token',
|
||||
);
|
||||
});
|
||||
|
||||
it('disables Apply when not dirty, invalid, or saving', () => {
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ isDirty: false }));
|
||||
const { rerender } = render(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
|
||||
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ validity: { valid: false, lineCount: 1 } }),
|
||||
);
|
||||
rerender(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
|
||||
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ isSaving: true }));
|
||||
rerender(
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('wires toolbar and footer buttons to the hook callbacks', () => {
|
||||
const value = hookValue();
|
||||
mockUseJsonEditor.mockReturnValue(value);
|
||||
const onClose = jest.fn();
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={onClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('json-editor-format'));
|
||||
expect(value.format).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('json-editor-reset'));
|
||||
expect(value.reset).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('json-editor-apply'));
|
||||
expect(value.apply).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('json-editor-cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards editor changes to setDraft', () => {
|
||||
const value = hookValue();
|
||||
mockUseJsonEditor.mockReturnValue(value);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('monaco'), {
|
||||
target: { value: '{"b":2}' },
|
||||
});
|
||||
expect(value.setDraft).toHaveBeenCalledWith('{"b":2}');
|
||||
});
|
||||
|
||||
it('applies on Cmd/Ctrl+Enter', () => {
|
||||
const value = hookValue();
|
||||
mockUseJsonEditor.mockReturnValue(value);
|
||||
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('monaco'), {
|
||||
key: 'Enter',
|
||||
metaKey: true,
|
||||
});
|
||||
expect(value.apply).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { useJsonEditor } from '../useJsonEditor';
|
||||
|
||||
const mockRefetch = jest.fn();
|
||||
const mockShowErrorModal = jest.fn();
|
||||
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1', refetch: mockRefetch }),
|
||||
}));
|
||||
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): { showErrorModal: jest.Mock } => ({
|
||||
showErrorModal: mockShowErrorModal,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
updateDashboardV2: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockUpdate = updateDashboardV2 as jest.Mock;
|
||||
const mockToastSuccess = toast.success as jest.Mock;
|
||||
|
||||
const dashboard = {
|
||||
id: 'dash-1',
|
||||
name: 'My dashboard',
|
||||
schemaVersion: 'v6',
|
||||
image: 'icon.png',
|
||||
tags: [{ key: 'env', value: 'prod' }],
|
||||
spec: {
|
||||
display: { name: 'My dashboard' },
|
||||
panels: {},
|
||||
layouts: [],
|
||||
variables: [],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
const serialized = JSON.stringify(dashboard, null, 2);
|
||||
|
||||
describe('useJsonEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUpdate.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('seeds the draft from the dashboard and reports valid, non-dirty state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
expect(result.current.validity.valid).toBe(true);
|
||||
expect(result.current.validity.lineCount).toBe(serialized.split('\n').length);
|
||||
});
|
||||
|
||||
it('flags invalid JSON with a line number and marks the draft dirty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft('{\n "name": ,\n}'));
|
||||
|
||||
expect(result.current.validity.valid).toBe(false);
|
||||
expect(result.current.validity.message).toBeDefined();
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('format() pretty-prints valid JSON and leaves invalid JSON untouched', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft('{"a":1}'));
|
||||
act(() => result.current.format());
|
||||
expect(result.current.draft).toBe('{\n "a": 1\n}');
|
||||
|
||||
act(() => result.current.setDraft('{bad'));
|
||||
act(() => result.current.format());
|
||||
expect(result.current.draft).toBe('{bad');
|
||||
});
|
||||
|
||||
it('reset() restores the last-applied text', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft('edited'));
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('apply() is a no-op when the draft is unchanged or invalid', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.apply();
|
||||
});
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.setDraft('{bad'));
|
||||
await act(async () => {
|
||||
await result.current.apply();
|
||||
});
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('apply() PUTs the narrowed body, toasts, refetches and calls onApplied', async () => {
|
||||
const onApplied = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied }),
|
||||
);
|
||||
|
||||
const next = { ...dashboard, name: 'Renamed' };
|
||||
act(() => result.current.setDraft(JSON.stringify(next)));
|
||||
await act(async () => {
|
||||
await result.current.apply();
|
||||
});
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
{ id: 'dash-1' },
|
||||
expect.objectContaining({
|
||||
name: 'Renamed',
|
||||
schemaVersion: 'v6',
|
||||
spec: next.spec,
|
||||
tags: next.tags,
|
||||
}),
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
expect(onApplied).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('apply() surfaces errors through the error modal', async () => {
|
||||
mockUpdate.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() =>
|
||||
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.setDraft(JSON.stringify({ ...dashboard, name: 'X' })),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.apply();
|
||||
});
|
||||
|
||||
expect(mockShowErrorModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds the draft when the drawer re-opens', () => {
|
||||
const onApplied = jest.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { isOpen: boolean }) =>
|
||||
useJsonEditor({ dashboard, isOpen: props.isOpen, onApplied }),
|
||||
{ initialProps: { isOpen: false } },
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft('stale edit'));
|
||||
expect(result.current.draft).toBe('stale edit');
|
||||
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.draft).toBe(serialized);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesDashboardSpecDTO,
|
||||
DashboardtypesUpdatableDashboardV2DTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Narrow a parsed (full Gettable-shaped) dashboard JSON down to the PUT-updatable
|
||||
* body. The editor shows the whole dashboard for readability, but the update
|
||||
* endpoint only accepts `{ name, schemaVersion, image, tags, spec }` — the
|
||||
* server owns `id`, `locked`, timestamps, etc., so we drop them here.
|
||||
*/
|
||||
export function dashboardToUpdatable(
|
||||
parsed: Record<string, unknown>,
|
||||
): DashboardtypesUpdatableDashboardV2DTO {
|
||||
const dashboard = parsed as Partial<DashboardtypesGettableDashboardV2DTO>;
|
||||
|
||||
return {
|
||||
name: dashboard.name ?? '',
|
||||
schemaVersion: dashboard.schemaVersion ?? 'v6',
|
||||
image: dashboard.image,
|
||||
tags: (dashboard.tags as TagtypesPostableTagDTO[] | null | undefined) ?? null,
|
||||
spec: dashboard.spec as DashboardtypesDashboardSpecDTO,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
|
||||
export const JSON_EDITOR_THEME = 'signoz-json';
|
||||
|
||||
function token(el: HTMLElement, name: string): string {
|
||||
return getComputedStyle(el).getPropertyValue(name).trim().replace('#', '');
|
||||
}
|
||||
|
||||
function isDark(hex: string): boolean {
|
||||
if (hex.length < 6) {
|
||||
return true;
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b < 128;
|
||||
}
|
||||
|
||||
export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
|
||||
const background = token(el, '--l1-background');
|
||||
const foreground = token(el, '--l1-foreground');
|
||||
const keyColor = token(el, '--bg-vanilla-400');
|
||||
const valueColor = token(el, '--bg-robin-400');
|
||||
|
||||
const rules: { token: string; foreground: string }[] = [];
|
||||
if (keyColor) {
|
||||
rules.push({ token: 'string.key.json', foreground: keyColor });
|
||||
}
|
||||
if (valueColor) {
|
||||
rules.push({ token: 'string.value.json', foreground: valueColor });
|
||||
}
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
if (background) {
|
||||
colors['editor.background'] = `#${background}`;
|
||||
}
|
||||
if (foreground) {
|
||||
colors['editor.foreground'] = `#${foreground}`;
|
||||
}
|
||||
|
||||
monaco.editor.defineTheme(JSON_EDITOR_THEME, {
|
||||
base: isDark(background) ? 'vs-dark' : 'vs',
|
||||
inherit: true,
|
||||
rules,
|
||||
colors,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { updateDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { dashboardToUpdatable } from './dashboardToUpdatable';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
export interface JsonValidity {
|
||||
valid: boolean;
|
||||
lineCount: number;
|
||||
/** 1-based line of the parse error, when known. */
|
||||
errorLine?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
isOpen: boolean;
|
||||
onApplied: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
draft: string;
|
||||
setDraft: (next: string) => void;
|
||||
validity: JsonValidity;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
format: () => void;
|
||||
reset: () => void;
|
||||
apply: () => Promise<void>;
|
||||
}
|
||||
|
||||
const serialize = (dashboard: DashboardtypesGettableDashboardV2DTO): string =>
|
||||
JSON.stringify(dashboard, null, 2);
|
||||
|
||||
/** Derive a 1-based line number from a `JSON.parse` "position N" error message. */
|
||||
function errorLineFromMessage(
|
||||
source: string,
|
||||
message: string,
|
||||
): number | undefined {
|
||||
const match = /position (\d+)/.exec(message);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const position = Number(match[1]);
|
||||
return source.slice(0, position).split('\n').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor state for the dashboard JSON drawer: tracks the editable `draft`
|
||||
* against the last-applied text, exposes live validation, and applies changes
|
||||
* via the full-document update endpoint.
|
||||
*/
|
||||
export function useJsonEditor({
|
||||
dashboard,
|
||||
isOpen,
|
||||
onApplied,
|
||||
}: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [appliedText, setAppliedText] = useState<string>(() =>
|
||||
serialize(dashboard),
|
||||
);
|
||||
const [draft, setDraft] = useState<string>(appliedText);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Re-seed the editor from the current dashboard each time the drawer opens so
|
||||
// it always reflects the latest persisted state (e.g. after a refetch).
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const next = serialize(dashboard);
|
||||
setAppliedText(next);
|
||||
setDraft(next);
|
||||
}
|
||||
}, [isOpen, dashboard]);
|
||||
|
||||
const validity = useMemo<JsonValidity>(() => {
|
||||
const lineCount = draft.split('\n').length;
|
||||
try {
|
||||
JSON.parse(draft);
|
||||
return { valid: true, lineCount };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
return {
|
||||
valid: false,
|
||||
lineCount,
|
||||
errorLine: errorLineFromMessage(draft, message),
|
||||
message,
|
||||
};
|
||||
}
|
||||
}, [draft]);
|
||||
|
||||
const isDirty = draft !== appliedText;
|
||||
|
||||
const format = useCallback((): void => {
|
||||
try {
|
||||
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
|
||||
} catch {
|
||||
// Leave the draft untouched when it can't be parsed.
|
||||
}
|
||||
}, [draft]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(appliedText);
|
||||
}, [appliedText]);
|
||||
|
||||
const apply = useCallback(async (): Promise<void> => {
|
||||
if (!validity.valid || !isDirty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const parsed = JSON.parse(draft) as Record<string, unknown>;
|
||||
await updateDashboardV2({ id: dashboardId }, dashboardToUpdatable(parsed));
|
||||
toast.success('Dashboard updated');
|
||||
refetch();
|
||||
onApplied();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
dashboardId,
|
||||
validity.valid,
|
||||
isDirty,
|
||||
draft,
|
||||
refetch,
|
||||
onApplied,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
validity,
|
||||
isDirty,
|
||||
isSaving,
|
||||
format,
|
||||
reset,
|
||||
apply,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
@@ -139,7 +140,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
{/* Row 2: the time selector floats top-right (declared first so the
|
||||
variables bar's content wraps around it); the variables bar
|
||||
collapses to one line and, when expanded, wraps full-width under it. */}
|
||||
<div className={styles.toolbarRow2}>
|
||||
<div className={styles.timeCluster}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
import RenameSectionModal from '../RenameSectionModal';
|
||||
import SectionTitleModal from '../SectionTitleModal';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
import SectionHeader, {
|
||||
type SectionDragHandle,
|
||||
@@ -146,8 +146,10 @@ function Section({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<RenameSectionModal
|
||||
<SectionTitleModal
|
||||
open={isRenaming}
|
||||
heading="Rename section"
|
||||
okText="Rename"
|
||||
initialValue={section.title}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsRenaming(false)}
|
||||
|
||||
@@ -2,21 +2,30 @@ import { useEffect, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
interface RenameSectionModalProps {
|
||||
interface SectionTitleModalProps {
|
||||
open: boolean;
|
||||
/** Modal heading, e.g. "Rename section" / "New section". */
|
||||
heading: string;
|
||||
/** Confirm button label, e.g. "Rename" / "Create section". */
|
||||
okText: string;
|
||||
initialValue: string;
|
||||
isSaving: boolean;
|
||||
placeholder?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (title: string) => void;
|
||||
}
|
||||
|
||||
function RenameSectionModal({
|
||||
/** Title-entry modal shared by section create and rename. */
|
||||
function SectionTitleModal({
|
||||
open,
|
||||
heading,
|
||||
okText,
|
||||
initialValue,
|
||||
isSaving,
|
||||
placeholder = 'Section name',
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: RenameSectionModalProps): JSX.Element {
|
||||
}: SectionTitleModalProps): JSX.Element {
|
||||
const [value, setValue] = useState<string>(initialValue);
|
||||
|
||||
// Reseed the field each time the modal opens.
|
||||
@@ -36,19 +45,19 @@ function RenameSectionModal({
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Rename section"
|
||||
title={heading}
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okText="Rename"
|
||||
okText={okText}
|
||||
okButtonProps={{ disabled: isSaving || !value.trim() }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
testId="rename-section-input"
|
||||
testId="section-title-input"
|
||||
autoFocus
|
||||
value={value}
|
||||
maxLength={120}
|
||||
placeholder="Section name"
|
||||
placeholder={placeholder}
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -61,4 +70,4 @@ function RenameSectionModal({
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameSectionModal;
|
||||
export default SectionTitleModal;
|
||||
@@ -12,6 +12,27 @@ import {
|
||||
} from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
|
||||
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
|
||||
|
||||
/**
|
||||
* Waits (via rAF) for the refetch to render the appended section, then scrolls
|
||||
* it into view. Polls because `refetch` resolves before React commits the new
|
||||
* section to the DOM; bails after ~40 frames.
|
||||
*/
|
||||
function scrollToNewSection(prevCount: number, attempts = 40): void {
|
||||
const sections = document.querySelectorAll(SECTION_SELECTOR);
|
||||
if (sections.length > prevCount) {
|
||||
sections[sections.length - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (attempts > 0) {
|
||||
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
|
||||
}
|
||||
}
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
}
|
||||
@@ -42,10 +63,12 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
!layouts || layouts.length === 0
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [op]);
|
||||
refetch();
|
||||
scrollToNewSection(prevSectionCount);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
|
||||
@@ -101,7 +101,7 @@ function VariableSelector({
|
||||
${variable.name}
|
||||
{variable.description ? (
|
||||
<Tooltip title={variable.description}>
|
||||
<SolidInfoCircle className={styles.infoIcon} size="md" />
|
||||
<SolidInfoCircle className={styles.infoIcon} size={14} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -1,12 +1,55 @@
|
||||
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
|
||||
a robin `$name` segment joined to a value segment. */
|
||||
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
|
||||
gap from the tags — horizontal/bottom padding comes from the toolbar. */
|
||||
.bar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.strip {
|
||||
display: flow-root;
|
||||
}
|
||||
|
||||
.stripExpanded {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
overflow: visible;
|
||||
clear: both;
|
||||
|
||||
.variableSlot,
|
||||
.moreButton {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: variablesExpandIn 200ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes variablesExpandIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.variableSlot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.variableSlotHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.moreButton {
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.variableItem {
|
||||
@@ -21,7 +64,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--bg-robin-300);
|
||||
@@ -33,8 +76,10 @@
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
margin-left: 4px;
|
||||
display: inline-flex;
|
||||
margin-left: 2px;
|
||||
color: var(--l2-foreground);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.variableValue {
|
||||
@@ -42,7 +87,7 @@
|
||||
min-width: 120px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l3-border);
|
||||
border-left: none;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--l2-background);
|
||||
@@ -55,8 +100,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Inner control fills the value segment; the segment provides the frame, so the
|
||||
control itself is borderless/transparent. */
|
||||
.control {
|
||||
width: 100%;
|
||||
min-width: 120px;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
|
||||
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
@@ -11,33 +16,76 @@ interface VariablesBarProps {
|
||||
/**
|
||||
* Runtime variable selector bar shown above the panels. Renders one control per
|
||||
* dashboard variable; selections live in the store + URL (never the spec).
|
||||
*
|
||||
* The pills sit on the line left of the floated time selector and collapse the
|
||||
* overflow behind a `+N` trigger. Expanding lets the bar wrap onto full-width
|
||||
* lines that flow underneath the time selector. Every selector stays mounted
|
||||
* either way so auto-selection and option fetching keep driving the panels.
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
useVariableSelection(dashboard);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
|
||||
itemCount: variables.length,
|
||||
gap: 8,
|
||||
reserveWidth: 48,
|
||||
enabled: !expanded,
|
||||
});
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasOverflow = overflowCount > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
{variables.map((variable) => (
|
||||
<VariableSelector
|
||||
key={variable.name}
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cx(styles.strip, { [styles.stripExpanded]: expanded })}
|
||||
>
|
||||
{variables.map((variable, index) => (
|
||||
<div
|
||||
key={variable.name}
|
||||
data-overflow-item="true"
|
||||
className={cx(styles.variableSlot, {
|
||||
[styles.variableSlotHidden]:
|
||||
!expanded && hasOverflow && index >= visibleCount,
|
||||
})}
|
||||
>
|
||||
<VariableSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasOverflow && (
|
||||
<span className={styles.moreButton}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
|
||||
aria-expanded={expanded}
|
||||
testId="dashboard-variables-more"
|
||||
onClick={(): void => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? 'Less' : `+${overflowCount}`}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ function ValueSelector({
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
className={styles.select}
|
||||
className={styles.control}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={
|
||||
|
||||
@@ -48,8 +48,15 @@ export const createVariableSelectionSlice: StateCreator<
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable empty map for dashboards with no stored selections. Returning an inline
|
||||
* `{}` here would hand zustand's useSyncExternalStore a new reference every call,
|
||||
* which it reads as a changed snapshot → infinite re-render loop.
|
||||
*/
|
||||
const EMPTY_SELECTION_MAP: VariableSelectionMap = {};
|
||||
|
||||
/** Selector: the selection map for a dashboard (empty if none). */
|
||||
export const selectVariableValues =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): VariableSelectionMap =>
|
||||
state.variableValues[dashboardId] ?? {};
|
||||
state.variableValues[dashboardId] ?? EMPTY_SELECTION_MAP;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Redirect, useParams } from 'react-router-dom';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
// Legacy /trace-old/:id now redirects to the current /trace/:id view,
|
||||
// preserving the query string and hash.
|
||||
export default function TraceDetailOldRedirect(): JSX.Element {
|
||||
const { id } = useParams<TraceDetailV2URLProps>();
|
||||
const { id } = useParams<TraceDetailV3URLProps>();
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
|
||||
90
frontend/src/pages/TraceDetailsV3/NoData/NoData.module.scss
Normal file
90
frontend/src/pages/TraceDetailsV3/NoData/NoData.module.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
.notFoundTrace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
width: 500px;
|
||||
gap: var(--spacing-12);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.notFoundImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.notFoundText1 {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.notFoundText2 {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.reasons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.reason {
|
||||
display: flex;
|
||||
padding: var(--spacing-6);
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-6);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
.reasonImg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.reasonText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.noneOfAbove {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.noneText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.actionBtns {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 160px;
|
||||
}
|
||||
73
frontend/src/pages/TraceDetailsV3/NoData/NoData.tsx
Normal file
73
frontend/src/pages/TraceDetailsV3/NoData/NoData.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { LifeBuoy, RefreshCw } from '@signozhq/icons';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import constructionUrl from '@/assets/Icons/construction.svg';
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
|
||||
function NoData(): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
return (
|
||||
<div className={styles.notFoundTrace} data-testid="trace-no-data">
|
||||
<section className={styles.description}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.notFoundImg} />
|
||||
<Typography.Text className={styles.notFoundText1}>
|
||||
Uh-oh! We cannot show the selected trace.
|
||||
<span className={styles.notFoundText2}>
|
||||
This can happen in either of the two scenarios -
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.reasons}>
|
||||
<div className={styles.reason}>
|
||||
<img src={constructionUrl} alt="no-data" className={styles.reasonImg} />
|
||||
<Typography.Text className={styles.reasonText}>
|
||||
The trace data has not been rendered on your SigNoz server yet. You can
|
||||
wait for a bit and refresh this page if this is the case.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.reason}>
|
||||
<img src={broomUrl} alt="no-data" className={styles.reasonImg} />
|
||||
<Typography.Text className={styles.reasonText}>
|
||||
The trace has been deleted as the data has crossed it’s retention period.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.noneOfAbove}>
|
||||
<Typography.Text className={styles.noneText}>
|
||||
If you feel the issue is none of the above, please contact support.
|
||||
</Typography.Text>
|
||||
<div className={styles.actionBtns}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.actionBtn}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={(): void => window.location.reload()}
|
||||
testId="trace-no-data-refresh-button"
|
||||
>
|
||||
Refresh this page
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.actionBtn}
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
onClick={(): void => handleContactSupport(isCloudUserVal)}
|
||||
testId="trace-no-data-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -0,0 +1,145 @@
|
||||
.noEvents {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.eventsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.event {
|
||||
:global(.ant-collapse) {
|
||||
border: none;
|
||||
}
|
||||
:global(.ant-collapse-content) {
|
||||
border-top: none;
|
||||
}
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
:global(.ant-collapse-content-box) {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
}
|
||||
:global(.ant-collapse-header) {
|
||||
display: flex;
|
||||
padding: var(--spacing-4) var(--spacing-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
padding-inline-start: 0px;
|
||||
padding-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.collapseTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.diamond {
|
||||
fill: var(--accent-primary);
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.attributeContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.attributeKey {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.timestampContainer {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
|
||||
.attributeValue {
|
||||
display: flex;
|
||||
padding: 2px var(--spacing-4);
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.timestampText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
padding: 2px var(--spacing-4);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.attributeValue {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.fullView {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Collapse, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { Diamond } from '@signozhq/icons';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EventAttribute from './components/EventAttribute';
|
||||
import NoData from './NoData/NoData';
|
||||
|
||||
import styles from './Events.module.scss';
|
||||
|
||||
interface IEventsTableProps {
|
||||
span: SpanV3;
|
||||
startTime: number;
|
||||
isSearchVisible: boolean;
|
||||
}
|
||||
|
||||
function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
const { span, startTime, isSearchVisible } = props;
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
const [modalContent, setModalContent] = useState<{
|
||||
title: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
const showAttributeModal = (title: string, content: string): void => {
|
||||
setModalContent({ title, content });
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setModalContent(null);
|
||||
};
|
||||
|
||||
const events = span.events;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{events.length === 0 && (
|
||||
<div className={styles.noEvents}>
|
||||
<NoData name="events" />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.eventsContainer}>
|
||||
{isSearchVisible && events.length > 0 && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search for events..."
|
||||
value={fieldSearchInput}
|
||||
onChange={(e): void => setFieldSearchInput(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{events
|
||||
.filter((eve) =>
|
||||
eve.name?.toLowerCase().includes(fieldSearchInput.toLowerCase()),
|
||||
)
|
||||
.map((event) => (
|
||||
<div
|
||||
className={styles.event}
|
||||
key={`${event.name} ${JSON.stringify(event.attributeMap)}`}
|
||||
>
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey="1"
|
||||
expandIconPosition="right"
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div className={styles.collapseTitle}>
|
||||
<Diamond size={14} className={styles.diamond} />
|
||||
<Typography.Text>{event.name}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.attributeContainer} key="timeUnixNano">
|
||||
<Typography.Text className={styles.attributeKey}>
|
||||
Start Time
|
||||
</Typography.Text>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
`${(event.timeUnixNano || 0) / 1e6 - startTime}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
since trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
`${(event.timeUnixNano || 0) / 1e6 - span.timestamp}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
since span start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{event.attributeMap &&
|
||||
Object.keys(event.attributeMap).map((attributeKey) => (
|
||||
<EventAttribute
|
||||
key={attributeKey}
|
||||
attributeKey={attributeKey}
|
||||
attributeValue={event.attributeMap[attributeKey]}
|
||||
onExpand={showAttributeModal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Modal
|
||||
title={modalContent?.title}
|
||||
open={!!modalContent}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width="80vw"
|
||||
centered
|
||||
>
|
||||
<pre className={styles.fullView}>{modalContent?.content}</pre>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventsTable;
|
||||
@@ -0,0 +1,20 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
|
||||
interface INoDataProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function NoData(props: INoDataProps): JSX.Element {
|
||||
const { name } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.noData}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
|
||||
<Typography.Text className={styles.noDataText}>
|
||||
No {name} found for selected span
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -0,0 +1,22 @@
|
||||
.popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
.preview {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: var(--spacing-4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Fullscreen } from '@signozhq/icons';
|
||||
|
||||
import styles from '../Events.module.scss';
|
||||
|
||||
import popoverStyles from './AttributeWithExpandablePopover.module.scss';
|
||||
|
||||
interface AttributeWithExpandablePopoverProps {
|
||||
attributeKey: string;
|
||||
attributeValue: string;
|
||||
onExpand: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
function AttributeWithExpandablePopover({
|
||||
attributeKey,
|
||||
attributeValue,
|
||||
onExpand,
|
||||
}: AttributeWithExpandablePopoverProps): JSX.Element {
|
||||
const popoverContent = (
|
||||
<div className={popoverStyles.popover}>
|
||||
<pre className={popoverStyles.preview}>{attributeValue}</pre>
|
||||
<Button
|
||||
onClick={(): void => onExpand(attributeKey, attributeValue)}
|
||||
size="sm"
|
||||
className={popoverStyles.expandButton}
|
||||
prefix={<Fullscreen size={14} />}
|
||||
>
|
||||
Expand
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.attributeContainer} key={attributeKey}>
|
||||
<Tooltip title={attributeKey}>
|
||||
<Typography.Text className={styles.attributeKey} truncate={1}>
|
||||
{attributeKey}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<Popover content={popoverContent} trigger="hover" placement="topRight">
|
||||
<Typography.Text className={styles.attributeValue} truncate={1}>
|
||||
{attributeValue}
|
||||
</Typography.Text>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeWithExpandablePopover;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from '../Events.module.scss';
|
||||
|
||||
import AttributeWithExpandablePopover from './AttributeWithExpandablePopover';
|
||||
|
||||
const EXPANDABLE_ATTRIBUTE_KEYS = ['exception.stacktrace', 'exception.message'];
|
||||
const ATTRIBUTE_LENGTH_THRESHOLD = 100;
|
||||
|
||||
interface EventAttributeProps {
|
||||
attributeKey: string;
|
||||
attributeValue: string;
|
||||
onExpand: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
function EventAttribute({
|
||||
attributeKey,
|
||||
attributeValue,
|
||||
onExpand,
|
||||
}: EventAttributeProps): JSX.Element {
|
||||
const shouldExpand =
|
||||
EXPANDABLE_ATTRIBUTE_KEYS.includes(attributeKey) ||
|
||||
attributeValue.length > ATTRIBUTE_LENGTH_THRESHOLD;
|
||||
|
||||
if (shouldExpand) {
|
||||
return (
|
||||
<AttributeWithExpandablePopover
|
||||
attributeKey={attributeKey}
|
||||
attributeValue={attributeValue}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.attributeContainer} key={attributeKey}>
|
||||
<Tooltip title={attributeKey}>
|
||||
<Typography.Text className={styles.attributeKey} truncate={1}>
|
||||
{attributeKey}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<div className={styles.wrapper}>
|
||||
<Tooltip title={attributeValue}>
|
||||
<Typography.Text className={styles.attributeValue} truncate={1}>
|
||||
{attributeValue}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventAttribute;
|
||||
@@ -27,9 +27,6 @@ import {
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
@@ -68,6 +65,9 @@ import {
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
import Events from './Events/Events';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
|
||||
@@ -424,9 +424,8 @@ function SpanDetailsContent({
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
|
||||
<Events
|
||||
span={{ ...selectedSpan, event: selectedSpan.events } as any}
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.spanLogs {
|
||||
margin-inline: var(--spacing-8);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spanLogsVirtuoso {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
.spanLogsListContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logsLoadingSkeleton {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.spanLogsEmptyContent {
|
||||
height: 100%;
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-48);
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-6);
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.noDataText1 {
|
||||
color: var(--l2-foreground);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.noDataText2 {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.actionSection {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
OPERATORS,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './SpanLogs.module.scss';
|
||||
|
||||
interface SpanLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
handleExplorerPageRedirect: () => void;
|
||||
emptyStateConfig?: EmptyLogsListConfig;
|
||||
}
|
||||
|
||||
function SpanLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
handleExplorerPageRedirect,
|
||||
emptyStateConfig,
|
||||
}: SpanLogsProps): JSX.Element {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
// Create trace_id and span_id filters for logs explorer navigation
|
||||
const createLogsFilter = useCallback(
|
||||
(targetSpanId: string): TagFilter => {
|
||||
const traceIdKey: BaseAutocompleteData = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
};
|
||||
|
||||
const spanIdKey: BaseAutocompleteData = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'span_id',
|
||||
};
|
||||
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: targetSpanId,
|
||||
key: spanIdKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
},
|
||||
[traceId],
|
||||
);
|
||||
|
||||
// Navigate to logs explorer with trace_id and span_id filters
|
||||
const handleLogClick = useCallback(
|
||||
(log: ILog): void => {
|
||||
// Determine if this is a span log or context log
|
||||
const isSpanLog = isLogSpanRelated(log.id);
|
||||
|
||||
// Extract log's span_id (handles both spanID and span_id properties)
|
||||
const logSpanId = log.spanID || log.span_id || '';
|
||||
|
||||
// Use appropriate span ID: current span for span logs, individual log's span for context logs
|
||||
const targetSpanId = isSpanLog ? spanId : logSpanId;
|
||||
const filters = createLogsFilter(targetSpanId);
|
||||
|
||||
// Create base query
|
||||
const baseQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
// Add appropriate filters to the query
|
||||
const updatedQuery = {
|
||||
...baseQuery,
|
||||
builder: {
|
||||
...baseQuery.builder,
|
||||
queryData: baseQuery.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
filters,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const queryParams = {
|
||||
[QueryParams.activeLogId]: `"${log.id}"`,
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
|
||||
};
|
||||
|
||||
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
|
||||
|
||||
openInNewTab(url);
|
||||
},
|
||||
[
|
||||
isLogSpanRelated,
|
||||
createLogsFilter,
|
||||
spanId,
|
||||
updateAllQueriesOperators,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
);
|
||||
|
||||
// Footer rendering for pagination
|
||||
const hasReachedEndOfLogs = false;
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, logToRender: ILog): JSX.Element => {
|
||||
const getIsSpanRelated = (log: ILog, currentSpanId: string): boolean => {
|
||||
if (log.spanID) {
|
||||
return log.spanID === currentSpanId;
|
||||
}
|
||||
return log.span_id === currentSpanId;
|
||||
};
|
||||
|
||||
const isSpanRelated = getIsSpanRelated(logToRender, spanId);
|
||||
|
||||
return (
|
||||
<RawLogView
|
||||
key={logToRender.id}
|
||||
data={logToRender}
|
||||
linesPerRow={1}
|
||||
fontSize={FontSize.MEDIUM}
|
||||
onLogClick={handleLogClick}
|
||||
isHighlighted={isSpanRelated}
|
||||
helpTooltip={
|
||||
isSpanRelated ? 'This log belongs to the current span' : undefined
|
||||
}
|
||||
selectedFields={[
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleLogClick, spanId],
|
||||
);
|
||||
|
||||
const renderFooter = useCallback((): JSX.Element | null => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasReachedEndOfLogs) {
|
||||
return <div className={styles.logsLoadingSkeleton}> *** End *** </div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [isFetching, hasReachedEndOfLogs]);
|
||||
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<div className={styles.spanLogsListContainer}>
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className={styles.spanLogsVirtuoso}
|
||||
key="span-logs-virtuoso"
|
||||
style={{ height: '100%' }}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
overscan={200}
|
||||
components={{
|
||||
Footer: renderFooter,
|
||||
}}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
),
|
||||
[logs, getItemContent, renderFooter],
|
||||
);
|
||||
|
||||
const renderNoLogsFound = (): JSX.Element => (
|
||||
<div className={styles.spanLogsEmptyContent}>
|
||||
<section className={styles.description}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
|
||||
<Typography.Text className={styles.noDataText1}>
|
||||
No logs found for selected span.
|
||||
<span className={styles.noDataText2}>
|
||||
View logs for the current trace.
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.actionSection}>
|
||||
<Button
|
||||
className={styles.actionBtn}
|
||||
variant="action"
|
||||
prefix={<Compass size={14} />}
|
||||
onClick={handleExplorerPageRedirect}
|
||||
size="md"
|
||||
>
|
||||
View Logs
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSpanLogsContent = (): JSX.Element | null => {
|
||||
if (isLoading || isFetching) {
|
||||
return <LogsLoading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <LogsError />;
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
if (emptyStateConfig) {
|
||||
return (
|
||||
<EmptyLogsSearch
|
||||
dataSource={DataSource.LOGS}
|
||||
panelType="LIST"
|
||||
customMessage={emptyStateConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderNoLogsFound();
|
||||
}
|
||||
|
||||
return renderContent;
|
||||
};
|
||||
|
||||
return <div className={styles.spanLogs}>{renderSpanLogsContent()}</div>;
|
||||
}
|
||||
SpanLogs.defaultProps = {
|
||||
emptyStateConfig: undefined,
|
||||
};
|
||||
|
||||
export default SpanLogs;
|
||||
@@ -0,0 +1,211 @@
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock Virtuoso to avoid complex virtualization
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }: any) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock RawLogView component
|
||||
jest.mock(
|
||||
'components/Logs/RawLogView',
|
||||
() =>
|
||||
function MockRawLogView({
|
||||
data,
|
||||
onLogClick,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`raw-log-${data.id}`}
|
||||
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||
title={helpTooltip}
|
||||
onClick={(e): void => onLogClick?.(data, e)}
|
||||
>
|
||||
<div>{data.body}</div>
|
||||
<div>{data.timestamp}</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LogsLoading component
|
||||
jest.mock('container/LogsLoading/LogsLoading', () => ({
|
||||
LogsLoading: function MockLogsLoading(): JSX.Element {
|
||||
return <div data-testid="logs-loading">Loading logs...</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock LogsError component
|
||||
jest.mock(
|
||||
'container/LogsError/LogsError',
|
||||
() =>
|
||||
function MockLogsError(): JSX.Element {
|
||||
return <div data-testid="logs-error">Error loading logs</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Don't mock EmptyLogsSearch - test the actual component behavior
|
||||
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
timeRange: {
|
||||
startTime: 1640995200000,
|
||||
endTime: 1640995260000,
|
||||
},
|
||||
logs: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLogSpanRelated: jest.fn().mockReturnValue(false),
|
||||
handleExplorerPageRedirect: jest.fn(),
|
||||
};
|
||||
|
||||
describe('SpanLogs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should show simple empty state when emptyStateConfig is not provided', () => {
|
||||
render(<SpanLogs {...defaultProps} />);
|
||||
|
||||
// Should show simple empty state (no emptyStateConfig provided)
|
||||
expect(
|
||||
screen.getByText('No logs found for selected span.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('View logs for the current trace.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should NOT show enhanced empty state
|
||||
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enhanced empty state when entire trace has no logs', () => {
|
||||
render(
|
||||
<SpanLogs
|
||||
{...defaultProps}
|
||||
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show enhanced empty state with custom message
|
||||
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This could be because :')).toBeInTheDocument();
|
||||
|
||||
// Should show description list
|
||||
expect(
|
||||
screen.getByText('Logs are not linked to Traces.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Logs are not being sent to SigNoz.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('No logs are associated with this particular trace/span.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should show documentation links
|
||||
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
|
||||
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show simple empty state
|
||||
expect(
|
||||
screen.queryByText('No logs found for selected span.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockHandleExplorerPageRedirect = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanLogs
|
||||
{...defaultProps}
|
||||
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logExplorerButton = screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
});
|
||||
await user.click(logExplorerButton);
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Creates a query payload for fetching logs related to a specific span
|
||||
* @param start - Start time in milliseconds
|
||||
* @param end - End time in milliseconds
|
||||
* @param filter - V5 filter expression for trace_id and span_id
|
||||
* @param order - Timestamp ordering ('desc' for newest first, 'asc' for oldest first)
|
||||
* @returns Query payload for logs API
|
||||
*/
|
||||
export const getSpanLogsQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
filter: Filter,
|
||||
order: 'asc' | 'desc' = 'desc',
|
||||
): GetQueryResultsProps => ({
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
query: {
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order,
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: uuidv4(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates tag filters for querying logs by trace_id only (for context logs)
|
||||
* @param traceId - The trace identifier
|
||||
* @returns Tag filters for the query builder
|
||||
*/
|
||||
export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||
items: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
id: uuidv4(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
},
|
||||
op: '=',
|
||||
value: traceId,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
|
||||
|
||||
interface UseSpanContextLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
interface UseSpanContextLogsReturn {
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
spanLogIds: Set<string>;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
hasTraceIdLogs: boolean;
|
||||
}
|
||||
|
||||
const traceIdKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
};
|
||||
/**
|
||||
* Creates v5 filter expression for querying logs by trace_id and span_id (for span logs)
|
||||
*/
|
||||
const createSpanLogsFilters = (traceId: string, spanId: string): Filter => {
|
||||
const spanIdKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'span_id',
|
||||
};
|
||||
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: spanId,
|
||||
key: spanIdKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
return convertFiltersToExpression(filters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates v5 filter expression for querying context logs with id constraints
|
||||
*/
|
||||
const createContextFilters = (
|
||||
traceId: string,
|
||||
logId: string,
|
||||
operator: 'lt' | 'gt',
|
||||
): Filter => {
|
||||
const idKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'id',
|
||||
};
|
||||
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(operator === 'lt' ? OPERATORS['<'] : OPERATORS['>']),
|
||||
value: logId,
|
||||
key: idKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
return convertFiltersToExpression(filters);
|
||||
};
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
export const useSpanContextLogs = ({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
isDrawerOpen = true,
|
||||
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Phase 1: Fetch span-specific logs (trace_id + span_id)
|
||||
const spanFilter = useMemo(
|
||||
() => createSpanLogsFilters(traceId, spanId),
|
||||
[traceId, spanId],
|
||||
);
|
||||
const spanQueryPayload = useMemo(
|
||||
() =>
|
||||
getSpanLogsQueryPayload(timeRange.startTime, timeRange.endTime, spanFilter),
|
||||
[timeRange.startTime, timeRange.endTime, spanFilter],
|
||||
);
|
||||
|
||||
const {
|
||||
data: spanData,
|
||||
isLoading: isSpanLoading,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_LOGS,
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(spanQueryPayload, ENTITY_VERSION_V5),
|
||||
enabled: !!traceId && !!spanId,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Extract span logs and track their IDs
|
||||
const spanLogs = useMemo(() => {
|
||||
if (!spanData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
setSpanLogIds(new Set());
|
||||
return [];
|
||||
}
|
||||
|
||||
const logs = spanData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
// Track span log IDs
|
||||
const logIds = new Set(logs.map((log: ILog) => log.id));
|
||||
setSpanLogIds(logIds);
|
||||
|
||||
return logs;
|
||||
}, [spanData]);
|
||||
|
||||
// Get first and last span logs for context queries
|
||||
const { firstSpanLog, lastSpanLog } = useMemo(() => {
|
||||
if (spanLogs.length === 0) {
|
||||
return { firstSpanLog: null, lastSpanLog: null };
|
||||
}
|
||||
|
||||
const sortedLogs = [...spanLogs].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
firstSpanLog: sortedLogs[0],
|
||||
lastSpanLog: sortedLogs[sortedLogs.length - 1],
|
||||
};
|
||||
}, [spanLogs]);
|
||||
// Phase 2: Fetch context logs before first span log
|
||||
const beforeFilter = useMemo(() => {
|
||||
if (!firstSpanLog) {
|
||||
return null;
|
||||
}
|
||||
return createContextFilters(traceId, firstSpanLog.id, 'lt');
|
||||
}, [traceId, firstSpanLog]);
|
||||
|
||||
const beforeQueryPayload = useMemo(() => {
|
||||
if (!beforeFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
beforeFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, beforeFilter]);
|
||||
|
||||
const { data: beforeData, isFetching: isBeforeFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_BEFORE_LOGS,
|
||||
traceId,
|
||||
firstSpanLog?.id,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(beforeQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: !!beforeQueryPayload && !!firstSpanLog,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Phase 3: Fetch context logs after last span log
|
||||
const afterFilter = useMemo(() => {
|
||||
if (!lastSpanLog) {
|
||||
return null;
|
||||
}
|
||||
return createContextFilters(traceId, lastSpanLog.id, 'gt');
|
||||
}, [traceId, lastSpanLog]);
|
||||
|
||||
const afterQueryPayload = useMemo(() => {
|
||||
if (!afterFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
afterFilter,
|
||||
'asc',
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, afterFilter]);
|
||||
|
||||
const { data: afterData, isFetching: isAfterFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_AFTER_LOGS,
|
||||
traceId,
|
||||
lastSpanLog?.id,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(afterQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: !!afterQueryPayload && !!lastSpanLog,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Extract context logs
|
||||
const beforeLogs = useMemo(() => {
|
||||
if (!beforeData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return beforeData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
}, [beforeData]);
|
||||
|
||||
const afterLogs = useMemo(() => {
|
||||
if (!afterData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return afterData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
}, [afterData]);
|
||||
|
||||
useEffect(() => {
|
||||
const combined = [...afterLogs.reverse(), ...spanLogs, ...beforeLogs];
|
||||
setAllLogs(combined);
|
||||
}, [beforeLogs, spanLogs, afterLogs]);
|
||||
|
||||
// Phase 4: Check for trace_id-only logs when span has no logs
|
||||
// This helps differentiate between "no logs for span" vs "no logs for trace"
|
||||
const traceOnlyFilter = useMemo(() => {
|
||||
if (spanLogs.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const filters = getTraceOnlyFilters(traceId);
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [traceId, spanLogs.length]);
|
||||
|
||||
const traceOnlyQueryPayload = useMemo(() => {
|
||||
if (!traceOnlyFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
traceOnlyFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
|
||||
|
||||
const { data: traceOnlyData } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
|
||||
traceId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
const hasTraceIdLogs = useMemo(() => {
|
||||
if (spanLogs.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return !!(
|
||||
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
|
||||
);
|
||||
}, [spanLogs.length, traceOnlyData]);
|
||||
|
||||
// Helper function to check if a log belongs to the span
|
||||
const isLogSpanRelated = useCallback(
|
||||
(logId: string): boolean => spanLogIds.has(logId),
|
||||
[spanLogIds],
|
||||
);
|
||||
|
||||
return {
|
||||
logs: allLogs,
|
||||
isLoading: isSpanLoading && spanLogs.length === 0,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||
spanLogIds,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
};
|
||||
};
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
@@ -81,7 +81,7 @@ function TraceDetailsHeader({
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
const { id: traceID } = useParams<TraceDetailV3URLProps>();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
|
||||
@@ -72,7 +72,7 @@ function FunnelDetailsView({
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span as any}
|
||||
span={span}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanLineActionButtons from '../index';
|
||||
|
||||
// Mock the useCopySpanLink hook
|
||||
jest.mock('hooks/trace/useCopySpanLink');
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1000,
|
||||
const mockSpan: SpanV3 = {
|
||||
span_id: 'test-span-id',
|
||||
trace_id: 'test-trace-id',
|
||||
parent_span_id: 'test-parent-span-id',
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
duration_nano: 1000,
|
||||
name: 'test-span',
|
||||
'service.name': 'test-service',
|
||||
has_error: false,
|
||||
status_message: 'test-status-message',
|
||||
status_code: 0,
|
||||
status_code_string: 'test-status-code-string',
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
kind_string: 'test-span-kind',
|
||||
has_children: false,
|
||||
has_sibling: false,
|
||||
sub_tree_node_count: 0,
|
||||
level: 0,
|
||||
attributes: {},
|
||||
resource: {},
|
||||
events: [],
|
||||
http_method: '',
|
||||
http_url: '',
|
||||
http_host: '',
|
||||
db_name: '',
|
||||
db_operation: '',
|
||||
external_http_method: '',
|
||||
external_http_url: '',
|
||||
response_status_code: '',
|
||||
is_remote: '',
|
||||
flags: 0,
|
||||
trace_state: '',
|
||||
};
|
||||
|
||||
describe('SpanLineActionButtons', () => {
|
||||
@@ -94,7 +104,7 @@ describe('SpanLineActionButtons', () => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mockUrlQuery.delete('spanId');
|
||||
mockUrlQuery.set('spanId', mockSpan.spanId);
|
||||
mockUrlQuery.set('spanId', mockSpan.span_id);
|
||||
const link = `${
|
||||
window.location.origin
|
||||
}${mockPathname}?${mockUrlQuery.toString()}`;
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Link } from '@signozhq/icons';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import styles from './SpanLineActionButtons.module.scss';
|
||||
|
||||
export interface SpanLineActionButtonsProps {
|
||||
span: Span;
|
||||
span: SpanV3;
|
||||
}
|
||||
export default function SpanLineActionButtons({
|
||||
span,
|
||||
|
||||
@@ -11,12 +11,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
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 { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
|
||||
@@ -9,7 +9,7 @@ import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/Funnel
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
|
||||
@@ -23,7 +23,7 @@ import './FunnelConfiguration.styles.scss';
|
||||
interface FunnelConfigurationProps {
|
||||
funnel: FunnelData;
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
span?: SpanV3;
|
||||
triggerAutoSave?: boolean;
|
||||
showNotifications?: boolean;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { Plus, Undo2 } from '@signozhq/icons';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import FunnelStep from './FunnelStep';
|
||||
import InterStepConfig from './InterStepConfig';
|
||||
@@ -18,7 +18,7 @@ function StepsContent({
|
||||
span,
|
||||
}: {
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
span?: SpanV3;
|
||||
}): JSX.Element {
|
||||
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
|
||||
const { hasEditPermission } = useAppContext();
|
||||
@@ -30,7 +30,7 @@ function StepsContent({
|
||||
|
||||
const stepWasAdded = handleAddStep();
|
||||
if (stepWasAdded) {
|
||||
handleReplaceStep(steps.length, span.serviceName, span.name);
|
||||
handleReplaceStep(steps.length, span['service.name'], span.name);
|
||||
}
|
||||
logEvent(
|
||||
'Trace Funnels: span added for a new step from trace details page',
|
||||
@@ -61,12 +61,12 @@ function StepsContent({
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
(step.service_name === span.serviceName &&
|
||||
(step.service_name === span['service.name'] &&
|
||||
step.span_name === span.name) ||
|
||||
!hasEditPermission
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
handleReplaceStep(index, span['service.name'], span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
|
||||
133
frontend/src/schemas/generated/transactionGroups.schema.json
Normal file
133
frontend/src/schemas/generated/transactionGroups.schema.json
Normal file
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"title": "TransactionGroups",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AuthtypesTransactionGroup"
|
||||
},
|
||||
"definitions": {
|
||||
"AuthtypesRelation": {
|
||||
"additionalProperties": false,
|
||||
"enum": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"assignee",
|
||||
"attach",
|
||||
"detach"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AuthtypesTransactionGroup": {
|
||||
"required": [
|
||||
"relation",
|
||||
"objectGroup"
|
||||
],
|
||||
"properties": {
|
||||
"objectGroup": {
|
||||
"$ref": "#/definitions/CoretypesObjectGroup"
|
||||
},
|
||||
"relation": {
|
||||
"$ref": "#/definitions/AuthtypesRelation"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CoretypesKind": {
|
||||
"additionalProperties": false,
|
||||
"enum": [
|
||||
"anonymous",
|
||||
"organization",
|
||||
"role",
|
||||
"serviceaccount",
|
||||
"user",
|
||||
"notification-channel",
|
||||
"route-policy",
|
||||
"apdex-setting",
|
||||
"auth-domain",
|
||||
"session",
|
||||
"cloud-integration",
|
||||
"cloud-integration-service",
|
||||
"integration",
|
||||
"dashboard",
|
||||
"public-dashboard",
|
||||
"ingestion-key",
|
||||
"ingestion-limit",
|
||||
"pipeline",
|
||||
"user-preference",
|
||||
"org-preference",
|
||||
"quick-filter",
|
||||
"ttl-setting",
|
||||
"rule",
|
||||
"planned-maintenance",
|
||||
"saved-view",
|
||||
"trace-funnel",
|
||||
"factor-password",
|
||||
"factor-api-key",
|
||||
"license",
|
||||
"subscription",
|
||||
"logs",
|
||||
"traces",
|
||||
"metrics",
|
||||
"audit-logs",
|
||||
"meter-metrics",
|
||||
"logs-field",
|
||||
"traces-field"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CoretypesObjectGroup": {
|
||||
"required": [
|
||||
"resource",
|
||||
"selectors"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"resource": {
|
||||
"$ref": "#/definitions/CoretypesResourceRef"
|
||||
},
|
||||
"selectors": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/CoretypesSelector"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CoretypesResourceRef": {
|
||||
"required": [
|
||||
"type",
|
||||
"kind"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"$ref": "#/definitions/CoretypesKind"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/CoretypesType"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CoretypesSelector": {
|
||||
"additionalProperties": false,
|
||||
"type": "string"
|
||||
},
|
||||
"CoretypesType": {
|
||||
"additionalProperties": false,
|
||||
"enum": [
|
||||
"user",
|
||||
"serviceaccount",
|
||||
"anonymous",
|
||||
"role",
|
||||
"organization",
|
||||
"metaresource",
|
||||
"telemetryresource"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"title": "WebSettings",
|
||||
"required": [
|
||||
"posthog",
|
||||
"appcues",
|
||||
@@ -794,6 +794,13 @@ notifications - 2050
|
||||
background: color-mix(in srgb, var(--l3-background) 20%, transparent);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Monaco Editor style overrides
|
||||
.monaco-editor .find-widget.visible {
|
||||
top: 30px !important;
|
||||
right: 45px !important;
|
||||
}
|
||||
|
||||
body.ai-assistant-panel-open {
|
||||
.PylonChat-bubbleFrameContainer,
|
||||
.PylonChat-chatWindowFrameContainer {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */
|
||||
/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */
|
||||
|
||||
export interface WebSettings {
|
||||
appcues: Appcues;
|
||||
|
||||
@@ -68,7 +68,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/metrics/{metric_name}/attributes", handler.New(
|
||||
if err := router.Handle("/api/v2/metrics/attributes", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAttributes),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetMetricAttributes",
|
||||
@@ -88,7 +88,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/metrics/{metric_name}/metadata", handler.New(
|
||||
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricMetadata),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetMetricMetadata",
|
||||
@@ -96,6 +96,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
Summary: "Get metric metadata",
|
||||
Description: "This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric",
|
||||
Request: nil,
|
||||
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
|
||||
RequestContentType: "",
|
||||
Response: new(metricsexplorertypes.MetricMetadata),
|
||||
ResponseContentType: "application/json",
|
||||
@@ -107,7 +108,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/metrics/{metric_name}/metadata", handler.New(
|
||||
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
|
||||
provider.authzMiddleware.EditAccess(provider.metricsExplorerHandler.UpdateMetricMetadata),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateMetricMetadata",
|
||||
@@ -126,7 +127,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/metrics/{metric_name}/highlights", handler.New(
|
||||
if err := router.Handle("/api/v2/metrics/highlights", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricHighlights),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetMetricHighlights",
|
||||
@@ -134,6 +135,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
Summary: "Get metric highlights",
|
||||
Description: "This endpoint returns highlights like number of datapoints, totaltimeseries, active time series, last received time for a specified metric",
|
||||
Request: nil,
|
||||
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
|
||||
RequestContentType: "",
|
||||
Response: new(metricsexplorertypes.MetricHighlightsResponse),
|
||||
ResponseContentType: "application/json",
|
||||
@@ -145,7 +147,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/metrics/{metric_name}/alerts", handler.New(
|
||||
if err := router.Handle("/api/v2/metrics/alerts", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAlerts),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetMetricAlerts",
|
||||
@@ -153,6 +155,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
Summary: "Get metric alerts",
|
||||
Description: "This endpoint returns associated alerts for a specified metric",
|
||||
Request: nil,
|
||||
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
|
||||
RequestContentType: "",
|
||||
Response: new(metricsexplorertypes.MetricAlertsResponse),
|
||||
ResponseContentType: "application/json",
|
||||
@@ -164,7 +167,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/metrics/{metric_name}/dashboards", handler.New(
|
||||
if err := router.Handle("/api/v2/metrics/dashboards", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricDashboards),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetMetricDashboards",
|
||||
@@ -172,6 +175,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
Summary: "Get metric dashboards",
|
||||
Description: "This endpoint returns associated dashboards for a specified metric",
|
||||
Request: nil,
|
||||
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
|
||||
RequestContentType: "",
|
||||
Response: new(metricsexplorertypes.MetricDashboardsResponse),
|
||||
ResponseContentType: "application/json",
|
||||
|
||||
@@ -11,17 +11,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func extractMetricName(req *http.Request) (string, error) {
|
||||
metricName := mux.Vars(req)["metric_name"]
|
||||
if metricName == "" {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path")
|
||||
}
|
||||
return metricName, nil
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
module metricsexplorer.Module
|
||||
}
|
||||
@@ -116,23 +107,17 @@ func (h *handler) UpdateMetricMetadata(rw http.ResponseWriter, req *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Extract metric_name from URL path
|
||||
vars := mux.Vars(req)
|
||||
metricName := vars["metric_name"]
|
||||
|
||||
if metricName == "" {
|
||||
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path"))
|
||||
return
|
||||
}
|
||||
|
||||
var in metricsexplorertypes.UpdateMetricMetadataRequest
|
||||
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set metric name from URL path
|
||||
in.MetricName = metricName
|
||||
if in.MetricName == "" {
|
||||
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required"))
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
err = h.module.UpdateMetricMetadata(req.Context(), orgID, &in)
|
||||
@@ -151,11 +136,16 @@ func (h *handler) GetMetricMetadata(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
metricName, err := extractMetricName(req)
|
||||
if err != nil {
|
||||
var in metricsexplorertypes.MetricNameQuery
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := in.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
metricName := in.MetricName
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
@@ -181,20 +171,24 @@ func (h *handler) GetMetricAlerts(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
metricName, err := extractMetricName(req)
|
||||
if err != nil {
|
||||
var in metricsexplorertypes.MetricNameQuery
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := in.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
|
||||
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.module.GetMetricAlerts(req.Context(), orgID, metricName)
|
||||
out, err := h.module.GetMetricAlerts(req.Context(), orgID, in.MetricName)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -209,20 +203,24 @@ func (h *handler) GetMetricDashboards(rw http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
metricName, err := extractMetricName(req)
|
||||
if err != nil {
|
||||
var in metricsexplorertypes.MetricNameQuery
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := in.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
|
||||
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.module.GetMetricDashboards(req.Context(), orgID, metricName)
|
||||
out, err := h.module.GetMetricDashboards(req.Context(), orgID, in.MetricName)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -237,20 +235,24 @@ func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
metricName, err := extractMetricName(req)
|
||||
if err != nil {
|
||||
var in metricsexplorertypes.MetricNameQuery
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := in.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
|
||||
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, metricName)
|
||||
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, in.MetricName)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -265,20 +267,12 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
metricName, err := extractMetricName(req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var in metricsexplorertypes.MetricAttributesRequest
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
in.MetricName = metricName
|
||||
|
||||
if err := in.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -286,7 +280,7 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
|
||||
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ func validateAndApplyDefaultExportLimits(queries []qbtypes.QueryEnvelope) error
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit cannot be more than %d", MaxExportRowCountLimit)
|
||||
}
|
||||
queries[idx].SetLimit(limit)
|
||||
|
||||
if queries[idx].GetOffset() < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset must be non-negative")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -70,12 +70,13 @@ func exportRawDataForSingleQuery(querier querier.Querier, ctx context.Context, o
|
||||
|
||||
queries := rangeRequest.CompositeQuery.Queries
|
||||
rowCountLimit := queries[queryIndex].GetLimit()
|
||||
startingOffset := queries[queryIndex].GetOffset()
|
||||
rowCount := 0
|
||||
|
||||
for rowCount < rowCountLimit {
|
||||
chunkSize := min(ChunkSize, rowCountLimit-rowCount)
|
||||
queries[queryIndex].SetLimit(chunkSize)
|
||||
queries[queryIndex].SetOffset(rowCount)
|
||||
queries[queryIndex].SetOffset(startingOffset + rowCount)
|
||||
|
||||
response, err := querier.QueryRange(ctx, orgID, rangeRequest)
|
||||
if err != nil {
|
||||
|
||||
@@ -80,8 +80,8 @@ type RoleWithTransactionGroups struct {
|
||||
|
||||
type PostableRole struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
|
||||
Description string `json:"description" required:"false"`
|
||||
TransactionGroups TransactionGroups `json:"transactionGroups" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type UpdatableRole struct {
|
||||
@@ -167,32 +167,40 @@ func (role *Role) ErrIfManaged() error {
|
||||
}
|
||||
|
||||
func (role *PostableRole) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableRole
|
||||
var temp Alias
|
||||
shadow := struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
TransactionGroups *json.RawMessage `json:"transactionGroups"`
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
if err := json.Unmarshal(data, &shadow); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.Name == "" {
|
||||
if shadow.Name == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name is missing from the request")
|
||||
}
|
||||
|
||||
if match := roleNameRegex.MatchString(temp.Name); !match {
|
||||
if match := roleNameRegex.MatchString(shadow.Name); !match {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name must contain only lowercase letters (a-z) and hyphens (-), and be at most 50 characters long.")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(temp.Name, managedRolePrefix) {
|
||||
if strings.HasPrefix(shadow.Name, managedRolePrefix) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "role name cannot start with %q as it is reserved for SigNoz managed roles.", managedRolePrefix)
|
||||
}
|
||||
|
||||
if temp.TransactionGroups == nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "transactionGroups is required").WithAdditional("send an empty array to create a role with no transaction groups")
|
||||
var transactionGroups TransactionGroups
|
||||
if shadow.TransactionGroups != nil {
|
||||
var err error
|
||||
transactionGroups, err = NewTransactionGroups(*shadow.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
role.Name = temp.Name
|
||||
role.Description = temp.Description
|
||||
role.TransactionGroups = temp.TransactionGroups
|
||||
role.Name = shadow.Name
|
||||
role.Description = shadow.Description
|
||||
role.TransactionGroups = transactionGroups
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -206,9 +214,6 @@ func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// A pointer distinguishes an omitted/null description from an explicit empty string: the field
|
||||
// must be sent (update reconciles to exactly what is given), but an empty string is allowed so a
|
||||
// caller can deliberately clear the description.
|
||||
if shadow.Description == nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "description is required").WithAdditional("send an empty string to clear the description")
|
||||
}
|
||||
|
||||
@@ -3,10 +3,22 @@ package authtypes
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type rawTransactionGroup struct {
|
||||
Relation string `json:"relation"`
|
||||
ObjectGroup struct {
|
||||
Resource struct {
|
||||
Type string `json:"type"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"resource"`
|
||||
Selectors []string `json:"selectors"`
|
||||
} `json:"objectGroup"`
|
||||
}
|
||||
|
||||
type Transaction struct {
|
||||
ID valuer.UUID `json:"-"`
|
||||
Relation Relation `json:"relation" required:"true"`
|
||||
@@ -39,16 +51,23 @@ func NewTransaction(relation Relation, object coretypes.Object) (*Transaction, e
|
||||
return &Transaction{ID: valuer.GenerateUUID(), Relation: relation, Object: object}, nil
|
||||
}
|
||||
|
||||
func NewTransactionGroup(relation Relation, objectGroup coretypes.ObjectGroup) (*TransactionGroup, error) {
|
||||
if err := coretypes.ErrIfVerbNotValidForResource(relation.Verb, objectGroup.Resource); err != nil {
|
||||
return nil, err
|
||||
func NewTransactionGroups(data []byte) (TransactionGroups, error) {
|
||||
var rawGroups []rawTransactionGroup
|
||||
if err := json.Unmarshal(data, &rawGroups); err != nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "transactionGroups must be an array of {relation, objectGroup} objects")
|
||||
}
|
||||
|
||||
if _, err := coretypes.NewObjectsFromObjectGroup(objectGroup); err != nil {
|
||||
return nil, err
|
||||
groups := make(TransactionGroups, 0, len(rawGroups))
|
||||
for index, rawGroup := range rawGroups {
|
||||
group, err := newTransactionGroup(rawGroup, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups = append(groups, group)
|
||||
}
|
||||
|
||||
return &TransactionGroup{Relation: relation, ObjectGroup: objectGroup}, nil
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func NewGettableTransaction(results []*TransactionWithAuthorization) []*GettableTransaction {
|
||||
@@ -88,26 +107,6 @@ func (transaction *Transaction) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transactionGroup *TransactionGroup) UnmarshalJSON(data []byte) error {
|
||||
var shadow = struct {
|
||||
Relation Relation
|
||||
ObjectGroup coretypes.ObjectGroup
|
||||
}{}
|
||||
|
||||
err := json.Unmarshal(data, &shadow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := NewTransactionGroup(shadow.Relation, shadow.ObjectGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*transactionGroup = *group
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transaction *Transaction) TransactionKey() string {
|
||||
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
|
||||
}
|
||||
@@ -156,3 +155,39 @@ func (groups TransactionGroups) selectorSet() map[string]struct{} {
|
||||
func (group *TransactionGroup) selectorKey(selector coretypes.Selector) string {
|
||||
return group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String() + "|" + selector.String()
|
||||
}
|
||||
|
||||
func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup, error) {
|
||||
verb, err := coretypes.NewVerb(raw.Relation)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].relation: %s", index, err.Error())
|
||||
}
|
||||
|
||||
resourceType, err := coretypes.NewType(raw.ObjectGroup.Resource.Type)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.resource.type: %s", index, err.Error())
|
||||
}
|
||||
|
||||
kind, err := coretypes.NewKind(raw.ObjectGroup.Resource.Kind)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.resource.kind: %s", index, err.Error())
|
||||
}
|
||||
|
||||
resourceRef := coretypes.ResourceRef{Type: resourceType, Kind: kind}
|
||||
if err := coretypes.ErrIfVerbNotValidForResource(verb, resourceRef); err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d]: %s", index, err.Error())
|
||||
}
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(raw.ObjectGroup.Selectors))
|
||||
for selectorIndex, rawSelector := range raw.ObjectGroup.Selectors {
|
||||
selector, err := resourceType.Selector(rawSelector)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
|
||||
}
|
||||
selectors = append(selectors, selector)
|
||||
}
|
||||
|
||||
return &TransactionGroup{
|
||||
Relation: Relation{Verb: verb},
|
||||
ObjectGroup: coretypes.ObjectGroup{Resource: resourceRef, Selectors: selectors},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,48 @@ func MustNewKind(str string) Kind {
|
||||
return kind
|
||||
}
|
||||
|
||||
func (name Kind) Enum() []any {
|
||||
return []any{
|
||||
KindAnonymous,
|
||||
KindOrganization,
|
||||
KindRole,
|
||||
KindServiceAccount,
|
||||
KindUser,
|
||||
KindNotificationChannel,
|
||||
KindRoutePolicy,
|
||||
KindApdexSetting,
|
||||
KindAuthDomain,
|
||||
KindSession,
|
||||
KindCloudIntegration,
|
||||
KindCloudIntegrationService,
|
||||
KindIntegration,
|
||||
KindDashboard,
|
||||
KindPublicDashboard,
|
||||
KindIngestionKey,
|
||||
KindIngestionLimit,
|
||||
KindPipeline,
|
||||
KindUserPreference,
|
||||
KindOrgPreference,
|
||||
KindQuickFilter,
|
||||
KindTTLSetting,
|
||||
KindRule,
|
||||
KindPlannedMaintenance,
|
||||
KindSavedView,
|
||||
KindTraceFunnel,
|
||||
KindFactorPassword,
|
||||
KindFactorAPIKey,
|
||||
KindLicense,
|
||||
KindSubscription,
|
||||
KindLogs,
|
||||
KindTraces,
|
||||
KindMetrics,
|
||||
KindAuditLogs,
|
||||
KindMeterMetrics,
|
||||
KindLogsField,
|
||||
KindTracesField,
|
||||
}
|
||||
}
|
||||
|
||||
func (name Kind) String() string {
|
||||
return name.val
|
||||
}
|
||||
|
||||
@@ -260,11 +260,27 @@ type MetricHighlightsResponse struct {
|
||||
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true"`
|
||||
}
|
||||
|
||||
// MetricNameQuery represents the query parameters for endpoints that take a metric name.
|
||||
type MetricNameQuery struct {
|
||||
MetricName string `query:"metricName" required:"true" description:"The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies)."`
|
||||
}
|
||||
|
||||
// Validate ensures MetricNameQuery contains acceptable values.
|
||||
func (q *MetricNameQuery) Validate() error {
|
||||
if q == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
if q.MetricName == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MetricAttributesRequest represents the query parameters for the metric attributes endpoint.
|
||||
type MetricAttributesRequest struct {
|
||||
MetricName string `json:"-"`
|
||||
Start *int64 `query:"start"`
|
||||
End *int64 `query:"end"`
|
||||
MetricName string `query:"metricName" required:"true" description:"The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies)."`
|
||||
Start *int64 `query:"start" description:"Start of the time range as a Unix timestamp in milliseconds."`
|
||||
End *int64 `query:"end" description:"End of the time range as a Unix timestamp in milliseconds."`
|
||||
}
|
||||
|
||||
// Validate ensures MetricAttributesRequest contains acceptable values.
|
||||
@@ -273,6 +289,10 @@ func (req *MetricAttributesRequest) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
|
||||
if req.MetricName == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
|
||||
}
|
||||
|
||||
if req.Start != nil && req.End != nil {
|
||||
if *req.Start >= *req.End {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start (%d) must be less than end (%d)", *req.Start, *req.End)
|
||||
|
||||
Reference in New Issue
Block a user