Compare commits

..

2 Commits

Author SHA1 Message Date
nityanandagohain
ad34f2c620 fix: update comments 2026-05-22 13:10:59 +05:30
nityanandagohain
aa140b3456 feat: trace based filters for logs, supporting aggregations as well 2026-05-21 18:47:25 +05:30
45 changed files with 1221 additions and 7930 deletions

View File

@@ -33,7 +33,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
@@ -101,8 +100,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -46,7 +46,6 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -134,8 +133,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), store, settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -32,9 +30,9 @@ type module struct {
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -227,38 +225,6 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy s
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, data)
}
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, postable)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updateable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
return module.pkgDashboardModule.ListV2(ctx, orgID, userID, params)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, userID, id)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -1,65 +0,0 @@
package postgressqlstore
// Lives in this package (rather than the listfilter package) so it can use
// the unexported newFormatter constructor without driving a real Postgres
// connection. Covers the only listfilter cases whose emitted SQL differs
// between SQLite and Postgres — the ones that go through JSONExtractString
// (`name`, `description`). All other operators (=, !=, BETWEEN, LIKE, IN,
// EXISTS, lower(...)) emit identical ANSI SQL on both dialects and are
// covered by the SQLite tests in the listfilter package itself.
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/listfilter"
)
func TestListFilterCompile_Postgres(t *testing.T) {
f := newFormatter(pgdialect.New())
cases := []struct {
name string
query string
wantSQL string
wantArgs []any
}{
{
name: "name = uses Postgres -> / ->> chain",
query: `name = 'overview'`,
wantSQL: `"dashboard"."data"->'data'->'display'->>'name' = ?`,
wantArgs: []any{"overview"},
},
{
name: "name CONTAINS — same JSON path, LIKE pattern",
query: `name CONTAINS 'overview'`,
wantSQL: `"dashboard"."data"->'data'->'display'->>'name' LIKE ?`,
wantArgs: []any{"%overview%"},
},
{
name: "name ILIKE — LOWER wraps the JSON path",
query: `name ILIKE 'Prod%'`,
wantSQL: `lower("dashboard"."data"->'data'->'display'->>'name') LIKE LOWER(?)`,
wantArgs: []any{"Prod%"},
},
{
name: "description = follows the same path shape",
query: `description = 'd1'`,
wantSQL: `"dashboard"."data"->'data'->'display'->>'description' = ?`,
wantArgs: []any{"d1"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out, err := listfilter.Compile(c.query, f)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, c.wantSQL, out.SQL)
assert.Equal(t, c.wantArgs, out.Args)
})
}
}

View File

@@ -18,32 +18,18 @@ import type {
} from 'react-query';
import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesJSONPatchDocumentDTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
GetPublicDashboard200,
GetPublicDashboardData200,
GetPublicDashboardDataPathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardsV2200,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -642,786 +628,3 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.
* @summary List dashboards (v2)
*/
export const listDashboardsV2 = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
signal,
});
};
export const getListDashboardsV2QueryKey = () => {
return [`/api/v2/dashboards`] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.
* @summary Create dashboard (v2)
*/
export const createDashboardV2 = (
dashboardtypesPostableDashboardV2DTO?: BodyType<DashboardtypesPostableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardV2201>({
url: `/api/v2/dashboards`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardV2DTO,
signal,
});
};
export const getCreateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
const mutationKey = ['createDashboardV2'];
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 createDashboardV2>>,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardV2(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardV2>>
>;
export type CreateDashboardV2MutationBody =
| BodyType<DashboardtypesPostableDashboardV2DTO>
| undefined;
export type CreateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard (v2)
*/
export const useCreateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
return useMutation(getCreateDashboardV2MutationOptions(options));
};
/**
* This endpoint returns a v2-shape dashboard with its tags and public sharing config (if any).
* @summary Get dashboard (v2)
*/
export const getDashboardV2 = (
{ id }: GetDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'GET',
signal,
});
};
export const getGetDashboardV2QueryKey = ({
id,
}: GetDashboardV2PathParameters) => {
return [`/api/v2/dashboards/${id}`] as const;
};
export const getGetDashboardV2QueryOptions = <
TData = Awaited<ReturnType<typeof getDashboardV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetDashboardV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetDashboardV2QueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDashboardV2>>> = ({
signal,
}) => getDashboardV2({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetDashboardV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getDashboardV2>>
>;
export type GetDashboardV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get dashboard (v2)
*/
export function useGetDashboardV2<
TData = Awaited<ReturnType<typeof getDashboardV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetDashboardV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetDashboardV2QueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get dashboard (v2)
*/
export const invalidateGetDashboardV2 = async (
queryClient: QueryClient,
{ id }: GetDashboardV2PathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetDashboardV2QueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.
* @summary Patch dashboard (v2)
*/
export const patchDashboardV2 = (
{ id }: PatchDashboardV2PathParameters,
dashboardtypesJSONPatchDocumentDTONull?: BodyType<DashboardtypesJSONPatchDocumentDTO | null> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PatchDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesJSONPatchDocumentDTONull,
signal,
});
};
export const getPatchDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
},
TContext
> => {
const mutationKey = ['patchDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchDashboardV2>>,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof patchDashboardV2>>
>;
export type PatchDashboardV2MutationBody =
| BodyType<DashboardtypesJSONPatchDocumentDTO | null>
| undefined;
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Patch dashboard (v2)
*/
export const usePatchDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
},
TContext
> => {
return useMutation(getPatchDashboardV2MutationOptions(options));
};
/**
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
* @summary Update dashboard (v2)
*/
export const updateDashboardV2 = (
{ id }: UpdateDashboardV2PathParameters,
dashboardtypesPostableDashboardV2DTO?: BodyType<DashboardtypesPostableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardV2DTO,
signal,
});
};
export const getUpdateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDashboardV2>>,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardV2>>
>;
export type UpdateDashboardV2MutationBody =
| BodyType<DashboardtypesPostableDashboardV2DTO>
| undefined;
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard (v2)
*/
export const useUpdateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardV2MutationOptions(options));
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)
*/
export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
});
};
export const getUnlockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unlockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof unlockDashboardV2>>,
{ pathParams: UnlockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unlockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnlockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unlockDashboardV2>>
>;
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unlock dashboard (v2)
*/
export const useUnlockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnlockDashboardV2MutationOptions(options));
};
/**
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Lock dashboard (v2)
*/
export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
});
};
export const getLockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['lockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof lockDashboardV2>>,
{ pathParams: LockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return lockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type LockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof lockDashboardV2>>
>;
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Lock dashboard (v2)
*/
export const useLockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
* @summary Unpin a dashboard for the current user (v2)
*/
export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/pins/me`,
method: 'DELETE',
signal,
});
};
export const getUnpinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unpinDashboardV2'];
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 unpinDashboardV2>>,
{ pathParams: UnpinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unpinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnpinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unpinDashboardV2>>
>;
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unpin a dashboard for the current user (v2)
*/
export const useUnpinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnpinDashboardV2MutationOptions(options));
};
/**
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
* @summary Pin a dashboard for the current user (v2)
*/
export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/pins/me`,
method: 'PUT',
signal,
});
};
export const getPinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['pinDashboardV2'];
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 pinDashboardV2>>,
{ pathParams: PinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return pinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type PinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof pinDashboardV2>>
>;
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Pin a dashboard for the current user (v2)
*/
export const usePinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
return useMutation(getPinDashboardV2MutationOptions(options));
};

File diff suppressed because it is too large Load Diff

View File

@@ -11,5 +11,4 @@ export enum FeatureKeys {
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
DASHBOARD_V2 = 'dashboard_v2',
}

View File

@@ -1,9 +0,0 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsDashboardV2(): boolean {
const { featureFlags } = useAppContext();
return Boolean(
featureFlags?.find((flag) => flag.name === FeatureKeys.DASHBOARD_V2)?.active,
);
}

View File

@@ -1,13 +1,3 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
import DashboardsListPage from './DashboardsListPage';
function DashboardsListPageEntry(): JSX.Element {
const isV2 = useIsDashboardV2();
if (isV2) {
return <DashboardsListPageV2 />;
}
return <DashboardsListPage />;
}
export default DashboardsListPageEntry;
export default DashboardsListPage;

View File

