mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-04 12:10:43 +01:00
Compare commits
3 Commits
issue-4293
...
nv/migrati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c3b981179 | ||
|
|
cfaa7de165 | ||
|
|
5b3cc2400f |
@@ -15477,6 +15477,72 @@ paths:
|
||||
summary: Lock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/migrate:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'This endpoint retries the v1→v2 (Perses) migration on a dashboard
|
||||
still stored in the v1 schema and returns the v2-shape result. It is idempotent:
|
||||
a dashboard already in the v2 schema is returned unchanged.'
|
||||
operationId: MigrateDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Migrate dashboard to v2
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/factor_password/forgot:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ import type {
|
||||
ListDashboardsV2200,
|
||||
ListDashboardsV2Params,
|
||||
LockDashboardV2PathParameters,
|
||||
MigrateDashboardV2200,
|
||||
MigrateDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
PinDashboardV2PathParameters,
|
||||
@@ -1804,6 +1806,85 @@ export const useLockDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.
|
||||
* @summary Migrate dashboard to v2
|
||||
*/
|
||||
export const migrateDashboardV2 = (
|
||||
{ id }: MigrateDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<MigrateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}/migrate`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getMigrateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['migrateDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
{ pathParams: MigrateDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return migrateDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type MigrateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>
|
||||
>;
|
||||
|
||||
export type MigrateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Migrate dashboard to v2
|
||||
*/
|
||||
export const useMigrateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getMigrateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
|
||||
@@ -11164,6 +11164,17 @@ export type UnlockDashboardV2PathParameters = {
|
||||
export type LockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type MigrateDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type MigrateDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// ** Helpers
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
@@ -389,11 +392,17 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
|
||||
export function toAttributeType(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
isMonotonic?: boolean,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
if (!metricType) {
|
||||
return '';
|
||||
}
|
||||
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
|
||||
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
|
||||
if (
|
||||
metricType === MetrictypesTypeDTO.sum &&
|
||||
isMonotonic === false &&
|
||||
temporality === MetrictypesTemporalityDTO.cumulative
|
||||
) {
|
||||
return ATTRIBUTE_TYPES.GAUGE;
|
||||
}
|
||||
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';
|
||||
|
||||
@@ -33,6 +33,7 @@ function AllAttributes({
|
||||
metricName,
|
||||
metricType,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
@@ -71,6 +72,7 @@ function AllAttributes({
|
||||
groupBy,
|
||||
limit,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -89,7 +91,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const goToMetricsExploreWithAppliedAttribute = useCallback(
|
||||
@@ -101,6 +103,7 @@ function AllAttributes({
|
||||
undefined,
|
||||
undefined,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -120,7 +123,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeValue]: value,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const handleKeyMenuItemClick = useCallback(
|
||||
|
||||
@@ -86,6 +86,7 @@ function MetricDetails({
|
||||
undefined,
|
||||
undefined,
|
||||
metadata?.isMonotonic,
|
||||
metadata?.temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -108,6 +109,7 @@ function MetricDetails({
|
||||
handleExplorerTabChange,
|
||||
metadata?.type,
|
||||
metadata?.isMonotonic,
|
||||
metadata?.temporality,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,6 +198,7 @@ function MetricDetails({
|
||||
metricName={metricName}
|
||||
metricType={metadata?.type}
|
||||
isMonotonic={metadata?.isMonotonic}
|
||||
temporality={metadata?.temporality}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
|
||||
@@ -147,6 +147,44 @@ describe('MetricDetails utils', () => {
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('treats a cumulative non-monotonic Sum as a Gauge', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
MetrictypesTemporalityDTO.cumulative,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.GAUGE,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
|
||||
});
|
||||
|
||||
it('treats a delta non-monotonic Sum as a Sum', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
MetrictypesTemporalityDTO.delta,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.SUM,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('should create correct query for GAUGE metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface AllAttributesProps {
|
||||
metricName: string;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
isMonotonic?: boolean;
|
||||
temporality?: MetrictypesTemporalityDTO;
|
||||
minTime?: number;
|
||||
maxTime?: number;
|
||||
}
|
||||
|
||||
@@ -89,12 +89,16 @@ export function getMetricDetailsQuery(
|
||||
groupBy?: string,
|
||||
limit?: number,
|
||||
isMonotonic?: boolean,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): Query {
|
||||
let timeAggregation;
|
||||
let spaceAggregation;
|
||||
let aggregateOperator;
|
||||
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
|
||||
const isNonMonotonicSum =
|
||||
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
|
||||
metricType === MetrictypesTypeDTO.sum &&
|
||||
isMonotonic === false &&
|
||||
temporality === MetrictypesTemporalityDTO.cumulative;
|
||||
|
||||
switch (metricType) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
@@ -131,7 +135,7 @@ export function getMetricDetailsQuery(
|
||||
break;
|
||||
}
|
||||
|
||||
const attributeType = toAttributeType(metricType, isMonotonic);
|
||||
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
|
||||
|
||||
return {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
|
||||
@@ -393,12 +393,13 @@ describe('selecting a metric type updates the aggregation options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-monotonic Sum metric is treated as Gauge', () => {
|
||||
it('cumulative non-monotonic Sum metric is treated as Gauge', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'active_connections',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
temporality: 'cumulative' as never,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -427,6 +428,36 @@ describe('selecting a metric type updates the aggregation options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('delta non-monotonic Sum metric is treated as Sum', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'queue_depth_delta',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
temporality: 'delta' as never,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'queue_depth_delta' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toStrictEqual([
|
||||
'Rate',
|
||||
'Increase',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toStrictEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Histogram metric shows no time options and P50–P99 space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
|
||||
@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
|
||||
function getAttributeType(
|
||||
metric: MetricsexplorertypesListMetricDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
return toAttributeType(metric.type, metric.isMonotonic);
|
||||
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
|
||||
}
|
||||
|
||||
function createAutocompleteData(
|
||||
|
||||
@@ -85,6 +85,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
|
||||
ID: "MigrateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Migrate dashboard to v2",
|
||||
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
|
||||
@@ -63,6 +63,9 @@ type Module interface {
|
||||
|
||||
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
|
||||
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
|
||||
|
||||
ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error)
|
||||
@@ -132,6 +135,8 @@ type Handler interface {
|
||||
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
MigrateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListForUserV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -207,6 +207,38 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) MigrateV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.MigrateV2(ctx, orgID, dashboardID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, true)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
@@ -121,6 +122,51 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
|
||||
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storable, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Already migrated: return as-is.
|
||||
if storable.IsV2() {
|
||||
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// v1→v2 needs v5-shaped queries; run v4→v5 in place first.
|
||||
transition.NewDashboardMigrateV5(module.settings.Logger(), nil, nil).Migrate(ctx, storable.Data)
|
||||
|
||||
v2, err := storable.ConvertV1ToV2()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, v2.ID, tagtypes.NewPostableTagsFromTags(v2.Tags))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v2.Tags = resolvedTags
|
||||
|
||||
storableV2, err := v2.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.Update(ctx, orgID, storableV2)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v2, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -232,6 +232,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
|
||||
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
|
||||
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
|
||||
sqlmigration.NewFillDashboardSpecCollectionsFactory(sqlstore, dashboardStore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
124
pkg/sqlmigration/106_fill_dashboard_spec_collections.go
Normal file
124
pkg/sqlmigration/106_fill_dashboard_spec_collections.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
// Required, non-nullable v2 spec fields, mapped to their empty value.
|
||||
var nullableSpecCollections = map[string]any{
|
||||
"variables": []any{},
|
||||
"panels": map[string]any{},
|
||||
"layouts": []any{},
|
||||
}
|
||||
|
||||
type fillDashboardSpecCollections struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
dashboardStore dashboardtypes.Store
|
||||
settings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewFillDashboardSpecCollectionsFactory(sqlstore sqlstore.SQLStore, dashboardStore dashboardtypes.Store) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("fill_dashboard_spec_collections"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &fillDashboardSpecCollections{sqlstore: sqlstore, dashboardStore: dashboardStore, settings: ps}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *fillDashboardSpecCollections) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up replaces a missing or null spec.variables / spec.panels / spec.layouts with the
|
||||
// empty collection. One transaction; v1 dashboards are skipped.
|
||||
func (migration *fillDashboardSpecCollections) Up(ctx context.Context, _ *bun.DB) error {
|
||||
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
var orgIDs []string
|
||||
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Model((*types.Organization)(nil)).Column("id").Scan(ctx, &orgIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range orgIDs {
|
||||
orgID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migration.fillOrg(ctx, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// fillOrg fills every v2 dashboard in the org that needs it, inside the caller's transaction.
|
||||
func (migration *fillDashboardSpecCollections) fillOrg(ctx context.Context, orgID valuer.UUID) error {
|
||||
// List, not ListV2: ListV2 paginates and excludes system dashboards; a migration needs every row.
|
||||
storables, err := migration.dashboardStore.List(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := migration.settings.Logger
|
||||
var stillInV1, malformedSpec, skippedNoNulls, migrated int
|
||||
for _, storable := range storables {
|
||||
if !storable.IsV2() {
|
||||
stillInV1++
|
||||
continue
|
||||
}
|
||||
// Raw data, not ToDashboardV2: decoding validates, and these are the rows it rejects.
|
||||
spec, ok := storable.Data["spec"].(map[string]any)
|
||||
if !ok {
|
||||
malformedSpec++
|
||||
logger.WarnContext(ctx, "v2 dashboard has no spec object; leaving it untouched", slog.String("org_id", orgID.String()), slog.String("dashboard_id", storable.ID.String()))
|
||||
continue
|
||||
}
|
||||
if !fillSpecCollections(spec) {
|
||||
skippedNoNulls++
|
||||
continue
|
||||
}
|
||||
if err := migration.dashboardStore.Update(ctx, orgID, storable); err != nil {
|
||||
return err
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
logger.InfoContext(ctx, "filled required collections on v2 dashboards",
|
||||
slog.String("org_id", orgID.String()),
|
||||
slog.Int("total", len(storables)),
|
||||
slog.Int("still_in_v1", stillInV1),
|
||||
slog.Int("malformed_spec", malformedSpec),
|
||||
slog.Int("skipped_no_nulls", skippedNoNulls),
|
||||
slog.Int("migrated", migrated),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillSpecCollections empties each absent or null required collection, reporting whether
|
||||
// anything changed. A present value is left alone whatever its shape, so a malformed one
|
||||
// still surfaces as a validation error.
|
||||
func fillSpecCollections(spec map[string]any) bool {
|
||||
changed := false
|
||||
for field, empty := range nullableSpecCollections {
|
||||
if value, present := spec[field]; !present || value == nil {
|
||||
spec[field] = empty
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (migration *fillDashboardSpecCollections) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -2303,6 +2303,15 @@ func unionTemporalities(existing, additional []metrictypes.Temporality) []metric
|
||||
return existing
|
||||
}
|
||||
|
||||
// resolveMetricType applies the non-monotonic-cumulative-sum-as-gauge rule.
|
||||
// Monotonicity is only meaningful for cumulative sums; delta sums always stay Sum.
|
||||
func resolveMetricType(metricType metrictypes.Type, isMonotonic bool, temporality metrictypes.Temporality) metrictypes.Type {
|
||||
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
|
||||
return metrictypes.GaugeType
|
||||
}
|
||||
return metricType
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, tableName string, adjustedStartTs, adjustedEndTs uint64, metricNames []string, extraConds ...string) (map[string][]metrictypes.Temporality, map[string]metrictypes.Type, error) {
|
||||
temporalities := make(map[string][]metrictypes.Temporality)
|
||||
types := make(map[string]metrictypes.Type)
|
||||
@@ -2339,9 +2348,7 @@ func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, t
|
||||
if temporality != metrictypes.Unknown {
|
||||
temporalities[metricName] = append(temporalities[metricName], temporality)
|
||||
}
|
||||
if metricType == metrictypes.SumType && !isMonotonic {
|
||||
metricType = metrictypes.GaugeType
|
||||
}
|
||||
metricType = resolveMetricType(metricType, isMonotonic, temporality)
|
||||
types[metricName] = metricType
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -2392,9 +2399,7 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
|
||||
if err := rows.Scan(&metricName, &temporality, &metricType, &isMonotonic); err != nil {
|
||||
return nil, nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to scan temporality result")
|
||||
}
|
||||
if metricType == metrictypes.SumType && !isMonotonic {
|
||||
metricType = metrictypes.GaugeType
|
||||
}
|
||||
metricType = resolveMetricType(metricType, isMonotonic, temporality)
|
||||
temporalities[metricName] = temporality
|
||||
types[metricName] = metricType
|
||||
}
|
||||
|
||||
64
pkg/telemetrymetadata/metric_type_test.go
Normal file
64
pkg/telemetrymetadata/metric_type_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package telemetrymetadata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveMetricType(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
inputMetricType metrictypes.Type
|
||||
inputIsMonotonic bool
|
||||
inputTemporality metrictypes.Temporality
|
||||
expectedMetricType metrictypes.Type
|
||||
}{
|
||||
{
|
||||
description: "delta non-monotonic sum stays a sum",
|
||||
inputMetricType: metrictypes.SumType,
|
||||
inputIsMonotonic: false,
|
||||
inputTemporality: metrictypes.Delta,
|
||||
expectedMetricType: metrictypes.SumType,
|
||||
},
|
||||
{
|
||||
description: "cumulative non-monotonic sum becomes a gauge",
|
||||
inputMetricType: metrictypes.SumType,
|
||||
inputIsMonotonic: false,
|
||||
inputTemporality: metrictypes.Cumulative,
|
||||
expectedMetricType: metrictypes.GaugeType,
|
||||
},
|
||||
{
|
||||
description: "cumulative monotonic sum stays a sum",
|
||||
inputMetricType: metrictypes.SumType,
|
||||
inputIsMonotonic: true,
|
||||
inputTemporality: metrictypes.Cumulative,
|
||||
expectedMetricType: metrictypes.SumType,
|
||||
},
|
||||
{
|
||||
description: "delta monotonic sum stays a sum",
|
||||
inputMetricType: metrictypes.SumType,
|
||||
inputIsMonotonic: true,
|
||||
inputTemporality: metrictypes.Delta,
|
||||
expectedMetricType: metrictypes.SumType,
|
||||
},
|
||||
{
|
||||
description: "gauge is unaffected by monotonicity",
|
||||
inputMetricType: metrictypes.GaugeType,
|
||||
inputIsMonotonic: false,
|
||||
inputTemporality: metrictypes.Unspecified,
|
||||
expectedMetricType: metrictypes.GaugeType,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
testCase.expectedMetricType,
|
||||
resolveMetricType(testCase.inputMetricType, testCase.inputIsMonotonic, testCase.inputTemporality),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
|
||||
updatedAt := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
spec := DashboardSpec{
|
||||
Display: Display{Name: "Test Dashboard"},
|
||||
Display: Display{Name: "Test Dashboard"},
|
||||
Variables: []Variable{},
|
||||
Panels: map[string]*Panel{
|
||||
"p1": {
|
||||
Kind: "Panel",
|
||||
|
||||
@@ -63,8 +63,13 @@ func (d *DashboardSpec) Validate() error {
|
||||
return d.validateLayouts()
|
||||
}
|
||||
|
||||
// validateVariables rejects two variables sharing the same name.
|
||||
// validateVariables rejects an absent or null list, and duplicate variable names.
|
||||
func (d *DashboardSpec) validateVariables() error {
|
||||
// Nil is an absent or explicitly null field; `[]` decodes non-nil. The schema
|
||||
// declares it required and non-nullable, so both are rejected.
|
||||
if d.Variables == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables: is required and must not be null; use [] for a dashboard with no variables")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(d.Variables))
|
||||
for i, v := range d.Variables {
|
||||
var name string
|
||||
@@ -94,6 +99,9 @@ func (d *DashboardSpec) validateVariables() error {
|
||||
}
|
||||
|
||||
func (d *DashboardSpec) validatePanels() error {
|
||||
if d.Panels == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels: is required and must not be null; use {} for a dashboard with no panels")
|
||||
}
|
||||
for key, panel := range d.Panels {
|
||||
if err := common.ValidateID(key); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.panels: %s", err.Error())
|
||||
@@ -252,6 +260,9 @@ const maxLayoutsPerDashboard = 500
|
||||
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
|
||||
// runs here so its errors can name the layout by index.
|
||||
func (d *DashboardSpec) validateLayouts() error {
|
||||
if d.Layouts == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: is required and must not be null; use [] for a dashboard with no layouts")
|
||||
}
|
||||
if len(d.Layouts) > maxLayoutsPerDashboard {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func TestInvalidateNotAJSON(t *testing.T) {
|
||||
// UnmarshalJSON methods (panel/query/variable plugin envelopes).
|
||||
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -77,14 +78,15 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateEmptySpec(t *testing.T) {
|
||||
// no variables no panels no links
|
||||
data := []byte(`{}`)
|
||||
// The three required collections must be present, but may be empty.
|
||||
data := []byte(`{"variables": [], "panels": {}, "layouts": []}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
assert.NoError(t, err, "expected valid")
|
||||
}
|
||||
|
||||
func TestValidateOnlyVariables(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
@@ -116,8 +118,60 @@ func TestValidateOnlyVariables(t *testing.T) {
|
||||
assert.NoError(t, err, "expected valid")
|
||||
}
|
||||
|
||||
// TestInvalidateAbsentOrNullRequiredCollections pins the strict reading of the
|
||||
// schema on the three required, non-nullable collections: an absent key breaks
|
||||
// `required`, an explicit null breaks the array/object type, and both are
|
||||
// rejected. Only the empty collection is accepted.
|
||||
func TestInvalidateAbsentOrNullRequiredCollections(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
specJSON string
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
description: "variables absent",
|
||||
specJSON: `{"panels": {}, "layouts": []}`,
|
||||
expectedPath: "spec.variables",
|
||||
},
|
||||
{
|
||||
description: "variables null",
|
||||
specJSON: `{"variables": null, "panels": {}, "layouts": []}`,
|
||||
expectedPath: "spec.variables",
|
||||
},
|
||||
{
|
||||
description: "panels absent",
|
||||
specJSON: `{"variables": [], "layouts": []}`,
|
||||
expectedPath: "spec.panels",
|
||||
},
|
||||
{
|
||||
description: "panels null",
|
||||
specJSON: `{"variables": [], "panels": null, "layouts": []}`,
|
||||
expectedPath: "spec.panels",
|
||||
},
|
||||
{
|
||||
description: "layouts absent",
|
||||
specJSON: `{"variables": [], "panels": {}}`,
|
||||
expectedPath: "spec.layouts",
|
||||
},
|
||||
{
|
||||
description: "layouts null",
|
||||
specJSON: `{"variables": [], "panels": {}, "layouts": null}`,
|
||||
expectedPath: "spec.layouts",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.description, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(c.specJSON))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), c.expectedPath+": is required and must not be null")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateDuplicateVariableNames(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
@@ -147,6 +201,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
|
||||
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
|
||||
listVarWithName := func(name string) []byte {
|
||||
return []byte(`{
|
||||
"panels": {},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
@@ -187,6 +242,7 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
|
||||
|
||||
func TestInvalidatePanelKey(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"bad key!": {
|
||||
"kind": "Panel",
|
||||
@@ -213,6 +269,7 @@ func TestInvalidatePanelKey(t *testing.T) {
|
||||
func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
listVar := func(specFields string) []byte {
|
||||
return []byte(`{
|
||||
"panels": {},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
@@ -295,11 +352,13 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
func TestInvalidateEmptyVariableName(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"text variable": []byte(`{
|
||||
"panels": {},
|
||||
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`),
|
||||
"list variable": []byte(`{
|
||||
"panels": {},
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
@@ -331,6 +390,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
{
|
||||
name: "unknown panel plugin",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -348,6 +408,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
{
|
||||
name: "unknown panel envelope kind",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Row",
|
||||
@@ -364,6 +425,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
{
|
||||
name: "unknown query plugin",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -387,6 +449,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
{
|
||||
name: "unknown query envelope kind",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -410,6 +473,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
{
|
||||
name: "empty query envelope kind",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -433,6 +497,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
{
|
||||
name: "unknown variable plugin",
|
||||
data: `{
|
||||
"panels": {},
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
@@ -460,6 +525,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
|
||||
func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"good": {
|
||||
"kind": "Panel",
|
||||
@@ -495,7 +561,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
layout := func(items string) []byte {
|
||||
return []byte(`{` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
|
||||
return []byte(`{"variables": [], ` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -547,6 +613,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
{
|
||||
name: "unknown field in panel spec",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -567,6 +634,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
{
|
||||
name: "unknown field in query spec",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -593,6 +661,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
{
|
||||
name: "unknown field in variable spec",
|
||||
data: `{
|
||||
"panels": {},
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
@@ -630,6 +699,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
{
|
||||
name: "wrong type on panel plugin field",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -650,6 +720,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
{
|
||||
name: "wrong type on query plugin field",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -676,6 +747,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
|
||||
{
|
||||
name: "wrong type on variable plugin field",
|
||||
data: `{
|
||||
"panels": {},
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
@@ -715,6 +787,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad signal in builder query",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -744,6 +817,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad line interpolation",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -764,6 +838,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad line style",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -784,6 +859,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad fill mode",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -804,6 +880,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad spanGaps fillLessThan",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -824,6 +901,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad time preference",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -844,6 +922,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad legend position",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -864,6 +943,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad legend mode",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -884,6 +964,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad threshold format",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -904,6 +985,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad comparison operator",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -924,6 +1006,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
{
|
||||
name: "bad precision",
|
||||
data: `{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -964,6 +1047,7 @@ func TestThresholdLabelOptional(t *testing.T) {
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -989,6 +1073,7 @@ func TestThresholdLabelOptional(t *testing.T) {
|
||||
|
||||
func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -1005,6 +1090,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
|
||||
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -1027,6 +1113,7 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
|
||||
// signoz/CompositeQuery, not by listing multiple top-level queries.
|
||||
func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -1136,6 +1223,7 @@ func TestValidateRequiredFields(t *testing.T) {
|
||||
|
||||
func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -1188,6 +1276,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -1251,6 +1340,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
|
||||
// then unmarshal it back (what would be read from DB), and verify defaults survive.
|
||||
func TestStorageRoundTrip(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
@@ -1336,7 +1426,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
|
||||
const validSpec = `"spec": {"panels": {}, "layouts": [], "links": []}`
|
||||
const validSpec = `"spec": {"variables": [], "panels": {}, "layouts": [], "links": []}`
|
||||
|
||||
tests := []struct {
|
||||
scenario string
|
||||
@@ -1348,13 +1438,13 @@ func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
scenario: "flag true with display.name derives name on conversion",
|
||||
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[],"links":[]}}`,
|
||||
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"variables":[],"panels":{},"layouts":[],"links":[]}}`,
|
||||
wantName: "",
|
||||
wantDisplay: "My Dashboard!",
|
||||
},
|
||||
{
|
||||
scenario: "flag true with non-empty name is rejected",
|
||||
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[],"links":[]}}`,
|
||||
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"variables":[],"panels":{},"layouts":[],"links":[]}}`,
|
||||
wantErr: true,
|
||||
wantErrMatch: "name must be empty when generateName is true",
|
||||
},
|
||||
@@ -1513,6 +1603,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
}
|
||||
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [],
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + panelKind + `", "spec": {}},
|
||||
@@ -1524,6 +1615,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
}
|
||||
mkComposite := func(panelKind, subType, subSpec string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [],
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + panelKind + `", "spec": {}},
|
||||
@@ -1574,6 +1666,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
func TestCommaSeparatedAggregationRejectedOnWrite(t *testing.T) {
|
||||
buildDashboardWithLogsAggregation := func(aggregationsJSON string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [],
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
@@ -1694,6 +1787,7 @@ func TestValidateGridItemLimit(t *testing.T) {
|
||||
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
|
||||
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
|
||||
"p2": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
|
||||
@@ -1714,6 +1808,7 @@ func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
|
||||
// two items are side by side so they clear the overlap check first.
|
||||
func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
|
||||
},
|
||||
@@ -1745,39 +1840,43 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
|
||||
expectedLabel string
|
||||
}{
|
||||
{
|
||||
scenario: "dashboard display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"display": {"name": "%s"}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
scenario: "dashboard display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{
|
||||
"variables": [],
|
||||
"panels": {},"display": {"name": "%s"}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "panel display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
|
||||
dashboardJSONFmt: `{"variables": [], "panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "list variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
|
||||
dashboardJSONFmt: `{"panels": {}, "variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "text variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
|
||||
dashboardJSONFmt: `{"panels": {}, "variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "layout title",
|
||||
limit: MaxLayoutTitleLen,
|
||||
dashboardJSONFmt: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
scenario: "layout title",
|
||||
limit: MaxLayoutTitleLen,
|
||||
dashboardJSONFmt: `{
|
||||
"variables": [],
|
||||
"panels": {},"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1797,7 +1896,9 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
|
||||
// A display name at exactly the limit is accepted.
|
||||
func TestValidateDisplayNameAtMaxLength(t *testing.T) {
|
||||
atLimit := strings.Repeat("x", MaxDisplayNameLen)
|
||||
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
|
||||
_, err := unmarshalDashboard([]byte(`{
|
||||
"variables": [],
|
||||
"panels": {},"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
|
||||
normalizePreV5GroupBy(q)
|
||||
normalizePreV5PageSize(q, rowLimitPanel)
|
||||
normalizeQueryLimit(q)
|
||||
normalizeQueryOffset(q)
|
||||
if needsAggregation {
|
||||
ensureDefaultAggregation(q)
|
||||
}
|
||||
@@ -198,6 +199,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
|
||||
assignMissingFormulaNames(formulas)
|
||||
for _, f := range formulas {
|
||||
normalizePreV5QueryData(f, widgetType, panelKind)
|
||||
normalizeQueryLimit(f)
|
||||
name := d.readString(f, "queryName")
|
||||
env := qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue()))
|
||||
backfillFormulaFields(env, f)
|
||||
@@ -219,6 +221,8 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
|
||||
normalizePreV5QueryData(op, widgetType, panelKind)
|
||||
normalizePreV5GroupBy(op)
|
||||
normalizeOrderByKeys(op)
|
||||
normalizeQueryLimit(op)
|
||||
normalizeQueryOffset(op)
|
||||
name := d.readString(op, "queryName")
|
||||
out = append(out, traceOperatorEnvelope(name, expression, op))
|
||||
}
|
||||
|
||||
@@ -618,15 +618,32 @@ func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeQueryLimit drops a limit above the v5 maximum (MaxQueryLimit); v1 allowed
|
||||
// larger/unbounded limits, and an over-max value fails validation. Removing it leaves
|
||||
// the query unlimited (the field is optional).
|
||||
// normalizeQueryLimit coerces limit to the int the v5 decode expects: v1 stored it
|
||||
// as a string ("5") or float, both of which fail the typed decode. An unparseable
|
||||
// value or one above the v5 maximum (MaxQueryLimit) is dropped, leaving the query
|
||||
// unlimited (the field is optional).
|
||||
func normalizeQueryLimit(query map[string]any) {
|
||||
limit, ok := coerceFloat(query["limit"])
|
||||
if !ok {
|
||||
if query["limit"] == nil {
|
||||
return
|
||||
}
|
||||
if limit > qb.MaxQueryLimit {
|
||||
limit, ok := coerceFloat(query["limit"])
|
||||
if !ok || limit > qb.MaxQueryLimit {
|
||||
delete(query, "limit")
|
||||
return
|
||||
}
|
||||
query["limit"] = int(limit)
|
||||
}
|
||||
|
||||
// normalizeQueryOffset coerces offset to the int the v5 decode expects; v1 could
|
||||
// store it as a string. An unparseable value is dropped (offset defaults to 0).
|
||||
func normalizeQueryOffset(query map[string]any) {
|
||||
if query["offset"] == nil {
|
||||
return
|
||||
}
|
||||
offset, ok := coerceFloat(query["offset"])
|
||||
if !ok {
|
||||
delete(query, "offset")
|
||||
return
|
||||
}
|
||||
query["offset"] = int(offset)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"variables": [],
|
||||
"display": {
|
||||
"name": "NV dashboard with sections",
|
||||
"description": ""
|
||||
|
||||
@@ -34,7 +34,7 @@ def test_create_and_get_public_dashboard(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "sample-title",
|
||||
"spec": {"display": {"name": "Sample Title"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Sample Title"}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_create_rejects_non_dns_name(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "Not A Label",
|
||||
"spec": {"display": {"name": "Not A Label"}},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Not A Label"}},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -114,7 +114,7 @@ def test_create_rejects_unknown_field(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-unknown",
|
||||
"spec": {"display": {"name": "Rejects Unknown"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Rejects Unknown"}, "links": []},
|
||||
"tags": [],
|
||||
"unknownfield": "boom",
|
||||
},
|
||||
@@ -139,7 +139,7 @@ def test_create_rejects_reserved_tag_key(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-reserved",
|
||||
"spec": {"display": {"name": "Rejects Reserved"}},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Rejects Reserved"}},
|
||||
"tags": [{"key": "source", "value": "x"}],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -163,7 +163,7 @@ def test_create_rejects_too_many_tags(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "too-many-tags",
|
||||
"spec": {"display": {"name": "Too Many"}},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Too Many"}},
|
||||
"tags": tags,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -187,7 +187,7 @@ def test_create_rejects_long_display_name(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "long-display-name",
|
||||
"spec": {"display": {"name": "x" * 129}},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "x" * 129}},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
@@ -205,6 +205,8 @@ def test_create_rejects_long_display_name(
|
||||
"schemaVersion": "v6",
|
||||
"name": "long-layout-title",
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"display": {"name": "Long Layout Title"},
|
||||
"links": [],
|
||||
"layouts": [{"kind": "Grid", "spec": {"display": {"title": "x" * 257}, "items": []}}],
|
||||
@@ -234,6 +236,8 @@ def test_create_rejects_all_value_without_multiselect(
|
||||
"schemaVersion": "v6",
|
||||
"name": "all-without-multi",
|
||||
"spec": {
|
||||
"panels": {},
|
||||
"layouts": [],
|
||||
"display": {"name": "All Without Multi"},
|
||||
"links": [],
|
||||
"variables": [
|
||||
@@ -302,6 +306,7 @@ def test_create_rejects_invalid_grid_layout(
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-overlap",
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"display": {"name": "Rejects Overlap"},
|
||||
"panels": {"p1": panel("P1"), "p2": panel("P2")},
|
||||
"layouts": [
|
||||
@@ -334,6 +339,7 @@ def test_create_rejects_invalid_grid_layout(
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-multiref",
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"display": {"name": "Rejects Multiref"},
|
||||
"panels": {"p1": panel("P1")},
|
||||
"layouts": [
|
||||
@@ -366,6 +372,8 @@ def test_create_rejects_invalid_grid_layout(
|
||||
"schemaVersion": "v6",
|
||||
"name": "rejects-too-many-items",
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"display": {"name": "Rejects Too Many"},
|
||||
"layouts": [
|
||||
{
|
||||
@@ -457,7 +465,7 @@ def test_update_rejects_malformed_id(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "malformed-id",
|
||||
"spec": {"display": {"name": "Malformed Id"}},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Malformed Id"}},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -479,7 +487,7 @@ def test_update_missing_dashboard_returns_not_found(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "missing-dashboard",
|
||||
"spec": {"display": {"name": "Missing Dashboard"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Missing Dashboard"}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -675,7 +683,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": name,
|
||||
"spec": {"display": {"name": display}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": display}, "links": []},
|
||||
"tags": tags,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -1037,7 +1045,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
update_body = {
|
||||
"schemaVersion": "v6",
|
||||
"name": "lc-alpha",
|
||||
"spec": {"display": {"name": "Alpha Overview"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Alpha Overview"}, "links": []},
|
||||
"tags": [
|
||||
{"key": "team", "value": "pulse"},
|
||||
{"key": "env", "value": "prod"},
|
||||
@@ -1081,7 +1089,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
|
||||
beta_body = {
|
||||
"schemaVersion": "v6",
|
||||
"name": "lc-beta",
|
||||
"spec": {"display": {"name": "Beta Overview"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Beta Overview"}, "links": []},
|
||||
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
|
||||
}
|
||||
response = requests.put(
|
||||
@@ -1185,7 +1193,7 @@ def test_dashboard_v2_tag_order_round_trips(
|
||||
]
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -1211,7 +1219,7 @@ def test_dashboard_v2_tag_order_round_trips(
|
||||
]
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -1240,7 +1248,7 @@ def test_dashboard_v2_tag_order_round_trips(
|
||||
]
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
|
||||
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -1277,7 +1285,7 @@ def test_dashboard_v2_pin_limit(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": f"pl-{i}",
|
||||
"spec": {"display": {"name": f"Pin Limit {i}"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": f"Pin Limit {i}"}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -1375,7 +1383,7 @@ def test_dashboard_v2_like_escaping(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": name,
|
||||
"spec": {"display": {"name": display}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": display}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
@@ -1465,6 +1473,8 @@ def test_dashboard_v2_get_by_metric_name(
|
||||
"schemaVersion": "v6",
|
||||
"name": "by-metric-builder",
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"layouts": [],
|
||||
"display": {"name": "by-metric-builder"},
|
||||
"links": [],
|
||||
"panels": {
|
||||
@@ -1516,6 +1526,8 @@ def test_dashboard_v2_get_by_metric_name(
|
||||
"schemaVersion": "v6",
|
||||
"name": "by-metric-ch-promql",
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"layouts": [],
|
||||
"display": {"name": "by-metric-ch-promql"},
|
||||
"links": [],
|
||||
"panels": {
|
||||
@@ -1581,6 +1593,8 @@ def test_dashboard_v2_get_by_metric_name(
|
||||
"schemaVersion": "v6",
|
||||
"name": "by-metric-promql",
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"layouts": [],
|
||||
"display": {"name": "by-metric-promql"},
|
||||
"links": [],
|
||||
"panels": {
|
||||
@@ -1626,6 +1640,8 @@ def test_dashboard_v2_get_by_metric_name(
|
||||
"schemaVersion": "v6",
|
||||
"name": "by-metric-false-positive",
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"layouts": [],
|
||||
"display": {"name": "by-metric-false-positive"},
|
||||
"links": [],
|
||||
"panels": {
|
||||
@@ -1757,6 +1773,8 @@ def test_dashboard_v2_rejects_comma_separated_aggregation(
|
||||
"name": f"agg-{uuid.uuid4().hex[:8]}",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"layouts": [],
|
||||
"display": {"name": "Aggregation"},
|
||||
"links": [],
|
||||
"panels": {
|
||||
@@ -1850,6 +1868,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
|
||||
"name": "roundtrip-zero-values",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"layouts": [],
|
||||
"display": {"name": "Roundtrip Zero Values", "description": ""},
|
||||
"duration": "",
|
||||
"refreshInterval": "",
|
||||
@@ -2089,6 +2108,8 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
|
||||
"name": f"enum-{uuid.uuid4().hex[:8]}",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"layouts": [],
|
||||
"display": {"name": "Enum"},
|
||||
"panels": {
|
||||
"ts": {
|
||||
@@ -2196,6 +2217,8 @@ def test_dashboard_v2_rejects_explicit_empty_enum(
|
||||
"name": f"enum-{uuid.uuid4().hex[:8]}",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"variables": [],
|
||||
"layouts": [],
|
||||
"display": {"name": "Enum"},
|
||||
"panels": {
|
||||
"p": {
|
||||
@@ -2225,6 +2248,8 @@ def test_dashboard_v2_rejects_explicit_empty_enum(
|
||||
"name": f"enum-{uuid.uuid4().hex[:8]}",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"panels": {},
|
||||
"layouts": [],
|
||||
"display": {"name": "Enum"},
|
||||
"variables": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import (
|
||||
MetricsReducedSampleLast60s,
|
||||
MetricsReducedSampleSum60s,
|
||||
MetricsReducedTimeSeries,
|
||||
)
|
||||
from fixtures.querier import aligned_epoch, query_metric_values
|
||||
|
||||
|
||||
# Same setup and expected values as 02_reduced_counter, but the metric is a
|
||||
# delta, non-monotonic Sum. It must still be treated as a Sum (read from the
|
||||
# sum_60s table), so the values match. The last_60s rows are decoys: a gauge
|
||||
# misclassification would read them (999.0) instead of the sum_60s counter.
|
||||
@pytest.mark.parametrize(
|
||||
"time_agg, expected",
|
||||
[
|
||||
# 2 groups x 5 minutes x 30.0 per 300s step
|
||||
("rate", 1.0), # 300 / 300s
|
||||
("increase", 300.0),
|
||||
],
|
||||
)
|
||||
def test_delta_nonmonotonic_sum_rate_and_increase(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_reduced_metrics: Callable[..., None],
|
||||
time_agg: str,
|
||||
expected: float,
|
||||
) -> None:
|
||||
metric_name = f"test_reduction_delta_nonmonotonic_sum_{time_agg}"
|
||||
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
|
||||
|
||||
# delta non-monotonic sum: MetricsReducedTimeSeries keeps the Delta temporality as-is
|
||||
time_series = [
|
||||
MetricsReducedTimeSeries(
|
||||
metric_name=metric_name,
|
||||
kept_labels={"service": service},
|
||||
timestamp=datetime.fromtimestamp(base_epoch, tz=UTC),
|
||||
temporality="Delta",
|
||||
type_="Sum",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for service in ("a", "b")
|
||||
]
|
||||
assert all(ts.temporality == "Delta" for ts in time_series)
|
||||
|
||||
insert_reduced_metrics(
|
||||
time_series,
|
||||
sum_samples=[
|
||||
MetricsReducedSampleSum60s(
|
||||
metric_name=metric_name,
|
||||
reduced_fingerprint=ts.fingerprint,
|
||||
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
|
||||
sum_value=30.0,
|
||||
count_series=2,
|
||||
count_samples=2,
|
||||
temporality="Delta",
|
||||
)
|
||||
for ts in time_series
|
||||
for minute in range(20)
|
||||
],
|
||||
# Decoy last_60s rows: WhichReducedSamplesTableToUse reads this table only
|
||||
# if the metric is (mis)classified as a Gauge. The 999.0 values are chosen
|
||||
# to differ from the sum_60s result, so a regression that treats this delta
|
||||
# sum as a Gauge makes the assertion below fail instead of silently passing.
|
||||
last_samples=[
|
||||
MetricsReducedSampleLast60s(
|
||||
metric_name=metric_name,
|
||||
reduced_fingerprint=ts.fingerprint,
|
||||
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
|
||||
sum_last=999.0,
|
||||
min_value=999.0,
|
||||
max_value=999.0,
|
||||
sum_values=999.0,
|
||||
count_series=2,
|
||||
count_samples=2,
|
||||
temporality="Delta",
|
||||
)
|
||||
for ts in time_series
|
||||
for minute in range(20)
|
||||
],
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
values = query_metric_values(signoz, token, metric_name, base_epoch, base_epoch + 20 * 60, time_agg, "sum", step_interval=300)
|
||||
|
||||
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(4)]
|
||||
assert [v["value"] for v in values] == [expected] * 4
|
||||
@@ -0,0 +1,62 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import build_builder_query, get_series_values, make_query_request
|
||||
|
||||
|
||||
# A delta, non-monotonic Sum queried without an explicit type must be treated as
|
||||
# a Sum: the server resolves the type and the delta rate/increase values must be
|
||||
# correct. Non-reduced delta values are temporality-driven, so this is a
|
||||
# forward-looking guard against a future change routing the delta path by type
|
||||
# (e.g. gauge -> avg/last).
|
||||
@pytest.mark.parametrize(
|
||||
"time_aggregation, expected",
|
||||
[
|
||||
("rate", 1.0), # 60 per 60s bucket / 60s
|
||||
("increase", 60.0),
|
||||
],
|
||||
)
|
||||
def test_delta_nonmonotonic_sum_is_treated_as_sum(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
time_aggregation: str,
|
||||
expected: float,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
start_ms = int((now - timedelta(minutes=6)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
metric_name = f"test_delta_nonmonotonic_sum_{time_aggregation}"
|
||||
|
||||
metrics = [
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "a"},
|
||||
timestamp=now - timedelta(minutes=minute),
|
||||
value=60.0,
|
||||
temporality="Delta",
|
||||
type_="Sum",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute in range(1, 6)
|
||||
]
|
||||
insert_metrics(metrics)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
# No type and no temporality: the server resolves both from the seeded series.
|
||||
query = build_builder_query("A", metric_name, time_aggregation, "sum")
|
||||
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
values = get_series_values(response.json(), "A")
|
||||
assert len(values) == 5, f"Expected 5 buckets, got {values}"
|
||||
for value in values:
|
||||
assert value["value"] == expected, f"Expected {expected}, got {value['value']}"
|
||||
@@ -94,7 +94,7 @@ def test_service_account_role_access_admin(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "admin-sa-dash",
|
||||
"spec": {"display": {"name": "admin-sa-dash"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "admin-sa-dash"}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"SIGNOZ-API-KEY": api_key},
|
||||
@@ -134,7 +134,7 @@ def test_service_account_role_access_editor(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "editor-sa-dash",
|
||||
"spec": {"display": {"name": "editor-sa-dash"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "editor-sa-dash"}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"SIGNOZ-API-KEY": api_key},
|
||||
@@ -174,7 +174,7 @@ def test_service_account_role_access_viewer(
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "viewer-sa-dash",
|
||||
"spec": {"display": {"name": "viewer-sa-dash"}, "links": []},
|
||||
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "viewer-sa-dash"}, "links": []},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"SIGNOZ-API-KEY": api_key},
|
||||
|
||||
Reference in New Issue
Block a user