mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-14 06:00:30 +01:00
Compare commits
8 Commits
nv/v2-list
...
platform-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35db1b02b | ||
|
|
0556e67739 | ||
|
|
79ea20b371 | ||
|
|
2bbd11c181 | ||
|
|
0f9891d1c1 | ||
|
|
b236a29a99 | ||
|
|
828459ab30 | ||
|
|
b572e30045 |
@@ -29,7 +29,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"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"
|
||||
@@ -104,8 +103,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), 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()
|
||||
|
||||
@@ -48,7 +48,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"
|
||||
@@ -152,8 +151,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)
|
||||
|
||||
1336
docs/api/openapi.yml
1336
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
@@ -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, sqlstore, settings, analytics, orgGetter, queryParser, tagModule)
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
|
||||
|
||||
return &module{
|
||||
pkgDashboardModule: pkgDashboardModule,
|
||||
@@ -199,80 +197,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) CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
|
||||
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.PublicConfig != nil {
|
||||
return nil, errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", id)
|
||||
}
|
||||
|
||||
publicDashboard := dashboardtypes.NewPublicDashboard(postable.TimeRangeEnabled, postable.DefaultTimeRange, id)
|
||||
if err := module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.PublicConfig = publicDashboard
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
|
||||
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.PublicConfig == nil {
|
||||
return nil, errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodePublicDashboardNotFound, "dashboard with id %s isn't public", id)
|
||||
}
|
||||
|
||||
existing.PublicConfig.Update(updatable.TimeRangeEnabled, updatable.DefaultTimeRange)
|
||||
if err := module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(existing.PublicConfig)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,34 +18,19 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateDashboardV2201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
CreatePublicDashboardV2200,
|
||||
CreatePublicDashboardV2PathParameters,
|
||||
DashboardtypesJSONPatchDocumentDTO,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
DeletePublicDashboardPathParameters,
|
||||
GetDashboardV2200,
|
||||
GetDashboardV2PathParameters,
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
LockDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UnlockDashboardV2PathParameters,
|
||||
UpdateDashboardV2200,
|
||||
UpdateDashboardV2PathParameters,
|
||||
UpdatePublicDashboardPathParameters,
|
||||
UpdatePublicDashboardV2200,
|
||||
UpdatePublicDashboardV2PathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -649,739 +634,3 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
|
||||
|
||||
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>;
|
||||
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
|
||||
> => {
|
||||
const mutationOptions = getCreateDashboardV2MutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
dashboardtypesJSONPatchDocumentDTO: BodyType<DashboardtypesJSONPatchDocumentDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PatchDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesJSONPatchDocumentDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesJSONPatchDocumentDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesJSONPatchDocumentDTO>;
|
||||
},
|
||||
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>;
|
||||
}
|
||||
> = (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>;
|
||||
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>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesJSONPatchDocumentDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getPatchDashboardV2MutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* 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>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardV2DTO,
|
||||
});
|
||||
};
|
||||
|
||||
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>;
|
||||
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
|
||||
> => {
|
||||
const mutationOptions = getUpdateDashboardV2MutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* 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) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
> => {
|
||||
const mutationOptions = getUnlockDashboardV2MutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* 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) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
> => {
|
||||
const mutationOptions = getLockDashboardV2MutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint creates the public sharing config for a v2 dashboard and returns the dashboard with the new public config attached. Lock state does not gate this endpoint.
|
||||
* @summary Make a dashboard v2 public
|
||||
*/
|
||||
export const createPublicDashboardV2 = (
|
||||
{ id }: CreatePublicDashboardV2PathParameters,
|
||||
dashboardtypesPostablePublicDashboardDTO: BodyType<DashboardtypesPostablePublicDashboardDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreatePublicDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}/public`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostablePublicDashboardDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreatePublicDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createPublicDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createPublicDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createPublicDashboardV2'];
|
||||
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 createPublicDashboardV2>>,
|
||||
{
|
||||
pathParams: CreatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createPublicDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreatePublicDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createPublicDashboardV2>>
|
||||
>;
|
||||
export type CreatePublicDashboardV2MutationBody =
|
||||
BodyType<DashboardtypesPostablePublicDashboardDTO>;
|
||||
export type CreatePublicDashboardV2MutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Make a dashboard v2 public
|
||||
*/
|
||||
export const useCreatePublicDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createPublicDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createPublicDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreatePublicDashboardV2MutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint updates the public sharing config (time range settings) of an already-public v2 dashboard. Lock state does not gate this endpoint.
|
||||
* @summary Update public sharing config for a dashboard v2
|
||||
*/
|
||||
export const updatePublicDashboardV2 = (
|
||||
{ id }: UpdatePublicDashboardV2PathParameters,
|
||||
dashboardtypesUpdatablePublicDashboardDTO: BodyType<DashboardtypesUpdatablePublicDashboardDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdatePublicDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}/public`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesUpdatablePublicDashboardDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdatePublicDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updatePublicDashboardV2'];
|
||||
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 updatePublicDashboardV2>>,
|
||||
{
|
||||
pathParams: UpdatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updatePublicDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdatePublicDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updatePublicDashboardV2>>
|
||||
>;
|
||||
export type UpdatePublicDashboardV2MutationBody =
|
||||
BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
|
||||
export type UpdatePublicDashboardV2MutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update public sharing config for a dashboard v2
|
||||
*/
|
||||
export const useUpdatePublicDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdatePublicDashboardV2PathParameters;
|
||||
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdatePublicDashboardV2MutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -596,6 +596,7 @@ function CustomTimePicker({
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
inputStatus === CustomTimePickerInputStatus.ERROR ? 'error' : '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CustomDomainSettings from '../CustomDomainSettings';
|
||||
|
||||
@@ -44,18 +44,20 @@ const mockHostsResponse: GetHosts200 = {
|
||||
};
|
||||
|
||||
describe('CustomDomainSettings', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
mockToastCustom.mockClear();
|
||||
});
|
||||
|
||||
it('renders active host URL in the trigger button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
// The active host is the non-default one (custom-host)
|
||||
@@ -63,20 +65,11 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('opens edit modal when clicking the edit button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /edit workspace link/i }),
|
||||
@@ -89,28 +82,20 @@ describe('CustomDomainSettings', () => {
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedBody = await req.json<Record<string, unknown>>();
|
||||
return res(ctx.status(200), ctx.json({}));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
// The input is inside the modal — find it by its role
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toStrictEqual({ name: 'myteam' });
|
||||
@@ -119,9 +104,6 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
it('shows contact support option when domain update returns 409', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(409),
|
||||
@@ -130,18 +112,14 @@ describe('CustomDomainSettings', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByRole('button', { name: /contact support/i }),
|
||||
@@ -149,24 +127,14 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('shows validation error when subdomain is less than 3 characters', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ab');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
fireEvent.change(input, { target: { value: 'ab' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/minimum 3 characters required/i),
|
||||
@@ -174,19 +142,12 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('shows all workspace URLs as links in the dropdown', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
// Open the URL dropdown
|
||||
await user.click(
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
|
||||
);
|
||||
|
||||
@@ -207,26 +168,19 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
it('calls toast.custom with new URL after successful domain update', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({})),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit workspace link/i }));
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
fireEvent.change(input, { target: { value: 'myteam' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
// Verify toast.custom was called
|
||||
await waitFor(() => {
|
||||
@@ -243,12 +197,6 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
describe('Workspace Name rendering', () => {
|
||||
it('renders org displayName when available from appContext', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
|
||||
@@ -259,12 +207,6 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { IDiskType } from 'types/api/disks/getDisks';
|
||||
import {
|
||||
PayloadPropsLogs,
|
||||
@@ -115,8 +109,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 1: S3 Enabled - Only Days in Dropdown', () => {
|
||||
it('should show only Days option for S3 retention and send correct API payload', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -159,8 +151,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
fireEvent.click(document.body);
|
||||
|
||||
// Change S3 retention value to 5 days
|
||||
await user.clear(s3Input);
|
||||
await user.type(s3Input, '5');
|
||||
fireEvent.change(s3Input, { target: { value: '5' } });
|
||||
|
||||
// Find the save button in the Logs row
|
||||
const saveButton = logsRow.querySelector(
|
||||
@@ -217,8 +208,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 2: S3 Disabled - Field Hidden', () => {
|
||||
it('should hide S3 retention field and send empty S3 values to API', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -245,7 +234,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const totalDropdown = logsRow.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(totalDropdown);
|
||||
fireEvent.mouseDown(totalDropdown);
|
||||
|
||||
// Wait for dropdown options to appear
|
||||
await waitFor(() => {
|
||||
@@ -259,11 +248,10 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
opt.textContent?.includes('Days'),
|
||||
);
|
||||
expect(daysOption).toBeInTheDocument();
|
||||
await user.click(daysOption as HTMLElement);
|
||||
fireEvent.click(daysOption as HTMLElement);
|
||||
|
||||
// Now change the value
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
fireEvent.change(totalInput, { target: { value: '60' } });
|
||||
|
||||
// Find the save button
|
||||
const saveButton = logsRow.querySelector(
|
||||
@@ -277,14 +265,14 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
|
||||
// Click save button
|
||||
await user.click(saveButton);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for modal to appear
|
||||
const okButton = await screen.findByRole('button', { name: /ok/i });
|
||||
expect(okButton).toBeInTheDocument();
|
||||
|
||||
// Click OK button
|
||||
await user.click(okButton);
|
||||
fireEvent.click(okButton);
|
||||
|
||||
// Verify API was called with empty S3 values (60 days)
|
||||
await waitFor(() => {
|
||||
@@ -333,8 +321,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
|
||||
describe('Test 4: Save Button State with S3 Disabled', () => {
|
||||
it('should disable save button when cold_storage_ttl_days is -1 and no changes made', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
@@ -365,8 +351,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
|
||||
// Change total retention value to trigger button enable
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
fireEvent.change(totalInput, { target: { value: '60' } });
|
||||
|
||||
// Button should now be enabled after change
|
||||
await waitFor(() => {
|
||||
@@ -374,8 +359,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
|
||||
// Revert to original value (30 days displays as 1 Month)
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '1');
|
||||
fireEvent.change(totalInput, { target: { value: '1' } });
|
||||
|
||||
// Button should be disabled again (back to original state)
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { patchRulePartial } from 'api/alerts/patchRulePartial';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { invalidateGetRuleByID } from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesRuleDTO,
|
||||
@@ -28,6 +30,7 @@ function ToggleAlertState({
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onToggleHandler = async (
|
||||
id: string,
|
||||
@@ -60,6 +63,9 @@ function ToggleAlertState({
|
||||
loading: false,
|
||||
payload: updatedRule,
|
||||
}));
|
||||
|
||||
invalidateGetRuleByID(queryClient, { id });
|
||||
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import MembersSettings from '../MembersSettings';
|
||||
|
||||
@@ -76,32 +76,27 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filters to pending invites via the filter dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /all members/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /all members/i }));
|
||||
|
||||
const pendingOption = await screen.findByText(/pending invites/i);
|
||||
await user.click(pendingOption);
|
||||
fireEvent.click(pendingOption);
|
||||
|
||||
await screen.findByText('charlie@signoz.io');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters members by name using the search input', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name or email/i),
|
||||
'bob',
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by name or email/i), {
|
||||
target: { value: 'bob' },
|
||||
});
|
||||
|
||||
await screen.findByText('Bob Jones');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
@@ -109,31 +104,25 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when an active member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(await screen.findByText('Alice Smith'));
|
||||
fireEvent.click(await screen.findByText('Alice Smith'));
|
||||
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(await screen.findByText('Dave Deleted'));
|
||||
fireEvent.click(await screen.findByText('Dave Deleted'));
|
||||
|
||||
expect(screen.queryByText('Member Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
|
||||
@@ -117,8 +117,7 @@ describe('CreateEdit Modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Form Validation', () => {
|
||||
describe('Form Validation', () => {
|
||||
it('shows validation error when submitting without required fields', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -127,7 +126,7 @@ describe('CreateEdit Modal', () => {
|
||||
const configureButtons = await screen.findAllByRole('button', {
|
||||
name: /configure/i,
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
fireEvent.click(configureButtons[0]);
|
||||
|
||||
const saveButton = await screen.findByRole('button', {
|
||||
name: /save changes/i,
|
||||
@@ -338,11 +337,8 @@ describe('CreateEdit Modal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Modal Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
describe('Modal Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
@@ -352,7 +348,7 @@ describe('CreateEdit Modal', () => {
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
// Ungate feature flag for all tests in this file
|
||||
jest.mock('../../config', () => ({ IS_ROLE_DETAILS_AND_CRUD_ENABLED: true }));
|
||||
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import RoleDetailsPage from '../RoleDetailsPage';
|
||||
|
||||
@@ -22,7 +29,7 @@ const allScopeObjectsResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -44,8 +51,7 @@ afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('RoleDetailsPage', () => {
|
||||
describe('RoleDetailsPage', () => {
|
||||
it('renders custom role header, tabs, description, permissions, and action buttons', async () => {
|
||||
setupDefaultHandlers();
|
||||
|
||||
@@ -57,20 +63,16 @@ describe.skip('RoleDetailsPage', () => {
|
||||
screen.findByText('Role — billing-manager'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Tab navigation
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('Members')).toBeInTheDocument();
|
||||
|
||||
// Role description (OverviewTab)
|
||||
expect(
|
||||
screen.getByText('Custom role for managing billing and invoices.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Permission items derived from mocked authz relations
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read')).toBeInTheDocument();
|
||||
|
||||
// Action buttons present for custom role
|
||||
expect(
|
||||
screen.getByRole('button', { name: /edit role details/i }),
|
||||
).toBeInTheDocument();
|
||||
@@ -96,14 +98,13 @@ describe.skip('RoleDetailsPage', () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Action buttons absent for managed role
|
||||
expect(screen.queryByText('Edit Role Details')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /delete role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edit flow: modal opens pre-filled and calls PATCH on save and verify', async () => {
|
||||
it('edit flow: modal opens pre-filled and calls PATCH on save', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
let description = customRoleResponse.data.description;
|
||||
server.use(
|
||||
@@ -138,21 +139,16 @@ describe.skip('RoleDetailsPage', () => {
|
||||
|
||||
await screen.findByText('Role — billing-manager');
|
||||
|
||||
// Open the edit modal
|
||||
await user.click(screen.getByRole('button', { name: /edit role details/i }));
|
||||
await expect(
|
||||
screen.findByText('Edit Role Details', {
|
||||
selector: '.ant-modal-title',
|
||||
}),
|
||||
screen.findByText('Edit Role Details', { selector: '.ant-modal-title' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Name field is disabled in edit mode (role rename is not allowed)
|
||||
const nameInput = screen.getByPlaceholderText(
|
||||
'Enter role name e.g. : Service Owner',
|
||||
);
|
||||
expect(nameInput).toBeDisabled();
|
||||
|
||||
// Update description and save
|
||||
const descField = screen.getByPlaceholderText(
|
||||
'A helpful description of the role',
|
||||
);
|
||||
@@ -168,9 +164,7 @@ describe.skip('RoleDetailsPage', () => {
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('Edit Role Details', {
|
||||
selector: '.ant-modal-title',
|
||||
}),
|
||||
screen.queryByText('Edit Role Details', { selector: '.ant-modal-title' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
@@ -219,58 +213,61 @@ describe.skip('RoleDetailsPage', () => {
|
||||
});
|
||||
|
||||
describe('permission side panel', () => {
|
||||
async function openCreatePanel(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
beforeEach(() => {
|
||||
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
|
||||
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
|
||||
data: customRoleResponse,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(roleApi, 'useGetObjects')
|
||||
.mockReturnValue({ data: emptyObjectsResponse, isLoading: false } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function openCreatePanel(): Promise<HTMLElement> {
|
||||
await screen.findByText('Role — billing-manager');
|
||||
await user.click(screen.getByText('Create'));
|
||||
fireEvent.click(screen.getByText('Create'));
|
||||
await screen.findByText('Edit Create Permissions');
|
||||
await screen.findByRole('button', { name: /dashboard/i });
|
||||
const panel = document.querySelector(
|
||||
'.permission-side-panel',
|
||||
) as HTMLElement;
|
||||
await within(panel).findByRole('button', { name: 'Role' });
|
||||
return panel;
|
||||
}
|
||||
|
||||
it('Save Changes is disabled until a resource scope is changed', async () => {
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
|
||||
// No change yet — config matches initial, unsavedCount = 0
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
|
||||
// Expand Dashboard and flip to All — now Save is enabled
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save changes/i }),
|
||||
).not.toBeDisabled();
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
|
||||
// check for what shown now - unsavedCount = 1
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).not.toBeDisabled();
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('set scope to All → patchObjects additions: ["*"], deletions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -278,23 +275,23 @@ describe.skip('RoleDetailsPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -306,14 +303,9 @@ describe.skip('RoleDetailsPage', () => {
|
||||
it('set scope to Only selected with IDs → patchObjects additions contain those IDs', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -327,23 +319,25 @@ describe.skip('RoleDetailsPage', () => {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'dash-1');
|
||||
const combobox = within(panel).getByRole('combobox');
|
||||
await user.type(combobox, 'role-001');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
// user.click waits for the tag-addition state to settle before clicking.
|
||||
await user.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['dash-1'],
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['role-001'],
|
||||
},
|
||||
],
|
||||
deletions: null,
|
||||
@@ -354,15 +348,13 @@ describe.skip('RoleDetailsPage', () => {
|
||||
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
jest.spyOn(roleApi, 'useGetObjects').mockReturnValue({
|
||||
data: allScopeObjectsResponse,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(allScopeObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
`${rolesApiBase}/:id/relations/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
@@ -370,26 +362,24 @@ describe.skip('RoleDetailsPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('Only selected'));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Only selected'));
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
// Should delete the '*' selector and add nothing
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: null,
|
||||
deletions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
resource: { kind: 'role', type: 'metaresources' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
@@ -398,36 +388,25 @@ describe.skip('RoleDetailsPage', () => {
|
||||
});
|
||||
|
||||
it('unsaved changes counter shown on scope change, Discard resets it', async () => {
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
const panel = await openCreatePanel();
|
||||
|
||||
// No unsaved changes indicator yet
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
|
||||
// Change dashboard scope to "All"
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'Role' }));
|
||||
fireEvent.click(screen.getByText('All'));
|
||||
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
|
||||
// Discard reverts to initial config — counter disappears, Save re-disabled
|
||||
await user.click(screen.getByRole('button', { name: /discard/i }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: /discard/i }));
|
||||
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: /save changes/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,24 +4,27 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import RolesSettings from '../RolesSettings';
|
||||
|
||||
const rolesApiURL = 'http://localhost/api/v1/roles';
|
||||
|
||||
describe('RolesSettings', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the header and search input', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the header and search input', () => {
|
||||
render(<RolesSettings />);
|
||||
|
||||
expect(screen.getByText('Roles')).toBeInTheDocument();
|
||||
@@ -34,12 +37,6 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('displays roles grouped by managed and custom sections', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
@@ -68,20 +65,13 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('filters roles by search query on name', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'billing');
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'billing' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('billing-manager'),
|
||||
@@ -92,20 +82,13 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('filters roles by search query on description', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'read-only');
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'read-only' },
|
||||
});
|
||||
|
||||
await expect(screen.findByText('signoz-viewer')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
|
||||
@@ -113,20 +96,13 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('shows empty state when search matches nothing', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const searchInput = screen.getByPlaceholderText('Search for roles...');
|
||||
|
||||
await user.type(searchInput, 'nonexistentrole');
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for roles...'), {
|
||||
target: { value: 'nonexistentrole' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('No roles match your search.'),
|
||||
@@ -183,12 +159,6 @@ describe('RolesSettings', () => {
|
||||
});
|
||||
|
||||
it('renders descriptions for all roles', async () => {
|
||||
server.use(
|
||||
rest.get(rolesApiURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<RolesSettings />);
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
@@ -123,8 +123,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -133,18 +131,16 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
|
||||
const activeOption = await screen.findByText(/Active ⎯/i);
|
||||
await user.click(activeOption);
|
||||
fireEvent.click(activeOption);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search by name filters accounts in real-time', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -153,10 +149,9 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name or email/i),
|
||||
'legacy',
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by name or email/i), {
|
||||
target: { value: 'legacy' },
|
||||
});
|
||||
|
||||
await screen.findByText('Legacy Bot');
|
||||
expect(screen.queryByText('CI Bot')).not.toBeInTheDocument();
|
||||
@@ -164,15 +159,13 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('clicking a row opens the drawer with account details visible', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /View service account CI Bot/i,
|
||||
}),
|
||||
@@ -184,7 +177,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('saving changes in the drawer refetches the list', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const listRefetchSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
@@ -206,15 +198,14 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
await screen.findByText('CI Bot');
|
||||
listRefetchSpy.mockClear();
|
||||
|
||||
await user.click(
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /View service account CI Bot/i }),
|
||||
);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'CI Bot Updated');
|
||||
fireEvent.change(nameInput, { target: { value: 'CI Bot Updated' } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await screen.findByDisplayValue('CI Bot Updated');
|
||||
await waitFor(() => {
|
||||
@@ -223,8 +214,6 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('"New Service Account" button opens the Create Service Account modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -233,9 +222,7 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function AlertState({
|
||||
let label;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
switch (state) {
|
||||
case 'no-data':
|
||||
case 'nodata':
|
||||
icon = (
|
||||
<CircleOff
|
||||
size={18}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
invalidateGetRuleByID,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useListRules,
|
||||
@@ -408,6 +409,7 @@ export const useAlertRuleStatusToggle = ({
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setAlertRuleState(data.data.state);
|
||||
invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
notifications.success({
|
||||
message: `Alert has been ${
|
||||
@@ -416,6 +418,7 @@ export const useAlertRuleStatusToggle = ({
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
|
||||
@@ -109,7 +109,8 @@ export type AlertRuleTimelineTableResponsePayload = {
|
||||
labels: AlertLabelsProps['labels'];
|
||||
};
|
||||
};
|
||||
type AlertState = 'firing' | 'normal' | 'no-data' | 'muted';
|
||||
|
||||
type AlertState = 'firing' | 'normal' | 'nodata' | 'muted';
|
||||
|
||||
export interface AlertRuleTimelineGraphResponse {
|
||||
start: number;
|
||||
|
||||
1
go.mod
1
go.mod
@@ -88,7 +88,6 @@ require (
|
||||
gonum.org/v1/gonum v0.17.0
|
||||
google.golang.org/api v0.265.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.2
|
||||
|
||||
@@ -14,201 +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/v2/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublicV2), handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Make a dashboard v2 public",
|
||||
Description: "This endpoint creates the public sharing config for a v2 dashboard and returns the dashboard with the new public config attached. Lock state does not gate this endpoint.",
|
||||
Request: new(dashboardtypes.PostablePublicDashboard),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.UpdatePublicV2), handler.OpenAPIDef{
|
||||
ID: "UpdatePublicDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update public sharing config for a dashboard v2",
|
||||
Description: "This endpoint updates the public sharing config (time range settings) of an already-public v2 dashboard. Lock state does not gate this endpoint.",
|
||||
Request: new(dashboardtypes.UpdatablePublicDashboard),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).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"},
|
||||
|
||||
@@ -49,30 +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)
|
||||
|
||||
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
|
||||
|
||||
CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*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 {
|
||||
@@ -95,29 +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)
|
||||
|
||||
PatchV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
LockV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UnlockV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreatePublicV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdatePublicV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
PinV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UnpinV2(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ 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/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
@@ -22,24 +20,20 @@ import (
|
||||
|
||||
type module struct {
|
||||
store dashboardtypes.Store
|
||||
sqlstore sqlstore.SQLStore
|
||||
settings factory.ScopedProviderSettings
|
||||
analytics analytics.Analytics
|
||||
orgGetter organization.Getter
|
||||
queryParser queryparser.QueryParser
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, 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,
|
||||
sqlstore: sqlstore,
|
||||
settings: scopedProviderSettings,
|
||||
analytics: analytics,
|
||||
orgGetter: orgGetter,
|
||||
queryParser: queryParser,
|
||||
tagModule: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,407 +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
|
||||
}
|
||||
|
||||
req := dashboardtypes.PostableDashboardV2{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&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, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
|
||||
}
|
||||
|
||||
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, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
|
||||
}
|
||||
|
||||
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, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
|
||||
}
|
||||
|
||||
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, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) CreatePublicV2(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.PostablePublicDashboard{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.CreatePublicV2(ctx, orgID, dashboardID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdatePublicV2(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.UpdatablePublicDashboard{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.UpdatePublicV2(ctx, orgID, dashboardID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
|
||||
}
|
||||
@@ -1,192 +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 (module *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 := dashboardtypes.NewDashboardV2(orgID, createdBy, postable, nil)
|
||||
var storableDashboard *dashboardtypes.StorableDashboard
|
||||
|
||||
err := module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dashboard.Info.Tags = resolvedTags
|
||||
|
||||
storable, err := dashboard.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storableDashboard = storable
|
||||
return module.store.Create(ctx, storable)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
module.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, public, err := module.store.GetV2(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 dashboardtypes.NewDashboardV2FromStorable(storable, public, 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.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(updateable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.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.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(*updateable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.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
|
||||
}
|
||||
return module.store.LockUnlockV2(ctx, orgID, id, lock, updatedBy)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store tagtypes.Store
|
||||
}
|
||||
|
||||
func NewModule(store tagtypes.Store) tag.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (m *module) SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error) {
|
||||
var tags []*tagtypes.Tag
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolved, err := m.createMany(ctx, orgID, kind, postable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tagIDs := make([]valuer.UUID, len(resolved))
|
||||
for i, t := range resolved {
|
||||
tagIDs[i] = t.ID
|
||||
}
|
||||
if err := m.syncLinksForResource(ctx, orgID, kind, resourceID, tagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
tags = resolved
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (m *module) createMany(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error) {
|
||||
if len(postable) == 0 {
|
||||
return []*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
toCreate, matched, err := m.resolve(ctx, orgID, kind, postable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err := m.store.CreateOrGet(ctx, toCreate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(matched, created...), nil
|
||||
}
|
||||
|
||||
func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) error {
|
||||
return m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.CreateRelations(ctx, tagtypes.NewTagRelations(kind, resourceID, tagIDs)); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.store.DeleteRelationsExcept(ctx, orgID, kind, resourceID, tagIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *module) ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
return m.store.ListByResource(ctx, orgID, kind, resourceID)
|
||||
}
|
||||
|
||||
func (m *module) ListForResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
|
||||
return m.store.ListByResources(ctx, orgID, kind, resourceIDs)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// resolve canonicalizes a batch of user-supplied (key, value) tag pairs against
|
||||
// the existing tags for an org. Lookup is case-insensitive on both key and
|
||||
// value (matching the storage uniqueness rule); when an existing row matches,
|
||||
// its display casing is reused. Inputs are deduped on (LOWER(key), LOWER(value));
|
||||
// the first input's casing wins on collisions. Returns:
|
||||
// - toCreate: new Tag rows the caller should insert (with pre-generated IDs)
|
||||
// - matched: existing rows the caller's input already pointed to. They
|
||||
// already carry authoritative IDs from the store.
|
||||
func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, []*tagtypes.Tag, error) {
|
||||
if len(postable) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
existing, err := m.store.List(ctx, orgID, kind)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
lowercaseTagsMap := make(map[string]*tagtypes.Tag, len(existing))
|
||||
for _, t := range existing {
|
||||
mapKey := strings.ToLower(t.Key) + "\x00" + strings.ToLower(t.Value)
|
||||
lowercaseTagsMap[mapKey] = t
|
||||
}
|
||||
|
||||
seenInRequestAlready := make(map[string]struct{}, len(postable)) // postable can have the same tag multiple times
|
||||
toCreate := make([]*tagtypes.Tag, 0)
|
||||
matched := make([]*tagtypes.Tag, 0)
|
||||
|
||||
for _, p := range postable {
|
||||
key, value, err := tagtypes.ValidatePostableTag(p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
lookup := strings.ToLower(key) + "\x00" + strings.ToLower(value)
|
||||
if _, dup := seenInRequestAlready[lookup]; dup {
|
||||
continue
|
||||
}
|
||||
seenInRequestAlready[lookup] = struct{}{}
|
||||
|
||||
if existingTag, ok := lowercaseTagsMap[lookup]; ok {
|
||||
matched = append(matched, existingTag)
|
||||
continue
|
||||
}
|
||||
toCreate = append(toCreate, tagtypes.NewTag(orgID, kind, key, value))
|
||||
}
|
||||
|
||||
return toCreate, matched, nil
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes/tagtypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testKind = coretypes.KindDashboard
|
||||
|
||||
func TestModule_Resolve(t *testing.T) {
|
||||
t.Run("empty input does not hit store", func(t *testing.T) {
|
||||
store := tagtypestest.NewStore()
|
||||
m := &module{store: store}
|
||||
|
||||
toCreate, matched, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, toCreate)
|
||||
assert.Empty(t, matched)
|
||||
assert.Zero(t, store.ListCallCount, "should not hit store when input is empty")
|
||||
})
|
||||
|
||||
t.Run("creates missing pairs and reuses existing", func(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
dbTag := tagtypes.NewTag(orgID, testKind, "team", "Pulse")
|
||||
dbTag2 := tagtypes.NewTag(orgID, testKind, "Database", "redis")
|
||||
store := tagtypestest.NewStore()
|
||||
store.Tags = []*tagtypes.Tag{dbTag, dbTag2}
|
||||
m := &module{store: store}
|
||||
|
||||
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
|
||||
{Key: "team", Value: "events"}, // new
|
||||
{Key: "DATABASE", Value: "REDIS"}, // case-only conflict
|
||||
{Key: "Brand", Value: "New"}, // new
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
createdLowerKVs := []string{}
|
||||
for _, tg := range toCreate {
|
||||
createdLowerKVs = append(createdLowerKVs, strings.ToLower(tg.Key)+"\x00"+strings.ToLower(tg.Value))
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"team\x00events", "brand\x00new"}, createdLowerKVs,
|
||||
"only the two missing pairs should be returned for insertion")
|
||||
|
||||
require.Len(t, matched, 1, "DATABASE:REDIS should hit the existing 'Database:redis' tag")
|
||||
assert.Same(t, dbTag2, matched[0], "matched should return the existing pointer with its authoritative ID")
|
||||
})
|
||||
|
||||
t.Run("dedupes inputs that map to the same lower(key)+lower(value)", func(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
store := tagtypestest.NewStore()
|
||||
m := &module{store: store}
|
||||
|
||||
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
|
||||
{Key: "Foo", Value: "Bar"},
|
||||
{Key: "foo", Value: "bar"},
|
||||
{Key: "FOO", Value: "BAR"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, matched)
|
||||
require.Len(t, toCreate, 1, "duplicate inputs must collapse into a single insert")
|
||||
assert.Equal(t, "Foo", toCreate[0].Key, "first input's casing wins")
|
||||
assert.Equal(t, "Bar", toCreate[0].Value, "first input's casing wins")
|
||||
})
|
||||
|
||||
t.Run("preserves existing casing on case-only match", func(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
dbTag := tagtypes.NewTag(orgID, testKind, "Team", "Pulse")
|
||||
store := tagtypestest.NewStore()
|
||||
store.Tags = []*tagtypes.Tag{dbTag}
|
||||
m := &module{store: store}
|
||||
|
||||
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
|
||||
{Key: "team", Value: "PULSE"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, toCreate)
|
||||
require.Len(t, matched, 1)
|
||||
assert.Equal(t, "Team", matched[0].Key)
|
||||
assert.Equal(t, "Pulse", matched[0].Value)
|
||||
})
|
||||
|
||||
t.Run("propagates validation error from any input", func(t *testing.T) {
|
||||
store := tagtypestest.NewStore()
|
||||
m := &module{store: store}
|
||||
|
||||
_, _, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, []tagtypes.PostableTag{
|
||||
{Key: "team", Value: "pulse"},
|
||||
{Key: "", Value: "x"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("propagates regex validation error", func(t *testing.T) {
|
||||
store := tagtypestest.NewStore()
|
||||
m := &module{store: store}
|
||||
|
||||
_, _, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, []tagtypes.PostableTag{
|
||||
{Key: "team!eng", Value: "pulse"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) tagtypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error) {
|
||||
tags := make([]*tagtypes.Tag, 0)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tags).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("kind = ?", kind).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) ListByResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
tags := make([]*tagtypes.Tag, 0)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tags).
|
||||
Join("JOIN tag_relation AS tr ON tr.tag_id = tag.id").
|
||||
Where("tr.kind = ?", kind).
|
||||
Where("tr.resource_id = ?", resourceID).
|
||||
Where("tag.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) ListByResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
|
||||
if len(resourceIDs) == 0 {
|
||||
return map[valuer.UUID][]*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
type joinedRow struct {
|
||||
tagtypes.Tag `bun:",extend"`
|
||||
ResourceID valuer.UUID `bun:"resource_id"`
|
||||
}
|
||||
|
||||
rows := make([]*joinedRow, 0)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rows).
|
||||
ColumnExpr("tag.*, tr.resource_id").
|
||||
Join("JOIN tag_relation AS tr ON tr.tag_id = tag.id").
|
||||
Where("tr.kind = ?", kind).
|
||||
Where("tr.resource_id IN (?)", bun.In(resourceIDs)).
|
||||
Where("tag.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[valuer.UUID][]*tagtypes.Tag)
|
||||
for _, r := range rows {
|
||||
tag := r.Tag
|
||||
out[r.ResourceID] = append(out[r.ResourceID], &tag)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateOrGet(ctx context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
|
||||
if len(tags) == 0 {
|
||||
return tags, nil
|
||||
}
|
||||
// DO UPDATE on a self-set is a deliberate no-op write whose only purpose
|
||||
// is to make RETURNING fire on conflicting rows. Without it, RETURNING is
|
||||
// silent on the conflict path and we'd have to refetch by (key, value) to
|
||||
// learn the existing rows' IDs after a concurrent-insert race. Setting
|
||||
// key = tag.key (the existing row's value) preserves the first writer's
|
||||
// casing on case-only collisions.
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&tags).
|
||||
On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
|
||||
Set("key = tag.key").
|
||||
Returning("*").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRelation) error {
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&relations).
|
||||
On("CONFLICT (kind, resource_id, tag_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) DeleteRelationsExcept(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, keepTagIDs []valuer.UUID) error {
|
||||
// Scope the delete to the caller's org via a subquery on tag — bun's
|
||||
// DELETE-with-JOIN syntax isn't uniformly portable across Postgres/SQLite.
|
||||
tagIDsToDelete := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
TableExpr("tag").
|
||||
Column("id").
|
||||
Where("org_id = ?", orgID)
|
||||
if len(keepTagIDs) > 0 {
|
||||
tagIDsToDelete = tagIDsToDelete.Where("id NOT IN (?)", bun.In(keepTagIDs))
|
||||
}
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*tagtypes.TagRelation)(nil)).
|
||||
Where("kind = ?", kind).
|
||||
Where("resource_id = ?", resourceID).
|
||||
Where("tag_id IN (?)", tagIDsToDelete).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: dbPath,
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*tagtypes.Tag)(nil)).
|
||||
IfNotExists().
|
||||
Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_org_kind_lower_key_lower_value ON tag (org_id, kind, LOWER(key), LOWER(value))`)
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
var dashboardKind = coretypes.KindDashboard
|
||||
|
||||
func tagsByLowerKeyValue(t *testing.T, db *bun.DB) map[string]*tagtypes.Tag {
|
||||
t.Helper()
|
||||
all := make([]*tagtypes.Tag, 0)
|
||||
require.NoError(t, db.NewSelect().Model(&all).Scan(context.Background()))
|
||||
out := map[string]*tagtypes.Tag{}
|
||||
for _, tag := range all {
|
||||
out[strings.ToLower(tag.Key)+"\x00"+strings.ToLower(tag.Value)] = tag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlstore := newTestStore(t)
|
||||
s := NewStore(sqlstore)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
tagA := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
|
||||
tagB := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
|
||||
preIDA := tagA.ID
|
||||
preIDB := tagB.ID
|
||||
|
||||
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{tagA, tagB})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
// No race → pre-generated IDs stand. The slice is what we passed in,
|
||||
// confirming Scan didn't reallocate.
|
||||
assert.Equal(t, preIDA, got[0].ID)
|
||||
assert.Equal(t, preIDB, got[1].ID)
|
||||
|
||||
// And the rows are in the DB.
|
||||
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
|
||||
require.Contains(t, stored, "tag\x00database")
|
||||
require.Contains(t, stored, "team\x00blr")
|
||||
assert.Equal(t, preIDA, stored["tag\x00database"].ID)
|
||||
assert.Equal(t, preIDB, stored["team\x00blr"].ID)
|
||||
}
|
||||
|
||||
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlstore := newTestStore(t)
|
||||
s := NewStore(sqlstore)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
// Simulate a concurrent insert: someone else has already inserted "tag:Database".
|
||||
winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
|
||||
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
|
||||
require.NoError(t, err)
|
||||
winnerID := winner.ID
|
||||
|
||||
// Now our request runs with a different pre-generated ID for the same
|
||||
// (key, value) — case differs but the functional unique index collapses
|
||||
// them. RETURNING should overwrite our stale ID with winner's ID.
|
||||
loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
|
||||
loserPreID := loser.ID
|
||||
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
|
||||
|
||||
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
|
||||
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
|
||||
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
|
||||
|
||||
// And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
|
||||
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
|
||||
require.Len(t, stored, 1)
|
||||
assert.Equal(t, winnerID, stored["tag\x00database"].ID)
|
||||
assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
|
||||
assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
|
||||
}
|
||||
|
||||
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlstore := newTestStore(t)
|
||||
s := NewStore(sqlstore)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
|
||||
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
|
||||
require.NoError(t, err)
|
||||
preExistingID := pre.ID
|
||||
|
||||
conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
|
||||
fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
|
||||
freshPreID := fresh.ID
|
||||
|
||||
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
|
||||
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// SyncTags resolves the given postable tags (creating new rows as needed)
|
||||
// and reconciles the resource's links to exactly that set, all in one transaction.
|
||||
SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error)
|
||||
|
||||
ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error)
|
||||
|
||||
// Resources with no tags are absent from the returned map.
|
||||
ListForResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -134,7 +134,7 @@ type RuleStateHistory struct {
|
||||
// One of ["normal", "firing"]
|
||||
OverallState AlertState `json:"overallState" ch:"overall_state"`
|
||||
OverallStateChanged bool `json:"overallStateChanged" ch:"overall_state_changed"`
|
||||
// One of ["normal", "firing", "no_data", "muted"]
|
||||
// One of ["normal", "firing", "nodata", "muted"]
|
||||
State AlertState `json:"state" ch:"state"`
|
||||
StateChanged bool `json:"stateChanged" ch:"state_changed"`
|
||||
UnixMilli int64 `json:"unixMilli" ch:"unix_milli"`
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -46,8 +45,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
emailing := emailingtest.New()
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
require.NoError(t, err)
|
||||
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), 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)
|
||||
@@ -55,9 +53,8 @@ func TestNewHandlers(t *testing.T) {
|
||||
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
|
||||
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -45,7 +45,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
@@ -89,7 +88,6 @@ type Modules struct {
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
Tag tag.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -115,7 +113,6 @@ func NewModules(
|
||||
cloudIntegrationModule cloudintegration.Module,
|
||||
retentionGetter retention.Getter,
|
||||
fl flagger.Flagger,
|
||||
tagModule tag.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
|
||||
@@ -148,6 +145,5 @@ func NewModules(
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
@@ -47,8 +46,7 @@ func TestNewModules(t *testing.T) {
|
||||
emailing := emailingtest.New()
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
require.NoError(t, err)
|
||||
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), 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)
|
||||
@@ -60,8 +58,7 @@ func TestNewModules(t *testing.T) {
|
||||
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger)
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -200,8 +200,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddServiceAccountManagedRoleTransactionsFactory(sqlstore),
|
||||
sqlmigration.NewAddSpanMapperFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddPinnedDashboardFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,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"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -107,7 +105,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),
|
||||
@@ -334,13 +332,8 @@ func New(
|
||||
// Initialize query parser (needed for dashboard module)
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
|
||||
// Initialize tag module — shared across modules that link entities to tags
|
||||
// (currently dashboard; future: alerts, RBAC). Built once here and injected
|
||||
// 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)
|
||||
@@ -462,7 +455,7 @@ func New(
|
||||
}
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,114 +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 addTags struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddTagsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_tags"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addTags{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addTags) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addTags) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
tagTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "tag",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "key", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "value", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "kind", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tagTableSQLs...)
|
||||
|
||||
// Case-insensitive uniqueness on (org_id, kind, key, value) — both Postgres
|
||||
// and SQLite (modernc 3.50.x) support expression indexes.
|
||||
tagUniqueIndexSQLs := migration.sqlschema.Operator().CreateIndex(
|
||||
(&sqlschema.UniqueIndex{
|
||||
TableName: "tag",
|
||||
ColumnNames: []sqlschema.ColumnName{"org_id", "kind", "key", "value"},
|
||||
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
|
||||
}).Named("uq_tag_org_kind_lower_key_lower_value"),
|
||||
)
|
||||
sqls = append(sqls, tagUniqueIndexSQLs...)
|
||||
|
||||
tagRelationsTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "tag_relation",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "kind", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "resource_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "tag_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("tag_id"),
|
||||
ReferencedTableName: sqlschema.TableName("tag"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tagRelationsTableSQLs...)
|
||||
|
||||
tagRelationUniqueIndexSQLs := migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "tag_relation",
|
||||
ColumnNames: []sqlschema.ColumnName{"kind", "resource_id", "tag_id"},
|
||||
},
|
||||
)
|
||||
sqls = append(sqls, tagRelationUniqueIndexSQLs...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addTags) Down(_ context.Context, _ *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package sqlschema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -51,23 +49,9 @@ type Index interface {
|
||||
ToDropSQL(fmter SQLFormatter) []byte
|
||||
}
|
||||
|
||||
// UniqueIndex models a unique index on a table.
|
||||
//
|
||||
// In the common case the index keys on plain columns: set only ColumnNames and
|
||||
// the SQL is emitted with each column identifier-quoted by the formatter
|
||||
// (`CREATE UNIQUE INDEX uq_t_a_b ON t (a, b)`).
|
||||
//
|
||||
// For functional indexes (e.g. case-insensitive uniqueness on `LOWER(col)`),
|
||||
// set Expressions to the raw SQL parts and use ColumnNames as metadata for
|
||||
// "which columns does this index touch". When Expressions is non-empty, it
|
||||
// overrides ColumnNames for SQL emission — each entry is written verbatim, so
|
||||
// the caller owns well-formedness — and the auto-generated name uses a hash
|
||||
// suffix instead of a readable column join because expressions aren't valid
|
||||
// identifier fragments.
|
||||
type UniqueIndex struct {
|
||||
TableName TableName
|
||||
ColumnNames []ColumnName
|
||||
Expressions []string
|
||||
name string
|
||||
}
|
||||
|
||||
@@ -87,28 +71,16 @@ func (index *UniqueIndex) Name() string {
|
||||
}
|
||||
b.WriteString(string(column))
|
||||
}
|
||||
|
||||
if len(index.Expressions) > 0 {
|
||||
if len(index.ColumnNames) > 0 {
|
||||
b.WriteString("_")
|
||||
}
|
||||
hasher := fnv.New32a()
|
||||
_, _ = hasher.Write([]byte(strings.Join(index.Expressions, "\x00")))
|
||||
fmt.Fprintf(&b, "%08x", hasher.Sum32())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (index *UniqueIndex) Named(name string) Index {
|
||||
copyOfColumnNames := make([]ColumnName, len(index.ColumnNames))
|
||||
copy(copyOfColumnNames, index.ColumnNames)
|
||||
copyOfExpressions := make([]string, len(index.Expressions))
|
||||
copy(copyOfExpressions, index.Expressions)
|
||||
|
||||
return &UniqueIndex{
|
||||
TableName: index.TableName,
|
||||
ColumnNames: copyOfColumnNames,
|
||||
Expressions: copyOfExpressions,
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
@@ -129,18 +101,7 @@ func (index *UniqueIndex) Equals(other Index) bool {
|
||||
if other.Type() != IndexTypeUnique {
|
||||
return false
|
||||
}
|
||||
otherUnique, ok := other.(*UniqueIndex)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Plain and functional indexes produce different SQL even if their column
|
||||
// sets overlap; require both shapes to match.
|
||||
if (len(index.Expressions) == 0) != (len(otherUnique.Expressions) == 0) {
|
||||
return false
|
||||
}
|
||||
if len(index.Expressions) > 0 && !slices.Equal(index.Expressions, otherUnique.Expressions) {
|
||||
return false
|
||||
}
|
||||
|
||||
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns())
|
||||
}
|
||||
|
||||
@@ -153,20 +114,12 @@ func (index *UniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
|
||||
sql = fmter.AppendIdent(sql, string(index.TableName))
|
||||
sql = append(sql, " ("...)
|
||||
|
||||
if len(index.Expressions) > 0 {
|
||||
for i, expr := range index.Expressions {
|
||||
if i > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
sql = append(sql, expr...)
|
||||
}
|
||||
} else {
|
||||
for i, column := range index.ColumnNames {
|
||||
if i > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
sql = fmter.AppendIdent(sql, string(column))
|
||||
for i, column := range index.ColumnNames {
|
||||
if i > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
|
||||
sql = fmter.AppendIdent(sql, string(column))
|
||||
}
|
||||
|
||||
sql = append(sql, ")"...)
|
||||
|
||||
@@ -38,43 +38,6 @@ func TestIndexToCreateSQL(t *testing.T) {
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "my_index" ON "users" ("id", "name", "email")`,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_SingleExpression",
|
||||
index: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_users_email_1e5a87f1" ON "users" (LOWER(email))`,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_MixedColumnsAndExpressions",
|
||||
index: &UniqueIndex{
|
||||
TableName: "tag",
|
||||
ColumnNames: []ColumnName{"org_id", "kind", "key", "value"},
|
||||
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_tag_org_id_kind_key_value_57e8f81f" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_ComplexExpression",
|
||||
index: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"first_name", "last_name"},
|
||||
Expressions: []string{"LOWER(TRIM(first_name) || ' ' || TRIM(last_name))"},
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_users_first_name_last_name_adb1ff53" ON "users" (LOWER(TRIM(first_name) || ' ' || TRIM(last_name)))`,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_Named",
|
||||
index: &UniqueIndex{
|
||||
TableName: "tag",
|
||||
ColumnNames: []ColumnName{"org_id", "kind", "key", "value"},
|
||||
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
|
||||
name: "uq_tag_org_kind_lower_key_lower_value",
|
||||
},
|
||||
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_tag_org_kind_lower_key_lower_value" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
|
||||
},
|
||||
{
|
||||
name: "PartialUnique_1Column",
|
||||
index: &PartialUniqueIndex{
|
||||
@@ -266,47 +229,6 @@ func TestIndexEquals(t *testing.T) {
|
||||
},
|
||||
equals: false,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_Same",
|
||||
a: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
b: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
equals: true,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_DifferentExpressions",
|
||||
a: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
b: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
Expressions: []string{"UPPER(email)"},
|
||||
},
|
||||
equals: false,
|
||||
},
|
||||
{
|
||||
name: "Unique_Functional_NotEqualToPlainSameColumns",
|
||||
a: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
},
|
||||
b: &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
},
|
||||
equals: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -316,75 +238,6 @@ func TestIndexEquals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueIndexFunctionalName(t *testing.T) {
|
||||
t.Run("autogen uses uq_<table>_<hash>", func(t *testing.T) {
|
||||
idx := &UniqueIndex{
|
||||
TableName: "tag",
|
||||
ColumnNames: []ColumnName{"org_id", "kind", "key", "value"},
|
||||
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
|
||||
}
|
||||
assert.Equal(t, "uq_tag_org_id_kind_key_value_57e8f81f", idx.Name())
|
||||
})
|
||||
|
||||
t.Run("same expressions produce the same name", func(t *testing.T) {
|
||||
a := &UniqueIndex{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
}
|
||||
b := &UniqueIndex{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
}
|
||||
assert.Equal(t, a.Name(), b.Name())
|
||||
})
|
||||
|
||||
t.Run("different expressions produce different names", func(t *testing.T) {
|
||||
a := &UniqueIndex{
|
||||
TableName: "users",
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
}
|
||||
b := &UniqueIndex{
|
||||
TableName: "users",
|
||||
Expressions: []string{"UPPER(email)"},
|
||||
}
|
||||
assert.NotEqual(t, a.Name(), b.Name())
|
||||
})
|
||||
|
||||
t.Run("expressions in different order produce different names", func(t *testing.T) {
|
||||
a := &UniqueIndex{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"org_id", "LOWER(key)"},
|
||||
}
|
||||
b := &UniqueIndex{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"LOWER(key)", "org_id"},
|
||||
}
|
||||
assert.NotEqual(t, a.Name(), b.Name())
|
||||
})
|
||||
|
||||
t.Run("functional autogen differs from plain autogen for same columns", func(t *testing.T) {
|
||||
plain := &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
}
|
||||
functional := &UniqueIndex{
|
||||
TableName: "users",
|
||||
ColumnNames: []ColumnName{"email"},
|
||||
Expressions: []string{"LOWER(email)"},
|
||||
}
|
||||
assert.Equal(t, "uq_users_email", plain.Name())
|
||||
assert.NotEqual(t, plain.Name(), functional.Name())
|
||||
})
|
||||
|
||||
t.Run("Named() override wins over hash", func(t *testing.T) {
|
||||
idx := (&UniqueIndex{
|
||||
TableName: "tag",
|
||||
Expressions: []string{"org_id", "LOWER(key)"},
|
||||
}).Named("my_functional_index")
|
||||
assert.Equal(t, "my_functional_index", idx.Name())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPartialUniqueIndexName(t *testing.T) {
|
||||
a := &PartialUniqueIndex{
|
||||
TableName: "users",
|
||||
|
||||
@@ -1,124 +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 := NewDashboardV2FromStorable(r.Dashboard, r.Public, tagsByEntity[r.Dashboard.ID])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dashboards[i] = &gettableDashboardWithPin{
|
||||
GettableDashboardV2: *NewGettableDashboardV2FromDashboardV2(v2),
|
||||
Pinned: r.Pinned,
|
||||
}
|
||||
}
|
||||
return &ListableDashboardV2{
|
||||
Dashboards: dashboards,
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,356 +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/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"`
|
||||
Info DashboardInfo `json:"info"`
|
||||
PublicConfig *PublicDashboard `json:"publicConfig,omitempty"`
|
||||
}
|
||||
|
||||
// DashboardInfo is the serializable view of a dashboard's contents — what the UI renders as "the dashboard JSON".
|
||||
type DashboardInfo struct {
|
||||
StoredDashboardInfo
|
||||
Tags []*tagtypes.Tag `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// StoredDashboardInfo is exactly what serializes into the dashboard.data column.
|
||||
type StoredDashboardInfo struct {
|
||||
Metadata DashboardMetadata `json:"metadata"`
|
||||
Data DashboardData `json:"data"`
|
||||
}
|
||||
|
||||
type DashboardMetadata struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Image string `json:"image,omitempty"`
|
||||
UploadedGrafana bool `json:"uploadedGrafana"`
|
||||
}
|
||||
|
||||
type PostableDashboardV2 struct {
|
||||
StoredDashboardInfo
|
||||
Tags []tagtypes.PostableTag `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateableDashboardV2 = PostableDashboardV2
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// patchableDashboardV2View is the JSON shape a patch is applied against.
|
||||
// It mirrors PostableDashboardV2 except `tags` is always emitted (even when
|
||||
// empty) — RFC 6902 `add /tags/-` requires the array to exist in the target
|
||||
// document, and PostableDashboardV2's own `omitempty` on tags would drop it.
|
||||
type patchableDashboardV2View struct {
|
||||
StoredDashboardInfo
|
||||
Tags []tagtypes.PostableTag `json:"tags"`
|
||||
}
|
||||
|
||||
// Apply runs the patch against the existing dashboard. The dashboard is
|
||||
// projected into the postable JSON shape, the patch is applied, and the
|
||||
// result is decoded back into an UpdateableDashboardV2 — which re-runs
|
||||
// the full v2 validation chain.
|
||||
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdateableDashboardV2, error) {
|
||||
base := patchableDashboardV2View{
|
||||
StoredDashboardInfo: existing.Info.StoredDashboardInfo,
|
||||
Tags: tagtypes.NewPostableTagsFromTags(existing.Info.Tags),
|
||||
}
|
||||
raw, err := json.Marshal(base)
|
||||
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
|
||||
}
|
||||
|
||||
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.Data.Display == nil || p.Data.Display.Name == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "data.display.name is required")
|
||||
}
|
||||
if err := p.validateTags(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Data.Validate()
|
||||
}
|
||||
|
||||
func (p *PostableDashboardV2) validateTags() error {
|
||||
if len(p.Tags) > MaxTagsPerDashboard {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
|
||||
}
|
||||
for _, tag := range p.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
|
||||
}
|
||||
|
||||
type GettableDashboardV2 struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
Locked bool `json:"locked"`
|
||||
Info GettableDashboardInfo `json:"info"`
|
||||
PublicConfig *GettablePublicDasbhboard `json:"publicConfig,omitempty"`
|
||||
}
|
||||
|
||||
type GettableDashboardInfo struct {
|
||||
StoredDashboardInfo
|
||||
Tags []*tagtypes.GettableTag `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
func NewGettableDashboardV2FromDashboardV2(dashboard *DashboardV2) *GettableDashboardV2 {
|
||||
gettable := &GettableDashboardV2{
|
||||
Identifiable: dashboard.Identifiable,
|
||||
TimeAuditable: dashboard.TimeAuditable,
|
||||
UserAuditable: dashboard.UserAuditable,
|
||||
OrgID: dashboard.OrgID,
|
||||
Locked: dashboard.Locked,
|
||||
Info: GettableDashboardInfo{
|
||||
StoredDashboardInfo: dashboard.Info.StoredDashboardInfo,
|
||||
Tags: tagtypes.NewGettableTagsFromTags(dashboard.Info.Tags),
|
||||
},
|
||||
}
|
||||
if dashboard.PublicConfig != nil {
|
||||
gettable.PublicConfig = NewGettablePublicDashboard(dashboard.PublicConfig)
|
||||
}
|
||||
return gettable
|
||||
}
|
||||
|
||||
func NewDashboardV2(orgID valuer.UUID, createdBy string, postable PostableDashboardV2, resolvedTags []*tagtypes.Tag) *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,
|
||||
Info: DashboardInfo{
|
||||
StoredDashboardInfo: StoredDashboardInfo{
|
||||
Metadata: postable.Metadata,
|
||||
Data: postable.Data,
|
||||
},
|
||||
Tags: resolvedTags,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// rejects rows that don't carry a v2-shape blob — those are pre-migration v1 dashboards that the v2 API can't render.
|
||||
func NewDashboardV2FromStorable(storable *StorableDashboard, public *StorablePublicDashboard, 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 StoredDashboardInfo
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal stored v2 dashboard data")
|
||||
}
|
||||
|
||||
var publicConfig *PublicDashboard
|
||||
if public != nil {
|
||||
publicConfig = NewPublicDashboardFromStorablePublicDashboard(public)
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
Identifiable: storable.Identifiable,
|
||||
TimeAuditable: storable.TimeAuditable,
|
||||
UserAuditable: storable.UserAuditable,
|
||||
OrgID: storable.OrgID,
|
||||
Locked: storable.Locked,
|
||||
Info: DashboardInfo{
|
||||
StoredDashboardInfo: stored,
|
||||
Tags: tags,
|
||||
},
|
||||
PublicConfig: publicConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) CanLockUnlock(lock bool, 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")
|
||||
}
|
||||
if d.Locked == lock {
|
||||
if lock {
|
||||
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "dashboard is already locked")
|
||||
}
|
||||
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "dashboard is already unlocked")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
|
||||
if err := d.CanLockUnlock(lock, isAdmin, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Locked = lock
|
||||
d.UpdatedBy = updatedBy
|
||||
d.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
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.Info.Metadata = updateable.Metadata
|
||||
d.Info.Data = updateable.Data
|
||||
d.Info.Tags = resolvedTags
|
||||
d.UpdatedBy = updatedBy
|
||||
d.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToStorableDashboard packages a Dashboard into the bun row that goes into
|
||||
// the dashboard table. Tags are intentionally omitted — they live in
|
||||
// tag_relations and are inserted separately by the caller.
|
||||
func (d *DashboardV2) ToStorableDashboard() (*StorableDashboard, error) {
|
||||
data, err := d.Info.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 (s StoredDashboardInfo) 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
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"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"},
|
||||
"data": {
|
||||
"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"
|
||||
},
|
||||
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}]
|
||||
}`
|
||||
|
||||
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")
|
||||
base := &DashboardV2{
|
||||
Info: DashboardInfo{
|
||||
StoredDashboardInfo: p.StoredDashboardInfo,
|
||||
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.Info.Metadata, out.Metadata)
|
||||
assert.Equal(t, base.Info.Data.Display.Name, out.Data.Display.Name)
|
||||
require.Equal(t, len(base.Info.Data.Panels), len(out.Data.Panels))
|
||||
for k, panel := range base.Info.Data.Panels {
|
||||
require.Contains(t, out.Data.Panels, k)
|
||||
assert.Equal(t, panel.Spec.Plugin.Kind, out.Data.Panels[k].Spec.Plugin.Kind)
|
||||
}
|
||||
assert.Len(t, out.Tags, len(base.Info.Tags))
|
||||
assert.Len(t, out.Data.Variables, len(base.Info.Data.Variables))
|
||||
assert.Len(t, out.Data.Layouts, len(base.Info.Data.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": "/data/display/name", "value": "Renamed"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Renamed", out.Data.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": "/data/display/name", "value": "Overwritten"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Overwritten", out.Data.Display.Name)
|
||||
})
|
||||
|
||||
t.Run("add data refreshInterval", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/data/refreshInterval", "value": "30s"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "30s", string(out.Data.RefreshInterval))
|
||||
})
|
||||
|
||||
t.Run("add panel leaves others untouched", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "add",
|
||||
"path": "/data/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.Data.Panels, 3)
|
||||
assert.Contains(t, out.Data.Panels, "p1")
|
||||
assert.Contains(t, out.Data.Panels, "p2")
|
||||
assert.Contains(t, out.Data.Panels, "p3")
|
||||
})
|
||||
|
||||
t.Run("replace single panel", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/data/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.Data.Panels["p2"].Spec.Plugin.Kind)
|
||||
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Data.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": "/data/panels/p2"},
|
||||
{"op": "remove", "path": "/data/layouts/0/spec/items/1"}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.Data.Panels, 1)
|
||||
assert.Contains(t, out.Data.Panels, "p1")
|
||||
assert.NotContains(t, out.Data.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": "/data/panels/p1/spec/queries/0/spec/plugin/spec/name",
|
||||
"value": "renamed"
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, out.Data.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": "/data/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.Data.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": "/data/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": "/data/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": "/data/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": "/data/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": "/data/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": "/data/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.Data.Panels, 3)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
|
||||
})
|
||||
|
||||
t.Run("append tag", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 3)
|
||||
assert.Equal(t, "env", out.Tags[2].Key)
|
||||
assert.Equal(t, "staging", out.Tags[2].Value)
|
||||
})
|
||||
|
||||
t.Run("append tag when none exist", func(t *testing.T) {
|
||||
noTagsBase := &DashboardV2{
|
||||
Info: DashboardInfo{
|
||||
StoredDashboardInfo: base.Info.StoredDashboardInfo,
|
||||
Tags: nil,
|
||||
},
|
||||
}
|
||||
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "team", "value": "new"}}]`).Apply(noTagsBase)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 1)
|
||||
assert.Equal(t, "team", out.Tags[0].Key)
|
||||
assert.Equal(t, "new", out.Tags[0].Value)
|
||||
})
|
||||
|
||||
t.Run("replace tag value", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/value", "value": "beta"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 2)
|
||||
assert.Equal(t, "team", out.Tags[0].Key)
|
||||
assert.Equal(t, "beta", out.Tags[0].Value)
|
||||
assert.Equal(t, "env", out.Tags[1].Key, "tag at index 1 untouched")
|
||||
assert.Equal(t, "prod", out.Tags[1].Value, "tag at index 1 untouched")
|
||||
for _, tag := range out.Tags {
|
||||
assert.NotEqual(t, "alpha", tag.Value, "old tag value must be gone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple ops applied in order", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "replace", "path": "/data/display/name", "value": "Multi-step"},
|
||||
{"op": "remove", "path": "/data/panels/p2"},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Multi-step", out.Data.Display.Name)
|
||||
assert.Len(t, out.Data.Panels, 1)
|
||||
assert.Len(t, out.Tags, 3)
|
||||
})
|
||||
|
||||
// `test` is an RFC 6902 precondition op: aborts the patch if the value
|
||||
// at the path doesn't equal the supplied value. Used for optimistic
|
||||
// concurrency. Here it matches, so the subsequent ops apply.
|
||||
t.Run("test op passes", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "test", "path": "/data/display/name", "value": "Service overview"},
|
||||
{"op": "replace", "path": "/data/display/name", "value": "Confirmed"}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Confirmed", out.Data.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": "/data/display/name", "value": "Wrong"},
|
||||
{"op": "replace", "path": "/data/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": "/data/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": "/data/display/name", "value": ""}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "data.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": "/data/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": "/data/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": "/data/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": "/data/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": "/tags/-", "value": {"key": "t", "value": "1"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "2"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "3"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "4"}}
|
||||
]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "at most")
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*Tag, error)
|
||||
|
||||
ListByResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*Tag, error)
|
||||
|
||||
ListByResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*Tag, error)
|
||||
|
||||
// CreateOrGet upserts the given tags and returns them with authoritative IDs.
|
||||
// On conflict on (org_id, kind, LOWER(key), LOWER(value)) — which
|
||||
// happens only when a concurrent insert raced ours, including casing-only
|
||||
// collisions — the returned entry carries the existing row's ID rather
|
||||
// than the pre-generated one in the input.
|
||||
CreateOrGet(ctx context.Context, tags []*Tag) ([]*Tag, error)
|
||||
|
||||
// CreateRelations inserts tag-resource relations. Conflicts on the composite primary key are ignored.
|
||||
CreateRelations(ctx context.Context, relations []*TagRelation) error
|
||||
|
||||
DeleteRelationsExcept(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, keepTagIDs []valuer.UUID) error
|
||||
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const (
|
||||
MAX_LEN_TAG_KEY = 32
|
||||
MAX_LEN_TAG_VALUE = 32
|
||||
)
|
||||
|
||||
var (
|
||||
tagKeyRegex = regexp.MustCompile(`^[a-zA-Z$_@{#][a-zA-Z0-9$_@#{}:/-]*$`)
|
||||
tagValueRegex = regexp.MustCompile(`^[a-zA-Z0-9$_@#{}:.+=/-]*$`)
|
||||
|
||||
ErrCodeTagInvalidKey = errors.MustNewCode("tag_invalid_key")
|
||||
ErrCodeTagInvalidValue = errors.MustNewCode("tag_invalid_value")
|
||||
ErrCodeTagNotFound = errors.MustNewCode("tag_not_found")
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
bun.BaseModel `bun:"table:tag,alias:tag"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Key string `json:"key" required:"true" bun:"key,type:text,notnull"`
|
||||
Value string `json:"value" required:"true" bun:"value,type:text,notnull"`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true" bun:"org_id,type:text,notnull"`
|
||||
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
|
||||
}
|
||||
|
||||
type PostableTag struct {
|
||||
Key string `json:"key" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
}
|
||||
|
||||
type GettableTag = PostableTag
|
||||
|
||||
func NewGettableTagFromTag(tag *Tag) *GettableTag {
|
||||
return &GettableTag{Key: tag.Key, Value: tag.Value}
|
||||
}
|
||||
|
||||
func NewGettableTagsFromTags(tags []*Tag) []*GettableTag {
|
||||
out := make([]*GettableTag, len(tags))
|
||||
for i, t := range tags {
|
||||
out[i] = NewGettableTagFromTag(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewPostableTagFromTag(tag *Tag) PostableTag {
|
||||
return PostableTag{Key: tag.Key, Value: tag.Value}
|
||||
}
|
||||
|
||||
func NewPostableTagsFromTags(tags []*Tag) []PostableTag {
|
||||
out := make([]PostableTag, len(tags))
|
||||
for i, t := range tags {
|
||||
out[i] = NewPostableTagFromTag(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewTag(orgID valuer.UUID, kind coretypes.Kind, key, value string) *Tag {
|
||||
now := time.Now()
|
||||
return &Tag{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
Key: key,
|
||||
Value: value,
|
||||
OrgID: orgID,
|
||||
Kind: kind,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePostableTag trims and validates a user-supplied (key, value) pair.
|
||||
// Returns the cleaned values on success. Entity-specific reserved-key checks
|
||||
// (e.g. dashboard column names that would collide with the list-query DSL) are
|
||||
// the caller's responsibility — perform them before calling into the tag module.
|
||||
func ValidatePostableTag(p PostableTag) (string, string, error) {
|
||||
key := strings.TrimSpace(p.Key)
|
||||
value := strings.TrimSpace(p.Value)
|
||||
if key == "" {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key cannot be empty")
|
||||
}
|
||||
if value == "" {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value cannot be empty")
|
||||
}
|
||||
if !tagKeyRegex.MatchString(key) {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key %q contains disallowed characters", key)
|
||||
}
|
||||
if !tagValueRegex.MatchString(value) {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value %q contains disallowed characters", value)
|
||||
}
|
||||
if utf8.RuneCountInString(key) > MAX_LEN_TAG_KEY {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key %q exceeds the %d-character limit", key, MAX_LEN_TAG_KEY)
|
||||
}
|
||||
if utf8.RuneCountInString(value) > MAX_LEN_TAG_VALUE {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value %q exceeds the %d-character limit", value, MAX_LEN_TAG_VALUE)
|
||||
}
|
||||
return key, value, nil
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type TagRelation struct {
|
||||
bun.BaseModel `bun:"table:tag_relation,alias:tag_relation"`
|
||||
|
||||
types.Identifiable
|
||||
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
|
||||
ResourceID valuer.UUID `json:"resourceId" required:"true" bun:"resource_id,type:text,notnull"`
|
||||
TagID valuer.UUID `json:"tagId" required:"true" bun:"tag_id,type:text,notnull"`
|
||||
}
|
||||
|
||||
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID) *TagRelation {
|
||||
return &TagRelation{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
Kind: kind,
|
||||
ResourceID: resourceID,
|
||||
TagID: tagID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewTagRelations(kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) []*TagRelation {
|
||||
relations := make([]*TagRelation, 0, len(tagIDs))
|
||||
for _, tagID := range tagIDs {
|
||||
relations = append(relations, NewTagRelation(kind, resourceID, tagID))
|
||||
}
|
||||
return relations
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidatePostableTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input PostableTag
|
||||
wantKey string
|
||||
wantValue string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "simple pair", input: PostableTag{Key: "team", Value: "pulse"}, wantKey: "team", wantValue: "pulse"},
|
||||
{name: "preserves casing", input: PostableTag{Key: "Team", Value: "Pulse"}, wantKey: "Team", wantValue: "Pulse"},
|
||||
{name: "trims key", input: PostableTag{Key: " team ", Value: "pulse"}, wantKey: "team", wantValue: "pulse"},
|
||||
{name: "trims value", input: PostableTag{Key: "team", Value: " pulse "}, wantKey: "team", wantValue: "pulse"},
|
||||
|
||||
{name: "empty key rejected", input: PostableTag{Key: "", Value: "pulse"}, wantError: true},
|
||||
{name: "empty value rejected", input: PostableTag{Key: "team", Value: ""}, wantError: true},
|
||||
{name: "whitespace-only key rejected", input: PostableTag{Key: " ", Value: "pulse"}, wantError: true},
|
||||
{name: "whitespace-only value rejected", input: PostableTag{Key: "team", Value: " "}, wantError: true},
|
||||
|
||||
{name: "slash accepted", input: PostableTag{Key: "team/eng", Value: "pulse/events"}, wantKey: "team/eng", wantValue: "pulse/events"},
|
||||
{name: "colon accepted", input: PostableTag{Key: "team:eng", Value: "env:prod"}, wantKey: "team:eng", wantValue: "env:prod"},
|
||||
{name: "extra punctuation accepted in both", input: PostableTag{Key: "a_b-c@d#e$f{g}h", Value: "a_b-c@d#e$f{g}h"}, wantKey: "a_b-c@d#e$f{g}h", wantValue: "a_b-c@d#e$f{g}h"},
|
||||
|
||||
// Key is strict; value allows the extra `. + =` plus leading digits.
|
||||
{name: "dot in key rejected", input: PostableTag{Key: "team.eng", Value: "pulse"}, wantError: true},
|
||||
{name: "dot in value accepted", input: PostableTag{Key: "team", Value: "pulse.events"}, wantKey: "team", wantValue: "pulse.events"},
|
||||
{name: "plus in key rejected", input: PostableTag{Key: "team+eng", Value: "pulse"}, wantError: true},
|
||||
{name: "plus in value accepted", input: PostableTag{Key: "team", Value: "a+b"}, wantKey: "team", wantValue: "a+b"},
|
||||
{name: "equals in key rejected", input: PostableTag{Key: "team=eng", Value: "pulse"}, wantError: true},
|
||||
{name: "equals in value accepted", input: PostableTag{Key: "team", Value: "a=b"}, wantKey: "team", wantValue: "a=b"},
|
||||
{name: "leading digit in key rejected", input: PostableTag{Key: "2024team", Value: "pulse"}, wantError: true},
|
||||
{name: "leading digit in value accepted", input: PostableTag{Key: "team", Value: "2024_team"}, wantKey: "team", wantValue: "2024_team"},
|
||||
|
||||
{name: "unicode letter in key rejected", input: PostableTag{Key: "チーム", Value: "pulse"}, wantError: true},
|
||||
{name: "unicode letter in value rejected", input: PostableTag{Key: "team", Value: "東京"}, wantError: true},
|
||||
|
||||
{name: "internal space in key rejected", input: PostableTag{Key: "team eng", Value: "pulse"}, wantError: true},
|
||||
{name: "internal space in value rejected", input: PostableTag{Key: "team", Value: "pulse two"}, wantError: true},
|
||||
|
||||
{name: "disallowed char in key rejected", input: PostableTag{Key: "team!eng", Value: "pulse"}, wantError: true},
|
||||
{name: "disallowed char in value rejected", input: PostableTag{Key: "team", Value: "pulse!one"}, wantError: true},
|
||||
{name: "control char rejected", input: PostableTag{Key: "team\tone", Value: "pulse"}, wantError: true},
|
||||
|
||||
{name: "key at the 32-char limit accepted", input: PostableTag{Key: "abcdefghijklmnopabcdefghijklmnop", Value: "pulse"}, wantKey: "abcdefghijklmnopabcdefghijklmnop", wantValue: "pulse"},
|
||||
{name: "value at the 32-char limit accepted", input: PostableTag{Key: "team", Value: "abcdefghijklmnopabcdefghijklmnop"}, wantKey: "team", wantValue: "abcdefghijklmnopabcdefghijklmnop"},
|
||||
{name: "key over the 32-char limit rejected", input: PostableTag{Key: "abcdefghijklmnopabcdefghijklmnopq", Value: "pulse"}, wantError: true},
|
||||
{name: "value over the 32-char limit rejected", input: PostableTag{Key: "team", Value: "abcdefghijklmnopabcdefghijklmnopq"}, wantError: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotKey, gotValue, err := ValidatePostableTag(tc.input)
|
||||
if tc.wantError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantKey, gotKey)
|
||||
assert.Equal(t, tc.wantValue, gotValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package tagtypestest
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MockStore is an in-memory tagtypes.MockStore implementation for tests. Most methods
|
||||
// are inert no-ops; List returns the contents of Tags and increments
|
||||
// ListCallCount so tests can assert on lookup behavior. Set Tags directly to
|
||||
// preload fixtures.
|
||||
type MockStore struct {
|
||||
Tags []*tagtypes.Tag
|
||||
ListCallCount int
|
||||
}
|
||||
|
||||
func NewStore() *MockStore {
|
||||
return &MockStore{}
|
||||
}
|
||||
|
||||
func (s *MockStore) List(_ context.Context, _ valuer.UUID, _ coretypes.Kind) ([]*tagtypes.Tag, error) {
|
||||
s.ListCallCount++
|
||||
out := make([]*tagtypes.Tag, len(s.Tags))
|
||||
copy(out, s.Tags)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MockStore) CreateOrGet(_ context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *MockStore) CreateRelations(_ context.Context, _ []*tagtypes.TagRelation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MockStore) ListByResource(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
return []*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
func (s *MockStore) ListByResources(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
|
||||
return map[valuer.UUID][]*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
func (s *MockStore) DeleteRelationsExcept(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ valuer.UUID, _ []valuer.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MockStore) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return cb(ctx)
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user