@@ -1,35 +0,0 @@
.page {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
gap: 8px;
height: 48px;
}
.headerLeft {
display: flex;
align-items: center;
gap: 8px;
}
.icon {
color: var(--l2-foreground);
}
.text {
color: var(--muted-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}

View File

@@ -1,41 +0,0 @@
import { useState } from 'react';
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
import { Typography } from '@signozhq/ui/typography';
import { LayoutGrid } from '@signozhq/icons';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import List from './components/List';
import styles from './DashboardsListPageV2.module.scss';
function DashboardsListPageV2(): JSX.Element {
const [showBanner, setShowBanner] = useState(true);
return (
<div className={styles.page}>
{showBanner && (
<AnnouncementBanner
type="warning"
onClose={(): void => setShowBanner(false)}
>
You&apos;re on the V2 dashboards page. If you landed here unintentionally,
please reach out to Ashwin.
</AnnouncementBanner>
)}
<div className={styles.header}>
<div className={styles.headerLeft}>
<LayoutGrid size={14} className={styles.icon} />
<Typography.Text className={styles.text}>Dashboards</Typography.Text>
</div>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
<List />
</div>
);
}
export default DashboardsListPageV2;

View File

@@ -1,5 +0,0 @@
function List(): JSX.Element {
return <div>Coming Soon</div>;
}
export default List;

View File

@@ -1,3 +0,0 @@
import List from './List';
export default List;

View File

@@ -1,3 +0,0 @@
import DashboardsListPageV2 from './DashboardsListPageV2';
export default DashboardsListPageV2;

1
go.mod
View File

@@ -89,7 +89,6 @@ require (
gonum.org/v1/gonum v0.17.0
google.golang.org/api v0.272.0
google.golang.org/protobuf v1.36.11
gopkg.in/evanphx/json-patch.v4 v4.13.0
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.35.3

View File

@@ -14,167 +14,6 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListV2), handler.OpenAPIDef{
ID: "ListDashboardsV2",
Tags: []string{"dashboard"},
Summary: "List dashboards (v2)",
Description: "Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Create dashboard (v2)",
Description: "This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
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"},
Summary: "Get dashboard (v2)",
Description: "This endpoint returns a v2-shape dashboard with its tags and public sharing config (if any).",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
ID: "UpdateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update dashboard (v2)",
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdateableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
ID: "PatchDashboardV2",
Tags: []string{"dashboard"},
Summary: "Patch dashboard (v2)",
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.",
Request: new(dashboardtypes.JSONPatchDocument),
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
// but our OpenAPI generator only reflects schemas for content types it
// understands (application/json, form-urlencoded, multipart) — anything
// else degrades to `type: string`. Declaring application/json here keeps
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Lock dashboard (v2)",
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
ID: "UnlockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unlock dashboard (v2)",
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
// ViewAccess: pinning only mutates the calling user's pin list, not the
// dashboard itself — anyone who can view a dashboard can bookmark it.
if err := router.Handle("/api/v2/dashboards/{id}/pins/me", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.PinV2), handler.OpenAPIDef{
ID: "PinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Pin a dashboard for the current user (v2)",
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/pins/me", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UnpinV2), handler.OpenAPIDef{
ID: "UnpinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unpin a dashboard for the current user (v2)",
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -49,26 +49,6 @@ type Module interface {
GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error)
statsreporter.StatsCollector
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error
}
type Handler interface {
@@ -91,25 +71,4 @@ type Handler interface {
LockUnlock(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(http.ResponseWriter, *http.Request)
GetV2(http.ResponseWriter, *http.Request)
ListV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
UnlockV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
PinV2(http.ResponseWriter, *http.Request)
UnpinV2(http.ResponseWriter, *http.Request)
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -25,10 +24,9 @@ type module struct {
analytics analytics.Analytics
orgGetter organization.Getter
queryParser queryparser.QueryParser
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard")
return &module{
store: store,
@@ -36,7 +34,6 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
analytics: analytics,
orgGetter: orgGetter,
queryParser: queryParser,
tagModule: tagModule,
}
}

View File

@@ -2,14 +2,10 @@ package impldashboard
import (
"context"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/listfilter"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -25,7 +21,7 @@ func NewStore(sqlstore sqlstore.SQLStore) dashboardtypes.Store {
func (store *store) Create(ctx context.Context, storabledashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
BunDB().
NewInsert().
Model(storabledashboard).
Exec(ctx)
@@ -67,201 +63,6 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.StorableDashboard, *dashboardtypes.StorablePublicDashboard, error) {
type joinedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
PublicID *valuer.UUID `bun:"public_id"`
PublicCreatedAt *time.Time `bun:"public_created_at"`
PublicUpdatedAt *time.Time `bun:"public_updated_at"`
PublicTimeRangeEnabled *bool `bun:"public_time_range_enabled"`
PublicDefaultTimeRange *string `bun:"public_default_time_range"`
}
row := &joinedRow{StorableDashboard: new(dashboardtypes.StorableDashboard)}
err := store.
sqlstore.
BunDB().
NewSelect().
Model(row).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.data, dashboard.locked, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
ColumnExpr("pd.id AS public_id, pd.created_at AS public_created_at, pd.updated_at AS public_updated_at, pd.time_range_enabled AS public_time_range_enabled, pd.default_time_range AS public_default_time_range").
Join("LEFT JOIN public_dashboard AS pd ON pd.dashboard_id = dashboard.id").
Where("dashboard.id = ?", id).
Where("dashboard.org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
if row.PublicID == nil {
return row.StorableDashboard, nil, nil
}
public := &dashboardtypes.StorablePublicDashboard{
Identifiable: types.Identifiable{ID: *row.PublicID},
TimeAuditable: types.TimeAuditable{CreatedAt: *row.PublicCreatedAt, UpdatedAt: *row.PublicUpdatedAt},
TimeRangeEnabled: *row.PublicTimeRangeEnabled,
DefaultTimeRange: *row.PublicDefaultTimeRange,
DashboardID: row.ID.StringValue(),
}
return row.StorableDashboard, public, nil
}
// ListV2 emits the joined dashboard ⨝ pinned_dashboard ⨝ public_dashboard
// query the spec calls for. Aliases:
//
// dashboard — the visitor expects this
// pinned_dashboard AS pin — only used inside this query
// public_dashboard AS pd — the visitor expects this
//
// Sort is "is_pinned DESC, <sort> <order>" so pinned dashboards float to the
// top inside the requested ordering. Title-sort goes through the same
// JSONExtractString path the visitor uses for name/description filtering.
func (store *store) ListV2(
ctx context.Context,
orgID valuer.UUID,
userID valuer.UUID,
params *dashboardtypes.ListDashboardsV2Params,
) ([]*dashboardtypes.DashboardListRow, bool, error) {
compiled, err := listfilter.Compile(params.Query, store.sqlstore.Formatter())
if err != nil {
return nil, false, err
}
type listedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
IsPinned bool `bun:"is_pinned"`
PublicID *valuer.UUID `bun:"public_id"`
PublicCreatedAt *time.Time `bun:"public_created_at"`
PublicUpdatedAt *time.Time `bun:"public_updated_at"`
PublicTimeRangeEnabled *bool `bun:"public_time_range_enabled"`
PublicDefaultTimeRange *string `bun:"public_default_time_range"`
}
rows := make([]*listedRow, 0)
q := store.sqlstore.
BunDB().
NewSelect().
Model(&rows).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.data, dashboard.locked, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
ColumnExpr("CASE WHEN pin.user_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned").
ColumnExpr("pd.id AS public_id, pd.created_at AS public_created_at, pd.updated_at AS public_updated_at, pd.time_range_enabled AS public_time_range_enabled, pd.default_time_range AS public_default_time_range").
Join("LEFT JOIN pinned_dashboard AS pin ON pin.user_id = ? AND pin.dashboard_id = dashboard.id", userID).
Join("LEFT JOIN public_dashboard AS pd ON pd.dashboard_id = dashboard.id").
Where("dashboard.org_id = ?", orgID)
if compiled != nil {
q = q.Where(compiled.SQL, compiled.Args...)
}
sortExpr, err := store.sortExprForListV2(params.Sort)
if err != nil {
return nil, false, err
}
q = q.
OrderExpr("is_pinned DESC").
OrderExpr(sortExpr + " " + strings.ToUpper(string(params.Order))).
Limit(params.Limit + 1).
Offset(params.Offset)
if err := q.Scan(ctx); err != nil {
return nil, false, err
}
hasMore := len(rows) > params.Limit
if hasMore {
rows = rows[:params.Limit]
}
out := make([]*dashboardtypes.DashboardListRow, len(rows))
for i, r := range rows {
row := &dashboardtypes.DashboardListRow{
Dashboard: r.StorableDashboard,
Pinned: r.IsPinned,
}
if r.PublicID != nil {
row.Public = &dashboardtypes.StorablePublicDashboard{
Identifiable: types.Identifiable{ID: *r.PublicID},
TimeAuditable: types.TimeAuditable{CreatedAt: *r.PublicCreatedAt, UpdatedAt: *r.PublicUpdatedAt},
TimeRangeEnabled: *r.PublicTimeRangeEnabled,
DefaultTimeRange: *r.PublicDefaultTimeRange,
DashboardID: r.ID.StringValue(),
}
}
out[i] = row
}
return out, hasMore, nil
}
// sortExprForListV2 maps a sort enum to the SQL expression to plug into
// ORDER BY. Title-sort routes through the SQLFormatter so it stays
// dialect-aware (matches what listfilter/visitor does for the name filter).
func (store *store) sortExprForListV2(sort dashboardtypes.ListSort) (string, error) {
switch sort {
case dashboardtypes.ListSortUpdatedAt:
return "dashboard.updated_at", nil
case dashboardtypes.ListSortCreatedAt:
return "dashboard.created_at", nil
case dashboardtypes.ListSortName:
return string(store.sqlstore.Formatter().JSONExtractString("dashboard.data", "$.data.display.name")), nil
}
return "", errors.Newf(errors.TypeInvalidInput, dashboardtypes.ErrCodeDashboardListInvalid,
"unsupported sort field %q", sort)
}
func (store *store) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.StorableDashboardData) error {
res, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*dashboardtypes.StorableDashboard)(nil)).
Set("data = ?", data).
Set("updated_by = ?", updatedBy).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
// Defends against the race where a delete lands between the caller's
// pre-update GetV2 and this update.
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
return nil
}
func (store *store) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, locked bool, updatedBy string) error {
res, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*dashboardtypes.StorableDashboard)(nil)).
Set("locked = ?", locked).
Set("updated_by = ?", updatedBy).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
return nil
}
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
storable := new(dashboardtypes.StorablePublicDashboard)
err := store.
@@ -416,51 +217,3 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
return cb(ctx)
})
}
// PinForUser combines the count check, the existence check, and the upsert in
// a single statement so the limit gate and the insert can't drift between two
// round-trips.
//
// pin exists? | count < 10? | WHERE passes? | effect | rows
// ------------|-------------|-------------------------|-----------------------------------|-----
// no | yes | yes (count branch) | INSERT new row | 1
// no | no | no | nothing (limit hit) | 0
// yes | yes | yes (count branch) | INSERT → conflict → no-op UPDATE | 1
// yes | no | yes (EXISTS OR branch) | INSERT → conflict → no-op UPDATE | 1
//
// rows = 0 is the only signal of a real limit hit.
func (store *store) PinForUser(ctx context.Context, pd *dashboardtypes.PinnedDashboard) error {
res, err := store.sqlstore.BunDBCtx(ctx).NewRaw(`
INSERT INTO pinned_dashboard (user_id, dashboard_id, org_id, pinned_at)
SELECT ?, ?, ?, ?
WHERE (SELECT COUNT(*) FROM pinned_dashboard WHERE user_id = ?) < ?
OR EXISTS (SELECT 1 FROM pinned_dashboard WHERE user_id = ? AND dashboard_id = ?)
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET user_id = EXCLUDED.user_id
`,
pd.UserID, pd.DashboardID, pd.OrgID, pd.PinnedAt,
pd.UserID, dashboardtypes.MaxPinnedDashboardsPerUser,
pd.UserID, pd.DashboardID,
).Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePinnedDashboardLimitHit,
"cannot pin more than %d dashboards", dashboardtypes.MaxPinnedDashboardsPerUser)
}
return nil
}
func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error {
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.PinnedDashboard)(nil)).
Where("user_id = ?", userID).
Where("dashboard_id = ?", dashboardID).
Exec(ctx)
return err
}

View File

@@ -1,323 +0,0 @@
package impldashboard
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (handler *handler) CreateV2(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, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
var req dashboardtypes.PostableDashboardV2
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreateV2(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, dashboard.ToGettableDashboardV2())
}
func (handler *handler) ListV2(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, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
userID, err := valuer.NewUUID(claims.IdentityID())
if err != nil {
render.Error(rw, err)
return
}
params := new(dashboardtypes.ListDashboardsV2Params)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
out, err := handler.module.ListV2(ctx, orgID, userID, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (handler *handler) GetV2(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, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
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.GetV2(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)
}
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, false)
}
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
isAdmin := false
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdateableDashboardV2{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PatchableDashboardV2{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PinV2(rw http.ResponseWriter, r *http.Request) {
handler.pinUnpinV2(rw, r, true)
}
func (handler *handler) UnpinV2(rw http.ResponseWriter, r *http.Request) {
handler.pinUnpinV2(rw, r, false)
}
func (handler *handler) pinUnpinV2(rw http.ResponseWriter, r *http.Request, pin bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
userID, err := valuer.NewUUID(claims.IdentityID())
if err != nil {
render.Error(rw, err)
return
}
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
}
if pin {
err = handler.module.PinV2(ctx, orgID, userID, dashboardID)
} else {
err = handler.module.UnpinV2(ctx, userID, dashboardID)
}
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -1,196 +0,0 @@
package impldashboard
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := postable.Validate(); err != nil {
return nil, err
}
dashboard := postable.NewDashboardV2WithoutTags(orgID, createdBy)
var storableDashboard *dashboardtypes.StorableDashboard
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Metadata.Tags)
if err != nil {
return err
}
dashboard.Data.Metadata.Tags = resolvedTags
storable, err := dashboard.ToStorableDashboard()
if err != nil {
return err
}
storableDashboard = storable
return m.store.Create(ctx, storable)
})
if err != nil {
return nil, err
}
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
return dashboard, nil
}
// ListV2 calls the store for the joined page (the store owns DSL compilation
// and limit+1/hasMore detection), batch-fetches tags for the returned
// dashboard ids, and hands off to the type-side constructor for assembly.
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
rows, hasMore, err := module.store.ListV2(ctx, orgID, userID, params)
if err != nil {
return nil, err
}
dashboardIDs := make([]valuer.UUID, len(rows))
for i, r := range rows {
dashboardIDs[i] = r.Dashboard.ID
}
tagsByDashboard, err := module.tagModule.ListForResources(ctx, orgID, coretypes.KindDashboard, dashboardIDs)
if err != nil {
return nil, err
}
return dashboardtypes.NewListableDashboardV2(rows, tagsByDashboard, hasMore)
}
func (module *module) GetV2(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
}
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updateable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Metadata.Tags)
if err != nil {
return err
}
err = existing.Update(updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.UpdateV2(ctx, orgID, id, updatedBy, storable.Data)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
updateable, err := patch.Apply(existing)
if err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Metadata.Tags)
if err != nil {
return err
}
err = existing.Update(*updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.UpdateV2(ctx, orgID, id, updatedBy, storable.Data)
})
if err != nil {
return nil, err
}
return existing, nil
}
// CreatePublicV2 is not supported in the community build.
func (module *module) CreatePublicV2(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
// UpdatePublicV2 is not supported in the community build.
func (module *module) UpdatePublicV2(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ dashboardtypes.UpdatablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.UpdateV2(ctx, orgID, id, updatedBy, storable.Data)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
if _, err := module.GetV2(ctx, orgID, id); err != nil {
return err
}
return module.store.PinForUser(ctx, &dashboardtypes.PinnedDashboard{
UserID: userID,
DashboardID: id,
OrgID: orgID,
PinnedAt: time.Now(),
})
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, userID, id)
}

