mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-28 00:40:33 +01:00
Compare commits
4 Commits
refactor/q
...
feat/llm-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503946f99f | ||
|
|
14961b2936 | ||
|
|
87a716124e | ||
|
|
bae9eea1b4 |
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
)
|
||||
@@ -25,8 +25,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterLogSize,
|
||||
Unit: zeustypes.MeterUnitBytes,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: logstelemetryschema.DBName,
|
||||
TableName: logstelemetryschema.LogsV2LocalTableName,
|
||||
DBName: telemetrylogs.DBName,
|
||||
TableName: telemetrylogs.LogsV2LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultLogsRetentionDays,
|
||||
},
|
||||
},
|
||||
@@ -36,8 +36,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterSpanSize,
|
||||
Unit: zeustypes.MeterUnitBytes,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: tracestelemetryschema.DBName,
|
||||
TableName: tracestelemetryschema.SpanIndexV3LocalTableName,
|
||||
DBName: telemetrytraces.DBName,
|
||||
TableName: telemetrytraces.SpanIndexV3LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultTracesRetentionDays,
|
||||
},
|
||||
},
|
||||
@@ -47,8 +47,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterDatapointCount,
|
||||
Unit: zeustypes.MeterUnitCount,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: metricstelemetryschema.DBName,
|
||||
TableName: metricstelemetryschema.SamplesV4LocalTableName,
|
||||
DBName: telemetrymetrics.DBName,
|
||||
TableName: telemetrymetrics.SamplesV4LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultMetricsRetentionDays,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7238,10 +7238,6 @@ components:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
overallStateChanged:
|
||||
type: boolean
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
type: string
|
||||
ruleId:
|
||||
type: string
|
||||
ruleName:
|
||||
@@ -9934,9 +9930,14 @@ paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the services metadata for the specified cloud
|
||||
provider, without any account context.
|
||||
provider
|
||||
operationId: ListServicesMetadata
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
@@ -9986,10 +9987,14 @@ paths:
|
||||
/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets a service definition for the specified cloud
|
||||
provider, without any account context.
|
||||
description: This endpoint gets a service for the specified cloud provider
|
||||
operationId: GetService
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/licensetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
@@ -153,7 +153,7 @@ func (provider *Provider) Collect(
|
||||
func buildOriginQuery(meterName string) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("toInt64(ifNull(min(unix_milli), 0))")
|
||||
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(sb.Equal("metric_name", meterName))
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func buildQuery(meterName string, segment *retentiontypes.RetentionPolicySegment
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", segment.StartMs),
|
||||
|
||||
@@ -8,16 +8,16 @@ import (
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
)
|
||||
|
||||
var (
|
||||
reductionRulesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.ReductionRulesTableName
|
||||
metadataTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.AttributesMetadataTableName
|
||||
bufferSeriesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.TimeseriesV4BufferTableName
|
||||
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
|
||||
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
|
||||
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
|
||||
)
|
||||
|
||||
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
|
||||
@@ -192,7 +192,7 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "uniq(fingerprint)")
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
@@ -229,8 +229,8 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
|
||||
// reduced sample tables.
|
||||
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, metricstelemetryschema.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -300,9 +300,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
ingestedTable := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName
|
||||
reducedLast := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedLastTableName
|
||||
reducedSum := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedSumTableName
|
||||
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
|
||||
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
|
||||
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
|
||||
@@ -352,16 +352,16 @@ func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []str
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
last, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uin
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint)", "count()")
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -470,7 +470,7 @@ func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string
|
||||
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
@@ -485,7 +485,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
@@ -499,7 +499,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
|
||||
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
|
||||
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
out := make(map[int64]uint64)
|
||||
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
@@ -507,7 +507,7 @@ func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
|
||||
sb.From(metricstelemetryschema.DBName + "." + table)
|
||||
sb.From(telemetrymetrics.DBName + "." + table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
|
||||
@@ -36,12 +36,14 @@ import type {
|
||||
GetConnectionCredentials200,
|
||||
GetConnectionCredentialsPathParameters,
|
||||
GetService200,
|
||||
GetServiceParams,
|
||||
GetServicePathParameters,
|
||||
ListAccountServicesMetadata200,
|
||||
ListAccountServicesMetadataPathParameters,
|
||||
ListAccounts200,
|
||||
ListAccountsPathParameters,
|
||||
ListServicesMetadata200,
|
||||
ListServicesMetadataParams,
|
||||
ListServicesMetadataPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateAccountPathParameters,
|
||||
@@ -1160,24 +1162,30 @@ export const invalidateGetConnectionCredentials = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the services metadata for the specified cloud provider, without any account context.
|
||||
* This endpoint lists the services metadata for the specified cloud provider
|
||||
* @summary List services metadata
|
||||
*/
|
||||
export const listServicesMetadata = (
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListServicesMetadata200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryKey = ({
|
||||
cloudProvider,
|
||||
}: ListServicesMetadataPathParameters) => {
|
||||
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
|
||||
export const getListServicesMetadataQueryKey = (
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryOptions = <
|
||||
@@ -1185,6 +1193,7 @@ export const getListServicesMetadataQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1196,11 +1205,12 @@ export const getListServicesMetadataQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
|
||||
queryOptions?.queryKey ??
|
||||
getListServicesMetadataQueryKey({ cloudProvider }, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1228,6 +1238,7 @@ export function useListServicesMetadata<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1238,6 +1249,7 @@ export function useListServicesMetadata<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListServicesMetadataQueryOptions(
|
||||
{ cloudProvider },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1254,10 +1266,11 @@ export function useListServicesMetadata<
|
||||
export const invalidateListServicesMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1265,26 +1278,29 @@ export const invalidateListServicesMetadata = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint gets a service definition for the specified cloud provider, without any account context.
|
||||
* This endpoint gets a service for the specified cloud provider
|
||||
* @summary Get service
|
||||
*/
|
||||
export const getService = (
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetService200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceQueryKey = ({
|
||||
cloudProvider,
|
||||
serviceId,
|
||||
}: GetServicePathParameters) => {
|
||||
export const getGetServiceQueryKey = (
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
@@ -1293,6 +1309,7 @@ export const getGetServiceQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1304,11 +1321,12 @@ export const getGetServiceQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
|
||||
queryOptions?.queryKey ??
|
||||
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
|
||||
signal,
|
||||
}) => getService({ cloudProvider, serviceId }, signal);
|
||||
}) => getService({ cloudProvider, serviceId }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1334,6 +1352,7 @@ export function useGetService<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1344,6 +1363,7 @@ export function useGetService<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceQueryOptions(
|
||||
{ cloudProvider, serviceId },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1360,10 +1380,11 @@ export function useGetService<
|
||||
export const invalidateGetService = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -8320,14 +8320,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
overallStateChanged: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedLogsLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedTracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10204,6 +10196,14 @@ export type GetConnectionCredentials200 = {
|
||||
export type ListServicesMetadataPathParameters = {
|
||||
cloudProvider: string;
|
||||
};
|
||||
export type ListServicesMetadataParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type ListServicesMetadata200 = {
|
||||
data: CloudintegrationtypesGettableServicesMetadataDTO;
|
||||
/**
|
||||
@@ -10216,6 +10216,14 @@ export type GetServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
serviceId: string;
|
||||
};
|
||||
export type GetServiceParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type GetService200 = {
|
||||
data: CloudintegrationtypesServiceDTO;
|
||||
/**
|
||||
|
||||
@@ -380,88 +380,4 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw logs body: extract lone `message` field', () => {
|
||||
function makeRawResult(
|
||||
rows: Array<{ timestamp: string; data: Record<string, any> }>,
|
||||
type: 'raw' | 'trace' = 'raw',
|
||||
): ReturnType<typeof convertV5ResponseToLegacy> {
|
||||
const v5Data = {
|
||||
type,
|
||||
data: { results: [{ queryName: 'A', rows }] },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
|
||||
const params = makeBaseParams(type as RequestType, [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: type === 'trace' ? 'traces' : 'logs',
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
|
||||
makeBaseSuccess({ data: v5Data }, params);
|
||||
|
||||
return convertV5ResponseToLegacy(input, { A: 'A' }, false);
|
||||
}
|
||||
|
||||
it('unwraps body when it is an object with only a message field', () => {
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: 'hello' } } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe('hello');
|
||||
});
|
||||
|
||||
it('leaves body unchanged when the object has keys besides message', () => {
|
||||
const body = { message: 'hello', level: 'INFO' };
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toStrictEqual(
|
||||
body,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a string body unchanged (use_json_body off)', () => {
|
||||
const result = makeRawResult([
|
||||
{
|
||||
timestamp: '2026-07-21T00:00:00Z',
|
||||
data: { body: '{"message":"hello"}' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
|
||||
'{"message":"hello"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('stringifies the nested object when message is an object', () => {
|
||||
const nested = { a: 1, b: 2 };
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: nested } } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
|
||||
JSON.stringify(nested),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add a body key to rows without a body (traces)', () => {
|
||||
const result = makeRawResult(
|
||||
[{ timestamp: '2026-07-21T00:00:00Z', data: { name: 'span-1' } }],
|
||||
'trace',
|
||||
);
|
||||
|
||||
const data = (result.payload.data.result[0].list?.[0] as any)?.data ?? {};
|
||||
expect('body' in data).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,19 +273,6 @@ function convertScalarWithFormatForWeb(
|
||||
});
|
||||
}
|
||||
|
||||
function extractOnlyMessageBody(body: unknown): unknown {
|
||||
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
|
||||
if (isJsonBody) {
|
||||
const keys = Object.keys(body);
|
||||
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
|
||||
if (hasOnlyMessageKey) {
|
||||
const { message } = body as { message: unknown };
|
||||
return typeof message === 'string' ? message : JSON.stringify(message);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 RawData to legacy format
|
||||
*/
|
||||
@@ -298,22 +285,14 @@ function convertRawData(
|
||||
queryName: rawData.queryName,
|
||||
legend: legendMap[rawData.queryName] || rawData.queryName,
|
||||
series: null,
|
||||
list: rawData.rows?.map((row) => {
|
||||
const data = {
|
||||
list: rawData.rows?.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
data: {
|
||||
// Map raw data to ILog structure - spread row.data first to include all properties
|
||||
...row.data,
|
||||
date: row.timestamp,
|
||||
} as any;
|
||||
|
||||
if ('body' in row.data) {
|
||||
data.body = extractOnlyMessageBody(row.data.body);
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
} as any,
|
||||
})),
|
||||
nextCursor: rawData.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
convertBuilderQueriesToV5,
|
||||
prepareQueryRangePayloadV5,
|
||||
} from './prepareQueryRangePayloadV5';
|
||||
import { prepareQueryRangePayloadV5 } from './prepareQueryRangePayloadV5';
|
||||
|
||||
jest.mock('lib/getStartEndRangeTime', () => ({
|
||||
__esModule: true,
|
||||
@@ -902,36 +899,3 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
expect(logSpec.filter).toStrictEqual({ expression: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertBuilderQueriesToV5 having normalization', () => {
|
||||
const buildSpec = (having: unknown): MetricBuilderQuery => {
|
||||
const [envelope] = convertBuilderQueriesToV5(
|
||||
{
|
||||
A: {
|
||||
dataSource: DataSource.METRICS,
|
||||
queryName: 'A',
|
||||
aggregations: [{ metricName: 'm', spaceAggregation: 'p99' }],
|
||||
having,
|
||||
} as unknown as IBuilderQuery,
|
||||
},
|
||||
'time_series',
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
return envelope.spec as MetricBuilderQuery;
|
||||
};
|
||||
|
||||
it.each([
|
||||
['a legacy V4 array', []],
|
||||
['a blank empty-object having', { expression: '' }],
|
||||
['a whitespace-only having', { expression: ' ' }],
|
||||
['a nullish having', undefined],
|
||||
])('drops %s (serializes to undefined)', (_label, having) => {
|
||||
expect(buildSpec(having).having).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves a real having expression', () => {
|
||||
expect(buildSpec({ expression: 'count() > 5' }).having).toStrictEqual({
|
||||
expression: 'count() > 5',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,21 +134,6 @@ function getFilter(queryData: IBuilderQuery): Filter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a builder query's `having` to the V5 shape, treating "no having filter" as absent.
|
||||
* V4 stored it as an array; V5 expects `{ expression }`. An array (legacy), a nullish value, or a
|
||||
* blank expression — the query builder seeds `{ expression: '' }` for an empty having — all mean
|
||||
* "no having" and must serialize to `undefined`. Emitting an empty `{ expression: '' }` sends a
|
||||
* no-op filter and, because a saved panel never carries one, reads an untouched panel as dirty.
|
||||
*/
|
||||
function normalizeHaving(having: unknown): Having | undefined {
|
||||
if (having == null || Array.isArray(having)) {
|
||||
return undefined;
|
||||
}
|
||||
const { expression } = having as Having;
|
||||
return expression?.trim() ? (having as Having) : undefined;
|
||||
}
|
||||
|
||||
function createBaseSpec(
|
||||
queryData: IBuilderQuery,
|
||||
requestType: RequestType,
|
||||
@@ -196,7 +181,12 @@ function createBaseSpec(
|
||||
)
|
||||
: undefined,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
having: normalizeHaving(queryData.having),
|
||||
// V4 uses having as array, V5 uses having as object with expression field
|
||||
// If having is an array (V4 format), treat it as undefined for V5
|
||||
having:
|
||||
isEmpty(queryData.having) || Array.isArray(queryData.having)
|
||||
? undefined
|
||||
: (queryData?.having as Having),
|
||||
functions: isEmpty(queryData.functions)
|
||||
? undefined
|
||||
: queryData.functions.map((func: QueryFunction): QueryFunction => {
|
||||
@@ -424,7 +414,10 @@ function createTraceOperatorBaseSpec(
|
||||
)
|
||||
: undefined,
|
||||
legend: isEmpty(legend) ? undefined : legend,
|
||||
having: normalizeHaving(having),
|
||||
// V4 uses having as array, V5 uses having as object with expression field
|
||||
// If having is an array (V4 format), treat it as undefined for V5
|
||||
having:
|
||||
isEmpty(having) || Array.isArray(having) ? undefined : (having as Having),
|
||||
selectFields: isEmpty(nonEmptySelectColumns)
|
||||
? undefined
|
||||
: nonEmptySelectColumns?.map(
|
||||
|
||||
@@ -213,9 +213,7 @@ describe.each([
|
||||
const callArgs = mockDownloadExportData.mock.calls[0][0];
|
||||
const query = callArgs.body.compositeQuery.queries[0];
|
||||
expect(query.spec.groupBy).toBeUndefined();
|
||||
// An empty having ({ expression: '' }) is a no-op filter and serializes to
|
||||
// undefined — same as the cleared groupBy above.
|
||||
expect(query.spec.having).toBeUndefined();
|
||||
expect(query.spec.having).toStrictEqual({ expression: '' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip/TextToolTip';
|
||||
@@ -34,7 +33,6 @@ import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
ALL_SELECTED_VALUE,
|
||||
filterOptionsBySearch,
|
||||
findOptionLabelText,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
SPACEKEY,
|
||||
@@ -1939,7 +1937,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const tag = (
|
||||
return (
|
||||
<div
|
||||
className={cx('ant-select-selection-item', {
|
||||
'ant-select-selection-item-active': isActive,
|
||||
@@ -1969,32 +1967,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// `label` arrives already cut to maxTagTextLength, so the reveal reads the
|
||||
// option's own text (falling back to the raw value for freeform tags).
|
||||
return (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
delayDuration={300}
|
||||
title={findOptionLabelText(options, value)}
|
||||
>
|
||||
{tag}
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for safety, should not be reached
|
||||
return <div />;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
isAllSelected,
|
||||
activeChipIndex,
|
||||
selectedChips,
|
||||
selectedValues,
|
||||
maxTagCount,
|
||||
options,
|
||||
],
|
||||
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
|
||||
);
|
||||
|
||||
// Simple onClear handler to prevent clearing ALL
|
||||
@@ -2013,58 +1992,51 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
// Self-provided so the per-tag tooltips work wherever this select is rendered,
|
||||
// without every consumer having to sit under an app-level provider.
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className={cx('custom-multiselect-wrapper', {
|
||||
'all-selected': allOptionShown || isAllSelected,
|
||||
<div
|
||||
className={cx('custom-multiselect-wrapper', {
|
||||
'all-selected': allOptionShown || isAllSelected,
|
||||
})}
|
||||
>
|
||||
{(allOptionShown || isAllSelected) && !searchText && (
|
||||
<div className="all-text">ALL</div>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
})}
|
||||
>
|
||||
{(allOptionShown || isAllSelected) && !searchText && (
|
||||
<div className="all-text">ALL</div>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={(newValue): void => {
|
||||
handleInternalChange(newValue, false);
|
||||
}}
|
||||
onClear={onClearHandler}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx(
|
||||
'custom-multiselect-dropdown-container',
|
||||
popupClassName,
|
||||
)}
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
tagRender={tagRender as any}
|
||||
placement={placement}
|
||||
listHeight={300}
|
||||
searchValue={searchText}
|
||||
maxTagTextLength={maxTagTextLength}
|
||||
maxTagCount={isAllSelected ? undefined : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={(newValue): void => {
|
||||
handleInternalChange(newValue, false);
|
||||
}}
|
||||
onClear={onClearHandler}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
tagRender={tagRender as any}
|
||||
placement={placement}
|
||||
listHeight={300}
|
||||
searchValue={searchText}
|
||||
maxTagTextLength={maxTagTextLength}
|
||||
maxTagCount={isAllSelected ? undefined : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CustomMultiSelect from '../CustomMultiSelect';
|
||||
|
||||
const OPTIONS = [
|
||||
{ label: 'checkout-service-prod', value: 'checkout-service-prod' },
|
||||
{ label: 'payments-service-prod', value: 'payments-service-prod' },
|
||||
{ label: 'cart-service-prod', value: 'cart-service-prod' },
|
||||
];
|
||||
|
||||
const SELECTED = ['checkout-service-prod', 'payments-service-prod'];
|
||||
|
||||
function renderSelect(): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CustomMultiSelect
|
||||
options={OPTIONS}
|
||||
value={SELECTED}
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Hovers an element and lets the tooltip's open delay elapse. */
|
||||
async function hover(element: HTMLElement): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(element);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe('CustomMultiSelect tag tooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("reveals a tag's untruncated value on hover", async () => {
|
||||
renderSelect();
|
||||
|
||||
await hover(screen.getByText('checkout-s...'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'checkout-service-prod',
|
||||
);
|
||||
});
|
||||
|
||||
// The `+N` placeholder stays the caller's to render — several callers already wrap
|
||||
// it in a tooltip of their own, and a second one would stack on top.
|
||||
it('leaves the +N overflow placeholder untouched', async () => {
|
||||
renderSelect();
|
||||
|
||||
await hover(screen.getByText('+1'));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -109,21 +109,6 @@ export const prioritizeOrAddOptionForMultiSelect = (
|
||||
return [...flatOutSelectedOptions, ...filteredOptions];
|
||||
};
|
||||
|
||||
export const findOptionLabelText = (
|
||||
options: OptionData[],
|
||||
value: string,
|
||||
): string => {
|
||||
const match = options
|
||||
.flatMap((option) =>
|
||||
'options' in option && Array.isArray(option.options)
|
||||
? option.options
|
||||
: [option],
|
||||
)
|
||||
.find((option) => option.value === value);
|
||||
|
||||
return typeof match?.label === 'string' ? match.label : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters options based on search text
|
||||
*/
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
// Padding for the tooltip body that hosts a scroll area: the right side is given
|
||||
// up so the scrollbar can sit flush against the tooltip's edge instead of floating
|
||||
// inset from it. `.scrollArea` puts that spacing back between the text and the bar.
|
||||
.tooltipContent {
|
||||
--tooltip-padding: var(--spacing-2) 0 var(--spacing-2) var(--spacing-4);
|
||||
}
|
||||
|
||||
// How tall a hover reveal grows before its content starts scrolling.
|
||||
.scrollArea {
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
// Keep the page behind the tooltip still once the list hits its end.
|
||||
overscroll-behavior: contain;
|
||||
// Gap between the content and the scrollbar (or the tooltip edge when short).
|
||||
padding-right: var(--spacing-4);
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import styles from './TooltipScrollArea.module.scss';
|
||||
|
||||
interface TooltipScrollAreaProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass as the hosting tooltip's content class (`tooltipContentProps`) so its
|
||||
* padding makes room for the scroll area's own edge handling.
|
||||
*/
|
||||
export const TOOLTIP_SCROLL_CONTENT_CLASS = styles.tooltipContent;
|
||||
|
||||
/**
|
||||
* Scroll container for hover reveals that list an unbounded number of items (tag
|
||||
* chips, variable values): caps the tooltip's height and scrolls past it. Plain CSS
|
||||
* overflow with a pinned-visible thin scrollbar — a tooltip is transient, so an
|
||||
* auto-hiding scrollbar would leave no hint that there is more below.
|
||||
*/
|
||||
function TooltipScrollArea({ children }: TooltipScrollAreaProps): JSX.Element {
|
||||
return <div className={styles.scrollArea}>{children}</div>;
|
||||
}
|
||||
|
||||
export default TooltipScrollArea;
|
||||
@@ -167,56 +167,6 @@ describe('getAutoContexts', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns panel edit context on the V2 panel editor', () => {
|
||||
const dashboardId = 'dash-123';
|
||||
const panelId = 'panel-abc';
|
||||
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
dashboardId,
|
||||
).replace(':panelId', panelId);
|
||||
|
||||
const contexts = getAutoContexts(pathname, '');
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_edit',
|
||||
widgetId: panelId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns new panel context on the unsaved new-panel editor', () => {
|
||||
const dashboardId = 'dash-123';
|
||||
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
dashboardId,
|
||||
).replace(':panelId', 'new');
|
||||
const startTime = '1700000000000';
|
||||
const endTime = '1700003600000';
|
||||
|
||||
const contexts = getAutoContexts(
|
||||
pathname,
|
||||
`?panelKind=TimeSeries&${QueryParams.startTime}=${startTime}&${QueryParams.endTime}=${endTime}`,
|
||||
);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_create',
|
||||
timeRange: { start: Number(startTime), end: Number(endTime) },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array on alert overview without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, '');
|
||||
|
||||
|
||||
@@ -17,28 +17,6 @@ describe('resolvePageType', () => {
|
||||
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.dashboard_detail);
|
||||
});
|
||||
|
||||
it('returns panel_edit on the V2 panel editor', () => {
|
||||
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
'dash-123',
|
||||
).replace(':panelId', 'panel-abc');
|
||||
|
||||
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.panel_edit);
|
||||
});
|
||||
|
||||
// `panel_create` has no PageTypeDTO counterpart yet, so it reports
|
||||
// `panel_edit` rather than degrading to `other`.
|
||||
it('returns panel_edit on the unsaved new-panel editor', () => {
|
||||
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
'dash-123',
|
||||
).replace(':panelId', 'new');
|
||||
|
||||
expect(resolvePageType(pathname, '?panelKind=TimeSeries')).toBe(
|
||||
PageTypeDTO.panel_edit,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns alerts_triggered on alert history without ruleId', () => {
|
||||
expect(resolvePageType(ROUTES.ALERT_HISTORY, '')).toBe(
|
||||
PageTypeDTO.alerts_triggered,
|
||||
|
||||
@@ -16,22 +16,17 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type { UploadFile } from 'antd';
|
||||
import getSessionStorage from 'api/browser/sessionstorage/get';
|
||||
import setSessionStorage from 'api/browser/sessionstorage/set';
|
||||
import {
|
||||
getListDashboardsForUserV2QueryKey,
|
||||
useListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
import type { SuccessResponseV2 } from 'types/api';
|
||||
import type { Dashboard } from 'types/api/dashboard/getAll';
|
||||
// eslint-disable-next-line
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -105,8 +100,6 @@ function autoContextLabel(ctx: MessageContext): string {
|
||||
return 'Current dashboard';
|
||||
case 'panel_edit':
|
||||
return 'Editing panel';
|
||||
case 'panel_create':
|
||||
return 'New panel';
|
||||
case 'panel_fullscreen':
|
||||
return 'Panel (fullscreen)';
|
||||
case 'dashboard_list':
|
||||
@@ -171,18 +164,6 @@ const TEXTAREA_MAX_HEIGHT_PX = 200;
|
||||
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
|
||||
/** sessionStorage key for the "voice input failed this tab" flag. */
|
||||
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
/**
|
||||
* The picker filters client-side, so it pulls one large page instead of
|
||||
* paginating. Shared with `getQueryData` below — the params are part of the
|
||||
* generated query key, so both sides must use the same object.
|
||||
*/
|
||||
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
|
||||
|
||||
function dashboardTitle(
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): string {
|
||||
return dashboard.spec.display?.name || dashboard.name || 'Untitled';
|
||||
}
|
||||
|
||||
interface SelectedContextItem {
|
||||
category: ContextCategory;
|
||||
@@ -735,11 +716,9 @@ export default function ChatInput({
|
||||
data: dashboardsResponse,
|
||||
isLoading: isDashboardsLoading,
|
||||
isError: isDashboardsError,
|
||||
} = useListDashboardsForUserV2(DASHBOARD_LIST_PARAMS, {
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
|
||||
staleTime: Infinity,
|
||||
},
|
||||
} = useGetAllDashboard({
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -786,12 +765,12 @@ export default function ChatInput({
|
||||
return ctx.resourceId;
|
||||
}
|
||||
if (ctx.type === 'dashboard' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<ListDashboardsForUserV2200>(
|
||||
getListDashboardsForUserV2QueryKey(DASHBOARD_LIST_PARAMS),
|
||||
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
|
||||
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
|
||||
);
|
||||
const dash = cached?.data?.dashboards?.find((d) => d.id === ctx.resourceId);
|
||||
if (dash) {
|
||||
return dashboardTitle(dash);
|
||||
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
|
||||
if (dash?.data.title) {
|
||||
return dash.data.title;
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
@@ -821,9 +800,9 @@ export default function ChatInput({
|
||||
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
|
||||
{
|
||||
Dashboards:
|
||||
dashboardsResponse?.data?.dashboards?.map((dashboard) => ({
|
||||
dashboardsResponse?.data?.map((dashboard) => ({
|
||||
id: dashboard.id,
|
||||
value: dashboardTitle(dashboard),
|
||||
value: dashboard.data.title ?? 'Untitled',
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data
|
||||
|
||||
@@ -2,13 +2,12 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
// The prefill flow only depends on the context-picker data hooks resolving to
|
||||
// empty lists (so the empty state renders) — mock them to skip real fetches.
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
|
||||
useGetAllDashboard: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { NEW_PANEL_ID } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
@@ -31,23 +30,22 @@ export function getAutoContexts(
|
||||
|
||||
// ── Dashboards ────────────────────────────────────────────────────────────
|
||||
|
||||
// Panel editor (V2). `panel/new` has no widget id yet and the schema requires
|
||||
// a non-empty `panel_edit.widgetId`, so it reports `panel_create` instead.
|
||||
const panelEditorMatch = matchPath<{ dashboardId: string; panelId: string }>(
|
||||
// Widget edit (panel_edit) — `/dashboard/:dashboardId/:widgetId`.
|
||||
const widgetMatch = matchPath<{ dashboardId: string; widgetId: string }>(
|
||||
pathname,
|
||||
{ path: ROUTES.DASHBOARD_PANEL_EDITOR, exact: true },
|
||||
{ path: ROUTES.DASHBOARD_WIDGET, exact: true },
|
||||
);
|
||||
if (panelEditorMatch) {
|
||||
const { dashboardId, panelId } = panelEditorMatch.params;
|
||||
const isNewPanel = panelId === NEW_PANEL_ID;
|
||||
if (widgetMatch) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardId,
|
||||
metadata: isNewPanel
|
||||
? { page: 'panel_create', ...sharedMetadata }
|
||||
: { page: 'panel_edit', widgetId: panelId, ...sharedMetadata },
|
||||
resourceId: widgetMatch.params.dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_edit',
|
||||
widgetId: widgetMatch.params.widgetId,
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ const PAGE_METADATA_TO_DTO: Record<string, PageTypeDTO> = {
|
||||
dashboard_detail: PageTypeDTO.dashboard_detail,
|
||||
dashboard_list: PageTypeDTO.dashboard_list,
|
||||
panel_edit: PageTypeDTO.panel_edit,
|
||||
// There is no panel_create, so sending panel_edit temporarily
|
||||
panel_create: PageTypeDTO.panel_edit,
|
||||
panel_fullscreen: PageTypeDTO.panel_fullscreen,
|
||||
logs_explorer: PageTypeDTO.logs_explorer,
|
||||
trace_detail: PageTypeDTO.trace_detail,
|
||||
|
||||
@@ -147,6 +147,7 @@ function ServiceDetails({
|
||||
cloudProvider: type,
|
||||
serviceId: serviceId || '',
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
query: {
|
||||
enabled: !!serviceId && !cloudAccountId,
|
||||
|
||||
@@ -39,12 +39,9 @@ function ServicesList({
|
||||
const {
|
||||
data: providerServicesMetadata,
|
||||
isLoading: isProviderServicesLoading,
|
||||
} = useListServicesMetadata(
|
||||
{ cloudProvider: type },
|
||||
{
|
||||
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
|
||||
},
|
||||
);
|
||||
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
|
||||
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
|
||||
});
|
||||
|
||||
const servicesMetadata = hasConnectedAccounts
|
||||
? accountServicesMetadata
|
||||
|
||||
@@ -15,6 +15,18 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
// Admin gating on the write controls flows through useAuthZ. Mock it directly
|
||||
// (synchronous) so the functional tests stay synchronous; the read-only block
|
||||
// below flips it to deny-all.
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
@@ -102,6 +114,8 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset URL state between tests — jsdom shares window.location across a file.
|
||||
window.history.pushState(null, '', '/');
|
||||
// Default to an admin; the read-only block overrides this.
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -507,4 +521,55 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The write APIs (create/update/delete group & mapper) are Admin-only on the
|
||||
// backend, so a non-admin gets a read-only view: the data renders, but every
|
||||
// write control is hidden.
|
||||
describe('read-only (non-admin)', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
});
|
||||
|
||||
it('hides the "Add a new group" button', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
expect(screen.queryByTestId('add-group-row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the group enable toggle and actions menu', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
expect(
|
||||
screen.queryByTestId('group-enabled-group-1'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('group-actions-group-1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mappers read-only: no "Add mapping" button or row actions', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
// The mapper still renders...
|
||||
await screen.findByTestId('mapper-target-mapper-1');
|
||||
// ...but every write control is gone.
|
||||
expect(screen.queryByTestId('add-mapper-group-1')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('mapper-enabled-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Mapping actions' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
|
||||
import styles from './GroupHeaderActions.module.scss';
|
||||
|
||||
@@ -16,7 +17,11 @@ function GroupHeaderActions({
|
||||
onToggle,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupHeaderActionsProps): JSX.Element {
|
||||
}: GroupHeaderActionsProps): JSX.Element | null {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.actions}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Mapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import { COLUMN_COUNT } from '../constants';
|
||||
import MapperRow from '../MapperRow/MapperRow';
|
||||
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
|
||||
@@ -39,6 +40,7 @@ function GroupMappers({
|
||||
onEditMapper,
|
||||
}: GroupMappersProps): JSX.Element {
|
||||
const { hydrateGroupMappers, removeMapper, toggleMapper } = editor;
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
|
||||
const hasServerId = group.serverId !== null;
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
@@ -144,16 +146,20 @@ function GroupMappers({
|
||||
/>
|
||||
));
|
||||
|
||||
// The add-mapping row trails every non-error state (including loading/empty).
|
||||
// The add-mapping row trails every non-error state (including loading/empty),
|
||||
// but only for users who can manage mappings — non-admins get a read-only view.
|
||||
let rows: JSX.Element[];
|
||||
if (isErrorMappers) {
|
||||
rows = [errorRow];
|
||||
} else if (isLoadingMappers && mapperCount === 0) {
|
||||
rows = [...skeletonRows, addMapperRow];
|
||||
rows = [...skeletonRows];
|
||||
} else if (mapperCount === 0) {
|
||||
rows = [emptyRow, addMapperRow];
|
||||
rows = [emptyRow];
|
||||
} else {
|
||||
rows = [...mapperRows, addMapperRow];
|
||||
rows = [...mapperRows];
|
||||
}
|
||||
if (canManage && !isErrorMappers) {
|
||||
rows.push(addMapperRow);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import cx from 'classnames';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
@@ -30,6 +31,7 @@ function MapperRow({
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: MapperRowProps): JSX.Element {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
const sources = mapper.sources ?? [];
|
||||
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remainingSources = sources.length - visibleSources.length;
|
||||
@@ -101,14 +103,16 @@ function MapperRow({
|
||||
)}
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.actionsCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DraftMapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import GroupHeader from './GroupHeader/GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers/GroupMappers';
|
||||
@@ -33,6 +34,7 @@ function MappingsTable({
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
|
||||
const drawer = useMapperFormDrawer();
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
|
||||
const { upsertMapper, removeMapper } = editor;
|
||||
|
||||
@@ -120,18 +122,20 @@ function MappingsTable({
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={editor.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={editor.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
<div className={styles.tableState} data-testid="mapper-groups-empty">
|
||||
|
||||
@@ -8,6 +8,7 @@ import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
|
||||
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
|
||||
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import TestTab from './TestTab/TestTab';
|
||||
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
|
||||
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
|
||||
|
||||
@@ -44,9 +45,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
{
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
disabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
children: null,
|
||||
children: <TestTab editor={editor} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
.skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.lineNumber {
|
||||
flex: 0 0 40px;
|
||||
padding-right: var(--padding-3);
|
||||
text-align: right;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.lineContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-border);
|
||||
animation: jsonSkeletonPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jsonSkeletonPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import styles from './JsonEditorSkeleton.module.scss';
|
||||
|
||||
interface SkeletonLine {
|
||||
indent: number;
|
||||
barWidths: number[];
|
||||
}
|
||||
|
||||
const SKELETON_LINES: SkeletonLine[] = [
|
||||
{ indent: 0, barWidths: [10] },
|
||||
{ indent: 1, barWidths: [90, 10] },
|
||||
{ indent: 2, barWidths: [155, 190] },
|
||||
{ indent: 2, barWidths: [135, 190] },
|
||||
{ indent: 2, barWidths: [150, 50] },
|
||||
{ indent: 2, barWidths: [180, 35] },
|
||||
{ indent: 2, barWidths: [185, 200] },
|
||||
{ indent: 1, barWidths: [14] },
|
||||
{ indent: 1, barWidths: [75, 10] },
|
||||
{ indent: 2, barWidths: [95, 90] },
|
||||
{ indent: 2, barWidths: [170, 85] },
|
||||
{ indent: 1, barWidths: [10] },
|
||||
{ indent: 0, barWidths: [10] },
|
||||
];
|
||||
|
||||
const INDENT_WIDTH_PX = 14;
|
||||
|
||||
function JsonEditorSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
data-testid="json-editor-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{SKELETON_LINES.map((line, lineIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={lineIndex} className={styles.line}>
|
||||
<span className={styles.lineNumber}>{lineIndex + 1}</span>
|
||||
<span
|
||||
className={styles.lineContent}
|
||||
style={{ paddingLeft: line.indent * INDENT_WIDTH_PX }}
|
||||
>
|
||||
{line.barWidths.map((width, barIndex) => (
|
||||
<span
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={barIndex}
|
||||
className={styles.bar}
|
||||
style={{ width }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditorSkeleton;
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
AttrChangeStatus,
|
||||
AttrDiffEntry,
|
||||
diffAttributeMaps,
|
||||
formatAttributeValue,
|
||||
} from './testPayload';
|
||||
import styles from './TestTab.module.scss';
|
||||
import { TestTabAttributes, TestTabResource } from './useTestSpanMapper';
|
||||
|
||||
interface TestResultProps {
|
||||
index: number;
|
||||
span: SpantypesSpanMapperTestSpanDTO;
|
||||
inputAttributes: TestTabAttributes;
|
||||
inputResource: TestTabResource;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Partial<
|
||||
Record<
|
||||
AttrChangeStatus,
|
||||
{ color: 'success' | 'robin' | 'sienna'; label: string }
|
||||
>
|
||||
> = {
|
||||
added: { color: 'success', label: 'populated' },
|
||||
changed: { color: 'robin', label: 'remapped' },
|
||||
removed: { color: 'sienna', label: 'moved out' },
|
||||
};
|
||||
|
||||
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
|
||||
added: styles.added,
|
||||
removed: styles.removed,
|
||||
};
|
||||
|
||||
interface ResultSection {
|
||||
key: string;
|
||||
title: string;
|
||||
entries: AttrDiffEntry[];
|
||||
}
|
||||
|
||||
function TestResult({
|
||||
index,
|
||||
span,
|
||||
inputAttributes,
|
||||
inputResource,
|
||||
}: TestResultProps): JSX.Element {
|
||||
const attributeEntries = useMemo(
|
||||
() => diffAttributeMaps(inputAttributes, span.attributes ?? {}),
|
||||
[inputAttributes, span.attributes],
|
||||
);
|
||||
const resourceEntries = useMemo(
|
||||
() => diffAttributeMaps(inputResource, span.resource ?? {}),
|
||||
[inputResource, span.resource],
|
||||
);
|
||||
|
||||
const sections: ResultSection[] = [
|
||||
{
|
||||
key: 'attributes',
|
||||
title: 'Resulting attributes',
|
||||
entries: attributeEntries,
|
||||
},
|
||||
];
|
||||
if (resourceEntries.length > 0) {
|
||||
sections.push({
|
||||
key: 'resource',
|
||||
title: 'Resulting resource',
|
||||
entries: resourceEntries,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className={styles.resultSection}
|
||||
data-testid={`test-result-${index}-${section.key}`}
|
||||
>
|
||||
<div className={styles.resultTitle}>{section.title}</div>
|
||||
|
||||
{section.entries.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>No keys in this map.</div>
|
||||
) : (
|
||||
<div className={styles.attrRows}>
|
||||
{section.entries.map((entry) => {
|
||||
const badge = STATUS_BADGE[entry.status];
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
|
||||
>
|
||||
<span className={styles.attrKey} title={entry.key}>
|
||||
{entry.key}
|
||||
</span>
|
||||
<span
|
||||
className={styles.attrValue}
|
||||
title={formatAttributeValue(entry.value)}
|
||||
>
|
||||
{formatAttributeValue(entry.value)}
|
||||
</span>
|
||||
{badge ? (
|
||||
<Badge color={badge.color} variant="outline">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestResult;
|
||||
@@ -0,0 +1,171 @@
|
||||
.testTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
margin-top: var(--spacing-6);
|
||||
}
|
||||
|
||||
// Heading + description on the left, Run button pinned to the top-right.
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-6);
|
||||
height: calc(100vh - 320px);
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.validationCallout {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// No top padding so results align with the editor's first line, which sits
|
||||
// flush against the top of its own panel.
|
||||
.resultsPanel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
// Pre-run empty state that fills the blank right half.
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.placeholderTitle {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.resultSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.resultEmpty {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.attrRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.attrRow {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(200px, 40%, 340px) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--padding-3);
|
||||
border-radius: var(--radius-2);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&.added {
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
&.removed {
|
||||
opacity: 0.65;
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attrKey {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.attrValue {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import MEditor from '@monaco-editor/react';
|
||||
import { Play } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import styles from './TestTab.module.scss';
|
||||
import JsonEditorSkeleton from './JsonEditorSkeleton';
|
||||
import TestResult from './TestResult';
|
||||
import { AttributeMappingEditor } from '../hooks/useAttributeMappingEditor';
|
||||
import {
|
||||
defineSignozJsonTheme,
|
||||
SIGNOZ_JSON_THEME_DARK,
|
||||
SIGNOZ_JSON_THEME_LIGHT,
|
||||
} from './jsonEditorTheme';
|
||||
import { useTestSpanMapper } from './useTestSpanMapper';
|
||||
|
||||
interface TestTabProps {
|
||||
editor: AttributeMappingEditor;
|
||||
}
|
||||
|
||||
function TestTab({ editor }: TestTabProps): JSX.Element {
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
isRunning,
|
||||
result,
|
||||
testedAttributes,
|
||||
testedResource,
|
||||
error,
|
||||
validationError,
|
||||
} = useTestSpanMapper(editor.snapshot, editor.groups);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
function renderResults(): JSX.Element {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.error} role="alert" data-testid="test-error">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (result) {
|
||||
if (result.length === 0) {
|
||||
return (
|
||||
<div className={styles.resultEmpty} data-testid="test-results-empty">
|
||||
No spans returned. The mappers produced no output for this input.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.results} data-testid="test-results">
|
||||
{result.map((span, index) => (
|
||||
<TestResult
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
span={span}
|
||||
inputAttributes={testedAttributes ?? {}}
|
||||
inputResource={testedResource ?? {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.placeholder} data-testid="test-results-placeholder">
|
||||
<span className={styles.placeholderTitle}>No results yet</span>
|
||||
<span>
|
||||
Run the test to see which target attributes your mappers populate.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.testTab} data-testid="test-tab">
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerText}>
|
||||
<h3 className={styles.heading}>Test with sample span</h3>
|
||||
<p className={styles.description}>
|
||||
Paste a JSON span object to see which target attributes get populated and
|
||||
which source key matched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
testId="run-test-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Play size={14} />}
|
||||
onClick={run}
|
||||
loading={isRunning}
|
||||
disabled={isRunning || validationError !== null}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div
|
||||
className={styles.editor}
|
||||
data-testid="test-span-input"
|
||||
aria-label="Sample span JSON"
|
||||
>
|
||||
<MEditor
|
||||
language="json"
|
||||
value={input}
|
||||
onChange={(value): void => setInput(value ?? '')}
|
||||
height="100%"
|
||||
loading={<JsonEditorSkeleton />}
|
||||
theme={isDarkMode ? SIGNOZ_JSON_THEME_DARK : SIGNOZ_JSON_THEME_LIGHT}
|
||||
beforeMount={defineSignozJsonTheme}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnPaste: true,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultsPanel} data-testid="test-results-panel">
|
||||
{renderResults()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
title={validationError}
|
||||
testId="test-input-error"
|
||||
className={styles.validationCallout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestTab;
|
||||
@@ -0,0 +1,38 @@
|
||||
//TODO: This is a local theme for now, but ideally for the editor as well. Just like prettyViewer, we have a theme. We should have a theme for the editor as well.
|
||||
import { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
export const SIGNOZ_JSON_THEME_DARK = 'signoz-attr-mapping-json-dark';
|
||||
export const SIGNOZ_JSON_THEME_LIGHT = 'signoz-attr-mapping-json-light';
|
||||
|
||||
const SHARED_THEME = {
|
||||
inherit: true,
|
||||
colors: {
|
||||
'editor.background': '#00000000', // transparent — inherit the panel bg
|
||||
},
|
||||
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.06,
|
||||
};
|
||||
|
||||
export function defineSignozJsonTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_DARK, {
|
||||
...SHARED_THEME,
|
||||
base: 'vs-dark',
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_ROBIN_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_VANILLA_400 },
|
||||
],
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_LIGHT, {
|
||||
...SHARED_THEME,
|
||||
base: 'vs',
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_ROBIN_500 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_INK_400 },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesPostableSpanMapperTestGroupDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { DraftGroup } from '../types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
groupDraftFromNode,
|
||||
mapperDraftFromNode,
|
||||
} from '../utils';
|
||||
|
||||
function shouldSendMappers(
|
||||
snap: DraftGroup | undefined,
|
||||
group: DraftGroup,
|
||||
): boolean {
|
||||
if (!snap) {
|
||||
return true;
|
||||
}
|
||||
return !isEqual(snap.mappers, group.mappers);
|
||||
}
|
||||
|
||||
export function buildTestGroups(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): SpantypesPostableSpanMapperTestGroupDTO[] {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
|
||||
return draft.map((group) => {
|
||||
const base = buildPostableGroup(groupDraftFromNode(group));
|
||||
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
|
||||
return {
|
||||
...base,
|
||||
mappers: shouldSendMappers(snap, group)
|
||||
? group.mappers.map((mapper) =>
|
||||
buildPostableMapper(mapperDraftFromNode(mapper)),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(parsed);
|
||||
return (
|
||||
keys.length > 0 &&
|
||||
keys.every((key) => key === 'attributes' || key === 'resource') &&
|
||||
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
|
||||
);
|
||||
}
|
||||
|
||||
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Paste a JSON span object to run the test.');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid JSON — check for trailing commas or missing quotes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new Error('Span must be a JSON object of attribute key-value pairs.');
|
||||
}
|
||||
|
||||
if (isSpanEnvelope(parsed)) {
|
||||
return {
|
||||
attributes: isPlainObject(parsed.attributes) ? parsed.attributes : {},
|
||||
resource: isPlainObject(parsed.resource) ? parsed.resource : {},
|
||||
};
|
||||
}
|
||||
|
||||
return { attributes: parsed, resource: {} };
|
||||
}
|
||||
|
||||
export function buildTestRequest(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
input: string,
|
||||
): SpantypesPostableSpanMapperTestDTO {
|
||||
return {
|
||||
groups: buildTestGroups(snapshot, draft),
|
||||
spans: [parseSpanInput(input)],
|
||||
};
|
||||
}
|
||||
|
||||
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
|
||||
|
||||
export interface AttrDiffEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
status: AttrChangeStatus;
|
||||
}
|
||||
|
||||
// Renders a diff value for display: strings as-is, everything else JSON-encoded.
|
||||
export function formatAttributeValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function diffAttributeMaps(
|
||||
inputAttributes: Record<string, unknown>,
|
||||
resultAttributes: Record<string, unknown>,
|
||||
): AttrDiffEntry[] {
|
||||
const added: AttrDiffEntry[] = [];
|
||||
const changed: AttrDiffEntry[] = [];
|
||||
const unchanged: AttrDiffEntry[] = [];
|
||||
const removed: AttrDiffEntry[] = [];
|
||||
|
||||
Object.entries(resultAttributes).forEach(([key, value]) => {
|
||||
if (!(key in inputAttributes)) {
|
||||
added.push({ key, value, status: 'added' });
|
||||
} else if (!isEqual(inputAttributes[key], value)) {
|
||||
changed.push({ key, value, status: 'changed' });
|
||||
} else {
|
||||
unchanged.push({ key, value, status: 'unchanged' });
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(inputAttributes).forEach(([key, value]) => {
|
||||
if (!(key in resultAttributes)) {
|
||||
removed.push({ key, value, status: 'removed' });
|
||||
}
|
||||
});
|
||||
|
||||
return [...added, ...changed, ...unchanged, ...removed];
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { buildTestRequest, parseSpanInput } from './testPayload';
|
||||
import { DraftGroup } from '../types';
|
||||
|
||||
export type TestTabAttributes = Record<string, unknown>;
|
||||
export type TestTabResource = Record<string, unknown>;
|
||||
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"attributes": {
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "llm-gateway",
|
||||
"deployment.environment": "production"
|
||||
}
|
||||
}`;
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
|
||||
return (
|
||||
axiosError?.response?.data?.error?.message ??
|
||||
(error instanceof Error ? error.message : 'Test failed. Please try again.')
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseTestSpanMapper {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
run: () => void;
|
||||
reset: () => void;
|
||||
isRunning: boolean;
|
||||
validationError: string | null;
|
||||
result: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
testedAttributes: TestTabAttributes | null;
|
||||
testedResource: TestTabResource | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useTestSpanMapper(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): UseTestSpanMapper {
|
||||
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
|
||||
null,
|
||||
);
|
||||
const [testedAttributes, setTestedAttributes] =
|
||||
useState<TestTabAttributes | null>(null);
|
||||
const [testedResource, setTestedResource] = useState<TestTabResource | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { mutate, isLoading } = useTestSpanMappers();
|
||||
|
||||
const validationError = useMemo((): string | null => {
|
||||
try {
|
||||
parseSpanInput(input);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Invalid span JSON.';
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTestedAttributes(null);
|
||||
setTestedResource(null);
|
||||
}, []);
|
||||
|
||||
const run = useCallback((): void => {
|
||||
reset();
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = buildTestRequest(snapshot, draft, input);
|
||||
} catch (parseError) {
|
||||
setError(apiErrorMessage(parseError));
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedSpan = body.spans?.[0];
|
||||
const submittedAttributes = (submittedSpan?.attributes ??
|
||||
{}) as TestTabAttributes;
|
||||
const submittedResource = (submittedSpan?.resource ?? {}) as TestTabResource;
|
||||
|
||||
mutate(
|
||||
{ data: body },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setTestedAttributes(submittedAttributes);
|
||||
setTestedResource(submittedResource);
|
||||
setResult(response.data?.spans ?? []);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(mutationError));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [snapshot, draft, input, mutate, reset]);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
reset,
|
||||
isRunning: isLoading,
|
||||
validationError,
|
||||
result,
|
||||
testedAttributes,
|
||||
testedResource,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
// The header's Save/Discard and the group toggle are Admin-gated via useAuthZ;
|
||||
// render as an admin so those controls are present.
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
|
||||
@@ -16,6 +23,7 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setupGroups();
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
|
||||
import styles from './AttributeMappingHeader.module.scss';
|
||||
|
||||
interface AttributeMappingHeaderProps {
|
||||
@@ -16,12 +17,13 @@ function AttributeMappingHeader({
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<Typography.Text as="p" size="base" color="muted">
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</Typography.Text>
|
||||
{isDirty && (
|
||||
{canManage && isDirty && (
|
||||
<div className={styles.pageHeaderActions}>
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
|
||||
@@ -35,6 +35,7 @@ function clone(groups: DraftGroup[]): DraftGroup[] {
|
||||
|
||||
export interface AttributeMappingEditor {
|
||||
groups: DraftGroup[];
|
||||
snapshot: DraftGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isDirty: boolean;
|
||||
@@ -304,6 +305,7 @@ export function useAttributeMappingEditor(): AttributeMappingEditor {
|
||||
|
||||
return {
|
||||
groups: draft ?? [],
|
||||
snapshot,
|
||||
isLoading: !ready || draft === null,
|
||||
isError: groupsQuery.isError,
|
||||
isDirty,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
const ADMIN_PERMISSION = [IsAdminPermission];
|
||||
|
||||
export function useCanManageAttributeMapping(): boolean {
|
||||
const { allowed } = useAuthZ(ADMIN_PERMISSION);
|
||||
return allowed;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getGroupContextMenuConfig } from '../contextConfig';
|
||||
|
||||
const GROUP_KEY = 'http.status_code';
|
||||
|
||||
const makeQuery = (dataType: string): Query =>
|
||||
({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
groupBy: [{ key: GROUP_KEY, dataType, type: 'attribute' }],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
}) as unknown as Query;
|
||||
|
||||
const clickedData: ClickedData = {
|
||||
column: { dataIndex: GROUP_KEY },
|
||||
record: { key: GROUP_KEY, timestamp: 0 },
|
||||
};
|
||||
|
||||
const renderGroupMenu = (query: Query): void => {
|
||||
const { items } = getGroupContextMenuConfig({
|
||||
query,
|
||||
clickedData,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
onColumnClick: jest.fn(),
|
||||
});
|
||||
render(<div>{items}</div>);
|
||||
};
|
||||
|
||||
describe('getGroupContextMenuConfig', () => {
|
||||
// A `number` group-by used to throw: it isn't a key of QUERY_BUILDER_OPERATORS_BY_TYPES.
|
||||
it('renders comparison operators for a `number` group-by column', () => {
|
||||
renderGroupMenu(makeQuery('number'));
|
||||
|
||||
expect(screen.getByText(`Filter by ${GROUP_KEY}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Is greater than or equal to')).toBeInTheDocument();
|
||||
expect(screen.getByText('Is less than')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only equality operators for a string group-by column', () => {
|
||||
renderGroupMenu(makeQuery(DataTypes.String));
|
||||
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Is not this')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Is greater than or equal to'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to equality operators when the column has no known data type', () => {
|
||||
renderGroupMenu(makeQuery(''));
|
||||
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Is greater than or equal to'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns no items for a non-table panel', () => {
|
||||
const config = getGroupContextMenuConfig({
|
||||
query: makeQuery('number'),
|
||||
clickedData,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
onColumnClick: jest.fn(),
|
||||
});
|
||||
|
||||
expect(config.items).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
getOperatorsByDataType,
|
||||
getQueryData,
|
||||
getViewQuery,
|
||||
isNumberDataType,
|
||||
isValidQueryName,
|
||||
} from '../drilldownUtils';
|
||||
import { METRIC_TO_LOGS_TRACES_MAPPINGS } from '../metricsCorrelationUtils';
|
||||
@@ -690,41 +687,4 @@ describe('drilldownUtils', () => {
|
||||
expect(expr).toContain(`name = 'GET /api'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperatorsByDataType', () => {
|
||||
it('gives numeric operators for the V5 `number` data type', () => {
|
||||
const operators = getOperatorsByDataType('number');
|
||||
expect(operators).toContain('>=');
|
||||
expect(operators).toContain('<');
|
||||
expect(operators).not.toContain('LIKE');
|
||||
});
|
||||
|
||||
it('gives numeric operators for the V3 int64 / float64 data types', () => {
|
||||
expect(getOperatorsByDataType(DataTypes.Int64)).toContain('>=');
|
||||
expect(getOperatorsByDataType(DataTypes.Float64)).toContain('>=');
|
||||
});
|
||||
|
||||
it('falls back to the string operators for unmapped, empty and missing types', () => {
|
||||
const stringOperators = getOperatorsByDataType(DataTypes.String);
|
||||
expect(getOperatorsByDataType('[]string')).toStrictEqual(stringOperators);
|
||||
expect(getOperatorsByDataType('')).toStrictEqual(stringOperators);
|
||||
expect(getOperatorsByDataType(undefined)).toStrictEqual(stringOperators);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNumberDataType', () => {
|
||||
it.each([DataTypes.Int64, DataTypes.Float64, 'number' as DataTypes])(
|
||||
'treats %s as numeric',
|
||||
(dataType) => {
|
||||
expect(isNumberDataType(dataType)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([DataTypes.String, DataTypes.bool, DataTypes.EMPTY, undefined])(
|
||||
'does not treat %s as numeric',
|
||||
(dataType) => {
|
||||
expect(isNumberDataType(dataType)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fromPerses,
|
||||
toPerses,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { ClickedData } from 'periscope/components/ContextMenu';
|
||||
|
||||
import { getGroupContextMenuConfig } from '../contextConfig';
|
||||
import {
|
||||
addFilterToQuery,
|
||||
getBaseMeta,
|
||||
isNumberDataType,
|
||||
} from '../drilldownUtils';
|
||||
|
||||
const GROUP_KEY = 'panja.pinger.status_code';
|
||||
|
||||
/**
|
||||
* A saved V2 table panel grouped by a numeric attribute. The backend rewrites `float64` to
|
||||
* `number` when it stores the panel, so `number` is what a reload actually carries.
|
||||
*/
|
||||
const savedPanelQueries = [
|
||||
{
|
||||
kind: 'signoz/scalar',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [{ metricName: 'probe_checks', spaceAggregation: 'sum' }],
|
||||
groupBy: [
|
||||
{
|
||||
name: GROUP_KEY,
|
||||
fieldDataType: 'number',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
],
|
||||
filter: { expression: '' },
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
|
||||
describe('drilldown on a numeric group-by column (V2 table panel)', () => {
|
||||
const v1Query = fromPerses(savedPanelQueries, PANEL_TYPES.TABLE);
|
||||
const groupByDataType = getBaseMeta(v1Query, GROUP_KEY)?.dataType;
|
||||
|
||||
it('sees the `number` type the panel was stored with', () => {
|
||||
expect(groupByDataType).toBe('number');
|
||||
});
|
||||
|
||||
it('offers the numeric operators instead of throwing', () => {
|
||||
const clickedData: ClickedData = {
|
||||
column: { dataIndex: GROUP_KEY },
|
||||
record: { key: GROUP_KEY, timestamp: 0 },
|
||||
};
|
||||
const { items } = getGroupContextMenuConfig({
|
||||
query: v1Query,
|
||||
clickedData,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
onColumnClick: jest.fn(),
|
||||
});
|
||||
render(<div>{items}</div>);
|
||||
|
||||
expect(screen.getByText(`Filter by ${GROUP_KEY}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Is greater than or equal to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters on an unquoted number', () => {
|
||||
// What the filter hooks branch on before coercing the clicked value.
|
||||
expect(isNumberDataType(groupByDataType)).toBe(true);
|
||||
|
||||
const refined = addFilterToQuery(v1Query, [
|
||||
{ filterKey: GROUP_KEY, filterValue: 200, operator: '=' },
|
||||
]);
|
||||
expect(refined.builder.queryData[0].filter?.expression).toBe(
|
||||
`${GROUP_KEY} = 200`,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the stored query shape untouched on the way back out', () => {
|
||||
const [envelope] = toPerses(v1Query, PANEL_TYPES.TABLE);
|
||||
const composite = envelope.spec.plugin
|
||||
.spec as Querybuildertypesv5CompositeQueryDTO;
|
||||
const [query] = composite.queries ?? [];
|
||||
// The generated envelope union doesn't discriminate `spec` by `type`.
|
||||
const spec = query?.spec as Querybuildertypesv5BuilderQuerySpecDTO;
|
||||
|
||||
expect(spec.groupBy?.[0]).toMatchObject({
|
||||
name: GROUP_KEY,
|
||||
fieldDataType: 'number',
|
||||
fieldContext: 'attribute',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
PANEL_TYPES,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getBaseMeta, getOperatorsByDataType } from './drilldownUtils';
|
||||
import { getBaseMeta } from './drilldownUtils';
|
||||
import { SUPPORTED_OPERATORS } from './menuOptions';
|
||||
import { BreakoutAttributeType } from './types';
|
||||
|
||||
@@ -46,9 +49,15 @@ export function getGroupContextMenuConfig({
|
||||
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
|
||||
const filterKey = clickedData?.column?.dataIndex;
|
||||
|
||||
const filterDataType = getBaseMeta(query, filterKey as string)?.dataType;
|
||||
const filterDataType =
|
||||
getBaseMeta(query, filterKey as string)?.dataType || 'string';
|
||||
|
||||
const filterOperators = getOperatorsByDataType(filterDataType).filter(
|
||||
const operators =
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
];
|
||||
|
||||
const filterOperators = operators.filter(
|
||||
(operator) => SUPPORTED_OPERATORS[operator],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { isApmMetric } from 'container/PanelWrapper/utils';
|
||||
@@ -55,44 +54,13 @@ export const getRoute = (key: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A group-by's `dataType` comes from the field metadata, which reports a numeric as `number`
|
||||
* (V5, what a saved V2 panel carries) or as `int64` / `float64` (V3) — all three are numeric.
|
||||
*/
|
||||
const NUMERIC_DATA_TYPES: string[] = [
|
||||
DataTypes.Int64,
|
||||
DataTypes.Float64,
|
||||
'number',
|
||||
];
|
||||
|
||||
export const isNumberDataType = (dataType: DataTypes | undefined): boolean => {
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
return NUMERIC_DATA_TYPES.includes(dataType);
|
||||
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
|
||||
};
|
||||
|
||||
/**
|
||||
* `QUERY_BUILDER_OPERATORS_BY_TYPES` is keyed by the V3 data types, so the `number` a saved V2
|
||||
* panel carries misses it — and a raw lookup returns `undefined`, which throws on the caller's
|
||||
* `.filter`. Resolve through this table and fall back to the string set.
|
||||
*/
|
||||
const OPERATOR_DATA_TYPES: Record<
|
||||
string,
|
||||
keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
> = {
|
||||
[DataTypes.String]: 'string',
|
||||
[DataTypes.Int64]: 'int64',
|
||||
[DataTypes.Float64]: 'float64',
|
||||
[DataTypes.bool]: 'bool',
|
||||
number: 'float64',
|
||||
};
|
||||
|
||||
export const getOperatorsByDataType = (dataType?: string): string[] =>
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
OPERATOR_DATA_TYPES[dataType ?? ''] ?? 'string'
|
||||
];
|
||||
|
||||
export interface FilterData {
|
||||
filterKey: string;
|
||||
filterValue: string | number;
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import {
|
||||
compareTableColumnValues,
|
||||
RowData,
|
||||
} from '../createTableColumnsFromQuery';
|
||||
|
||||
// Builds a minimal RowData row. Values are intentionally loosely typed because
|
||||
// real query responses can put objects/arrays into cells despite RowData's
|
||||
// declared `string | number` index signature (that mismatch is the bug under test).
|
||||
const row = (value: unknown, dataIndex = 'col'): RowData =>
|
||||
({
|
||||
timestamp: 0,
|
||||
key: 'k',
|
||||
[dataIndex]: value,
|
||||
}) as unknown as RowData;
|
||||
|
||||
describe('compareTableColumnValues', () => {
|
||||
it('sorts numerically when both cells are numbers', () => {
|
||||
expect(compareTableColumnValues(row(1), row(2), 'col')).toBeLessThan(0);
|
||||
expect(compareTableColumnValues(row(2), row(1), 'col')).toBeGreaterThan(0);
|
||||
expect(compareTableColumnValues(row(5), row(5), 'col')).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts numeric-looking strings numerically, not lexically', () => {
|
||||
// "10" vs "9": numeric branch => 10 - 9 > 0. A string compare would be < 0.
|
||||
expect(compareTableColumnValues(row('10'), row('9'), 'col')).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the `<dataIndex>_without_unit` value for numeric comparison', () => {
|
||||
const a = row('2 ms');
|
||||
const b = row('10 ms');
|
||||
a.col_without_unit = 2;
|
||||
b.col_without_unit = 10;
|
||||
expect(compareTableColumnValues(a, b, 'col')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('falls back to locale string compare for non-numeric strings', () => {
|
||||
expect(compareTableColumnValues(row('abc'), row('abd'), 'col')).toBe(
|
||||
'abc'.localeCompare('abd'),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats null/undefined cells as empty string (no "null"/"undefined")', () => {
|
||||
// Empty string sorts before a real word, and two empties are equal.
|
||||
expect(compareTableColumnValues(row(null), row('a'), 'col')).toBeLessThan(0);
|
||||
expect(compareTableColumnValues(row(undefined), row(undefined), 'col')).toBe(
|
||||
0,
|
||||
);
|
||||
// If null coerced to the literal "null", this would sort after "a".
|
||||
expect(
|
||||
compareTableColumnValues(row(null), row('a'), 'col'),
|
||||
).not.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not throw when a cell value is an object', () => {
|
||||
const a = row({ foo: 'bar' });
|
||||
const b = row({ foo: 'baz' });
|
||||
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
|
||||
expect(typeof compareTableColumnValues(a, b, 'col')).toBe('number');
|
||||
});
|
||||
|
||||
it('does not throw when a cell value is an array', () => {
|
||||
const a = row([1, 2]);
|
||||
const b = row([3, 4]);
|
||||
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
|
||||
// String([1,2]) === "1,2" < String([3,4]) === "3,4"
|
||||
expect(compareTableColumnValues(a, b, 'col')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('does not throw when one cell is numeric and the other is an object', () => {
|
||||
const a = row(42);
|
||||
const b = row({ foo: 'bar' });
|
||||
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
|
||||
expect(typeof compareTableColumnValues(a, b, 'col')).toBe('number');
|
||||
});
|
||||
|
||||
it('does not throw for a number cell compared against an "N/A" cell', () => {
|
||||
// http.status_code column: [null, "200", ...] -> ["N/A", 200, ...]
|
||||
const numberCell = row(200); // numeric string "200" becomes the number 200
|
||||
const naCell = row('N/A'); // null becomes the string "N/A"
|
||||
|
||||
// Both orderings — antd's sorter compares pairs in both directions.
|
||||
expect(() =>
|
||||
compareTableColumnValues(numberCell, naCell, 'col'),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
compareTableColumnValues(naCell, numberCell, 'col'),
|
||||
).not.toThrow();
|
||||
expect(typeof compareTableColumnValues(numberCell, naCell, 'col')).toBe(
|
||||
'number',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -637,21 +637,6 @@ const generateData = (
|
||||
return data;
|
||||
};
|
||||
|
||||
export const compareTableColumnValues = (
|
||||
a: RowData,
|
||||
b: RowData,
|
||||
dataIndex: string,
|
||||
): number => {
|
||||
const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
|
||||
const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
|
||||
|
||||
if (!isNaN(valueA) && !isNaN(valueB)) {
|
||||
return valueA - valueB;
|
||||
}
|
||||
|
||||
return String(a[dataIndex] ?? '').localeCompare(String(b[dataIndex] ?? ''));
|
||||
};
|
||||
|
||||
const generateTableColumns = (
|
||||
dynamicColumns: DynamicColumns,
|
||||
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
||||
@@ -665,8 +650,18 @@ const generateTableColumns = (
|
||||
title: item.title,
|
||||
width: QUERY_TABLE_CONFIG.width,
|
||||
render: renderColumnCell && renderColumnCell[dataIndex],
|
||||
sorter: (a: RowData, b: RowData): number =>
|
||||
compareTableColumnValues(a, b, dataIndex),
|
||||
sorter: (a: RowData, b: RowData): number => {
|
||||
const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
|
||||
const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
|
||||
|
||||
if (!isNaN(valueA) && !isNaN(valueB)) {
|
||||
return valueA - valueB;
|
||||
}
|
||||
|
||||
return ((a[dataIndex] as string) || '').localeCompare(
|
||||
(b[dataIndex] as string) || '',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [...acc, column];
|
||||
|
||||
@@ -113,15 +113,3 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Hidden tags revealed on hovering the `+N` badge: the same chips as inline, one per
|
||||
// line (easier to scan than a wrapped cloud) and width-capped so a long tag wraps
|
||||
// instead of stretching the tooltip off-screen.
|
||||
.overflowTags {
|
||||
display: flex;
|
||||
max-width: 360px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ import { linkifyText } from 'utils/linkifyText';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
|
||||
import TagsOverflowTooltip from './TagsOverflowTooltip';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
@@ -234,10 +231,7 @@ function DashboardInfo({
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple
|
||||
title={<TagsOverflowTooltip tags={remainingTags} />}
|
||||
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
|
||||
>
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<span data-testid="dashboard-tags-overflow">
|
||||
<TagBadge>+{remainingTags.length}</TagBadge>
|
||||
</span>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import TooltipScrollArea from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
|
||||
interface TagsOverflowTooltipProps {
|
||||
/** The tags the cluster isn't showing inline. */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
function TagsOverflowTooltip({ tags }: TagsOverflowTooltipProps): JSX.Element {
|
||||
return (
|
||||
<TooltipScrollArea>
|
||||
<div className={styles.overflowTags} data-testid="dashboard-tags-tooltip">
|
||||
{tags.map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
</div>
|
||||
</TooltipScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagsOverflowTooltip;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import TagsOverflowTooltip from '../TagsOverflowTooltip';
|
||||
|
||||
describe('TagsOverflowTooltip', () => {
|
||||
it('renders every hidden tag as its own chip', () => {
|
||||
render(
|
||||
<TagsOverflowTooltip tags={['production', 'team-checkout', 'tier-1']} />,
|
||||
);
|
||||
|
||||
const chips = screen
|
||||
.getByTestId('dashboard-tags-tooltip')
|
||||
.querySelectorAll('[data-slot="badge"]');
|
||||
|
||||
expect(Array.from(chips).map((chip) => chip.textContent)).toStrictEqual([
|
||||
'production',
|
||||
'team-checkout',
|
||||
'tier-1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
from the collapsible config sections above by the same hairline divider. */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
background: var(--l2-border);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Flame } from '@signozhq/icons';
|
||||
import { Bell } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
|
||||
@@ -38,7 +38,7 @@ function ConfigActions({
|
||||
<div className={styles.list}>
|
||||
<ConfigActionRow
|
||||
testId="panel-editor-v2-create-alert"
|
||||
icon={<Flame size={14} />}
|
||||
icon={<Bell size={14} />}
|
||||
label="Create alert"
|
||||
onClick={(): void => createAlert(panel, panelId)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../../../styles/scrollbar' as *;
|
||||
|
||||
.config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8,24 +6,33 @@
|
||||
background-color: var(--l1-background);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 44px;
|
||||
|
||||
@include custom-scrollbar;
|
||||
//TODO: replace this with custom-scrollbar mixin
|
||||
// Thin, unobtrusive scrollbar (replaces the chunky native bar).
|
||||
$thumb: color-mix(in srgb, var(--bg-vanilla-100) 16%, transparent);
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $thumb transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $thumb;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.marker {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 4px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--primary);
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 16px 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -42,10 +49,11 @@
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin: 0 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -53,7 +61,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -63,19 +71,20 @@
|
||||
}
|
||||
|
||||
.divider {
|
||||
// flex-shrink:0 keeps the 1px line from collapsing to 0 once the pane
|
||||
// content overflows and the flex column starts shrinking its children.
|
||||
flex-shrink: 0;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
background: var(--l2-border);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.sectionsContainer {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > * {
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
& > * + * {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +77,8 @@ function ConfigPane({
|
||||
return (
|
||||
<div className={styles.config}>
|
||||
<header className={styles.heading}>
|
||||
<span className={styles.marker} />
|
||||
<Typography.Text>Panel Details</Typography.Text>
|
||||
<Typography.Text>Panel settings</Typography.Text>
|
||||
</header>
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.group}>
|
||||
<div className={styles.field}>
|
||||
@@ -110,7 +108,7 @@ function ConfigPane({
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.sectionsContainer}>
|
||||
<span className={styles.eyebrow}>DISPLAY OPTIONS</span>
|
||||
<span className={styles.eyebrow}>Display</span>
|
||||
<div className={styles.sections}>
|
||||
{sections.map((config) => (
|
||||
<SectionSlot
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface SectionHeaderQuickAddConfig {
|
||||
label: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
interface SectionHeaderQuickAddProps {
|
||||
action: SectionHeaderQuickAddConfig;
|
||||
/** Expands the section and runs the editor's registered add handler. */
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** Quick-add control rendered in a configuration section's header, beside the chevron. */
|
||||
function SectionHeaderQuickAdd({
|
||||
action,
|
||||
onClick,
|
||||
}: SectionHeaderQuickAddProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title="Quick Add" side="top" arrow>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label={action.label}
|
||||
// Not `testId`: TooltipTrigger's Slot merge overwrites it with undefined.
|
||||
data-testid={action.testId}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionHeaderQuickAdd;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type PanelFormattingSlice,
|
||||
@@ -10,41 +9,19 @@ import {
|
||||
import type { SectionEditorContext } from '../sectionContext';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
import SettingsSection from '../SettingsSection/SettingsSection';
|
||||
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
|
||||
|
||||
// `yAxisUnit` is derived from the spec below, not forwarded, so it's omitted.
|
||||
type SectionSlotProps = {
|
||||
config: SectionConfig;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
|
||||
|
||||
// Per-section header content; `trigger` expands the section and runs the editor's handler.
|
||||
const SECTION_HEADER_SLOT: Partial<
|
||||
Record<SectionKind, (trigger: () => void) => ReactNode>
|
||||
> = {
|
||||
[SectionKind.Thresholds]: (trigger): ReactNode => (
|
||||
<SectionHeaderQuickAdd
|
||||
action={{
|
||||
label: 'Add Threshold',
|
||||
testId: 'panel-editor-v2-add-threshold-header',
|
||||
}}
|
||||
onClick={trigger}
|
||||
/>
|
||||
),
|
||||
[SectionKind.ContextLinks]: (trigger): ReactNode => (
|
||||
<SectionHeaderQuickAdd
|
||||
action={{
|
||||
label: 'Add Context Link',
|
||||
testId: 'panel-editor-v2-add-link-header',
|
||||
}}
|
||||
onClick={trigger}
|
||||
/>
|
||||
),
|
||||
};
|
||||
} & Omit<SectionEditorContext, 'yAxisUnit'>;
|
||||
|
||||
/**
|
||||
* Renders one configuration section: its collapsible wrapper plus the registered editor
|
||||
* for `config.kind`. Renders nothing when the kind has no editor yet.
|
||||
* for `config.kind`, wired through the registry's spec lens. Renders nothing when the
|
||||
* kind has no editor yet (sections roll out incrementally), so a kind can declare a
|
||||
* section before its editor exists.
|
||||
*/
|
||||
function SectionSlot({
|
||||
config,
|
||||
@@ -59,43 +36,13 @@ function SectionSlot({
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
const editor = resolveSectionEditor(config.kind);
|
||||
// Controlled so the header slot can expand on click; list sections open when populated.
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (config.kind === SectionKind.Visualization) {
|
||||
return true;
|
||||
}
|
||||
const value = editor?.get(spec);
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
});
|
||||
// The editor mounts only while open, so a collapsed-click defers the handler until it registers.
|
||||
const actionHandlerRef = useRef<(() => void) | null>(null);
|
||||
const pendingActionRef = useRef(false);
|
||||
|
||||
const registerHeaderAction = useCallback(
|
||||
(handler: (() => void) | null): void => {
|
||||
actionHandlerRef.current = handler;
|
||||
if (handler && pendingActionRef.current) {
|
||||
pendingActionRef.current = false;
|
||||
handler();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const triggerHeaderAction = useCallback((): void => {
|
||||
setOpen(true);
|
||||
if (actionHandlerRef.current) {
|
||||
actionHandlerRef.current();
|
||||
} else {
|
||||
pendingActionRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// A kind can hide a section based on current spec state (e.g. Histogram legend once
|
||||
// queries are merged) — skip it before resolving the editor.
|
||||
if (config.isHidden?.(spec)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editor = resolveSectionEditor(config.kind);
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
@@ -104,19 +51,17 @@ function SectionSlot({
|
||||
const { Component, get, update } = editor;
|
||||
// Atomic sections carry no `controls`; controlled ones do.
|
||||
const controls = 'controls' in config ? config.controls : undefined;
|
||||
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
|
||||
// The panel's formatting unit, forwarded to editors that scope to it (thresholds
|
||||
// restrict their unit picker to this unit's category, as in V1).
|
||||
const yAxisUnit = (spec.plugin.spec as { formatting?: PanelFormattingSlice })
|
||||
.formatting?.unit;
|
||||
|
||||
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
// Open Visualization by default so the type switcher is visible.
|
||||
defaultOpen={config.kind === SectionKind.Visualization}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
@@ -131,7 +76,6 @@ function SectionSlot({
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type SectionConfig,
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import SectionSlot from '../SectionSlot';
|
||||
|
||||
const THRESHOLDS_CONFIG: SectionConfig = {
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
};
|
||||
|
||||
function makeSpec(thresholds: unknown[] = []): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: { thresholds } },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
// Stateful harness so onChange feeds back into the spec (as ConfigPane owns it).
|
||||
function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
|
||||
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(
|
||||
makeSpec(initial),
|
||||
);
|
||||
return (
|
||||
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
|
||||
);
|
||||
}
|
||||
|
||||
describe('SectionSlot header action', () => {
|
||||
it('shows the header "+" while the section is collapsed', () => {
|
||||
render(<Harness />);
|
||||
|
||||
// Collapsed: body (inline add) hidden, but the header quick-add is available.
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-add-threshold'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-add-threshold-header'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts expanded when the section already has items', () => {
|
||||
render(
|
||||
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
|
||||
);
|
||||
|
||||
// Body is shown on mount (no header click needed) because content exists.
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-add-threshold'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands the section and adds a threshold when the header "+" is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-add-threshold-header'));
|
||||
|
||||
// Expanded, with a fresh row opened in edit mode.
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-add-threshold'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,10 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
|
||||
.toggle {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
padding: 0 !important;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -46,6 +37,8 @@
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--l2-border);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.open {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
@@ -10,74 +9,45 @@ interface SettingsSectionProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state; when set, the section defers to `onOpenChange`. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Rendered between the title and the chevron. */
|
||||
headerSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible container for one configuration section in the V2 panel editor's ConfigPane.
|
||||
* Collapsible container for one configuration section in the V2 panel editor's
|
||||
* ConfigPane. Header shows an icon tile (accented when expanded), the title, and a
|
||||
* rotating chevron; sections are separated by hairline dividers (no surrounding boxes),
|
||||
* matching the Configure-panel design.
|
||||
*/
|
||||
function SettingsSection({
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
headerSlot,
|
||||
children,
|
||||
}: SettingsSectionProps): JSX.Element {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const isOpen = isControlled ? open : internalOpen;
|
||||
|
||||
const toggle = (): void => {
|
||||
const next = !isOpen;
|
||||
if (!isControlled) {
|
||||
setInternalOpen(next);
|
||||
}
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.header}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={`config-section-${serializedTitle}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{icon && (
|
||||
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</button>
|
||||
{headerSlot}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={cx(styles.chevron, { [styles.open]: isOpen })}
|
||||
/>
|
||||
}
|
||||
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
|
||||
tabIndex={-1}
|
||||
onClick={toggle}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={`config-section-${serializedTitle}`}
|
||||
onClick={(): void => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{icon && (
|
||||
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={cx(styles.chevron, { [styles.open]: isOpen })}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && <div className={styles.body}>{children}</div>}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import SettingsSection from '../SettingsSection';
|
||||
|
||||
describe('SettingsSection', () => {
|
||||
it('renders an arbitrary headerSlot node beside the header', () => {
|
||||
render(
|
||||
<SettingsSection
|
||||
title="Thresholds"
|
||||
headerSlot={
|
||||
<button type="button" aria-label="custom action" data-testid="my-action" />
|
||||
}
|
||||
>
|
||||
<div>body</div>
|
||||
</SettingsSection>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('my-action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is collapsed by default: hides the body until the header is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SettingsSection title="Thresholds">
|
||||
<div data-testid="body">body</div>
|
||||
</SettingsSection>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('config-section-thresholds'));
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defers to onOpenChange when open is controlled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = jest.fn();
|
||||
const { rerender } = render(
|
||||
<SettingsSection title="Thresholds" open={false} onOpenChange={onOpenChange}>
|
||||
<div data-testid="body">body</div>
|
||||
</SettingsSection>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('config-section-thresholds'));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
|
||||
rerender(
|
||||
<SettingsSection title="Thresholds" open onOpenChange={onOpenChange}>
|
||||
<div data-testid="body">body</div>
|
||||
</SettingsSection>,
|
||||
);
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ConfigPane from '../ConfigPane';
|
||||
|
||||
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
|
||||
() => ({
|
||||
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
function spec(unit?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'CPU', description: 'usage' },
|
||||
@@ -33,23 +40,7 @@ function renderConfigPane(
|
||||
panelId: 'panel-1',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
// Stateful so typed edits feed back into the spec, as the panel editor owns it.
|
||||
function Harness(): JSX.Element {
|
||||
const [currentSpec, setCurrentSpec] = useState(props.spec);
|
||||
return (
|
||||
<ConfigPane
|
||||
{...props}
|
||||
spec={currentSpec}
|
||||
onChangeSpec={(next): void => {
|
||||
props.onChangeSpec(next);
|
||||
setCurrentSpec(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
render(<ConfigPane {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
@@ -63,15 +54,14 @@ describe('ConfigPane', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports title edits through onChangeSpec (into spec.display)', async () => {
|
||||
const user = userEvent.setup();
|
||||
it('reports title edits through onChangeSpec (into spec.display)', () => {
|
||||
const { onChangeSpec } = renderConfigPane();
|
||||
|
||||
const title = screen.getByTestId('panel-editor-v2-title');
|
||||
await user.clear(title);
|
||||
await user.type(title, 'Memory');
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
|
||||
target: { value: 'Memory' },
|
||||
});
|
||||
|
||||
expect(onChangeSpec).toHaveBeenLastCalledWith(
|
||||
expect(onChangeSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
display: { name: 'Memory', description: 'usage' },
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// Fill the section field so the select lines up with the other full-width controls.
|
||||
.select {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background-60);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -7,7 +5,6 @@
|
||||
}
|
||||
|
||||
.list {
|
||||
@include custom-scrollbar;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,4 @@ export interface SectionEditorContext {
|
||||
stepInterval?: number;
|
||||
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
|
||||
metricUnit?: string;
|
||||
/** An editor registers the handler its header action (e.g. a quick-add "+") triggers; `null` to clear. */
|
||||
registerHeaderAction?: (handler: (() => void) | null) => void;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import type { DashboardtypesLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import ContextLinkDialog from './ContextLinkDialog';
|
||||
import ContextLinkListItem from './ContextLinkListItem';
|
||||
import { useContextLinkVariables } from './useContextLinkVariables';
|
||||
@@ -22,9 +21,7 @@ import styles from './ContextLinksSection.module.scss';
|
||||
function ContextLinksSection({
|
||||
value,
|
||||
onChange,
|
||||
registerHeaderAction,
|
||||
}: SectionEditorProps<SectionKind.ContextLinks> &
|
||||
Pick<SectionEditorContext, 'registerHeaderAction'>): JSX.Element {
|
||||
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
|
||||
const links = value ?? [];
|
||||
const variables = useContextLinkVariables();
|
||||
|
||||
@@ -34,16 +31,6 @@ function ContextLinksSection({
|
||||
index: null,
|
||||
});
|
||||
|
||||
const openAddDialog = useCallback(
|
||||
(): void => setDialog({ open: true, index: null }),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerHeaderAction?.(openAddDialog);
|
||||
return (): void => registerHeaderAction?.(null);
|
||||
}, [registerHeaderAction, openAddDialog]);
|
||||
|
||||
const removeAt = (index: number): void =>
|
||||
onChange(links.filter((_, i) => i !== index));
|
||||
|
||||
@@ -79,7 +66,7 @@ function ContextLinksSection({
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
data-testid="panel-editor-v2-add-link"
|
||||
onClick={openAddDialog}
|
||||
onClick={(): void => setDialog({ open: true, index: null })}
|
||||
>
|
||||
Add Context Link
|
||||
</Button>
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
}
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Stacked per-column unit pickers; each column keeps the standard field layout.
|
||||
|
||||
@@ -96,9 +96,6 @@
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
}
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.invalidUnit {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -63,10 +63,7 @@ type ThresholdsSectionProps = {
|
||||
/** `variant` picks the row editor + element shape; defaults to `label`. */
|
||||
controls?: { variant?: ThresholdVariant };
|
||||
onChange: (next: AnyThreshold[]) => void;
|
||||
} & Pick<
|
||||
SectionEditorContext,
|
||||
'yAxisUnit' | 'tableColumns' | 'registerHeaderAction'
|
||||
>;
|
||||
} & Pick<SectionEditorContext, 'yAxisUnit' | 'tableColumns'>;
|
||||
|
||||
/**
|
||||
* Edits the `thresholds` slice for every panel kind. All variants share the same
|
||||
@@ -80,7 +77,6 @@ function ThresholdsSection({
|
||||
onChange,
|
||||
yAxisUnit,
|
||||
tableColumns = [],
|
||||
registerHeaderAction,
|
||||
}: ThresholdsSectionProps): JSX.Element {
|
||||
const variant = controls?.variant ?? ThresholdVariant.LABEL;
|
||||
const thresholds = value ?? [];
|
||||
@@ -97,17 +93,12 @@ function ThresholdsSection({
|
||||
onChange(thresholds.map((t, i) => (i === index ? next : t)));
|
||||
};
|
||||
|
||||
const addThreshold = useCallback((): void => {
|
||||
const current = value ?? [];
|
||||
onChange([...current, defaultThreshold(variant, tableColumns)]);
|
||||
setEditingIndex(current.length);
|
||||
setUnsavedIndex(current.length);
|
||||
}, [value, onChange, variant, tableColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
registerHeaderAction?.(addThreshold);
|
||||
return (): void => registerHeaderAction?.(null);
|
||||
}, [registerHeaderAction, addThreshold]);
|
||||
const addThreshold = (): void => {
|
||||
const nextIndex = thresholds.length;
|
||||
onChange([...thresholds, defaultThreshold(variant, tableColumns)]);
|
||||
setEditingIndex(nextIndex);
|
||||
setUnsavedIndex(nextIndex);
|
||||
};
|
||||
|
||||
const beginEdit = (index: number): void => {
|
||||
editSnapshot.current = thresholds[index] ?? null;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
@@ -68,11 +67,6 @@ function Header({
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare={false}
|
||||
enableFeedback={false}
|
||||
/>
|
||||
{showSwitchToView && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
|
||||
import Header from '../Header';
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): unknown => ({
|
||||
isCloudUser: true,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseIsAIAssistantEnabled = useIsAIAssistantEnabled as jest.Mock;
|
||||
|
||||
function renderHeader(): void {
|
||||
// AppLayout supplies the TooltipProvider in the app; the header is rendered bare here.
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<TooltipProvider>
|
||||
<Header
|
||||
isDirty={false}
|
||||
isSaving={false}
|
||||
onSave={jest.fn()}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('PanelEditor Header', () => {
|
||||
afterEach(() => {
|
||||
mockUseIsAIAssistantEnabled.mockReset();
|
||||
});
|
||||
|
||||
// The editor is a full page, so the side nav's Noz entry point is gone while it is
|
||||
// open — the header has to offer it instead.
|
||||
it('offers Noz alongside the editor actions', () => {
|
||||
mockUseIsAIAssistantEnabled.mockReturnValue(true);
|
||||
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open Noz' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-close')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits Noz when the AI assistant is disabled', () => {
|
||||
mockUseIsAIAssistantEnabled.mockReturnValue(false);
|
||||
|
||||
renderHeader();
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Open Noz' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -174,13 +174,11 @@ const baseProps = {
|
||||
function setup(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
|
||||
draftOverrides?: { isSpecDirty?: boolean; draft?: DashboardtypesPanelDTO },
|
||||
draftOverrides?: { isSpecDirty?: boolean },
|
||||
): void {
|
||||
// The live draft can diverge from the seed `panel`; default to the seed.
|
||||
const draftPanel = draftOverrides?.draft ?? panel;
|
||||
mockUseDraft.mockReturnValue({
|
||||
draft: draftPanel,
|
||||
spec: draftPanel.spec,
|
||||
draft: panel,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
|
||||
});
|
||||
@@ -273,23 +271,6 @@ describe('PanelEditorContainer composition', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a query-less new panel clean after the staged-query sync seeds its draft (regression)', () => {
|
||||
// Staged-query sync populated the draft with the seed; dirty must read the seed
|
||||
// `panel` (query-less), not the draft, else an untouched new panel reads dirty.
|
||||
const committedDraft = makePanel('signoz/TimeSeriesPanel', [
|
||||
{ spec: { plugin: { kind: 'signoz/CompositeQuery', spec: {} } } },
|
||||
]);
|
||||
setup(
|
||||
makePanel('signoz/TimeSeriesPanel'),
|
||||
{ isNew: true },
|
||||
{ draft: committedDraft },
|
||||
);
|
||||
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel that already has a query saveable (e.g. list auto-runs one)', () => {
|
||||
const seededQuery = {
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs' } } },
|
||||
|
||||
@@ -2,7 +2,6 @@ import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -10,7 +9,7 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
|
||||
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
// Exercises the REAL query builder provider (not mocks) so the dirty check is
|
||||
@@ -46,28 +45,6 @@ function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
/** Providers whose URL already carries `query` as the compositeQuery param (a mid-edit refresh). */
|
||||
function makeUrlWrapper(
|
||||
query: Query,
|
||||
): ({ children }: { children: React.ReactNode }) => JSX.Element {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
return function UrlWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<AllTheProviders initialRoute={`/?${params.toString()}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
it('an untouched existing panel is NOT query-dirty on mount', async () => {
|
||||
const saved = makeSavedQueries();
|
||||
@@ -90,33 +67,6 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[DataSource.METRICS, PANEL_TYPES.TIME_SERIES],
|
||||
[DataSource.LOGS, PANEL_TYPES.TIME_SERIES],
|
||||
[DataSource.TRACES, PANEL_TYPES.TIME_SERIES],
|
||||
[DataSource.LOGS, PANEL_TYPES.LIST],
|
||||
])(
|
||||
'a NEW %s panel (%s, no savedQueries) is NOT query-dirty on mount',
|
||||
async (ds, pt) => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel([]),
|
||||
panelType: pt,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
alwaysSerializeQuery: true,
|
||||
signal: ds as unknown as TelemetrytypesSignalDTO,
|
||||
// savedQueries omitted — new panel.
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
|
||||
// An older saved query carries only a few fields; the builder re-emits many more
|
||||
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
|
||||
@@ -162,80 +112,6 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('a saved panel with no `having` is NOT dirty on mount (builder seeds an empty having)', async () => {
|
||||
// Reproduces the reported bug: a panel saved as a bare signoz/BuilderQuery that never
|
||||
// carried a `having`. On editor open the builder hydrates from the URL and
|
||||
// prepareQueryBuilderData seeds an empty `{ expression: '' }`, so a verbatim envelope
|
||||
// compare flags the untouched panel as dirty. Seed via the URL (not resetQuery) so the
|
||||
// hydration path that synthesizes the having actually runs.
|
||||
const savedNoHaving: DashboardtypesQueryDTO[] = [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
name: 'A',
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_latency.bucket',
|
||||
temporality: 'delta',
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p99',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
filter: { expression: 'service.name IN $service_name' },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'service.name',
|
||||
signal: '',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: '',
|
||||
},
|
||||
],
|
||||
order: [
|
||||
{
|
||||
key: {
|
||||
name: '__result',
|
||||
signal: '',
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
},
|
||||
direction: 'desc',
|
||||
},
|
||||
],
|
||||
limit: 100,
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
|
||||
// The editor puts the (having-less) seed query into the URL; the provider then hydrates
|
||||
// from it, which is where the empty having is added.
|
||||
const seededInUrl = fromPerses(savedNoHaving, panelType);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel(savedNoHaving),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
savedQueries: savedNoHaving,
|
||||
}),
|
||||
{ wrapper: makeUrlWrapper(seededInUrl) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
|
||||
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
|
||||
// carries the last-run (edited) query. The builder must hydrate from the URL —
|
||||
@@ -254,8 +130,23 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(editedInUrl)),
|
||||
);
|
||||
const setSpec = jest.fn();
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`/?${params.toString()}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
@@ -266,7 +157,7 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
refetch: jest.fn(),
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper: makeUrlWrapper(editedInUrl) },
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// The URL edit is retained → dirty, and it's synced into the draft so the
|
||||
|
||||
@@ -159,12 +159,11 @@ function PanelEditorContainer({
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// New panels are savable once seeded with a query (List auto-seeds one). Read the
|
||||
// seed `panel`, not the live `draft` — the staged-query sync commits the seed into
|
||||
// the draft on open, which would falsely dirty an untouched query-less new panel.
|
||||
// A new panel is savable once it has a query to run — List auto-seeds one; other
|
||||
// kinds open query-less, so there's nothing to save until the user builds one.
|
||||
const isDirty = useMemo(
|
||||
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
|
||||
() => isSpecDirty || isQueryDirty || (isNew && draft.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
|
||||
);
|
||||
|
||||
const isListPanel = panelKind === 'signoz/ListPanel';
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Centred, vertically-stacked panel state (no query / no data / error). Fills
|
||||
// the panel body below the header and centres its content both axes. When the
|
||||
// panel is too small to fit icon + text + actions, it scrolls instead of
|
||||
// clipping the buttons at the edge (`safe center` keeps the top reachable).
|
||||
// the panel body below the header and centres its content both axes.
|
||||
.message {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: safe center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
|
||||
// Match the shared panel scrollbar (chart legend, table/list panels).
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
// Muted glyph in a soft tinted disc so the icon reads as decorative chrome
|
||||
@@ -59,5 +51,4 @@
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -83,32 +83,6 @@ describe('preparePieData', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('substitutes a legend format template with the group-by values', () => {
|
||||
const table = tableWith(
|
||||
[
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
],
|
||||
[
|
||||
{ data: { 'service.name': 'adservice', A: 100 } },
|
||||
{ data: { 'service.name': 'cartservice', A: 200 } },
|
||||
],
|
||||
{ legend: 'service.name = {{service.name}}' },
|
||||
);
|
||||
|
||||
const slices = preparePieData(args([table]));
|
||||
|
||||
expect(slices.map((s) => s.label)).toStrictEqual([
|
||||
'service.name = adservice',
|
||||
'service.name = cartservice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefixes the group when multiple value columns are grouped', () => {
|
||||
const table = tableWith(
|
||||
[
|
||||
|
||||
@@ -14,16 +14,15 @@ import type {
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Antenna,
|
||||
BarChart,
|
||||
Columns3,
|
||||
Hash,
|
||||
Link2,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
Palette,
|
||||
PencilRuler,
|
||||
Scale3D,
|
||||
Signpost,
|
||||
Wallpaper,
|
||||
Ruler,
|
||||
SlidersHorizontal,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
// Derived from an actual icon component so the type stays exact (size is a
|
||||
@@ -158,14 +157,14 @@ export type SectionConfig =
|
||||
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
|
||||
// live in the ConfigPane section registry.
|
||||
export const SECTION_METADATA = {
|
||||
[SectionKind.Formatting]: { title: 'Formatting & Units', icon: PencilRuler },
|
||||
[SectionKind.Axes]: { title: 'Axes', icon: Scale3D },
|
||||
[SectionKind.Legend]: { title: 'Legend', icon: Signpost },
|
||||
[SectionKind.Formatting]: { title: 'Formatting & Units', icon: Hash },
|
||||
[SectionKind.Axes]: { title: 'Axes', icon: Ruler },
|
||||
[SectionKind.Legend]: { title: 'Legend', icon: Layers },
|
||||
[SectionKind.ChartAppearance]: { title: 'Chart appearance', icon: Palette },
|
||||
[SectionKind.Visualization]: { title: 'Visualization', icon: Wallpaper },
|
||||
[SectionKind.Visualization]: { title: 'Visualization', icon: LayoutDashboard },
|
||||
[SectionKind.Buckets]: { title: 'Histogram / Buckets', icon: BarChart },
|
||||
[SectionKind.Thresholds]: { title: 'Thresholds', icon: Antenna },
|
||||
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link2 },
|
||||
[SectionKind.Thresholds]: { title: 'Thresholds', icon: SlidersHorizontal },
|
||||
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link },
|
||||
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
|
||||
} as const satisfies Record<SectionKind, SectionMetadata>;
|
||||
|
||||
|
||||
@@ -67,16 +67,6 @@
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
// The values behind a pill's `+N`, one bullet each, width-capped so a long value
|
||||
// wraps instead of stretching the tooltip off-screen.
|
||||
.overflowValues {
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
list-style: disc outside;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// Shared by the Text and value selectors: strips the antd control chrome so the
|
||||
// selector blends into the variable pill.
|
||||
.control {
|
||||
@@ -138,3 +128,13 @@
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.overflowName {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,28 @@ import { selectVariablesExpanded } from '../store/slices/collapseSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import AddVariableFull from './components/AddVariable/AddVariableFull';
|
||||
import AddVariableIcon from './components/AddVariable/AddVariableIcon';
|
||||
import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
|
||||
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
import { useVariableSelection } from './hooks/useVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
// Short display of a variable's current selection, for the collapsed +N tooltip.
|
||||
function formatSelection(selection: VariableSelection | undefined): string {
|
||||
if (!selection) {
|
||||
return '—';
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return 'ALL';
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.join(', ') : '—';
|
||||
}
|
||||
return value === '' || value === null || value === undefined
|
||||
? '—'
|
||||
: String(value);
|
||||
}
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
@@ -115,12 +130,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
) : (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
|
||||
title={
|
||||
<HiddenVariablesTooltip
|
||||
variables={hiddenVariables}
|
||||
selections={selection}
|
||||
/>
|
||||
<div className={styles.overflowTooltip}>
|
||||
{hiddenVariables.map((variable) => (
|
||||
<div key={variable.name}>
|
||||
<span className={styles.overflowName}>{variable.name}</span>:{' '}
|
||||
{formatSelection(selection[variable.name])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{moreButton}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../selectionTypes';
|
||||
import HiddenVariablesTooltip from '../components/HiddenVariablesTooltip/HiddenVariablesTooltip';
|
||||
|
||||
function variable(name: string): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), name };
|
||||
}
|
||||
|
||||
function renderTooltip(
|
||||
names: string[],
|
||||
selections: VariableSelectionMap,
|
||||
): HTMLElement {
|
||||
render(
|
||||
<HiddenVariablesTooltip
|
||||
variables={names.map(variable)}
|
||||
selections={selections}
|
||||
/>,
|
||||
);
|
||||
return screen.getByTestId('hidden-variables-tooltip');
|
||||
}
|
||||
|
||||
describe('HiddenVariablesTooltip', () => {
|
||||
it('names each hidden variable with a $ prefix, apart from its value', () => {
|
||||
renderTooltip(['env', 'service'], {
|
||||
env: { value: 'production', allSelected: false },
|
||||
service: { value: ['checkout', 'cart'], allSelected: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText('$env')).toBeInTheDocument();
|
||||
expect(screen.getByText('$service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('bullets out a variable holding several values', () => {
|
||||
renderTooltip(['service'], {
|
||||
service: { value: ['checkout', 'cart', 'api'], allSelected: false },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('listitem').map((item) => item.textContent),
|
||||
).toStrictEqual(['checkout', 'cart', 'api']);
|
||||
});
|
||||
|
||||
it('keeps a lone value unbulleted', () => {
|
||||
renderTooltip(['env'], {
|
||||
env: { value: 'production', allSelected: false },
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels all-selected and unset variables', () => {
|
||||
renderTooltip(['env', 'host'], {
|
||||
env: { value: null, allSelected: true },
|
||||
});
|
||||
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every hidden variable, however many there are', () => {
|
||||
const names = Array.from({ length: 12 }, (_, i) => `var${i}`);
|
||||
|
||||
const tooltip = renderTooltip(names, {});
|
||||
|
||||
expect(tooltip).toHaveTextContent('$var0');
|
||||
expect(tooltip).toHaveTextContent('$var11');
|
||||
});
|
||||
|
||||
it('bullets every value it is given, without truncating', () => {
|
||||
const values = Array.from({ length: 14 }, (_, i) => `v${i}`);
|
||||
|
||||
renderTooltip(['service'], {
|
||||
service: { value: values, allSelected: false },
|
||||
});
|
||||
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import ValueSelector from '../components/selectors/ValueSelector';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const VALUES = ['checkout-service-prod', 'payments-service-prod'];
|
||||
// A strict subset of the options — selecting every option renders as ALL instead.
|
||||
const OPTIONS = [...VALUES, 'cart-service-prod'];
|
||||
|
||||
function renderSelector(
|
||||
selection: VariableSelection,
|
||||
options: string[],
|
||||
multiSelect = true,
|
||||
): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={options}
|
||||
variableType="dynamic"
|
||||
multiSelect={multiSelect}
|
||||
showAllOption
|
||||
selection={selection}
|
||||
onChange={jest.fn()}
|
||||
emptyFallback={{ value: [], allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Hovers an element and lets the tooltip's open delay elapse. */
|
||||
async function hover(element: HTMLElement): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(element);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe('ValueSelector', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("reveals a tag's full value on hovering that tag", async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
// maxTagCount={1} + maxTagTextLength={10} → the one visible tag is cut short.
|
||||
await hover(screen.getByText('checkout-s...'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'checkout-service-prod',
|
||||
);
|
||||
});
|
||||
|
||||
it('reveals the hidden values on hovering the +N overflow', async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
await hover(screen.getByText('+1'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(
|
||||
'payments-service-prod',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reveal anything from the rest of the control', async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
await hover(screen.getByTestId('variable-select-env'));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ALL for an all-selected variable', () => {
|
||||
renderSelector({ value: null, allSelected: true }, ['a', 'b']);
|
||||
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describeSelection } from '../utils/selectionDisplay';
|
||||
|
||||
describe('describeSelection', () => {
|
||||
it('reports an all-selected variable as ALL', () => {
|
||||
expect(describeSelection({ value: null, allSelected: true })).toStrictEqual({
|
||||
kind: 'all',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists a multi-select selection', () => {
|
||||
expect(
|
||||
describeSelection({ value: ['a', 'b'], allSelected: false }),
|
||||
).toStrictEqual({ kind: 'values', values: ['a', 'b'] });
|
||||
});
|
||||
|
||||
it('keeps every value, however many there are', () => {
|
||||
const values = Array.from({ length: 30 }, (_, i) => `v${i}`);
|
||||
|
||||
expect(
|
||||
describeSelection({ value: values, allSelected: false }),
|
||||
).toStrictEqual({ kind: 'values', values });
|
||||
});
|
||||
|
||||
it('lists a single value on its own', () => {
|
||||
expect(
|
||||
describeSelection({ value: 'production', allSelected: false }),
|
||||
).toStrictEqual({ kind: 'values', values: ['production'] });
|
||||
});
|
||||
|
||||
it('reports a missing or empty selection as empty', () => {
|
||||
expect(describeSelection(undefined)).toStrictEqual({ kind: 'empty' });
|
||||
expect(describeSelection({ value: '', allSelected: false })).toStrictEqual({
|
||||
kind: 'empty',
|
||||
});
|
||||
expect(describeSelection({ value: [], allSelected: false })).toStrictEqual({
|
||||
kind: 'empty',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { VariableFetchState } from '../../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useFetchedVariableOptions } from '../hooks/useFetchedVariableOptions';
|
||||
|
||||
jest.mock('react-redux', () => ({ useSelector: jest.fn() }));
|
||||
|
||||
jest.mock('api/dynamicVariables/getFieldValues', () => ({
|
||||
getFieldValues: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseSelector = useSelector as unknown as jest.Mock;
|
||||
const mockGetFieldValues = getFieldValues as unknown as jest.Mock;
|
||||
|
||||
function fieldValues(values: string[]): unknown {
|
||||
return { data: { normalizedValues: values, complete: true } };
|
||||
}
|
||||
|
||||
/** A promise resolved by the test, so the in-flight window is deterministic. */
|
||||
function deferred(): {
|
||||
promise: Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
} {
|
||||
let settle: (value: unknown) => void = () => {};
|
||||
const promise = new Promise<unknown>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return { promise, resolve: settle };
|
||||
}
|
||||
|
||||
function dynamicVariable(name: string): VariableFormModel {
|
||||
return {
|
||||
...emptyVariableFormModel(),
|
||||
name,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'service.name',
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe('useFetchedVariableOptions', () => {
|
||||
beforeEach(() => {
|
||||
mockUseSelector.mockImplementation((selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
globalTime: {
|
||||
minTime: 1_000,
|
||||
maxTime: 2_000,
|
||||
isAutoRefreshDisabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockGetFieldValues.mockReset();
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableResolvedEmpty: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the previous options while a new fetch cycle is in flight', async () => {
|
||||
const refetch = deferred();
|
||||
mockGetFieldValues
|
||||
.mockResolvedValueOnce(fieldValues(['prod', 'staging']))
|
||||
.mockReturnValueOnce(refetch.promise);
|
||||
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: { env: VariableFetchState.Loading },
|
||||
variableCycleIds: { env: 1 },
|
||||
});
|
||||
|
||||
const variable = dynamicVariable('env');
|
||||
const { result } = renderHook(
|
||||
() => useFetchedVariableOptions(variable, [variable], {}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.options).toStrictEqual(['prod', 'staging']),
|
||||
);
|
||||
|
||||
// What a sibling dynamic's selection change does: bump the cycle id, which keys
|
||||
// a fresh request. The options must not blink empty in the meantime, or an ALL
|
||||
// selection (rendered from them) falls back to the placeholder.
|
||||
act(() => {
|
||||
useDashboardStore.setState({ variableCycleIds: { env: 2 } });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(true));
|
||||
expect(result.current.options).toStrictEqual(['prod', 'staging']);
|
||||
|
||||
await act(async () => {
|
||||
refetch.resolve(fieldValues(['prod']));
|
||||
await refetch.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.options).toStrictEqual(['prod']));
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
.tooltip {
|
||||
display: flex;
|
||||
max-width: 360px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
--typography-text-display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// A lone value, ALL, or the unset dash.
|
||||
.value {
|
||||
--typography-text-display: block;
|
||||
font-weight: var(--font-weight-medium);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// One bullet per value when a variable holds several.
|
||||
.values {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
list-style: disc outside;
|
||||
}
|
||||
|
||||
.valueItem {
|
||||
font-weight: var(--font-weight-medium);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import TooltipScrollArea from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableFormModel } from '../../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../../selectionTypes';
|
||||
import SelectionValue from './SelectionValue';
|
||||
import styles from './HiddenVariablesTooltip.module.scss';
|
||||
|
||||
interface HiddenVariablesTooltipProps {
|
||||
/** The variables the collapsed bar isn't showing, in bar order. */
|
||||
variables: VariableFormModel[];
|
||||
selections: VariableSelectionMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the collapsed bar's `+N` tooltip: what each hidden variable is set to.
|
||||
* Name and value sit on their own lines — `$name` muted above its value, which
|
||||
* bullets out when there are several — so the two read apart at a glance instead of
|
||||
* running together as `name: value`.
|
||||
* Every hidden variable is listed; the box scrolls rather than truncating.
|
||||
*/
|
||||
function HiddenVariablesTooltip({
|
||||
variables,
|
||||
selections,
|
||||
}: HiddenVariablesTooltipProps): JSX.Element {
|
||||
return (
|
||||
<TooltipScrollArea>
|
||||
<div className={styles.tooltip} data-testid="hidden-variables-tooltip">
|
||||
{variables.map((variable) => (
|
||||
<div className={styles.row} key={variable.name}>
|
||||
<Typography.Text size="xs" color="muted" className={styles.name}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<SelectionValue selection={selections[variable.name]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export default HiddenVariablesTooltip;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { describeSelection } from '../../utils/selectionDisplay';
|
||||
import styles from './HiddenVariablesTooltip.module.scss';
|
||||
|
||||
interface SelectionValueProps {
|
||||
selection?: VariableSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* A variable's current value as tooltip content: `ALL`, a dash when unset, the lone
|
||||
* value on its own, or — when it holds several — one bullet per value so the list
|
||||
* doesn't read as one run-on string.
|
||||
*/
|
||||
function SelectionValue({ selection }: SelectionValueProps): JSX.Element {
|
||||
const display = describeSelection(selection);
|
||||
|
||||
if (display.kind !== 'values') {
|
||||
return (
|
||||
<Typography.Text size="sm" className={styles.value}>
|
||||
{display.kind === 'all' ? 'ALL' : '—'}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (display.values.length === 1) {
|
||||
return (
|
||||
<Typography.Text size="sm" className={styles.value}>
|
||||
{display.values[0]}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.values}>
|
||||
{display.values.map((value) => (
|
||||
<Typography.Text asChild size="sm" className={styles.valueItem} key={value}>
|
||||
<li>{value}</li>
|
||||
</Typography.Text>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectionValue;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import TooltipScrollArea, {
|
||||
TOOLTIP_SCROLL_CONTENT_CLASS,
|
||||
} from 'components/TooltipScrollArea/TooltipScrollArea';
|
||||
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
interface OverflowValuesTooltipProps {
|
||||
/** The selected values the pill hides behind this `+N`. */
|
||||
values: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The multi-select's `+N` overflow item, revealing on hover the values it stands
|
||||
* for — one bullet each, all of them, scrolling rather than truncating. The
|
||||
* scrollbar stays put instead of auto-hiding, so a capped list reads as scrollable.
|
||||
*/
|
||||
function OverflowValuesTooltip({
|
||||
values,
|
||||
}: OverflowValuesTooltipProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
delayDuration={300}
|
||||
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
|
||||
title={
|
||||
<TooltipScrollArea>
|
||||
<ul className={styles.overflowValues}>
|
||||
{values.map((value) => (
|
||||
<li key={value}>{value}</li>
|
||||
))}
|
||||
</ul>
|
||||
</TooltipScrollArea>
|
||||
}
|
||||
>
|
||||
{/* rc-select copies this node into the overflow wrapper's `title`; an empty
|
||||
one keeps the browser's own "[object Object]" tooltip out of the way. */}
|
||||
<span title="">+{values.length}</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverflowValuesTooltip;
|
||||
@@ -6,7 +6,6 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
|
||||
import OverflowValuesTooltip from './OverflowValuesTooltip';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
@@ -98,13 +97,7 @@ function ValueSelector({
|
||||
placeholder="Select value"
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): JSX.Element => (
|
||||
<OverflowValuesTooltip
|
||||
values={omitted.map((item) =>
|
||||
typeof item.label === 'string' ? item.label : String(item.value ?? ''),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
|
||||
// Offer ALL only once options load, else a concrete value reads as "all".
|
||||
enableAllSelection={showAllOption && options.length > 0}
|
||||
onDropdownVisibleChange={(open): void => {
|
||||
|
||||
@@ -89,7 +89,6 @@ export function useFetchedVariableOptions(
|
||||
enabled: variable.type === 'QUERY' && canFetch,
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime,
|
||||
keepPreviousData: true,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
@@ -125,7 +124,6 @@ export function useFetchedVariableOptions(
|
||||
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime,
|
||||
keepPreviousData: true,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
|
||||
/**
|
||||
* What a selection reads as in a tooltip: the ALL label, nothing at all, or the
|
||||
* concrete values. Never truncated — the tooltip scrolls instead.
|
||||
*/
|
||||
export type SelectionDisplay =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'values'; values: string[] };
|
||||
|
||||
/** Describes a selection for display. */
|
||||
export function describeSelection(
|
||||
selection: VariableSelection | undefined,
|
||||
): SelectionDisplay {
|
||||
if (!selection) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return { kind: 'all' };
|
||||
}
|
||||
|
||||
const { value } = selection;
|
||||
const values = (Array.isArray(value) ? value : [value])
|
||||
.filter((entry) => entry !== '' && entry !== null && entry !== undefined)
|
||||
.map(String);
|
||||
|
||||
return values.length === 0 ? { kind: 'empty' } : { kind: 'values', values };
|
||||
}
|
||||
@@ -387,23 +387,15 @@ describe('usePanelQuery', () => {
|
||||
expect(result.current.pagination?.canNext).toBe(false);
|
||||
});
|
||||
|
||||
it('drives canNext from the cursor OR a full page (offset fallback for non-timestamp sorts)', () => {
|
||||
// Full page, no cursor → the backend's offset path (a non-timestamp sort skips the
|
||||
// window/cursor path), so a full page is the has-more signal.
|
||||
it('drives canNext from the response cursor, not the row count', () => {
|
||||
// Full page but no cursor → backend says these are the last rows.
|
||||
withResponse(rawResponse(25));
|
||||
const fullPage = renderHook(() =>
|
||||
const noCursor = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(fullPage.result.current.pagination?.canNext).toBe(true);
|
||||
expect(noCursor.result.current.pagination?.canNext).toBe(false);
|
||||
|
||||
// Partial page, no cursor → the last page.
|
||||
withResponse(rawResponse(3));
|
||||
const partialPage = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
);
|
||||
expect(partialPage.result.current.pagination?.canNext).toBe(false);
|
||||
|
||||
// Cursor present (even on a partial page) → more rows (timestamp window path).
|
||||
// Cursor present (even on a partial page) → more rows.
|
||||
withResponse(rawResponse(3, 'cursor-1'));
|
||||
const withCursor = renderHook(() =>
|
||||
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
|
||||
|
||||
@@ -283,10 +283,8 @@ export function usePanelQuery({
|
||||
[pageSize],
|
||||
);
|
||||
|
||||
// Paging handles for raw/list panels. The backend only emits `nextCursor` on the
|
||||
// timestamp-ordered window path (isWindowList); a non-timestamp sort falls back to plain
|
||||
// offset paging with no cursor. So treat a full page as a has-more signal too — matching the
|
||||
// offset heuristic the logs/traces explorers use (there's no total count on the wire).
|
||||
// Paging handles for raw/list panels. The backend sets `nextCursor` iff the page filled,
|
||||
// so it's the authoritative has-more signal (there's no total count on the wire).
|
||||
const pagination = useMemo<PanelPagination | undefined>(() => {
|
||||
if (!isPaginated) {
|
||||
return undefined;
|
||||
@@ -302,7 +300,7 @@ export function usePanelQuery({
|
||||
return {
|
||||
pageIndex: Math.floor(safeOffset / safePageSize),
|
||||
canPrev: safeOffset > 0,
|
||||
canNext: !!result?.nextCursor || (result?.rows?.length ?? 0) >= safePageSize,
|
||||
canNext: !!result?.nextCursor,
|
||||
goPrev,
|
||||
goNext,
|
||||
pageSize: safePageSize,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// small ones as data URIs — they ship in the bundle and render with no network
|
||||
// call. Logos are a large, JSON-only catalogue, so `?url` keeps them as emitted
|
||||
// files fetched (and cached) on demand rather than bloating the bundle.
|
||||
const iconModules = import.meta.glob('../../../assets/Icons/**/*.{svg,png}', {
|
||||
const iconModules = import.meta.glob('../../../assets/Icons/**/*.svg', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
@@ -136,8 +136,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
ID: "ListServicesMetadata",
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "List services metadata",
|
||||
Description: "This endpoint lists the services metadata for the specified cloud provider, without any account context.",
|
||||
Description: "This endpoint lists the services metadata for the specified cloud provider",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.ListServicesMetadataParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.GettableServicesMetadata),
|
||||
ResponseContentType: "application/json",
|
||||
@@ -176,8 +177,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
ID: "GetService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "Get service",
|
||||
Description: "This endpoint gets a service definition for the specified cloud provider, without any account context.",
|
||||
Description: "This endpoint gets a service for the specified cloud provider",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.GetServiceParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.Service),
|
||||
ResponseContentType: "application/json",
|
||||
|
||||
@@ -2,82 +2,329 @@ package contextlinks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
tracesV3 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v3"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
)
|
||||
|
||||
// PrepareParamsForTracesV5 returns the traces explorer query params for the
|
||||
// given range and filter; the traces explorer writes its time params in
|
||||
// nanoseconds.
|
||||
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
|
||||
return prepareExplorerParams("traces", start.UnixNano(), end.UnixNano(), whereClause)
|
||||
}
|
||||
func PrepareLinksToTraces(start, end time.Time, filterItems []v3.FilterItem) string {
|
||||
|
||||
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
|
||||
// range and filter; the logs explorer writes its time params in milliseconds.
|
||||
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
|
||||
return prepareExplorerParams("logs", start.UnixMilli(), end.UnixMilli(), whereClause)
|
||||
}
|
||||
// Traces list view expects time in nanoseconds
|
||||
tr := URLShareableTimeRange{
|
||||
Start: start.UnixNano(),
|
||||
End: end.UnixNano(),
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
options := URLShareableOptions{
|
||||
MaxLines: 2,
|
||||
Format: "list",
|
||||
SelectColumns: tracesV3.TracesListViewDefaultSelectedColumns,
|
||||
}
|
||||
|
||||
period, _ := json.Marshal(tr)
|
||||
urlEncodedTimeRange := url.QueryEscape(string(period))
|
||||
|
||||
builderQuery := v3.BuilderQuery{
|
||||
DataSource: v3.DataSourceTraces,
|
||||
QueryName: "A",
|
||||
AggregateOperator: v3.AggregateOperatorNoOp,
|
||||
AggregateAttribute: v3.AttributeKey{},
|
||||
Filters: &v3.FilterSet{
|
||||
Items: filterItems,
|
||||
Operator: "AND",
|
||||
},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Having: []v3.Having{},
|
||||
StepInterval: 60,
|
||||
OrderBy: []v3.OrderBy{
|
||||
{
|
||||
ColumnName: "timestamp",
|
||||
Order: "desc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
|
||||
func prepareExplorerParams(dataSource string, start, end int64, whereClause string) url.Values {
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: "builder",
|
||||
QueryType: string(v3.QueryTypeBuilder),
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{{
|
||||
DataSource: dataSource,
|
||||
Filter: &FilterExpression{Expression: whereClause},
|
||||
}},
|
||||
QueryData: []LinkQuery{
|
||||
{BuilderQuery: builderQuery},
|
||||
},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(urlData)
|
||||
compositeQuery := url.QueryEscape(url.QueryEscape(string(data)))
|
||||
|
||||
optionsData, _ := json.Marshal(options)
|
||||
urlEncodedOptions := url.QueryEscape(string(optionsData))
|
||||
|
||||
return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions)
|
||||
}
|
||||
|
||||
func PrepareLinksToLogs(start, end time.Time, filterItems []v3.FilterItem) string {
|
||||
|
||||
// Logs list view expects time in milliseconds
|
||||
tr := URLShareableTimeRange{
|
||||
Start: start.UnixMilli(),
|
||||
End: end.UnixMilli(),
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
options := URLShareableOptions{
|
||||
MaxLines: 2,
|
||||
Format: "list",
|
||||
SelectColumns: []v3.AttributeKey{},
|
||||
}
|
||||
|
||||
period, _ := json.Marshal(tr)
|
||||
urlEncodedTimeRange := url.QueryEscape(string(period))
|
||||
|
||||
builderQuery := v3.BuilderQuery{
|
||||
DataSource: v3.DataSourceLogs,
|
||||
QueryName: "A",
|
||||
AggregateOperator: v3.AggregateOperatorNoOp,
|
||||
AggregateAttribute: v3.AttributeKey{},
|
||||
Filters: &v3.FilterSet{
|
||||
Items: filterItems,
|
||||
Operator: "AND",
|
||||
},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Having: []v3.Having{},
|
||||
StepInterval: 60,
|
||||
OrderBy: []v3.OrderBy{
|
||||
{
|
||||
ColumnName: "timestamp",
|
||||
Order: "desc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: string(v3.QueryTypeBuilder),
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{
|
||||
{BuilderQuery: builderQuery},
|
||||
},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(urlData)
|
||||
compositeQuery := url.QueryEscape(url.QueryEscape(string(data)))
|
||||
|
||||
optionsData, _ := json.Marshal(options)
|
||||
urlEncodedOptions := url.QueryEscape(string(optionsData))
|
||||
|
||||
return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions)
|
||||
}
|
||||
|
||||
// The following function is used to prepare the where clause for the query
|
||||
// `lbls` contains the key value pairs of the labels from the result of the query
|
||||
// We iterate over the where clause and replace the labels with the actual values
|
||||
// There are two cases:
|
||||
// 1. The label is present in the where clause
|
||||
// 2. The label is not present in the where clause
|
||||
//
|
||||
// Example for case 2:
|
||||
// Latency by serviceName without any filter
|
||||
// In this case, for each service with latency > threshold we send a notification
|
||||
// The expectation will be that clicking on the related traces for service A, will
|
||||
// take us to the traces page with the filter serviceName=A
|
||||
// So for all the missing labels in the where clause, we add them as key = value
|
||||
//
|
||||
// Example for case 1:
|
||||
// Severity text IN (WARN, ERROR)
|
||||
// In this case, the Severity text will appear in the `lbls` if it were part of the group
|
||||
// by clause, in which case we replace it with the actual value for the notification
|
||||
// i.e Severity text = WARN
|
||||
// If the Severity text is not part of the group by clause, then we add it as it is.
|
||||
func PrepareFilters(labels map[string]string, whereClauseItems []v3.FilterItem, groupByItems []v3.AttributeKey, keys map[string]v3.AttributeKey) []v3.FilterItem {
|
||||
filterItems := make([]v3.FilterItem, 0)
|
||||
|
||||
//delete predefined alert labels
|
||||
for _, label := range PredefinedAlertLabels {
|
||||
delete(labels, label)
|
||||
}
|
||||
|
||||
added := make(map[string]struct{})
|
||||
|
||||
for _, item := range whereClauseItems {
|
||||
exists := false
|
||||
for key, value := range labels {
|
||||
if item.Key.Key == key {
|
||||
// if the label is present in the where clause, replace it with key = value
|
||||
filterItems = append(filterItems, v3.FilterItem{
|
||||
Key: item.Key,
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: value,
|
||||
})
|
||||
exists = true
|
||||
added[key] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// if there is no label for the filter item, add it as it is
|
||||
filterItems = append(filterItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
// if there are labels which are not part of the where clause, but
|
||||
// exist in the result, then they could be part of the group by clause
|
||||
for key, value := range labels {
|
||||
if _, ok := added[key]; !ok {
|
||||
// start by taking the attribute key from the keys map, if not present, create a new one
|
||||
var attributeKey v3.AttributeKey
|
||||
var attrFound bool
|
||||
|
||||
// as of now this logic will only apply for logs
|
||||
for _, tKey := range utils.GenerateEnrichmentKeys(v3.AttributeKey{Key: key}) {
|
||||
if val, ok := keys[tKey]; ok {
|
||||
attributeKey = val
|
||||
attrFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// check if the attribute key is directly present, as of now this will always be false for logs
|
||||
// as for logs it will be satisfied in the condition above
|
||||
if !attrFound {
|
||||
attributeKey, attrFound = keys[key]
|
||||
}
|
||||
|
||||
// if the attribute key is not present, create a new one
|
||||
if !attrFound {
|
||||
attributeKey = v3.AttributeKey{Key: key}
|
||||
}
|
||||
|
||||
// if there is a group by item with the same key, use that instead
|
||||
for _, groupByItem := range groupByItems {
|
||||
if groupByItem.Key == key {
|
||||
attributeKey = groupByItem
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
filterItems = append(filterItems, v3.FilterItem{
|
||||
Key: attributeKey,
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return filterItems
|
||||
}
|
||||
|
||||
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
|
||||
|
||||
// Traces list view expects time in nanoseconds
|
||||
tr := URLShareableTimeRange{
|
||||
Start: start.UnixNano(),
|
||||
End: end.UnixNano(),
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
options := URLShareableOptions{}
|
||||
|
||||
period, _ := json.Marshal(tr)
|
||||
|
||||
linkQuery := LinkQuery{
|
||||
BuilderQuery: v3.BuilderQuery{
|
||||
DataSource: v3.DataSourceTraces,
|
||||
QueryName: "A",
|
||||
AggregateOperator: v3.AggregateOperatorNoOp,
|
||||
AggregateAttribute: v3.AttributeKey{},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Having: []v3.Having{},
|
||||
StepInterval: 60,
|
||||
},
|
||||
Filter: &FilterExpression{Expression: whereClause},
|
||||
}
|
||||
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: string(v3.QueryTypeBuilder),
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{
|
||||
linkQuery,
|
||||
},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(urlData)
|
||||
compositeQuery := url.QueryEscape(string(data))
|
||||
|
||||
optionsData, _ := json.Marshal(options)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("compositeQuery", url.QueryEscape(string(data)))
|
||||
params.Set("startTime", strconv.FormatInt(start, 10))
|
||||
params.Set("endTime", strconv.FormatInt(end, 10))
|
||||
params.Set("compositeQuery", compositeQuery)
|
||||
params.Set("timeRange", string(period))
|
||||
params.Set("startTime", strconv.FormatInt(tr.Start, 10))
|
||||
params.Set("endTime", strconv.FormatInt(tr.End, 10))
|
||||
params.Set("options", string(optionsData))
|
||||
return params
|
||||
}
|
||||
|
||||
// BuilderQueryForSignal returns the filter expression and group-by keys of the
|
||||
// builder query for the given signal, or found=false when the composite query
|
||||
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts).
|
||||
// TODO(srikanthccv): re-visit this and support multiple queries.
|
||||
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
switch signal {
|
||||
case telemetrytypes.SignalLogs:
|
||||
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
|
||||
case telemetrytypes.SignalTraces:
|
||||
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
|
||||
|
||||
func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
var q qbtypes.QueryBuilderQuery[T]
|
||||
found := false
|
||||
for _, query := range queries {
|
||||
if query.Type != qbtypes.QueryTypeBuilder {
|
||||
continue
|
||||
}
|
||||
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
|
||||
q = spec
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found || q.Signal != signal {
|
||||
return "", nil, false
|
||||
// Logs list view expects time in milliseconds
|
||||
tr := URLShareableTimeRange{
|
||||
Start: start.UnixMilli(),
|
||||
End: end.UnixMilli(),
|
||||
PageSize: 100,
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if q.Filter != nil {
|
||||
filterExpr = q.Filter.Expression
|
||||
options := URLShareableOptions{}
|
||||
|
||||
period, _ := json.Marshal(tr)
|
||||
|
||||
linkQuery := LinkQuery{
|
||||
BuilderQuery: v3.BuilderQuery{
|
||||
DataSource: v3.DataSourceLogs,
|
||||
QueryName: "A",
|
||||
AggregateOperator: v3.AggregateOperatorNoOp,
|
||||
AggregateAttribute: v3.AttributeKey{},
|
||||
Expression: "A",
|
||||
Disabled: false,
|
||||
Having: []v3.Having{},
|
||||
StepInterval: 60,
|
||||
},
|
||||
Filter: &FilterExpression{Expression: whereClause},
|
||||
}
|
||||
return filterExpr, q.GroupBy, true
|
||||
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: string(v3.QueryTypeBuilder),
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{
|
||||
linkQuery,
|
||||
},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(urlData)
|
||||
compositeQuery := url.QueryEscape(string(data))
|
||||
|
||||
optionsData, _ := json.Marshal(options)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("compositeQuery", compositeQuery)
|
||||
params.Set("timeRange", string(period))
|
||||
params.Set("startTime", strconv.FormatInt(tr.Start, 10))
|
||||
params.Set("endTime", strconv.FormatInt(tr.End, 10))
|
||||
params.Set("options", string(optionsData))
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package contextlinks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuilderQueryForSignal(t *testing.T) {
|
||||
logQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{Expression: "severity_text = 'ERROR'"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
},
|
||||
}
|
||||
traceQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
}
|
||||
promQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypePromQL,
|
||||
Spec: qbtypes.PromQuery{Name: "C"},
|
||||
}
|
||||
|
||||
t.Run("logs query among mixed queries", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "severity_text = 'ERROR'", filterExpr)
|
||||
require.Len(t, groupBy, 1)
|
||||
assert.Equal(t, "service.name", groupBy[0].Name)
|
||||
})
|
||||
|
||||
t.Run("traces query without filter", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, traceQuery}, telemetrytypes.SignalTraces)
|
||||
require.True(t, found)
|
||||
assert.Empty(t, filterExpr)
|
||||
assert.Empty(t, groupBy)
|
||||
})
|
||||
|
||||
t.Run("no builder query for signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("no builder queries at all", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("unsupported signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery}, telemetrytypes.SignalMetrics)
|
||||
assert.False(t, found)
|
||||
})
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
package contextlinks
|
||||
|
||||
import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
// TODO(srikanthccv): Fix the URL management.
|
||||
type URLShareableTimeRange struct {
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
PageSize int64 `json:"pageSize"`
|
||||
}
|
||||
|
||||
type FilterExpression struct {
|
||||
Expression string `json:"expression,omitempty"`
|
||||
}
|
||||
|
||||
// LinkQuery carries the only fields the explorer pages read from a shared
|
||||
// link; the frontend fills in the rest of the query shape with defaults.
|
||||
type Aggregation struct {
|
||||
Expression string `json:"expression,omitempty"`
|
||||
}
|
||||
|
||||
type LinkQuery struct {
|
||||
DataSource string `json:"dataSource"`
|
||||
Filter *FilterExpression `json:"filter,omitempty"`
|
||||
v3.BuilderQuery
|
||||
Filter *FilterExpression `json:"filter,omitempty"`
|
||||
Aggregations []*Aggregation `json:"aggregations,omitempty"`
|
||||
}
|
||||
|
||||
type URLShareableBuilderQuery struct {
|
||||
@@ -27,4 +36,10 @@ type URLShareableCompositeQuery struct {
|
||||
Builder URLShareableBuilderQuery `json:"builder"`
|
||||
}
|
||||
|
||||
type URLShareableOptions struct {
|
||||
MaxLines int `json:"maxLines"`
|
||||
Format string `json:"format"`
|
||||
SelectColumns []v3.AttributeKey `json:"selectColumns"`
|
||||
}
|
||||
|
||||
var PredefinedAlertLabels = []string{ruletypes.LabelThresholdName, ruletypes.LabelSeverityName, ruletypes.LabelLastSeen}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user