View File

@@ -1,33 +0,0 @@
package filterquery
import (
"fmt"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/antlr4-go/antlr/v4"
)
func Parse(query string) (antlr.ParseTree, *antlr.CommonTokenStream, *ErrorCollector) {
collector := NewErrorCollector()
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
lexer.RemoveErrorListeners()
lexer.AddErrorListener(collector)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parser := grammar.NewFilterQueryParser(tokens)
parser.RemoveErrorListeners()
parser.AddErrorListener(collector)
return parser.Query(), tokens, collector
}
type ErrorCollector struct {
*antlr.DefaultErrorListener
Errors []string
}
func NewErrorCollector() *ErrorCollector {
return &ErrorCollector{}
}
func (c *ErrorCollector) SyntaxError(_ antlr.Recognizer, _ any, line, column int, msg string, _ antlr.RecognitionException) {
c.Errors = append(c.Errors, fmt.Sprintf("syntax error at %d:%d — %s", line, column, msg))
}

View File

@@ -199,7 +199,16 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return q.executeWindowList(ctx)
}
stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
fromMS, toMS := q.fromMS, q.toMS
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
fromMS, toMS, overlap = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
return emptyResultFor(q.kind, q.spec.Name), nil
}
}
stmt, err := q.stmtBuilder.Build(ctx, fromMS, toMS, q.kind, q.spec, q.variables)
if err != nil {
return nil, err
}
@@ -215,6 +224,81 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return result, nil
}
// narrowWindowByTraceID inspects the filter for trace_id predicates and clamps
// [fromMS,toMS] to the time range stored in signoz_traces.distributed_trace_summary.
// Returns the (possibly narrowed) window and overlap=false when the trace lies
// completely outside the query window — callers should short-circuit in that case.
//
// When the trace_id is not present in trace_summary the behaviour differs by
// signal:
// - traces: trace_summary is derived from the spans table, so a missing row
// means no spans exist for that trace_id; we short-circuit to empty.
// - logs: logs can carry a trace_id even when traces are not ingested at all
// (e.g. traces disabled). We must not short-circuit; instead leave the
// window untouched and let the query run.
func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toMS uint64) (uint64, uint64, bool) {
if q.spec.Filter == nil || q.spec.Filter.Expression == "" {
return fromMS, toMS, true
}
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if !found || len(traceIDs) == 0 {
return fromMS, toMS, true
}
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
if !ok {
if q.spec.Signal == telemetrytypes.SignalTraces {
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; short-circuiting traces query to empty",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, false
}
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; leaving time range untouched for logs",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, true
}
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if traceStartMS == 0 || traceEndMS == 0 {
return fromMS, toMS, true
}
if traceStartMS > toMS || traceEndMS < fromMS {
return fromMS, toMS, false
}
if traceStartMS > fromMS {
fromMS = traceStartMS
}
if traceEndMS < toMS {
toMS = traceEndMS
}
q.logger.DebugContext(ctx, "optimized time range using trace_id lookup",
slog.String("signal", q.spec.Signal.StringValue()),
slog.Any("trace_ids", traceIDs),
slog.Uint64("start", fromMS),
slog.Uint64("end", toMS))
return fromMS, toMS, true
}
// emptyResultFor returns an empty result payload appropriate for the given kind.
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
var value any
switch kind {
case qbtypes.RequestTypeTimeSeries:
value = &qbtypes.TimeSeriesData{QueryName: queryName}
case qbtypes.RequestTypeScalar:
value = &qbtypes.ScalarData{QueryName: queryName}
default:
value = &qbtypes.RawData{QueryName: queryName}
}
return &qbtypes.Result{
Type: kind,
Value: value,
}
}
// executeWithContext executes the query with query window and step context for partial value detection.
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -310,42 +394,22 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
totalBytes := uint64(0)
start := time.Now()
// Check if filter contains trace_id(s) and optimize time range if needed
if q.spec.Signal == telemetrytypes.SignalTraces &&
q.spec.Filter != nil && q.spec.Filter.Expression != "" {
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if found && len(traceIDs) > 0 {
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if !ok {
q.logger.DebugContext(ctx, "failed to get trace time range", slog.Any("trace_ids", traceIDs))
} else if traceStartMS > 0 && traceEndMS > 0 {
// no overlap — nothing to return
if uint64(traceStartMS) > toMS || uint64(traceEndMS) < fromMS {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
// clamp window to trace time range before bucketing
if uint64(traceStartMS) > fromMS {
fromMS = uint64(traceStartMS)
}
if uint64(traceEndMS) < toMS {
toMS = uint64(traceEndMS)
}
q.logger.DebugContext(ctx, "optimized time range for traces", slog.Any("trace_ids", traceIDs), slog.Uint64("start", fromMS), slog.Uint64("end", toMS))
}
// Check if filter contains trace_id(s) and optimize time range if needed.
// Applies to both traces (the listing this branch was built for) and logs
// (which carry trace_id and benefit from the same clamp before bucketing).
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
fromMS, toMS, overlap = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
}

View File

@@ -49,7 +49,7 @@ func TestNewHandlers(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)

View File

@@ -50,7 +50,7 @@ func TestNewModules(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)

View File

@@ -205,7 +205,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRoleCRUDTuplesFactory(sqlstore),
sqlmigration.NewAddIntegrationDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewAddSourceToDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewAddPinnedDashboardFactory(sqlstore, sqlschema),
)
}

View File

@@ -33,7 +33,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
@@ -108,7 +107,7 @@ func New(
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, authz.Config, licensing.Licensing, []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
meterReporterProviderFactories func(context.Context, factory.ProviderSettings, flagger.Flagger, licensing.Licensing, telemetrystore.TelemetryStore, retention.Getter, organization.Getter, zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string),
@@ -340,8 +339,8 @@ func New(
// where needed.
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
// Initialize dashboard module (needed for authz registry)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
// Initialize dashboard module
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
// Initialize user getter
userGetter := impluser.NewGetter(userStore, userRoleStore, flagger)

View File

@@ -1,71 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addPinnedDashboard struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddPinnedDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_pinned_dashboard"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addPinnedDashboard{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addPinnedDashboard) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addPinnedDashboard) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// Composite PK on (user_id, dashboard_id) prevents accidental double-pins
// for the same user/dashboard pair. Only org_id carries an FK — user_id and
// dashboard_id mirror tag_relations and skip FKs because cascade deletes
// are disabled at the platform level (see tags spec).
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "pinned_dashboard",
Columns: []*sqlschema.Column{
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "pinned_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false, Default: "current_timestamp"},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addPinnedDashboard) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -1,125 +0,0 @@
package dashboardtypes
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
DefaultListLimit = 20
MaxListLimit = 200
)
// ListSort is the sort field for the dashboard list endpoint. The value is a
// stable enum so callers can't ask for arbitrary columns.
type ListSort string
const (
ListSortUpdatedAt ListSort = "updated_at"
ListSortCreatedAt ListSort = "created_at"
ListSortName ListSort = "name"
)
type ListOrder string
const (
ListOrderAsc ListOrder = "asc"
ListOrderDesc ListOrder = "desc"
)
var ErrCodeDashboardListInvalid = errors.MustNewCode("dashboard_list_invalid")
type ListDashboardsV2Params struct {
Query string `query:"query"`
Sort ListSort `query:"sort"`
Order ListOrder `query:"order"`
Limit int `query:"limit"`
Offset int `query:"offset"`
}
// Validate fills in defaults (sort=updated_at, order=desc, limit=20) and
// rejects out-of-allowlist sort/order values and bad limit/offset. Limit is
// clamped to MaxListLimit on the high side. Lowercases sort/order so callers
// can pass them in any case.
func (p *ListDashboardsV2Params) Validate() error {
if p.Sort == "" {
p.Sort = ListSortUpdatedAt
} else {
p.Sort = ListSort(strings.ToLower(string(p.Sort)))
switch p.Sort {
case ListSortUpdatedAt, ListSortCreatedAt, ListSortName:
default:
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid sort %q — expected one of: updated_at, created_at, name", p.Sort)
}
}
if p.Order == "" {
p.Order = ListOrderDesc
} else {
p.Order = ListOrder(strings.ToLower(string(p.Order)))
switch p.Order {
case ListOrderAsc, ListOrderDesc:
default:
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid order %q — expected asc or desc", p.Order)
}
}
if p.Limit == 0 {
p.Limit = DefaultListLimit
} else if p.Limit < 0 {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid limit %d — must be a positive integer", p.Limit)
} else if p.Limit > MaxListLimit {
p.Limit = MaxListLimit
}
if p.Offset < 0 {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid offset %d — must be a non-negative integer", p.Offset)
}
return nil
}
type gettableDashboardWithPin struct {
GettableDashboardV2
Pinned bool `json:"pinned"`
}
type ListableDashboardV2 struct {
Dashboards []*gettableDashboardWithPin `json:"dashboards"`
HasMore bool `json:"hasMore"`
}
// DashboardListRow is the per-row shape Store.ListV2 returns. Bundles the
// joined dashboard / public_dashboard / pinned_dashboard data so the module
// layer can attach tags and assemble the gettable view.
type DashboardListRow struct {
Dashboard *StorableDashboard
Public *StorablePublicDashboard // nil if no public_dashboard row exists
Pinned bool
}
func NewListableDashboardV2(rows []*DashboardListRow, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, hasMore bool) (*ListableDashboardV2, error) {
dashboards := make([]*gettableDashboardWithPin, len(rows))
for i, r := range rows {
v2, err := r.Dashboard.ToDashboardV2(tagsByEntity[r.Dashboard.ID])
if err != nil {
return nil, err
}
dashboards[i] = &gettableDashboardWithPin{
GettableDashboardV2: v2.ToGettableDashboardV2(),
Pinned: r.Pinned,
}
}
return &ListableDashboardV2{
Dashboards: dashboards,
HasMore: hasMore,
}, nil
}

View File

@@ -1,61 +0,0 @@
package listfilter
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
var ErrCodeDashboardListFilterInvalid = errors.MustNewCode("dashboard_list_filter_invalid")
// reservedOps lists the operators each reserved (column-level) DSL key accepts.
// Any non-reserved key is treated as a tag key and uses tagKeyOps.
var reservedOps = map[dashboardtypes.DSLKey]map[qbtypesv5.FilterOperator]struct{}{
dashboardtypes.DSLKeyName: stringSearchOps(),
dashboardtypes.DSLKeyDescription: stringSearchOps(),
dashboardtypes.DSLKeyCreatedAt: numericRangeOps(),
dashboardtypes.DSLKeyUpdatedAt: numericRangeOps(),
dashboardtypes.DSLKeyCreatedBy: stringSearchOps(),
dashboardtypes.DSLKeyLocked: opsSet(qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual),
dashboardtypes.DSLKeyPublic: opsSet(qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual),
}
// tagKeyOps applies to every non-reserved DSL key — the operator targets the
// tag's value with an implicit case-insensitive match on the tag's key.
var tagKeyOps = opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
qbtypesv5.FilterOperatorExists, qbtypesv5.FilterOperatorNotExists,
)
func stringSearchOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
)
}
func numericRangeOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq,
qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween,
)
}
func opsSet(ops ...qbtypesv5.FilterOperator) map[qbtypesv5.FilterOperator]struct{} {
m := make(map[qbtypesv5.FilterOperator]struct{}, len(ops))
for _, op := range ops {
m[op] = struct{}{}
}
return m
}

View File

@@ -1,39 +0,0 @@
package listfilter
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type Compiled struct {
SQL string
Args []any
}
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(query) == 0 {
return nil, nil
}
queryVisitor := newVisitor(formatter)
frag, syntaxErrs := queryVisitor.compile(query)
if len(syntaxErrs) > 0 {
return nil, errors.NewInvalidInputf(ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
}
if len(queryVisitor.errors) > 0 {
return nil, errors.NewInvalidInputf(ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
if frag == nil || frag.sql == "" {
return nil, nil
}
return &Compiled{
SQL: frag.sql,
Args: frag.args,
}, nil
}

View File

@@ -1,507 +0,0 @@
package listfilter
import (
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
)
type compileCase struct {
subtestName string
dslQueryToCompile string
nilExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
}
func runCompileCases(t *testing.T, cases []compileCase) {
t.Helper()
for _, c := range cases {
t.Run(c.subtestName, func(t *testing.T) {
out, err := Compile(c.dslQueryToCompile, formatter(t))
if c.expectedErrShouldContain != "" {
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
return
}
require.NoError(t, err)
if c.nilExpected {
assert.Nil(t, out)
return
}
require.NotNil(t, out)
if c.expectedSQL != "" {
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
}
if c.expectedArgs != nil {
require.Len(t, out.Args, len(c.expectedArgs))
for i, want := range c.expectedArgs {
// time.Time values can carry semantically-equal instants
// in different *Location representations (UTC vs Local vs
// FixedZone). Compare via .Equal() instead of DeepEqual.
if wantT, ok := want.(time.Time); ok {
gotT, ok := out.Args[i].(time.Time)
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
continue
}
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
}
}
})
}
}
func TestCompile_Empty(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "empty query yields nil", dslQueryToCompile: "", nilExpected: true},
})
}
func TestCompile_Name(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name =",
dslQueryToCompile: `name = 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.data.display.name') = ?`,
expectedArgs: []any{"overview"},
},
{
// QUOTED_TEXT in the grammar covers both '…' and "…" — visitor
// strips whichever quote pair surrounds the value.
subtestName: "name = with double-quoted value",
dslQueryToCompile: `name = "something"`,
expectedSQL: `json_extract("dashboard"."data", '$.data.display.name') = ?`,
expectedArgs: []any{"something"},
},
{
subtestName: "name CONTAINS",
dslQueryToCompile: `name CONTAINS 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.data.display.name') LIKE ?`,
expectedArgs: []any{"%overview%"},
},
{
subtestName: "name ILIKE — emitted as LOWER(col) LIKE LOWER(?) for dialect parity",
dslQueryToCompile: `name ILIKE 'Prod%'`,
expectedSQL: `lower(json_extract("dashboard"."data", '$.data.display.name')) LIKE LOWER(?)`,
expectedArgs: []any{"Prod%"},
},
{
subtestName: "CONTAINS escapes % in user input",
dslQueryToCompile: `name CONTAINS '50%'`,
expectedSQL: `json_extract("dashboard"."data", '$.data.display.name') LIKE ?`,
expectedArgs: []any{`%50\%%`},
},
})
}
func TestCompile_CreatedByLocked(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "created_by LIKE",
dslQueryToCompile: `created_by LIKE '%@signoz.io'`,
expectedSQL: `dashboard.created_by LIKE ?`,
expectedArgs: []any{"%@signoz.io"},
},
{
subtestName: "locked = true",
dslQueryToCompile: `locked = true`,
expectedSQL: `dashboard.locked = ?`,
expectedArgs: []any{true},
},
})
}
func TestCompile_Public(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "public = true", dslQueryToCompile: `public = true`, expectedSQL: `pd.id IS NOT NULL`},
{subtestName: "public = false", dslQueryToCompile: `public = false`, expectedSQL: `pd.id IS NULL`},
{subtestName: "public != true", dslQueryToCompile: `public != true`, expectedSQL: `pd.id IS NULL`},
})
}
func TestCompile_Timestamps(t *testing.T) {
ist := time.FixedZone("+05:30", 5*60*60+30*60)
runCompileCases(t, []compileCase{
{
subtestName: "created_at >= RFC3339",
dslQueryToCompile: `created_at >= '2026-03-10T00:00:00Z'`,
expectedSQL: `dashboard.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)},
},
{
subtestName: "updated_at BETWEEN",
dslQueryToCompile: `updated_at BETWEEN '2026-03-10T00:00:00Z' AND '2026-03-20T00:00:00Z'`,
expectedSQL: `dashboard.updated_at BETWEEN ? AND ?`,
expectedArgs: []any{
time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC),
time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
},
},
{
subtestName: "created_at >= IST timestamp",
dslQueryToCompile: `created_at >= '2026-03-10T05:30:00+05:30'`,
expectedSQL: `dashboard.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 3, 10, 5, 30, 0, 0, ist)},
},
})
}
// Tag operators wrap each predicate in EXISTS / NOT EXISTS. Any non-reserved
// key is a tag key — `team = 'pulse'` matches a tag with key=team value=pulse,
// `tag = 'prod'` matches a tag with key=tag value=prod, and so on.
func TestCompile_Tag(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "team = wraps in EXISTS",
dslQueryToCompile: `team = 'pulse'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"team", "pulse"},
},
{
subtestName: "tag = is just a regular tag-key filter",
dslQueryToCompile: `tag = 'database'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"tag", "database"},
},
{
subtestName: "team != wraps in NOT EXISTS with positive inner",
dslQueryToCompile: `team != 'pulse'`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"team", "pulse"},
},
{
subtestName: "team IN — inner is single placeholder list on t.value",
dslQueryToCompile: `team IN ['pulse', 'events']`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)`,
expectedArgs: []any{"team", "pulse", "events"},
},
{
subtestName: "team NOT IN",
dslQueryToCompile: `team NOT IN ['pulse', 'events']`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)`,
expectedArgs: []any{"team", "pulse", "events"},
},
{
subtestName: "team LIKE — wildcard on value",
dslQueryToCompile: `team LIKE 'pulse%'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ?
)`,
expectedArgs: []any{"team", "pulse%"},
},
{
subtestName: "team NOT LIKE",
dslQueryToCompile: `team NOT LIKE 'staging%'`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ?
)`,
expectedArgs: []any{"team", "staging%"},
},
{
subtestName: "database EXISTS — asserts a tag with key=database is present",
dslQueryToCompile: `database EXISTS`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{"database"},
},
{
subtestName: "database NOT EXISTS",
dslQueryToCompile: `database NOT EXISTS`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{"database"},
},
{
subtestName: "tag-key matching is case-insensitive — TEAM lowercased",
dslQueryToCompile: `TEAM = 'pulse'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"team", "pulse"},
},
})
}
func TestCompile_BooleanComposition(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "AND chain — flat arg list",
dslQueryToCompile: `locked = true AND public = true`,
expectedSQL: `dashboard.locked = ? AND pd.id IS NOT NULL`,
expectedArgs: []any{true},
},
{
subtestName: "OR chain",
dslQueryToCompile: `locked = true OR public = true`,
expectedSQL: `dashboard.locked = ? OR pd.id IS NOT NULL`,
expectedArgs: []any{true},
},
{
subtestName: "parens preserve precedence",
dslQueryToCompile: `(locked = true OR public = true) AND created_by = 'a@b.com'`,
expectedSQL: `(dashboard.locked = ? OR pd.id IS NOT NULL) AND dashboard.created_by = ?`,
expectedArgs: []any{true, "a@b.com"},
},
})
}
// Distinct from operator-suffix negation (NOT IN / NOT LIKE / NOT EXISTS).
// Driven by the unaryExpression rule (`NOT? primary`), so NOT binds to
// exactly one primary and only widens via parens.
func TestCompile_NOT(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "NOT on a single comparison",
dslQueryToCompile: `NOT name = 'foo'`,
expectedSQL: `NOT (json_extract("dashboard"."data", '$.data.display.name') = ?)`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT binds tightly to its primary in an AND chain",
dslQueryToCompile: `NOT name = 'foo' AND created_by = 'alice'`,
expectedSQL: `NOT (json_extract("dashboard"."data", '$.data.display.name') = ?) AND dashboard.created_by = ?`,
expectedArgs: []any{"foo", "alice"},
},
{
subtestName: "NOT applied to the second term in an AND chain",
dslQueryToCompile: `locked = true AND NOT name = 'foo'`,
expectedSQL: `dashboard.locked = ? AND NOT (json_extract("dashboard"."data", '$.data.display.name') = ?)`,
expectedArgs: []any{true, "foo"},
},
{
subtestName: "NOT around a parenthesized OR",
dslQueryToCompile: `NOT (locked = true OR public = true)`,
expectedSQL: `NOT ((dashboard.locked = ? OR pd.id IS NOT NULL))`,
expectedArgs: []any{true},
},
{
subtestName: "double NOT via parens",
dslQueryToCompile: `NOT (NOT name = 'foo')`,
expectedSQL: `NOT ((NOT (json_extract("dashboard"."data", '$.data.display.name') = ?)))`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT on a tag equality",
dslQueryToCompile: `NOT team = 'pulse'`,
expectedSQL: `
NOT (
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)`,
expectedArgs: []any{"team", "pulse"},
},
{
subtestName: "NOT team = ... AND name = ...",
dslQueryToCompile: `NOT team = 'pulse' AND name = 'overview'`,
expectedSQL: `
NOT (
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)
AND json_extract("dashboard"."data", '$.data.display.name') = ?`,
expectedArgs: []any{"team", "pulse", "overview"},
},
})
}
func TestCompile_ComplexExamples(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name CONTAINS + tag LIKE + created_by + database =",
dslQueryToCompile: `name CONTAINS 'overview' AND tag LIKE 'prod%' AND created_by = 'naman.verma@signoz.io' AND database = 'mongo'`,
expectedSQL: `
json_extract("dashboard"."data", '$.data.display.name') LIKE ?
AND EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ?
)
AND dashboard.created_by = ?
AND EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"%overview%", "tag", "prod%", "naman.verma@signoz.io", "database", "mongo"},
},
{
subtestName: "team IN AND database EXISTS",
dslQueryToCompile: `team IN ['pulse', 'events'] AND database EXISTS`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)
AND EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{"team", "pulse", "events", "database"},
},
{
subtestName: "nested OR / AND with parens",
dslQueryToCompile: `(database IN ['sql', 'redis', 'mongo'] OR name LIKE '%database%') AND (team = 'pulse' OR name LIKE '%pulse%')`,
expectedSQL: `
(
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?, ?)
)
OR json_extract("dashboard"."data", '$.data.display.name') LIKE ?
)
AND (
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
OR json_extract("dashboard"."data", '$.data.display.name') LIKE ?
)`,
expectedArgs: []any{"database", "sql", "redis", "mongo", "%database%", "team", "pulse", "%pulse%"},
},
})
}
func TestCompile_Rejections(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "rejects op outside per-reserved-key allowlist",
dslQueryToCompile: `name BETWEEN 'a' AND 'z'`,
expectedErrShouldContain: "operator",
},
{
subtestName: "rejects BETWEEN on a tag key",
dslQueryToCompile: `team BETWEEN 'a' AND 'z'`,
expectedErrShouldContain: "operator",
},
{
subtestName: "rejects non-bool on locked",
dslQueryToCompile: `locked = 'yes'`,
expectedErrShouldContain: "boolean",
},
{
subtestName: "rejects non-RFC3339 timestamp",
dslQueryToCompile: `created_at >= 'not-a-date'`,
expectedErrShouldContain: "RFC3339",
},
{
subtestName: "rejects REGEXP — not yet supported",
dslQueryToCompile: `name REGEXP '.*'`,
expectedErrShouldContain: "REGEXP",
},
{
subtestName: "rejects syntax error from grammar",
dslQueryToCompile: `name = `,
expectedErrShouldContain: "syntax",
},
})
}
func formatter(t *testing.T) sqlstore.SQLFormatter {
t.Helper()
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return p.Formatter()
}
func normalizeSQL(s string) string {
s = strings.Join(strings.Fields(s), " ")
s = strings.ReplaceAll(s, "( ", "(")
s = strings.ReplaceAll(s, " )", ")")
return s
}

View File

@@ -1,590 +0,0 @@
package listfilter
import (
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/parser/filterquery"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/antlr4-go/antlr/v4"
)
// fragment is one composable WHERE fragment. sql uses `?` placeholders;
// args lines up positionally with the placeholders.
type fragment struct {
sql string
args []any
}
func newFragment(sql string, args ...any) *fragment {
return &fragment{sql: sql, args: args}
}
type visitor struct {
grammar.BaseFilterQueryVisitor
formatter sqlstore.SQLFormatter
errors []string
}
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
return &visitor{
formatter: formatter,
}
}
// Emitted WHERE fragment uses aliases `dashboard` and `pd` (public_dashboard).
func (v *visitor) compile(query string) (*fragment, []string) {
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return nil, collector.Errors
}
frag, _ := v.visit(tree).(*fragment)
return frag, nil
}
func (v *visitor) visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
return tree.Accept(v)
}
// ════════════════════════════════════════════════════════════════════════
// methods from grammar.BaseFilterQueryVisitor that are overridden
// ════════════════════════════════════════════════════════════════════════
func (v *visitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.visit(ctx.Expression())
}
func (v *visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.visit(ctx.OrExpression())
}
func (v *visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
parts := ctx.AllAndExpression()
frags := make([]*fragment, 0, len(parts))
for _, p := range parts {
if f, ok := v.visit(p).(*fragment); ok && f != nil {
frags = append(frags, f)
}
}
return joinFragments(frags, "OR")
}
func (v *visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
parts := ctx.AllUnaryExpression()
frags := make([]*fragment, 0, len(parts))
for _, p := range parts {
if f, ok := v.visit(p).(*fragment); ok && f != nil {
frags = append(frags, f)
}
}
return joinFragments(frags, "AND")
}
func (v *visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
f, _ := v.visit(ctx.Primary()).(*fragment)
if f == nil {
return nil
}
if ctx.NOT() != nil {
return newFragment("NOT ("+f.sql+")", f.args...)
}
return f
}
func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.OrExpression() != nil {
f, _ := v.visit(ctx.OrExpression()).(*fragment)
if f == nil {
return nil
}
return newFragment("("+f.sql+")", f.args...)
}
if ctx.Comparison() != nil {
return v.visit(ctx.Comparison())
}
// Bare keys, values, full text, and function calls are not part of the
// dashboard list DSL.
v.addErr("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
return nil
}
// VisitComparison dispatches a single `key OP value` term. A key that matches
// a reserved DSL key (name, description, etc.) becomes a column-level
// predicate; any other identifier is treated as a tag key — the operator
// applies to the tag's value, with a case-insensitive match on the tag's key.
func (v *visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key, ok := v.parseKey(ctx)
if !ok {
return nil
}
op, ok := v.opFromContext(ctx)
if !ok {
return nil
}
if reservedOpSet, isReserved := reservedOps[dashboardtypes.DSLKey(key)]; isReserved {
if _, allowed := reservedOpSet[op]; !allowed {
v.addErr("operator %s is not allowed for key %q", opName(op), key)
return nil
}
switch dashboardtypes.DSLKey(key) {
case dashboardtypes.DSLKeyName:
return v.emitJSONStringComparison(ctx, op, "$.data.display.name")
case dashboardtypes.DSLKeyDescription:
return v.emitJSONStringComparison(ctx, op, "$.data.display.description")
case dashboardtypes.DSLKeyCreatedAt:
return v.emitTimestampComparison(ctx, op, "dashboard.created_at")
case dashboardtypes.DSLKeyUpdatedAt:
return v.emitTimestampComparison(ctx, op, "dashboard.updated_at")
case dashboardtypes.DSLKeyCreatedBy:
return v.emitStringComparison(ctx, op, "dashboard.created_by")
case dashboardtypes.DSLKeyLocked:
return v.emitBoolComparison(ctx, op, "dashboard.locked")
case dashboardtypes.DSLKeyPublic:
return v.emitPublicComparison(ctx, op)
}
}
if _, allowed := tagKeyOps[op]; !allowed {
v.addErr("operator %s is not allowed on a tag-key filter", opName(op))
return nil
}
return v.emitTagComparison(ctx, op, key)
}
func (v *visitor) parseKey(ctx *grammar.ComparisonContext) (string, bool) {
keyText := strings.ToLower(strings.TrimSpace(ctx.Key().GetText()))
if keyText == "" {
v.addErr("filter key cannot be empty")
return "", false
}
return keyText, true
}
func (v *visitor) opFromContext(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
switch {
case ctx.EQUALS() != nil:
return qbtypesv5.FilterOperatorEqual, true
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
return qbtypesv5.FilterOperatorNotEqual, true
case ctx.LT() != nil:
return qbtypesv5.FilterOperatorLessThan, true
case ctx.LE() != nil:
return qbtypesv5.FilterOperatorLessThanOrEq, true
case ctx.GT() != nil:
return qbtypesv5.FilterOperatorGreaterThan, true
case ctx.GE() != nil:
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
case ctx.BETWEEN() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotBetween, true
}
return qbtypesv5.FilterOperatorBetween, true
case ctx.LIKE() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotLike, true
}
return qbtypesv5.FilterOperatorLike, true
case ctx.ILIKE() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotILike, true
}
return qbtypesv5.FilterOperatorILike, true
case ctx.CONTAINS() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotContains, true
}
return qbtypesv5.FilterOperatorContains, true
case ctx.REGEXP() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotRegexp, true
}
return qbtypesv5.FilterOperatorRegexp, true
case ctx.InClause() != nil:
return qbtypesv5.FilterOperatorIn, true
case ctx.NotInClause() != nil:
return qbtypesv5.FilterOperatorNotIn, true
case ctx.EXISTS() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotExists, true
}
return qbtypesv5.FilterOperatorExists, true
}
v.addErr("could not determine operator in expression %q", ctx.GetText())
return qbtypesv5.FilterOperatorUnknown, false
}
// ─── per-key emitters ────────────────────────────────────────────────────────
func (v *visitor) emitJSONStringComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, jsonPath string) *fragment {
colExpr := string(v.formatter.JSONExtractString("dashboard.data", jsonPath))
return v.emitStringOp(ctx, op, colExpr, string(dashboardtypes.DSLKeyName))
}
func (v *visitor) emitStringComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
return v.emitStringOp(ctx, op, colExpr, string(dashboardtypes.DSLKeyCreatedBy))
}
// emitStringOp covers all the operators the spec allows on text-shaped keys
// (name, description, created_by). Tag uses a separate emitter that wraps each
// produced fragment in an EXISTS subquery.
func (v *visitor) emitStringOp(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr, keyForErr string) *fragment {
switch op {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
val, ok := v.singleString(ctx, keyForErr)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(op)+" ?", val)
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
val, ok := v.singleString(ctx, keyForErr)
if !ok {
return nil
}
// SQLite has no ILIKE keyword and Postgres LIKE is case-sensitive — emit
// LOWER(col) LIKE LOWER(?) so behavior is identical on both dialects.
lowerCol := string(v.formatter.LowerExpression(colExpr))
return newFragment(lowerCol+" "+opName(iLikeToLike(op))+" LOWER(?)", val)
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
val, ok := v.singleString(ctx, keyForErr)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(containsToLike(op))+" ?", "%"+escapeLike(val)+"%")
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
v.addErr("REGEXP filtering on %q is not yet supported", keyForErr)
return nil
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
vals, ok := v.stringList(ctx, keyForErr)
if !ok {
return nil
}
return inFragment(colExpr, op, vals)
}
v.addErr("operator %s on %q is not implemented", opName(op), keyForErr)
return nil
}
func (v *visitor) emitTimestampComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
switch op {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
t, ok := v.singleTimestamp(ctx)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(op)+" ?", t)
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
ts, ok := v.twoTimestamps(ctx)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(op)+" ? AND ?", ts[0], ts[1])
}
v.addErr("operator %s on timestamp is not implemented", opName(op))
return nil
}
func (v *visitor) emitBoolComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
b, ok := v.singleBool(ctx)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(op)+" ?", b)
}
// emitPublicComparison renders `public = true|false` against the LEFT-joined
// public_dashboard alias `pd`. The spec says public is a virtual column whose
// truthiness is the existence of a row in public_dashboard.
func (v *visitor) emitPublicComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator) *fragment {
b, ok := v.singleBool(ctx)
if !ok {
return nil
}
want := b
if op == qbtypesv5.FilterOperatorNotEqual {
want = !b
}
if want {
return newFragment("pd.id IS NOT NULL")
}
return newFragment("pd.id IS NULL")
}
const tagSubqueryPrefix = "SELECT 1 FROM tag_relations tr JOIN tag t ON t.id = tr.tag_id " +
"WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id " +
"AND LOWER(t.key) = LOWER(?)"
// emitTagComparison wraps the inner predicate in EXISTS (or NOT EXISTS for the
// negated operators). The inner predicate matches the tag's key
// case-insensitively and applies the user's operator to the tag's value.
// EXISTS / NOT EXISTS skip the value predicate — they assert the existence
// (or absence) of any tag with the given key.
func (v *visitor) emitTagComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, key string) *fragment {
if op == qbtypesv5.FilterOperatorExists || op == qbtypesv5.FilterOperatorNotExists {
wrapper := "EXISTS"
if op == qbtypesv5.FilterOperatorNotExists {
wrapper = "NOT EXISTS"
}
return newFragment(wrapper+" ("+tagSubqueryPrefix+")", key)
}
// All other tag operators take the positive form of the value predicate
// and toggle the EXISTS wrapper for negation. Inverse() flips Not<X> → <X>.
negated := op.IsNegativeOperator()
posOp := op
if negated {
posOp = op.Inverse()
}
inner := v.emitStringOp(ctx, posOp, "t.value", key)
if inner == nil {
return nil
}
wrapper := "EXISTS"
if negated {
wrapper = "NOT EXISTS"
}
args := append([]any{key}, inner.args...)
return newFragment(wrapper+" ("+tagSubqueryPrefix+" AND "+inner.sql+")", args...)
}
// ─── value extraction helpers ───────────────────────────────────────────────
func (v *visitor) addErr(format string, args ...any) {
v.errors = append(v.errors, fmt.Sprintf(format, args...))
}
func (v *visitor) singleString(ctx *grammar.ComparisonContext, keyForErr string) (string, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addErr("expected exactly one value for %q", keyForErr)
return "", false
}
return v.stringValue(values[0], keyForErr)
}
func (v *visitor) singleBool(ctx *grammar.ComparisonContext) (bool, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addErr("expected a single boolean (true/false)")
return false, false
}
return v.boolValue(values[0])
}
func (v *visitor) singleTimestamp(ctx *grammar.ComparisonContext) (time.Time, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addErr("expected a single RFC3339 timestamp")
return time.Time{}, false
}
return v.timestampValue(values[0])
}
func (v *visitor) twoTimestamps(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
values := ctx.AllValue()
if len(values) != 2 {
v.addErr("BETWEEN expects two RFC3339 timestamps")
return [2]time.Time{}, false
}
a, ok1 := v.timestampValue(values[0])
b, ok2 := v.timestampValue(values[1])
if !ok1 || !ok2 {
return [2]time.Time{}, false
}
return [2]time.Time{a, b}, true
}
func (v *visitor) stringList(ctx *grammar.ComparisonContext, keyForErr string) ([]string, bool) {
var valuesCtx []grammar.IValueContext
switch {
case ctx.InClause() != nil:
ic := ctx.InClause()
if ic.ValueList() != nil {
valuesCtx = ic.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{ic.Value()}
}
case ctx.NotInClause() != nil:
nc := ctx.NotInClause()
if nc.ValueList() != nil {
valuesCtx = nc.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{nc.Value()}
}
default:
v.addErr("IN clause is missing for %q", keyForErr)
return nil, false
}
if len(valuesCtx) == 0 {
v.addErr("IN list for %q is empty", keyForErr)
return nil, false
}
out := make([]string, 0, len(valuesCtx))
for _, vc := range valuesCtx {
s, ok := v.stringValue(vc, keyForErr)
if !ok {
return nil, false
}
out = append(out, s)
}
return out, true
}
func (v *visitor) stringValue(ctx grammar.IValueContext, keyForErr string) (string, bool) {
if ctx.QUOTED_TEXT() != nil {
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
}
if ctx.KEY() != nil {
// Bare tokens are accepted as strings, mirroring the FilterQuery lexer's
// treatment of unquoted identifiers on the value side.
return ctx.KEY().GetText(), true
}
v.addErr("expected a string value for %q, got %q", keyForErr, ctx.GetText())
return "", false
}
func (v *visitor) boolValue(ctx grammar.IValueContext) (bool, bool) {
if ctx.BOOL() == nil {
v.addErr("expected a boolean (true/false), got %q", ctx.GetText())
return false, false
}
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
}
func (v *visitor) timestampValue(ctx grammar.IValueContext) (time.Time, bool) {
if ctx.QUOTED_TEXT() == nil {
v.addErr("expected an RFC3339 timestamp string, got %q", ctx.GetText())
return time.Time{}, false
}
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
v.addErr("invalid RFC3339 timestamp %q: %s", raw, err.Error())
return time.Time{}, false
}
return t, true
}
// ─── fragment helpers ────────────────────────────────────────────────────────
func joinFragments(frags []*fragment, conn string) *fragment {
if len(frags) == 0 {
return nil
}
if len(frags) == 1 {
return frags[0]
}
parts := make([]string, len(frags))
args := make([]any, 0)
for i, f := range frags {
parts[i] = f.sql
args = append(args, f.args...)
}
return newFragment(strings.Join(parts, " "+conn+" "), args...)
}
func inFragment(colExpr string, op qbtypesv5.FilterOperator, vals []string) *fragment {
placeholders := strings.Repeat("?, ", len(vals))
placeholders = placeholders[:len(placeholders)-2]
args := make([]any, len(vals))
for i, s := range vals {
args[i] = s
}
return newFragment(colExpr+" "+opName(op)+" ("+placeholders+")", args...)
}
// opName returns the user-facing spelling of a FilterOperator. For the
// operators we emit directly into SQL (=, !=, <, LIKE, IN, BETWEEN, …) the
// spelling doubles as the SQL keyword. For the operators we don't emit
// directly (ILIKE, CONTAINS, REGEXP, EXISTS, NOT EXISTS) it's only used in
// error messages.
func opName(op qbtypesv5.FilterOperator) string {
switch op {
case qbtypesv5.FilterOperatorEqual:
return "="
case qbtypesv5.FilterOperatorNotEqual:
return "!="
case qbtypesv5.FilterOperatorLessThan:
return "<"
case qbtypesv5.FilterOperatorLessThanOrEq:
return "<="
case qbtypesv5.FilterOperatorGreaterThan:
return ">"
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return ">="
case qbtypesv5.FilterOperatorBetween:
return "BETWEEN"
case qbtypesv5.FilterOperatorNotBetween:
return "NOT BETWEEN"
case qbtypesv5.FilterOperatorLike:
return "LIKE"
case qbtypesv5.FilterOperatorNotLike:
return "NOT LIKE"
case qbtypesv5.FilterOperatorILike:
return "ILIKE"
case qbtypesv5.FilterOperatorNotILike:
return "NOT ILIKE"
case qbtypesv5.FilterOperatorContains:
return "CONTAINS"
case qbtypesv5.FilterOperatorNotContains:
return "NOT CONTAINS"
case qbtypesv5.FilterOperatorRegexp:
return "REGEXP"
case qbtypesv5.FilterOperatorNotRegexp:
return "NOT REGEXP"
case qbtypesv5.FilterOperatorIn:
return "IN"
case qbtypesv5.FilterOperatorNotIn:
return "NOT IN"
case qbtypesv5.FilterOperatorExists:
return "EXISTS"
case qbtypesv5.FilterOperatorNotExists:
return "NOT EXISTS"
}
return "?"
}
// iLikeToLike maps ILIKE → LIKE for the LOWER(col) LIKE LOWER(?) emission.
func iLikeToLike(op qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
if op == qbtypesv5.FilterOperatorNotILike {
return qbtypesv5.FilterOperatorNotLike
}
return qbtypesv5.FilterOperatorLike
}
// containsToLike maps CONTAINS → LIKE for the LIKE '%val%' emission.
func containsToLike(op qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
if op == qbtypesv5.FilterOperatorNotContains {
return qbtypesv5.FilterOperatorNotLike
}
return qbtypesv5.FilterOperatorLike
}
// escapeLike escapes the LIKE meta-characters % and _ in user input so that a
// CONTAINS query of `50%` doesn't match every value containing `50`.
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
func trimQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
s = s[1 : len(s)-1]
}
}
s = strings.ReplaceAll(s, `\\`, `\`)
s = strings.ReplaceAll(s, `\'`, `'`)
return s
}

View File

@@ -1,398 +0,0 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
jsonpatch "gopkg.in/evanphx/json-patch.v4"
)
const (
SchemaVersion = "v6"
MaxTagsPerDashboard = 5
)
type DSLKey string
const (
DSLKeyName DSLKey = "name"
DSLKeyDescription DSLKey = "description"
DSLKeyCreatedAt DSLKey = "created_at"
DSLKeyUpdatedAt DSLKey = "updated_at"
DSLKeyCreatedBy DSLKey = "created_by"
DSLKeyLocked DSLKey = "locked"
DSLKeyPublic DSLKey = "public"
)
// reservedDSLKeys are dashboard column-level filter names in the list-query DSL.
// A tag whose key collides with one of these would make the DSL ambiguous, so
// they're rejected (case-insensitively) at write time.
var reservedDSLKeys = map[DSLKey]struct{}{
DSLKeyName: {},
DSLKeyDescription: {},
DSLKeyCreatedAt: {},
DSLKeyUpdatedAt: {},
DSLKeyCreatedBy: {},
DSLKeyLocked: {},
DSLKeyPublic: {},
}
type DashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Data DashboardV2Data `json:"data"`
}
func (d *DashboardV2) CanUpdate() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
return nil
}
func (d *DashboardV2) Update(updateable UpdateableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
return err
}
d.Data.Metadata = updateable.Metadata.toDashboardV2Metadata(d.OrgID)
d.Data.Spec = updateable.Spec
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
if d.CreatedBy != updatedBy && !isAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
return nil
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
type DashboardV2Data struct {
Metadata DashboardV2Metadata `json:"metadata"`
Spec DashboardSpec `json:"spec"`
}
type DashboardV2Metadata struct {
DashboardV2MetadataBase
Tags []*tagtypes.Tag `json:"tags"`
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion"`
Image string `json:"image,omitempty"`
}
// ════════════════════════════════════════════════════════════════════════
// Postable
// ════════════════════════════════════════════════════════════════════════
type PostableDashboardV2 struct {
Metadata PostableDashboardV2Metadata `json:"metadata"`
Spec DashboardSpec `json:"spec"`
}
func (postable PostableDashboardV2) NewDashboardV2WithoutTags(orgID valuer.UUID, createdBy string) *DashboardV2 {
now := time.Now()
return &DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: createdBy},
OrgID: orgID,
Locked: false,
Data: DashboardV2Data{
Metadata: postable.Metadata.toDashboardV2Metadata(orgID),
Spec: postable.Spec,
},
}
}
type PostableDashboardV2Metadata struct {
DashboardV2MetadataBase
Tags []tagtypes.PostableTag `json:"tags"`
}
func (m PostableDashboardV2Metadata) toDashboardV2Metadata(orgID valuer.UUID) DashboardV2Metadata {
return DashboardV2Metadata{
DashboardV2MetadataBase: m.DashboardV2MetadataBase,
Tags: tagtypes.NewTagsFromPostableTags(orgID, coretypes.KindDashboard, m.Tags),
}
}
func (m DashboardV2Metadata) toPostableDashboardV2Metadata() PostableDashboardV2Metadata {
return PostableDashboardV2Metadata{
DashboardV2MetadataBase: m.DashboardV2MetadataBase,
Tags: tagtypes.NewPostableTagsFromTags(m.Tags),
}
}
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableDashboardV2
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PostableDashboardV2(tmp)
return p.Validate()
}
func (p *PostableDashboardV2) Validate() error {
if p.Metadata.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "metadata.schemaVersion must be %q, got %q", SchemaVersion, p.Metadata.SchemaVersion)
}
if p.Spec.Display == nil || p.Spec.Display.Name == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.display.name is required")
}
if err := p.validateTags(); err != nil {
return err
}
return p.Spec.Validate()
}
func (p *PostableDashboardV2) validateTags() error {
if len(p.Metadata.Tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
for _, tag := range p.Metadata.Tags {
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key)
}
}
return nil
}
// ════════════════════════════════════════════════════════════════════════
// Gettable
// ════════════════════════════════════════════════════════════════════════
type GettableDashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Data GettableDashboardV2Data `json:"data"`
}
type GettableDashboardV2Data struct {
Metadata GettableDashboardV2Metadata `json:"metadata"`
Spec DashboardSpec `json:"spec"`
}
type GettableDashboardV2Metadata struct {
DashboardV2MetadataBase
Tags []*tagtypes.GettableTag `json:"tags"`
}
func (m DashboardV2Metadata) toGettableDashboardV2Metadata() GettableDashboardV2Metadata {
return GettableDashboardV2Metadata{
DashboardV2MetadataBase: m.DashboardV2MetadataBase,
Tags: tagtypes.NewGettableTagsFromTags(m.Tags),
}
}
func (d DashboardV2) ToGettableDashboardV2() GettableDashboardV2 {
return GettableDashboardV2{
Identifiable: d.Identifiable,
TimeAuditable: d.TimeAuditable,
UserAuditable: d.UserAuditable,
OrgID: d.OrgID,
Locked: d.Locked,
Data: d.Data.toGettableDashboardData(),
}
}
func (d DashboardV2Data) toGettableDashboardData() GettableDashboardV2Data {
return GettableDashboardV2Data{
Metadata: d.Metadata.toGettableDashboardV2Metadata(),
Spec: d.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Storable
// ════════════════════════════════════════════════════════════════════════
// StorableDashboardV2Data is exactly what serializes into the dashboard.data column.
type StorableDashboardV2Data struct {
Metadata StorableDashboardV2Metadata `json:"metadata"`
Spec DashboardSpec `json:"spec"`
}
func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardData, error) {
raw, err := json.Marshal(s)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v2 dashboard data")
}
out := StorableDashboardData{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal v2 dashboard data")
}
return out, nil
}
type StorableDashboardV2Metadata = DashboardV2MetadataBase
func (stored StorableDashboardV2Data) toDashboardV2Data(tags []*tagtypes.Tag) DashboardV2Data {
return DashboardV2Data{
Metadata: DashboardV2Metadata{
DashboardV2MetadataBase: stored.Metadata,
Tags: tags,
},
Spec: stored.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Updateable
// ════════════════════════════════════════════════════════════════════════
type UpdateableDashboardV2 = PostableDashboardV2
func (d DashboardV2) toUpdateableDashboardV2() UpdateableDashboardV2 {
return PostableDashboardV2{
Metadata: d.Data.Metadata.toPostableDashboardV2Metadata(),
Spec: d.Data.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Patchable
// ════════════════════════════════════════════════════════════════════════
// PatchableDashboardV2 is an RFC 6902 JSON Patch document applied against a
// PostableDashboardV2-shaped view of an existing dashboard. Patch ops can
// target any field — including individual entries inside `data.panels`,
// `data.panels.<id>.spec.queries`, or `tags` — without re-sending the rest of
// the dashboard.
type PatchableDashboardV2 struct {
patch jsonpatch.Patch
}
// JSONPatchDocument is the OpenAPI-facing schema for an RFC 6902 patch body.
// PatchableDashboardV2 has only an internal `jsonpatch.Patch` field, so the
// reflector would emit an empty schema; the handler def points at this type
// instead so consumers see the array-of-ops shape.
type JSONPatchDocument []JSONPatchOperation
// JSONPatchOperation is one RFC 6902 op. Not every field is valid on every
// op kind (e.g. `value` is required for add/replace/test, ignored for remove;
// `from` is required for move/copy) — the JSON Patch RFC governs that.
type JSONPatchOperation struct {
Op string `json:"op" required:"true"`
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /data/display/name, /data/panels/<id>, /data/panels/<id>/spec/queries/0, /tags/-."`
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /data/panels/<id> takes a DashboardtypesPanel; /data/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /data/variables/N takes a DashboardtypesVariable; /data/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /data/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
}
// PrepareJSONSchema constrains the `op` field to the six RFC 6902 verbs.
func (JSONPatchOperation) PrepareJSONSchema(s *jsonschema.Schema) error {
op, ok := s.Properties["op"]
if !ok || op.TypeObject == nil {
return errors.NewInternalf(errors.CodeInternal, "JSONPatchOperation schema missing `op` property")
}
op.TypeObject.WithEnum("add", "remove", "replace", "move", "copy", "test")
s.Properties["op"] = op
return nil
}
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
patch, err := jsonpatch.DecodePatch(data)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
p.patch = patch
return nil
}
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdateableDashboardV2, error) {
existingAsUpdateable := existing.toUpdateableDashboardV2()
raw, err := json.Marshal(existingAsUpdateable)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
}
patched, err := p.patch.Apply(raw)
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
out := &UpdateableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {
return nil, err
}
return out, nil
}
// ════════════════════════════════════════════════════════════════════════
// Convertors
// ════════════════════════════════════════════════════════════════════════
func (d *DashboardV2) ToStorableDashboard() (*StorableDashboard, error) {
storableDashboardV2Data := StorableDashboardV2Data{
Metadata: StorableDashboardV2Metadata{
SchemaVersion: d.Data.Metadata.SchemaVersion,
Image: d.Data.Metadata.Image,
},
Spec: d.Data.Spec,
}
data, err := storableDashboardV2Data.toStorableDashboardData()
if err != nil {
return nil, err
}
return &StorableDashboard{
Identifiable: types.Identifiable{ID: d.ID},
TimeAuditable: d.TimeAuditable,
UserAuditable: d.UserAuditable,
OrgID: d.OrgID,
Locked: d.Locked,
Data: data,
}, nil
}
func (storable StorableDashboard) ToDashboardV2(tags []*tagtypes.Tag) (*DashboardV2, error) {
metadata, _ := storable.Data["metadata"].(map[string]any)
if metadata == nil || metadata["schemaVersion"] != SchemaVersion {
return nil, errors.Newf(errors.TypeUnsupported, ErrCodeDashboardInvalidData, "dashboard %s is not in %s schema", storable.ID, SchemaVersion)
}
raw, err := json.Marshal(storable.Data)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal stored v2 dashboard data")
}
var stored StorableDashboardV2Data
if err := json.Unmarshal(raw, &stored); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal stored v2 dashboard data")
}
return &DashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Data: stored.toDashboardV2Data(tags),
}, nil
}

View File

@@ -12,11 +12,11 @@ import (
"github.com/perses/perses/pkg/model/api/v1/common"
)
// DashboardSpec is the SigNoz dashboard v2 spec shape. It mirrors
// DashboardData is the SigNoz dashboard v2 spec shape. It mirrors
// v1.DashboardSpec (Perses) field-for-field, except every common.Plugin
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
type DashboardSpec struct {
type DashboardData struct {
Display *common.Display `json:"display,omitempty"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
Variables []Variable `json:"variables,omitempty"`
@@ -31,15 +31,15 @@ type DashboardSpec struct {
// Unmarshal + validate entry point
// ══════════════════════════════════════════════
func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
func (d *DashboardData) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias DashboardSpec
type alias DashboardData
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid dashboard spec")
}
*d = DashboardSpec(tmp)
*d = DashboardData(tmp)
return d.Validate()
}
@@ -47,7 +47,7 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
// Cross-field validation
// ══════════════════════════════════════════════
func (d *DashboardSpec) Validate() error {
func (d *DashboardData) Validate() error {
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)

View File

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

View File

@@ -12,8 +12,8 @@ import (
"github.com/stretchr/testify/require"
)
func unmarshalDashboard(data []byte) (*DashboardSpec, error) {
var d DashboardSpec
func unmarshalDashboard(data []byte) (*DashboardData, error) {
var d DashboardData
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
@@ -40,7 +40,7 @@ func TestInvalidateNotAJSON(t *testing.T) {
}
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
// DashboardSpec.UnmarshalJSON. The wrap stamps a consistent type/code on
// DashboardData.UnmarshalJSON. The wrap stamps a consistent type/code on
// decode failures, but must not smother the rich messages produced by nested
// UnmarshalJSON methods (panel/query/variable/datasource plugin envelopes).
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
@@ -820,7 +820,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
raw, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err)
var data DashboardSpec
var data DashboardData
require.NoError(t, json.Unmarshal(raw, &data), "initial unmarshal")
marshaled, err := json.Marshal(data)
@@ -832,7 +832,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
remarshaled, err := json.Marshal(asMap)
require.NoError(t, err, "map → JSON (read-back shape)")
var roundtripped DashboardSpec
var roundtripped DashboardData
require.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
}

View File

@@ -1,6 +1,6 @@
package dashboardtypes
// TestDashboardSpecMatchesPerses asserts that DashboardData
// TestDashboardDataMatchesPerses asserts that DashboardData
// and every nested SigNoz-owned type cover the JSON field set of their Perses
// counterpart.
@@ -16,13 +16,13 @@ import (
"github.com/stretchr/testify/assert"
)
func TestDashboardSpecMatchesPerses(t *testing.T) {
func TestDashboardDataMatchesPerses(t *testing.T) {
cases := []struct {
name string
ours reflect.Type
perses reflect.Type
}{
{"DashboardSpec", typeOf[DashboardSpec](), typeOf[v1.DashboardSpec]()},
{"DashboardSpec", typeOf[DashboardData](), typeOf[v1.DashboardSpec]()},
{"Panel", typeOf[Panel](), typeOf[v1.Panel]()},
{"PanelSpec", typeOf[PanelSpec](), typeOf[v1.PanelSpec]()},
{"Query", typeOf[Query](), typeOf[v1.Query]()},
@@ -38,10 +38,10 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
missing, extra := drift(c.ours, c.perses)
assert.Empty(t, missing,
"DashboardSpec (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
"DashboardData (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
c.ours.Name(), c.perses.Name())
assert.Empty(t, extra,
"DashboardSpec (%s) has json fields absent on Perses %s — upstream likely removed a field or we added one without the counterpart",
"DashboardData (%s) has json fields absent on Perses %s — upstream likely removed a field or we added one without the counterpart",
c.ours.Name(), c.perses.Name())
})
}

View File

@@ -1,22 +0,0 @@
package dashboardtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
const MaxPinnedDashboardsPerUser = 10
var ErrCodePinnedDashboardLimitHit = errors.MustNewCode("pinned_dashboard_limit_hit")
type PinnedDashboard struct {
bun.BaseModel `bun:"table:pinned_dashboard,alias:pinned_dashboard"`
UserID valuer.UUID `bun:"user_id,pk,type:text"`
DashboardID valuer.UUID `bun:"dashboard_id,pk,type:text"`
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
PinnedAt time.Time `bun:"pinned_at,notnull,default:current_timestamp"`
}

View File

@@ -32,21 +32,4 @@ type Store interface {
DeletePublic(context.Context, string) error
RunInTx(context.Context, func(context.Context) error) error
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
GetV2(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, *StorablePublicDashboard, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data StorableDashboardData) error
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, locked bool, updatedBy string) error
// bool return is hasMore — the store fetches Limit+1 to detect it.
ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *ListDashboardsV2Params) ([]*DashboardListRow, bool, error)
// Returns ErrCodePinnedDashboardLimitHit when the user is at MaxPinnedDashboardsPerUser.
PinForUser(ctx context.Context, pd *PinnedDashboard) error
UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error
}

View File

@@ -69,14 +69,6 @@ func NewPostableTagsFromTags(tags []*Tag) []PostableTag {
return out
}
func NewTagsFromPostableTags(orgID valuer.UUID, kind coretypes.Kind, tags []PostableTag) []*Tag {
out := make([]*Tag, len(tags))
for i, t := range tags {
out[i] = NewTag(orgID, kind, t.Key, t.Value)
}
return out
}
func NewTag(orgID valuer.UUID, kind coretypes.Kind, key, value string) *Tag {
now := time.Now()
return &Tag{

View File

@@ -1,288 +0,0 @@
import json
from collections.abc import Callable
from http import HTTPStatus
from pathlib import Path
import pytest
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
_PERSES_FIXTURE = (
Path(__file__).parents[4]
/ "pkg/types/dashboardtypes/dashboardtypesv2/testdata/perses.json"
)
def _post_dashboard(signoz: SigNoz, token: str, body: dict) -> requests.Response:
return requests.post(
signoz.self.host_configs["8080"].get("/api/v2/dashboards"),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
def test_empty_body_rejected_for_missing_schema_version(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(signoz, admin_token, {})
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'metadata.schemaVersion must be "v6", got ""'
def test_missing_display_name_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(signoz, admin_token, {"metadata": {"schemaVersion": "v6"}})
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == "data.display.name is required"
def test_minimal_valid_body_creates_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "test name"}},
},
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["status"] == "success"
data = body["data"]
assert data["info"]["data"]["display"]["name"] == "test name"
assert data["info"]["metadata"]["schemaVersion"] == "v6"
def test_unknown_root_field_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "test name"}},
"unknownfieldattheroot": "shouldgiveanerror",
},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'json: unknown field "unknownfieldattheroot"'
def test_unknown_nested_field_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {
"display": {
"name": "test name",
"unknownfieldinside": "shouldgiveanerror",
},
},
},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'json: unknown field "unknownfieldinside"'
def test_perses_fixture_creates_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""The perses.json fixture is the kitchen-sink dashboard the schema tests
use; round-tripping it through the create API exercises the full plugin
surface end-to-end."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
data = json.loads(_PERSES_FIXTURE.read_text())
response = _post_dashboard(
signoz,
admin_token,
{"metadata": {"schemaVersion": "v6"}, "data": data},
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["status"] == "success"
assert body["data"]["info"]["data"]["display"]["name"] == data["display"]["name"]
def test_tag_casing_is_inherited_from_existing_parent(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""A second dashboard tagged with a sibling under a casing-variant parent
path should adopt the existing parent's casing while keeping the
user-supplied casing for the new leaf segment."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
first = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "dac"}},
"tags": [{"name": "engineering/US/NYC"}],
},
)
assert first.status_code == HTTPStatus.CREATED
first_tags = first.json()["data"]["info"]["tags"]
assert first_tags == [{"name": "engineering/US/NYC"}]
second = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "dac"}},
"tags": [{"name": "engineering/us/SF"}],
},
)
assert second.status_code == HTTPStatus.CREATED
second_tags = second.json()["data"]["info"]["tags"]
assert second_tags == [{"name": "engineering/US/SF"}]
# ─── list filter DSL ─────────────────────────────────────────────────────────
# All fixtures carry a marker tag so each test query can be ANDed with
# `tag = '__lst_v2_filter_test'` server-side. That guarantees no leakage
# from any other test file/module sharing the session-scoped DB. The marker
# rules out a `tag NOT EXISTS` case (a fixture that's no-tags by design
# can't also carry the marker) — that operator is covered by the visitor
# unit tests in pkg/types/dashboardtypes/listfilter.
_LIST_FIXTURE_MARKER_TAG = "__lst_v2_filter_test"
_LIST_FIXTURE_DASHBOARDS = [
("lst-overview-prod", ["team/pulse", "prod", "team/frontend"]),
("lst-overview-dev", ["team/pulse", "dev"]),
("lst-database-prod", ["team/storage", "prod", "database/postgres"]),
("lst-database-test", ["team/storage", "test", "database/redis"]),
("lst-frontend-team", ["team/pulse", "team/frontend"]),
]
@pytest.fixture(name="list_fixture_dashboards", scope="module")
def list_fixture_dashboards(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
for name, tags in _LIST_FIXTURE_DASHBOARDS:
body: dict = {
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": name}},
"tags": [{"name": t} for t in [*tags, _LIST_FIXTURE_MARKER_TAG]],
}
response = _post_dashboard(signoz, admin_token, body)
assert response.status_code == HTTPStatus.CREATED, response.text
@pytest.mark.parametrize(
("filter_query", "expected_names"),
[
(
"name = 'lst-overview-prod' AND tag = 'team/frontend'",
{"lst-overview-prod"},
),
(
"name LIKE 'database/%' AND tag = 'team/storage'",
{"lst-database-prod", "lst-database-test"},
),
(
"(name CONTAINS 'overview' OR name CONTAINS 'frontend') AND tag NOT IN ['dev']",
{"lst-overview-prod", "lst-frontend-team"},
),
(
"NOT tag = 'prod' AND name CONTAINS 'lst-'",
{"lst-overview-dev", "lst-database-test", "lst-frontend-team"},
),
(
"tag = 'team/pulse' AND tag != 'dev'",
{"lst-overview-prod", "lst-frontend-team"},
),
(
"tag IN ['dev', 'test'] OR name = 'lst-overview-prod'",
{"lst-overview-dev", "lst-database-test", "lst-overview-prod"},
),
(
"tag = 'team/pulse' AND tag LIKE 'database/%'",
{"lst-overview-dev", "lst-database-test", "lst-overview-prod"},
),
],
)
def test_list_v2_filter_dsl(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
list_fixture_dashboards: None, # pylint: disable=unused-argument
filter_query: str,
expected_names: set[str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
scoped_query = f"({filter_query}) AND tag = '{_LIST_FIXTURE_MARKER_TAG}'"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/dashboards"),
params={"query": scoped_query, "limit": 200},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["status"] == "success"
returned_names = {
d["info"]["data"]["display"]["name"] for d in body["data"]["dashboards"]
}
assert returned_names == expected_names

View File

@@ -20,6 +20,7 @@ from fixtures.querier import (
index_series_by_label,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_logs_list(
@@ -2293,3 +2294,333 @@ def test_logs_formula_orderby_and_limit(
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
def test_logs_list_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that filtering logs by trace_id uses the trace_summary lookup to
narrow the query window before scanning the logs table:
1. Returns the matching log (narrow window, single bucket).
2. Does not return duplicate logs when the query window should span multiple
exponential buckets (>1 h). But is clamped to the timerange of trace.
3. Returns no results when the query window does not contain the trace.
4. Logs carrying a trace_id whose trace is NOT in trace_summary (e.g.
traces disabled) are still returned — the lookup miss must not
short-circuit logs queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
other_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
other_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-filter-service",
"cloud.provider": "integration",
}
# Populate signoz_traces.distributed_trace_summary by inserting spans for
# the target trace_id. trace_summary records min/max of span timestamps
# (it ignores span duration), so two spans are inserted to give the trace
# a non-trivial recorded window of [now-10s, now-5s].
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Insert logs:
# - one with the target trace_id, at a timestamp within the trace's
# recorded window (now-10s..now-5s, padded ±1s).
# - one with a different trace_id; must never appear in target_trace_id
# results.
# - one with an orphan trace_id whose trace was never ingested — used to
# verify the lookup miss does NOT short-circuit logs queries.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=7),
resources=common_resources,
attributes={"http.method": "GET"},
body="log inside the target trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=3),
resources=common_resources,
attributes={"http.method": "POST"},
body="log with a different trace_id",
severity_text="INFO",
trace_id=other_trace_id,
span_id=other_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={"http.method": "PUT"},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _query(start_ms: int, end_ms: int, trace_id: str) -> list:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
return response.json()["data"]["data"]["results"][0]["rows"] or []
now_ms = int(now.timestamp() * 1000)
# --- Test 1: narrow window (single bucket, <1 h) ---
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
narrow_rows = _query(narrow_start_ms, now_ms, target_trace_id)
assert len(narrow_rows) == 1, f"Expected 1 log for trace_id filter (narrow window), got {len(narrow_rows)}"
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
assert narrow_rows[0]["data"]["span_id"] == target_root_span_id
# --- Test 2: wide window (>1 h, camp to the timerange from trace_summary) ---
# Should still return exactly one log — no duplicates from multi-bucket scan.
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_rows = _query(wide_start_ms, now_ms, target_trace_id)
assert len(wide_rows) == 1, f"Expected 1 log for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression"
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
assert wide_rows[0]["data"]["span_id"] == target_root_span_id
# --- Test 3: window that does not contain the trace returns no results ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_rows = _query(past_start_ms, past_end_ms, target_trace_id)
assert len(past_rows) == 0, f"Expected 0 logs for trace_id filter outside time window, got {len(past_rows)}"
# --- Test 4: trace_id not present in trace_summary still returns logs ---
orphan_rows = _query(narrow_start_ms, now_ms, orphan_trace_id)
assert len(orphan_rows) == 1, f"Expected 1 log for orphan trace_id (no trace_summary entry), got {len(orphan_rows)} — logs query may have been incorrectly short-circuited"
assert orphan_rows[0]["data"]["trace_id"] == orphan_trace_id
def test_logs_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) logs queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to an
empty result.
3. A trace_id with no row in trace_summary (e.g. traces disabled) still
returns the matching logs — the lookup miss must not short-circuit
logs aggregation queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-agg-service",
"cloud.provider": "integration",
}
# trace_summary records min/max of span timestamps (it ignores duration),
# so insert two spans to give the trace a recorded window wide enough to
# comfortably contain the log timestamps below.
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Two logs for the target trace_id, both inside the recorded trace window.
# One additional log carries an orphan trace_id with no row in
# trace_summary — used to verify that the lookup miss does not
# short-circuit logs aggregations.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=9),
resources=common_resources,
attributes={},
body="log A inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=6),
resources=common_resources,
attributes={},
body="log B inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> float:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0
series = aggregations[0].get("series") or []
if not series:
return 0
return sum(v["value"] for v in series[0]["values"])
now_ms = int(now.timestamp() * 1000)
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns 2 logs ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
# --- Test 2: window outside the trace short-circuits to empty ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
# --- Test 3: trace_id not present in trace_summary still returns logs ---
orphan_count = _count(narrow_start_ms, now_ms, orphan_trace_id)
assert orphan_count == 1, f"Expected count=1 for orphan trace_id aggregation, got {orphan_count} — query may have been incorrectly short-circuited"

View File

@@ -2123,3 +2123,116 @@ def test_traces_list_filter_by_trace_id(
past_rows = _query(past_start_ms, past_end_ms)
assert len(past_rows) == 0, f"Expected 0 spans for trace_id filter outside time window, got {len(past_rows)}"
def test_traces_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) traces queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to empty.
3. Filter referencing a trace_id with no row in trace_summary
short-circuits to empty (trace_summary is authoritative for traces).
"""
target_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
missing_trace_id = TraceIdGenerator.trace_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "traces-agg-filter-service",
"cloud.provider": "integration",
}
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={"http.request.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> float:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0
series = aggregations[0].get("series") or []
if not series:
return 0
return sum(v["value"] for v in series[0]["values"])
now_ms = int(now.timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns both spans ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
# --- Test 2: window outside the trace short-circuits to empty ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
# --- Test 3: trace_id with no entry in trace_summary short-circuits ---
missing_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
missing_count = _count(missing_start_ms, now_ms, missing_trace_id)
assert missing_count == 0, f"Expected count=0 for trace_id absent from trace_summary, got {missing_count}"