mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 13:30:36 +01:00
Compare commits
11 Commits
settings-e
...
nv/v2-publ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d98e9ebf6 | ||
|
|
15e99e43ff | ||
|
|
8c766f8c10 | ||
|
|
99b32f00b9 | ||
|
|
76f8646c69 | ||
|
|
28c00e298a | ||
|
|
4592b12256 | ||
|
|
b990d40c5f | ||
|
|
95a0d7c035 | ||
|
|
e678728c61 | ||
|
|
42d3e7e0e4 |
@@ -2700,6 +2700,13 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettablePublicDashboardDataV2:
|
||||
properties:
|
||||
dashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -15859,6 +15866,138 @@ paths:
|
||||
summary: Update my organization
|
||||
tags:
|
||||
- orgs
|
||||
/api/v2/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the sanitized v2-shape dashboard data for
|
||||
public access. Each panel query is reduced to a safe field subset, so filters
|
||||
and raw query strings are not exposed.
|
||||
operationId: GetPublicDashboardDataV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardDataV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get public dashboard data (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/public/dashboards/{id}/panels/{key}/query_range:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns query range results for a panel of a v2-shape
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: key
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get query range result (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/readyz:
|
||||
get:
|
||||
operationId: Readyz
|
||||
|
||||
@@ -29,6 +29,7 @@ type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
querier querier.Querier
|
||||
licensing licensing.Licensing
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
@@ -41,6 +42,7 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
|
||||
settings: scopedProviderSettings,
|
||||
querier: querier,
|
||||
licensing: licensing,
|
||||
tagModule: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +134,49 @@ func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.U
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, storableDashboard.OrgID, coretypes.KindDashboard, storableDashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storableDashboard.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "dashboard",
|
||||
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRangeV2",
|
||||
})
|
||||
|
||||
dashboard, err := module.GetDashboardByPublicIDV2(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicDashboard, err := module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange(startTimeRaw, endTimeRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query, err := dashboard.GetPanelQuery(startTime, endTime, panelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,6 +33,10 @@ import type {
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
@@ -1364,6 +1368,217 @@ export const useLockDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const getPublicDashboardDataV2 = (
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardDataV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryKey = ({
|
||||
id,
|
||||
}: GetPublicDashboardDataV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetPublicDashboardDataV2QueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
> = ({ signal }) => getPublicDashboardDataV2({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
>;
|
||||
export type GetPublicDashboardDataV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardDataV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardDataV2QueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardDataV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardDataV2QueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(id && key),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
>;
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
|
||||
* @summary List dashboards for the current user (v2)
|
||||
|
||||
@@ -4710,6 +4710,11 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
dashboard?: DashboardtypesGettableDashboardV2DTO;
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -10253,6 +10258,29 @@ export type GetMyOrganization200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetPublicDashboardDataV2200 = {
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Readyz200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -336,5 +336,61 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/public/dashboards/{id}", handler.New(provider.authzMiddleware.CheckWithoutClaims(
|
||||
provider.dashboardHandler.GetPublicDataV2,
|
||||
authtypes.Relation{Verb: coretypes.VerbRead},
|
||||
coretypes.ResourceMetaResourcePublicDashboard,
|
||||
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
|
||||
}, []string{}), handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboardDataV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get public dashboard data (v2)",
|
||||
Description: "This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettablePublicDashboardDataV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/public/dashboards/{id}/panels/{key}/query_range", handler.New(provider.authzMiddleware.CheckWithoutClaims(
|
||||
provider.dashboardHandler.GetPublicWidgetQueryRangeV2,
|
||||
authtypes.Relation{Verb: coretypes.VerbRead},
|
||||
coretypes.ResourceMetaResourcePublicDashboard,
|
||||
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
|
||||
}, []string{}), handler.OpenAPIDef{
|
||||
ID: "GetPublicDashboardPanelQueryRangeV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get query range result (v2)",
|
||||
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(querybuildertypesv5.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ type Module interface {
|
||||
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
|
||||
|
||||
// get the v2 dashboard data by public dashboard id
|
||||
GetDashboardByPublicIDV2(context.Context, valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// gets the query results by panel key and public shared id for a v2 dashboard
|
||||
GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
@@ -89,6 +95,10 @@ type Handler interface {
|
||||
|
||||
GetPublicWidgetQueryRange(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublicDataV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublicWidgetQueryRangeV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdatePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeletePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -247,6 +247,14 @@ func (module *module) GetPublicWidgetQueryRange(context.Context, valuer.UUID, ui
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(_ context.Context, _ valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRangeV2(context.Context, valuer.UUID, string, string, string) (*qbtypes.QueryRangeResponse, error) {
|
||||
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
func (module *module) GetPublicDashboardSelectorsAndOrg(_ context.Context, _ valuer.UUID, _ []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
|
||||
return nil, valuer.UUID{}, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
@@ -344,3 +344,53 @@ func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublicDataV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.GetDashboardByPublicIDV2(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard, err := handler.module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypes.NewPublicDashboardDataFromDashboardV2(dashboard, publicDashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
panelKey, ok := mux.Vars(r)["key"]
|
||||
if !ok || panelKey == "" {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "panel key is missing from the path"))
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeResults)
|
||||
}
|
||||
|
||||
209
pkg/types/dashboardtypes/perses_public_dashboard.go
Normal file
209
pkg/types/dashboardtypes/perses_public_dashboard.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Gettable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// GettablePublicDashboardDataV2 is the anonymous-facing payload of a v2 dashboard.
|
||||
type GettablePublicDashboardDataV2 struct {
|
||||
Dashboard *GettableDashboardV2 `json:"dashboard"`
|
||||
PublicDashboard *GettablePublicDasbhboard `json:"publicDashboard"`
|
||||
}
|
||||
|
||||
// NewPublicDashboardDataFromDashboardV2 builds the anonymous v2 payload: panel queries
|
||||
// are redacted, and only the body fields v1 exposed (name, metadata, tags, spec) are set.
|
||||
func NewPublicDashboardDataFromDashboardV2(dashboard *DashboardV2, publicDashboard *PublicDashboard) *GettablePublicDashboardDataV2 {
|
||||
spec := dashboard.Spec
|
||||
redactPanelQueries(&spec)
|
||||
|
||||
return &GettablePublicDashboardDataV2{
|
||||
Dashboard: &GettableDashboardV2{
|
||||
DashboardV2MetadataBase: dashboard.DashboardV2MetadataBase,
|
||||
Name: dashboard.Name,
|
||||
Tags: tagtypes.NewGettableTagsFromTags(dashboard.Tags),
|
||||
Spec: spec,
|
||||
},
|
||||
PublicDashboard: &GettablePublicDasbhboard{
|
||||
TimeRangeEnabled: publicDashboard.TimeRangeEnabled,
|
||||
DefaultTimeRange: publicDashboard.DefaultTimeRange,
|
||||
PublicPath: publicDashboard.PublicPath(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Redaction
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func redactPanelQueries(spec *DashboardSpec) {
|
||||
panels := make(map[string]*Panel, len(spec.Panels))
|
||||
for key, panel := range spec.Panels {
|
||||
if panel == nil {
|
||||
panels[key] = nil
|
||||
continue
|
||||
}
|
||||
redacted := *panel
|
||||
queries := make([]Query, len(redacted.Spec.Queries))
|
||||
for i, query := range redacted.Spec.Queries {
|
||||
query.Spec.Plugin.Spec = redactQuery(query.Spec.Plugin.Spec)
|
||||
queries[i] = query
|
||||
}
|
||||
redacted.Spec.Queries = queries
|
||||
panels[key] = &redacted
|
||||
}
|
||||
spec.Panels = panels
|
||||
}
|
||||
|
||||
func redactQuery(spec any) any {
|
||||
switch s := spec.(type) {
|
||||
case *qb.CompositeQuery:
|
||||
if s == nil {
|
||||
return spec
|
||||
}
|
||||
queries := make([]qb.QueryEnvelope, len(s.Queries))
|
||||
for i, envelope := range s.Queries {
|
||||
envelope.Spec = redactLeafQuery(envelope.Spec)
|
||||
queries[i] = envelope
|
||||
}
|
||||
return &qb.CompositeQuery{Queries: queries}
|
||||
case *BuilderQuerySpec:
|
||||
if s == nil {
|
||||
return spec
|
||||
}
|
||||
return &BuilderQuerySpec{Spec: redactLeafQuery(s.Spec)}
|
||||
case *qb.PromQuery:
|
||||
return redactQueryPtr(s)
|
||||
case *qb.ClickHouseQuery:
|
||||
return redactQueryPtr(s)
|
||||
case *qb.QueryBuilderFormula:
|
||||
return redactQueryPtr(s)
|
||||
case *qb.QueryBuilderTraceOperator:
|
||||
return redactQueryPtr(s)
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
func redactQueryPtr[T any](s *T) any {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
redacted := redactLeafQuery(*s).(T)
|
||||
return &redacted
|
||||
}
|
||||
|
||||
func redactLeafQuery(spec any) any {
|
||||
switch s := spec.(type) {
|
||||
case qb.QueryBuilderQuery[qb.LogAggregation]:
|
||||
return redactBuilderQuery(s)
|
||||
case qb.QueryBuilderQuery[qb.MetricAggregation]:
|
||||
return redactBuilderQuery(s)
|
||||
case qb.QueryBuilderQuery[qb.TraceAggregation]:
|
||||
return redactBuilderQuery(s)
|
||||
case qb.PromQuery:
|
||||
return qb.PromQuery{Name: s.Name, Legend: s.Legend}
|
||||
case qb.ClickHouseQuery:
|
||||
return qb.ClickHouseQuery{Name: s.Name, Legend: s.Legend}
|
||||
case qb.QueryBuilderFormula:
|
||||
return qb.QueryBuilderFormula{Name: s.Name, Expression: s.Expression, Legend: s.Legend}
|
||||
case qb.QueryBuilderTraceOperator:
|
||||
return qb.QueryBuilderTraceOperator{
|
||||
Name: s.Name,
|
||||
Expression: s.Expression,
|
||||
Aggregations: s.Aggregations,
|
||||
GroupBy: s.GroupBy,
|
||||
Legend: s.Legend,
|
||||
}
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
func redactBuilderQuery[T any](q qb.QueryBuilderQuery[T]) qb.QueryBuilderQuery[T] {
|
||||
return qb.QueryBuilderQuery[T]{
|
||||
Name: q.Name,
|
||||
Signal: q.Signal,
|
||||
Source: q.Source,
|
||||
Aggregations: q.Aggregations,
|
||||
GroupBy: q.GroupBy,
|
||||
Legend: q.Legend,
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Panel query
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string) (*qb.QueryRangeRequest, error) {
|
||||
panel, ok := d.Spec.Panels[panelKey]
|
||||
if !ok || panel == nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidInput, "panel with key %q doesn't exist", panelKey)
|
||||
}
|
||||
// Validator guarantees exactly one query per panel.
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "panel %q must have exactly one query", panelKey)
|
||||
}
|
||||
|
||||
query := panel.Spec.Queries[0]
|
||||
composite, err := buildV5CompositeQueryFromPlugin(query.Spec.Plugin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fillGaps lives on the panel visualization; only timeseries and bar chart carry it.
|
||||
fillGaps := false
|
||||
switch panelSpec := panel.Spec.Plugin.Spec.(type) {
|
||||
case *TimeSeriesPanelSpec:
|
||||
if panelSpec != nil {
|
||||
fillGaps = panelSpec.Visualization.FillSpans
|
||||
}
|
||||
case *BarChartPanelSpec:
|
||||
if panelSpec != nil {
|
||||
fillGaps = panelSpec.Visualization.FillSpans
|
||||
}
|
||||
}
|
||||
|
||||
return &qb.QueryRangeRequest{
|
||||
SchemaVersion: "v1",
|
||||
Start: startTime,
|
||||
End: endTime,
|
||||
RequestType: query.Kind,
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildV5CompositeQueryFromPlugin(plugin QueryPlugin) (qb.CompositeQuery, error) {
|
||||
switch spec := plugin.Spec.(type) {
|
||||
case *qb.CompositeQuery:
|
||||
if spec == nil {
|
||||
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "composite query is empty")
|
||||
}
|
||||
return *spec, nil
|
||||
case *BuilderQuerySpec:
|
||||
if spec == nil {
|
||||
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "builder query is empty")
|
||||
}
|
||||
return wrapEnvelope(qb.QueryTypeBuilder, spec.Spec), nil
|
||||
case *qb.PromQuery:
|
||||
return wrapEnvelope(qb.QueryTypePromQL, *spec), nil
|
||||
case *qb.ClickHouseQuery:
|
||||
return wrapEnvelope(qb.QueryTypeClickHouseSQL, *spec), nil
|
||||
case *qb.QueryBuilderFormula:
|
||||
return wrapEnvelope(qb.QueryTypeFormula, *spec), nil
|
||||
case *qb.QueryBuilderTraceOperator:
|
||||
return wrapEnvelope(qb.QueryTypeTraceOperator, *spec), nil
|
||||
}
|
||||
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "unsupported query kind %q", plugin.Kind)
|
||||
}
|
||||
|
||||
func wrapEnvelope(queryType qb.QueryType, spec any) qb.CompositeQuery {
|
||||
return qb.CompositeQuery{Queries: []qb.QueryEnvelope{{Type: queryType, Spec: spec}}}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
t.Run("returns error when the panel does not exist", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := dashboard.GetPanelQuery(1, 2, "wrongPanelKey")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
|
||||
t.Run("returns error when the panel is nil", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
|
||||
t.Run("returns error unless the panel has exactly one query", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
queries []Query
|
||||
}{
|
||||
{description: "zero queries", queries: nil},
|
||||
{description: "two queries", queries: []Query{{}, {}}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{Queries: tc.queries},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("builds a single-envelope request for a builder query", func(t *testing.T) {
|
||||
builder := qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: builder},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(100, 200, "panel-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "v1", req.SchemaVersion)
|
||||
assert.Equal(t, uint64(100), req.Start)
|
||||
assert.Equal(t, uint64(200), req.End)
|
||||
assert.Equal(t, qb.RequestTypeTimeSeries, req.RequestType)
|
||||
require.Len(t, req.CompositeQuery.Queries, 1)
|
||||
assert.Equal(t, qb.QueryTypeBuilder, req.CompositeQuery.Queries[0].Type)
|
||||
assert.Equal(t, builder, req.CompositeQuery.Queries[0].Spec)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.False(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
|
||||
t.Run("uses a composite query as-is", func(t *testing.T) {
|
||||
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
|
||||
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
{Type: qb.QueryTypePromQL, Spec: qb.PromQuery{Name: "B"}},
|
||||
}}
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindComposite,
|
||||
Spec: composite,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, composite.Queries, req.CompositeQuery.Queries)
|
||||
})
|
||||
|
||||
t.Run("wraps a leaf query in a single typed envelope", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
plugin QueryPlugin
|
||||
expectedType qb.QueryType
|
||||
}{
|
||||
{
|
||||
description: "promql",
|
||||
plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &qb.PromQuery{Name: "A", Query: "up"}},
|
||||
expectedType: qb.QueryTypePromQL,
|
||||
},
|
||||
{
|
||||
description: "clickhouse",
|
||||
plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &qb.ClickHouseQuery{Name: "A", Query: "SELECT 1"}},
|
||||
expectedType: qb.QueryTypeClickHouseSQL,
|
||||
},
|
||||
{
|
||||
description: "formula",
|
||||
plugin: QueryPlugin{Kind: QueryKindFormula, Spec: &qb.QueryBuilderFormula{Name: "F1", Expression: "A / B"}},
|
||||
expectedType: qb.QueryTypeFormula,
|
||||
},
|
||||
{
|
||||
description: "trace operator",
|
||||
plugin: QueryPlugin{Kind: QueryKindTraceOperator, Spec: &qb.QueryBuilderTraceOperator{Name: "T1", Expression: "A => B"}},
|
||||
expectedType: qb.QueryTypeTraceOperator,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{Plugin: tc.plugin},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, req.CompositeQuery.Queries, 1)
|
||||
assert.Equal(t, tc.expectedType, req.CompositeQuery.Queries[0].Type)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
panelPlugin PanelPlugin
|
||||
expectedFillGaps bool
|
||||
}{
|
||||
{
|
||||
description: "timeseries with fillSpans enabled",
|
||||
panelPlugin: PanelPlugin{Kind: PanelKindTimeSeries, Spec: &TimeSeriesPanelSpec{Visualization: TimeSeriesVisualization{FillSpans: true}}},
|
||||
expectedFillGaps: true,
|
||||
},
|
||||
{
|
||||
description: "timeseries with fillSpans disabled",
|
||||
panelPlugin: PanelPlugin{Kind: PanelKindTimeSeries, Spec: &TimeSeriesPanelSpec{Visualization: TimeSeriesVisualization{FillSpans: false}}},
|
||||
expectedFillGaps: false,
|
||||
},
|
||||
{
|
||||
description: "bar chart with fillSpans enabled",
|
||||
panelPlugin: PanelPlugin{Kind: PanelKindBarChart, Spec: &BarChartPanelSpec{Visualization: BarChartVisualization{FillSpans: true}}},
|
||||
expectedFillGaps: true,
|
||||
},
|
||||
{
|
||||
description: "table panel has no fillSpans",
|
||||
panelPlugin: PanelPlugin{Kind: PanelKindTable, Spec: &TablePanelSpec{}},
|
||||
expectedFillGaps: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: tc.panelPlugin,
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.Equal(t, tc.expectedFillGaps, req.FormatOptions.FillGaps)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error for an unsupported plugin spec", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeTimeSeries,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: "not-a-query",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRedactLeafQuery(t *testing.T) {
|
||||
t.Run("builder query drops filter, having, limit and keeps display fields", func(t *testing.T) {
|
||||
input := qb.QueryBuilderQuery[qb.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qb.MetricAggregation{{MetricName: "system_cpu_usage"}},
|
||||
GroupBy: []qb.GroupByKey{{}},
|
||||
Legend: "cpu",
|
||||
Disabled: true,
|
||||
StepInterval: qb.Step{Duration: time.Minute},
|
||||
Filter: &qb.Filter{Expression: "service.name = 'checkout'"},
|
||||
Having: &qb.Having{},
|
||||
Limit: 100,
|
||||
}
|
||||
|
||||
redacted, ok := redactLeafQuery(input).(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok)
|
||||
|
||||
// dropped (not in the v1 whitelist)
|
||||
assert.Nil(t, redacted.Filter)
|
||||
assert.Nil(t, redacted.Having)
|
||||
assert.Zero(t, redacted.Limit)
|
||||
assert.Zero(t, redacted.StepInterval)
|
||||
assert.False(t, redacted.Disabled)
|
||||
|
||||
// kept (display)
|
||||
assert.Equal(t, "A", redacted.Name)
|
||||
assert.Equal(t, telemetrytypes.SignalMetrics, redacted.Signal)
|
||||
assert.Equal(t, "cpu", redacted.Legend)
|
||||
require.Len(t, redacted.Aggregations, 1)
|
||||
assert.Equal(t, "system_cpu_usage", redacted.Aggregations[0].MetricName)
|
||||
assert.Len(t, redacted.GroupBy, 1)
|
||||
})
|
||||
|
||||
t.Run("promql query drops the raw query, step and disabled", func(t *testing.T) {
|
||||
redacted, ok := redactLeafQuery(qb.PromQuery{
|
||||
Name: "A",
|
||||
Query: "sum(rate(http_requests_total[5m]))",
|
||||
Step: qb.Step{Duration: time.Minute},
|
||||
Disabled: true,
|
||||
Legend: "rps",
|
||||
}).(qb.PromQuery)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Empty(t, redacted.Query)
|
||||
assert.Zero(t, redacted.Step)
|
||||
assert.False(t, redacted.Disabled)
|
||||
assert.Equal(t, "A", redacted.Name)
|
||||
assert.Equal(t, "rps", redacted.Legend)
|
||||
})
|
||||
|
||||
t.Run("clickhouse query drops the raw query string", func(t *testing.T) {
|
||||
redacted, ok := redactLeafQuery(qb.ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT * FROM signoz_logs WHERE user = 'admin'",
|
||||
Legend: "logs",
|
||||
}).(qb.ClickHouseQuery)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Empty(t, redacted.Query)
|
||||
assert.Equal(t, "A", redacted.Name)
|
||||
assert.Equal(t, "logs", redacted.Legend)
|
||||
})
|
||||
|
||||
t.Run("formula keeps its expression but drops limit and having", func(t *testing.T) {
|
||||
redacted, ok := redactLeafQuery(qb.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A / B",
|
||||
Legend: "ratio",
|
||||
Limit: 50,
|
||||
Having: &qb.Having{},
|
||||
}).(qb.QueryBuilderFormula)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, "A / B", redacted.Expression)
|
||||
assert.Equal(t, "F1", redacted.Name)
|
||||
assert.Equal(t, "ratio", redacted.Legend)
|
||||
assert.Zero(t, redacted.Limit)
|
||||
assert.Nil(t, redacted.Having)
|
||||
})
|
||||
|
||||
t.Run("trace operator drops filter but keeps expression and aggregations", func(t *testing.T) {
|
||||
redacted, ok := redactLeafQuery(qb.QueryBuilderTraceOperator{
|
||||
Name: "T1",
|
||||
Expression: "A => B",
|
||||
Aggregations: []qb.TraceAggregation{{}},
|
||||
Legend: "spans",
|
||||
Filter: &qb.Filter{Expression: "http.status_code = 500"},
|
||||
ReturnSpansFrom: "A",
|
||||
Limit: 10,
|
||||
}).(qb.QueryBuilderTraceOperator)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Nil(t, redacted.Filter)
|
||||
assert.Empty(t, redacted.ReturnSpansFrom)
|
||||
assert.Zero(t, redacted.Limit)
|
||||
assert.Equal(t, "A => B", redacted.Expression)
|
||||
assert.Equal(t, "T1", redacted.Name)
|
||||
assert.Len(t, redacted.Aggregations, 1)
|
||||
})
|
||||
|
||||
t.Run("unknown value is returned unchanged", func(t *testing.T) {
|
||||
assert.Equal(t, "passthrough", redactLeafQuery("passthrough"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRedactQueryPluginWrappers(t *testing.T) {
|
||||
t.Run("builder plugin pointer is redacted and stays a pointer", func(t *testing.T) {
|
||||
plugin := &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.LogAggregation]{
|
||||
Name: "A",
|
||||
Filter: &qb.Filter{Expression: "body contains 'secret'"},
|
||||
}}
|
||||
|
||||
result, ok := redactQuery(plugin).(*BuilderQuerySpec)
|
||||
require.True(t, ok)
|
||||
|
||||
builder, ok := result.Spec.(qb.QueryBuilderQuery[qb.LogAggregation])
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, builder.Filter)
|
||||
assert.Equal(t, "A", builder.Name)
|
||||
})
|
||||
|
||||
t.Run("composite plugin redacts every sub-query envelope", func(t *testing.T) {
|
||||
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
|
||||
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A", Filter: &qb.Filter{Expression: "x = 1"}}},
|
||||
{Type: qb.QueryTypePromQL, Spec: qb.PromQuery{Name: "B", Query: "up"}},
|
||||
}}
|
||||
|
||||
result, ok := redactQuery(composite).(*qb.CompositeQuery)
|
||||
require.True(t, ok)
|
||||
require.Len(t, result.Queries, 2)
|
||||
|
||||
builder, ok := result.Queries[0].Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok)
|
||||
assert.Nil(t, builder.Filter)
|
||||
|
||||
prom, ok := result.Queries[1].Spec.(qb.PromQuery)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, prom.Query)
|
||||
})
|
||||
|
||||
t.Run("promql plugin pointer drops the raw query", func(t *testing.T) {
|
||||
result, ok := redactQuery(&qb.PromQuery{Name: "A", Query: "up"}).(*qb.PromQuery)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, result.Query)
|
||||
assert.Equal(t, "A", result.Name)
|
||||
})
|
||||
|
||||
t.Run("nil composite pointer is returned unchanged without panicking", func(t *testing.T) {
|
||||
var nilComposite *qb.CompositeQuery
|
||||
assert.Equal(t, nilComposite, redactQuery(nilComposite))
|
||||
})
|
||||
|
||||
t.Run("unknown plugin spec is returned unchanged", func(t *testing.T) {
|
||||
assert.Equal(t, 42, redactQuery(42))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRedactPanelQueries(t *testing.T) {
|
||||
t.Run("redacts panel queries without mutating the source spec", func(t *testing.T) {
|
||||
sourcePlugin := &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{
|
||||
Name: "A",
|
||||
Filter: &qb.Filter{Expression: "service.name = 'payments'"},
|
||||
}}
|
||||
sourcePanel := &Panel{Spec: PanelSpec{
|
||||
Queries: []Query{{Spec: QuerySpec{Plugin: QueryPlugin{Kind: QueryKindBuilder, Spec: sourcePlugin}}}},
|
||||
}}
|
||||
spec := DashboardSpec{Panels: map[string]*Panel{"panel-1": sourcePanel}}
|
||||
|
||||
redactPanelQueries(&spec)
|
||||
|
||||
// redacted output has no filter
|
||||
redactedPanel := spec.Panels["panel-1"]
|
||||
require.NotNil(t, redactedPanel)
|
||||
redactedBuilder := redactedPanel.Spec.Queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec).Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
assert.Nil(t, redactedBuilder.Filter)
|
||||
|
||||
// source is untouched: original plugin still carries the filter
|
||||
sourceBuilder := sourcePlugin.Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.NotNil(t, sourceBuilder.Filter)
|
||||
assert.Equal(t, "service.name = 'payments'", sourceBuilder.Filter.Expression)
|
||||
assert.NotSame(t, sourcePanel, redactedPanel)
|
||||
})
|
||||
|
||||
t.Run("preserves nil panels without panicking", func(t *testing.T) {
|
||||
spec := DashboardSpec{Panels: map[string]*Panel{"panel-1": nil}}
|
||||
|
||||
redactPanelQueries(&spec)
|
||||
|
||||
panel, ok := spec.Panels["panel-1"]
|
||||
assert.True(t, ok)
|
||||
assert.Nil(t, panel)
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -212,6 +213,30 @@ func (typ *PublicDashboard) PublicPath() string {
|
||||
return "/public/dashboard/" + typ.ID.StringValue()
|
||||
}
|
||||
|
||||
// ResolveTimeRange returns the [start, end] window in epoch millis for a public
|
||||
// widget/panel query: the caller-supplied range when the dashboard allows it,
|
||||
// otherwise now minus the configured default range.
|
||||
func (typ *PublicDashboard) ResolveTimeRange(startTimeRaw, endTimeRaw string) (uint64, uint64, error) {
|
||||
if typ.TimeRangeEnabled {
|
||||
startTime, err := strconv.ParseUint(startTimeRaw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, errors.New(errors.TypeInvalidInput, ErrCodePublicDashboardInvalidInput, "invalid startTime")
|
||||
}
|
||||
endTime, err := strconv.ParseUint(endTimeRaw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, errors.New(errors.TypeInvalidInput, ErrCodePublicDashboardInvalidInput, "invalid endTime")
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
timeRange, err := time.ParseDuration(typ.DefaultTimeRange)
|
||||
if err != nil {
|
||||
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "stored defaultTimeRange %q is not a valid duration", typ.DefaultTimeRange)
|
||||
}
|
||||
now := time.Now()
|
||||
return uint64(now.Add(-timeRange).UnixMilli()), uint64(now.UnixMilli()), nil
|
||||
}
|
||||
|
||||
func (typ *PostablePublicDashboard) UnmarshalJSON(data []byte) error {
|
||||
type alias PostablePublicDashboard
|
||||
var temp alias
|
||||
|
||||
117
pkg/types/dashboardtypes/public_dashboard_test.go
Normal file
117
pkg/types/dashboardtypes/public_dashboard_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublicDashboardResolveTimeRange(t *testing.T) {
|
||||
t.Run("returns the explicit range when time range is enabled", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
startTimeRaw string
|
||||
endTimeRaw string
|
||||
expectedStart uint64
|
||||
expectedEnd uint64
|
||||
}{
|
||||
{
|
||||
description: "valid epoch millis",
|
||||
startTimeRaw: "1700000000000",
|
||||
endTimeRaw: "1700000600000",
|
||||
expectedStart: 1700000000000,
|
||||
expectedEnd: 1700000600000,
|
||||
},
|
||||
{
|
||||
description: "zero start is allowed",
|
||||
startTimeRaw: "0",
|
||||
endTimeRaw: "1700000600000",
|
||||
expectedStart: 0,
|
||||
expectedEnd: 1700000600000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: true}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange(tc.startTimeRaw, tc.endTimeRaw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedStart, startTime)
|
||||
assert.Equal(t, tc.expectedEnd, endTime)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects an invalid explicit range when time range is enabled", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
startTimeRaw string
|
||||
endTimeRaw string
|
||||
}{
|
||||
{description: "non-numeric startTime", startTimeRaw: "abc", endTimeRaw: "1700000600000"},
|
||||
{description: "empty startTime", startTimeRaw: "", endTimeRaw: "1700000600000"},
|
||||
{description: "negative startTime", startTimeRaw: "-1", endTimeRaw: "1700000600000"},
|
||||
{description: "non-numeric endTime", startTimeRaw: "1700000000000", endTimeRaw: "xyz"},
|
||||
{description: "empty endTime", startTimeRaw: "1700000000000", endTimeRaw: ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: true}
|
||||
|
||||
_, _, err := publicDashboard.ResolveTimeRange(tc.startTimeRaw, tc.endTimeRaw)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("derives the range from now and the default when time range is disabled", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
description string
|
||||
defaultTimeRange string
|
||||
expectedWidthMS uint64
|
||||
}{
|
||||
{description: "one hour", defaultTimeRange: "1h", expectedWidthMS: uint64(time.Hour.Milliseconds())},
|
||||
{description: "thirty minutes", defaultTimeRange: "30m", expectedWidthMS: uint64((30 * time.Minute).Milliseconds())},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: tc.defaultTimeRange}
|
||||
|
||||
before := uint64(time.Now().UnixMilli())
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange("ignored", "ignored")
|
||||
after := uint64(time.Now().UnixMilli())
|
||||
|
||||
require.NoError(t, err)
|
||||
// end is "now"; both bounds share the same instant, so the width is exact.
|
||||
assert.GreaterOrEqual(t, endTime, before)
|
||||
assert.LessOrEqual(t, endTime, after)
|
||||
assert.Equal(t, tc.expectedWidthMS, endTime-startTime)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores caller-supplied bounds when time range is disabled", func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: "1h"}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange("123", "456")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, uint64(123), startTime)
|
||||
assert.NotEqual(t, uint64(456), endTime)
|
||||
assert.Equal(t, uint64(time.Hour.Milliseconds()), endTime-startTime)
|
||||
})
|
||||
|
||||
t.Run("returns an internal error for an unparseable stored default range", func(t *testing.T) {
|
||||
publicDashboard := &PublicDashboard{TimeRangeEnabled: false, DefaultTimeRange: "not-a-duration"}
|
||||
|
||||
_, _, err := publicDashboard.ResolveTimeRange("", "")
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInternal))
|
||||
})
|
||||
}
|
||||
@@ -6,13 +6,6 @@ import {
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
import {
|
||||
detectPersona,
|
||||
detectSettingsEnv,
|
||||
type Persona,
|
||||
type SettingsEnv,
|
||||
} from '../helpers/persona';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
|
||||
@@ -27,11 +20,6 @@ export const ADMIN: User = {
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
// Per-worker persona/env caches by user email. Detection is constant for a
|
||||
// given backend + user, so it runs once per worker.
|
||||
const personaByUser = new Map<string, Promise<Persona>>();
|
||||
const envByUser = new Map<string, Promise<SettingsEnv>>();
|
||||
|
||||
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
@@ -84,10 +72,6 @@ export const test = base.extend<{
|
||||
* storageState is held in memory and reused for all later requests.
|
||||
*/
|
||||
authedPage: Page;
|
||||
|
||||
persona: Persona;
|
||||
|
||||
env: SettingsEnv;
|
||||
}>({
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
@@ -109,24 +93,6 @@ export const test = base.extend<{
|
||||
await use(page);
|
||||
await ctx.close();
|
||||
},
|
||||
|
||||
persona: async ({ authedPage, user }, use) => {
|
||||
let task = personaByUser.get(user.email);
|
||||
if (!task) {
|
||||
task = detectPersona(authedPage);
|
||||
personaByUser.set(user.email, task);
|
||||
}
|
||||
await use(await task);
|
||||
},
|
||||
|
||||
env: async ({ authedPage, user }, use) => {
|
||||
let task = envByUser.get(user.email);
|
||||
if (!task) {
|
||||
task = detectSettingsEnv(authedPage);
|
||||
envByUser.set(user.email, task);
|
||||
}
|
||||
await use(await task);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from './dashboards';
|
||||
|
||||
export type Tier =
|
||||
| 'cloud'
|
||||
| 'enterprise'
|
||||
| 'community'
|
||||
| 'community-enterprise';
|
||||
export type Role = 'ADMIN' | 'EDITOR' | 'VIEWER' | 'ANONYMOUS';
|
||||
|
||||
export interface Persona {
|
||||
tier: Tier;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface SettingsEnv {
|
||||
isGatewayEnabled: boolean;
|
||||
}
|
||||
|
||||
interface AuthzCheckItem {
|
||||
authorized?: boolean;
|
||||
object?: { selector?: string };
|
||||
}
|
||||
|
||||
interface FeatureFlag {
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const LICENSE_URL = '/api/v3/licenses/active';
|
||||
const AUTHZ_CHECK_URL = '/api/v1/authz/check';
|
||||
const FEATURES_URL = '/api/v1/features';
|
||||
|
||||
// Mirrors IsAdmin/Editor/Viewer in frontend/src/hooks/useAuthZ/legacy.ts:
|
||||
// relation 'assignee' on resource kind/type 'role', selector = preset role id.
|
||||
const ROLE_PROBES: { role: Exclude<Role, 'ANONYMOUS'>; selector: string }[] = [
|
||||
{ role: 'ADMIN', selector: 'signoz-admin' },
|
||||
{ role: 'EDITOR', selector: 'signoz-editor' },
|
||||
{ role: 'VIEWER', selector: 'signoz-viewer' },
|
||||
];
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function parseOverride(): Persona | null {
|
||||
const raw = process.env.SIGNOZ_E2E_PERSONA;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parts = raw.toLowerCase().split('-');
|
||||
const roleRaw = parts.pop();
|
||||
const tier = parts.join('-') as Tier;
|
||||
const role = roleRaw?.toUpperCase() as Role;
|
||||
return { tier, role };
|
||||
}
|
||||
|
||||
async function detectTier(page: Page, token: string): Promise<Tier> {
|
||||
const res = await page.request.get(LICENSE_URL, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (res.status() === 404) {
|
||||
return 'community-enterprise';
|
||||
}
|
||||
if (res.status() === 501) {
|
||||
return 'community';
|
||||
}
|
||||
const body = await res.json();
|
||||
const platform = body?.data?.platform;
|
||||
if (platform === 'CLOUD') {
|
||||
return 'cloud';
|
||||
}
|
||||
return 'enterprise';
|
||||
}
|
||||
|
||||
async function detectRole(page: Page, token: string): Promise<Role> {
|
||||
const payload = ROLE_PROBES.map((p) => ({
|
||||
relation: 'assignee',
|
||||
object: {
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selector: p.selector,
|
||||
},
|
||||
}));
|
||||
const res = await page.request.post(AUTHZ_CHECK_URL, {
|
||||
headers: authHeaders(token),
|
||||
data: payload,
|
||||
});
|
||||
const body = await res.json();
|
||||
const items: AuthzCheckItem[] = body?.data ?? [];
|
||||
const granted = new Set(
|
||||
items.filter((i) => i?.authorized).map((i) => i?.object?.selector),
|
||||
);
|
||||
for (const p of ROLE_PROBES) {
|
||||
if (granted.has(p.selector)) {
|
||||
return p.role;
|
||||
}
|
||||
}
|
||||
return 'ANONYMOUS';
|
||||
}
|
||||
|
||||
export async function detectPersona(page: Page): Promise<Persona> {
|
||||
const override = parseOverride();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const token = await authToken(page);
|
||||
const [tier, role] = await Promise.all([
|
||||
detectTier(page, token),
|
||||
detectRole(page, token),
|
||||
]);
|
||||
return { tier, role };
|
||||
}
|
||||
|
||||
export async function detectSettingsEnv(page: Page): Promise<SettingsEnv> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get(FEATURES_URL, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: FeatureFlag[] = body?.data ?? [];
|
||||
const gateway = flags.find((f) => f?.name === 'gateway');
|
||||
return { isGatewayEnabled: !!gateway?.active };
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect } from '../fixtures/auth';
|
||||
|
||||
// Verbatim from frontend/src/constants/routes.ts
|
||||
export const SETTINGS_ROUTES = {
|
||||
WORKSPACE: '/settings',
|
||||
MY_SETTINGS: '/settings/my-settings',
|
||||
ORG_SETTINGS: '/settings/org-settings',
|
||||
ALL_CHANNELS: '/settings/channels',
|
||||
INGESTION: '/settings/ingestion-settings',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES: '/settings/roles',
|
||||
MEMBERS: '/settings/members',
|
||||
SERVICE_ACCOUNTS: '/settings/service-accounts',
|
||||
SHORTCUTS: '/settings/shortcuts',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
INTEGRATIONS: '/integrations',
|
||||
} as const;
|
||||
|
||||
export type SettingsRoute =
|
||||
(typeof SETTINGS_ROUTES)[keyof typeof SETTINGS_ROUTES];
|
||||
|
||||
// Sidenav item data-testid == itemKey in menuItems.tsx settingsNavSections.
|
||||
export const NAV_TESTID: Record<string, string> = {
|
||||
[SETTINGS_ROUTES.WORKSPACE]: 'workspace',
|
||||
[SETTINGS_ROUTES.MY_SETTINGS]: 'account',
|
||||
[SETTINGS_ROUTES.ALL_CHANNELS]: 'notification-channels',
|
||||
[SETTINGS_ROUTES.BILLING]: 'billing',
|
||||
[SETTINGS_ROUTES.INTEGRATIONS]: 'integrations',
|
||||
[SETTINGS_ROUTES.MCP_SERVER]: 'mcp-server',
|
||||
[SETTINGS_ROUTES.ROLES]: 'roles',
|
||||
[SETTINGS_ROUTES.MEMBERS]: 'members',
|
||||
[SETTINGS_ROUTES.SERVICE_ACCOUNTS]: 'service-accounts',
|
||||
[SETTINGS_ROUTES.INGESTION]: 'ingestion',
|
||||
[SETTINGS_ROUTES.ORG_SETTINGS]: 'sso',
|
||||
[SETTINGS_ROUTES.SHORTCUTS]: 'keyboard-shortcuts',
|
||||
};
|
||||
|
||||
export async function gotoSettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.WORKSPACE);
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSettingsTab(
|
||||
page: Page,
|
||||
route: SettingsRoute,
|
||||
): Promise<void> {
|
||||
const testid = NAV_TESTID[route];
|
||||
await page.getByTestId('settings-page-sidenav').getByTestId(testid).click();
|
||||
await expect(page).toHaveURL(new RegExp(route.replace(/\//g, '\\/')));
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { Persona, SettingsEnv, Tier } from './persona';
|
||||
import { SETTINGS_ROUTES, NAV_TESTID } from './settings';
|
||||
|
||||
// Mirrors the isEnabled effect in frontend/src/pages/Settings/Settings.tsx.
|
||||
// Returns the set of sidenav item testids (itemKeys) that should be visible.
|
||||
export function visibleNavItems(
|
||||
persona: Persona,
|
||||
_env: SettingsEnv,
|
||||
): Set<string> {
|
||||
const { tier, role } = persona;
|
||||
const isAdmin = role === 'ADMIN';
|
||||
const isEditor = role === 'EDITOR';
|
||||
const isViewer = role === 'VIEWER';
|
||||
|
||||
// Defaults that start enabled in menuItems.tsx settingsNavSections.
|
||||
const s = new Set<string>([
|
||||
'workspace',
|
||||
'account',
|
||||
'notification-channels',
|
||||
'keyboard-shortcuts',
|
||||
]);
|
||||
|
||||
const enableForAllUsers = (): void => {
|
||||
s.add('roles');
|
||||
s.add('service-accounts');
|
||||
};
|
||||
|
||||
if (tier === 'cloud') {
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
[
|
||||
'billing',
|
||||
'integrations',
|
||||
'ingestion',
|
||||
'sso',
|
||||
'members',
|
||||
'mcp-server',
|
||||
].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isEditor) {
|
||||
['ingestion', 'integrations', 'mcp-server'].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isViewer) {
|
||||
s.add('mcp-server');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
if (tier === 'enterprise') {
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
[
|
||||
'billing',
|
||||
'integrations',
|
||||
'sso',
|
||||
'members',
|
||||
'ingestion',
|
||||
'mcp-server',
|
||||
].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isEditor) {
|
||||
['integrations', 'ingestion', 'mcp-server'].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isViewer) {
|
||||
s.add('mcp-server');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// community / community-enterprise (!cloud && !enterprise)
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
s.add('sso');
|
||||
s.add('members');
|
||||
}
|
||||
// billing & integrations explicitly disabled for non-cloud users.
|
||||
s.delete('billing');
|
||||
s.delete('integrations');
|
||||
return s;
|
||||
}
|
||||
|
||||
// Mirrors getRoutes() in frontend/src/pages/Settings/utils.ts.
|
||||
// Returns the set of /settings route paths that are mounted (navigable).
|
||||
export function registeredRoutes(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
): Set<string> {
|
||||
const { tier, role } = persona;
|
||||
const isAdmin = role === 'ADMIN';
|
||||
const isEditor = role === 'EDITOR';
|
||||
const isCloud = tier === 'cloud';
|
||||
const isEnterprise = tier === 'enterprise';
|
||||
|
||||
const r = new Set<string>([
|
||||
SETTINGS_ROUTES.WORKSPACE, // generalSettings — always
|
||||
SETTINGS_ROUTES.ALL_CHANNELS, // always
|
||||
SETTINGS_ROUTES.SERVICE_ACCOUNTS, // always
|
||||
SETTINGS_ROUTES.ROLES, // always
|
||||
SETTINGS_ROUTES.MY_SETTINGS, // always
|
||||
SETTINGS_ROUTES.SHORTCUTS, // always
|
||||
SETTINGS_ROUTES.MCP_SERVER, // always
|
||||
]);
|
||||
|
||||
// organizationSettings — gated by current_org_settings; mirrored as admin-only.
|
||||
if (isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.ORG_SETTINGS);
|
||||
}
|
||||
// multiIngestionSettings if gateway && (admin||editor); cloud read-only if cloud && !gateway.
|
||||
if (
|
||||
(env.isGatewayEnabled && (isAdmin || isEditor)) ||
|
||||
(isCloud && !env.isGatewayEnabled)
|
||||
) {
|
||||
r.add(SETTINGS_ROUTES.INGESTION);
|
||||
}
|
||||
// membersSettings if admin.
|
||||
if (isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.MEMBERS);
|
||||
}
|
||||
// billing if (cloud||enterprise) && admin.
|
||||
if ((isCloud || isEnterprise) && isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.BILLING);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// Skip reason when a route's nav item is hidden for the persona; null when
|
||||
// visible. Centralised so every skip reads identically and is greppable.
|
||||
export function personaSkipReason(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
route: string,
|
||||
): string | null {
|
||||
const visible = visibleNavItems(persona, env);
|
||||
const testid = NAV_TESTID[route];
|
||||
if (testid && visible.has(testid)) {
|
||||
return null;
|
||||
}
|
||||
return `PERSONA_SKIP: ${route} hidden for ${persona.tier}×${persona.role}`;
|
||||
}
|
||||
|
||||
// Second skip axis: a route is visible but renders tier-specific CONTENT (e.g.
|
||||
// /settings shows a cloud support card vs self-hosted retention controls).
|
||||
// Gates a test to the tiers whose content it asserts. Shares the PERSONA_SKIP:
|
||||
// prefix.
|
||||
export function tierSkipReason(
|
||||
persona: Persona,
|
||||
allowedTiers: Tier[],
|
||||
label: string,
|
||||
): string | null {
|
||||
if (allowedTiers.includes(persona.tier)) {
|
||||
return null;
|
||||
}
|
||||
return `PERSONA_SKIP: ${label} not applicable for tier ${persona.tier} (needs ${allowedTiers.join(
|
||||
'|',
|
||||
)})`;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
personaSkipReason,
|
||||
tierSkipReason,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Workspace (/settings) has two views: cloud (retention inputs disabled, no Save,
|
||||
// GeneralSettingsCloud support card) and self-hosted (interactive inputs, per-row Save).
|
||||
// Retention inputs in compact mode have no data-testid — role/text/CSS fallback.
|
||||
|
||||
async function gotoWorkspace(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.WORKSPACE);
|
||||
// Retention data is fetched server-side; allow margin for the API response.
|
||||
await expect(page.locator('.retention-controls-container')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
function retentionRow(page: Page, signal: string) {
|
||||
return page.locator('.retention-row').filter({ hasText: signal });
|
||||
}
|
||||
|
||||
function retentionInput(page: Page, signal: string) {
|
||||
return retentionRow(page, signal).locator('input[type="number"]').first();
|
||||
}
|
||||
|
||||
function saveButton(page: Page, signal: string) {
|
||||
return retentionRow(page, signal).getByRole('button', { name: /^save$/i });
|
||||
}
|
||||
|
||||
// Tier sets for the two Workspace content variants.
|
||||
const CLOUD_TIERS = ['cloud'] as const;
|
||||
const SELF_HOSTED_TIERS = [
|
||||
'enterprise',
|
||||
'community',
|
||||
'community-enterprise',
|
||||
] as const;
|
||||
|
||||
test.describe('Settings — Workspace / General page', () => {
|
||||
test('TC-01 page renders retention controls and license-key row', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
// Scoped to avoid strict-mode conflict with the sidenav item.
|
||||
await expect(page.locator('.general-settings-title')).toContainText(
|
||||
'Workspace',
|
||||
);
|
||||
await expect(page.locator('.general-settings-subtitle')).toContainText(
|
||||
'Manage your workspace settings.',
|
||||
);
|
||||
|
||||
await expect(page.getByText('Retention Controls')).toBeVisible();
|
||||
|
||||
await expect(retentionRow(page, 'Metrics')).toBeVisible();
|
||||
await expect(retentionRow(page, 'Traces')).toBeVisible();
|
||||
await expect(retentionRow(page, 'Logs')).toBeVisible();
|
||||
|
||||
await expect(retentionInput(page, 'Metrics')).toBeVisible();
|
||||
await expect(retentionInput(page, 'Traces')).toBeVisible();
|
||||
await expect(retentionInput(page, 'Logs')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('license-key-row-copy-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
// RISK MODE: read-only — only asserts disabled state, nothing is mutated.
|
||||
test('TC-02 cloud view — retention inputs are disabled and support card is visible', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view'),
|
||||
tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view') ??
|
||||
undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
await expect(retentionInput(page, 'Metrics')).toBeDisabled();
|
||||
await expect(retentionInput(page, 'Traces')).toBeDisabled();
|
||||
await expect(retentionInput(page, 'Logs')).toBeDisabled();
|
||||
|
||||
await expect(saveButton(page, 'Metrics')).toHaveCount(0);
|
||||
await expect(saveButton(page, 'Traces')).toHaveCount(0);
|
||||
await expect(saveButton(page, 'Logs')).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
page.getByText(/please.*email us.*or connect.*via chat support/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// RISK MODE: never clicks Save — only asserts enable-on-change / disable-on-clear; no PUT/POST.
|
||||
test('TC-03 self-hosted view — retention input enables/disables Save — no save triggered', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(
|
||||
persona,
|
||||
[...SELF_HOSTED_TIERS],
|
||||
'self-hosted retention controls',
|
||||
),
|
||||
tierSkipReason(
|
||||
persona,
|
||||
[...SELF_HOSTED_TIERS],
|
||||
'self-hosted retention controls',
|
||||
) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
const metricsInput = retentionInput(page, 'Metrics');
|
||||
const metricsSaveBtn = saveButton(page, 'Metrics');
|
||||
|
||||
const originalValue = await metricsInput.inputValue();
|
||||
|
||||
try {
|
||||
await metricsInput.fill('9999');
|
||||
await expect(metricsSaveBtn).toBeEnabled();
|
||||
|
||||
await metricsInput.fill('');
|
||||
await expect(metricsSaveBtn).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText(/retention period for .+ is not set yet/i),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
// Restore so unsaved UI state does not leak to other workers sharing this stack.
|
||||
await metricsInput.fill(originalValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
personaSkipReason,
|
||||
tierSkipReason,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Ingestion page, two variants gated by env.isGatewayEnabled / tier:
|
||||
// MultiIngestionSettings (gateway ON) vs read-only IngestionSettings (cloud, gateway OFF).
|
||||
// RISK MODE — READ-ONLY: never create/edit/delete keys or rate limits; create
|
||||
// button and copy affordances asserted for presence only, never clicked.
|
||||
// Each TC guards its variant via test.skip so bodies stay branch-free
|
||||
// (playwright/no-conditional-in-test).
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
async function gotoIngestion(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.INGESTION);
|
||||
// Ingestion keys/settings are fetched server-side; allow margin for the API response.
|
||||
await expect(
|
||||
page
|
||||
.locator('.ingestion-key-container, .ingestion-settings-container')
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
test.describe('Settings — Ingestion page', () => {
|
||||
test('TC-01 MultiIngestionSettings — page chrome, search, table, and create affordance render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(
|
||||
persona,
|
||||
['cloud', 'enterprise'],
|
||||
'MultiIngestionSettings (gateway)',
|
||||
) || !env.isGatewayEnabled,
|
||||
!env.isGatewayEnabled
|
||||
? 'PERSONA_SKIP: gateway feature flag is OFF — MultiIngestionSettings does not render'
|
||||
: (tierSkipReason(
|
||||
persona,
|
||||
['cloud', 'enterprise'],
|
||||
'MultiIngestionSettings (gateway)',
|
||||
) ?? undefined),
|
||||
);
|
||||
|
||||
await gotoIngestion(page);
|
||||
|
||||
const container = page.locator('.ingestion-key-container');
|
||||
await expect(container).toBeVisible();
|
||||
|
||||
// Exact name match avoids the subtitle partial match.
|
||||
await expect(
|
||||
container.getByRole('heading', { name: 'Ingestion Keys' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
container.getByText(/Create and manage ingestion keys/i),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByPlaceholder('Search for ingestion key...'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByRole('button', { name: /new ingestion key/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(container.locator('.ingestion-keys-table')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.locator('.ingestion-key-url-label', { hasText: 'Ingestion URL' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 IngestionSettings (read-only) — table rows for URL, key, and region render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
|
||||
);
|
||||
// This view only renders on cloud when gateway is disabled
|
||||
test.skip(
|
||||
env.isGatewayEnabled,
|
||||
'PERSONA_SKIP: gateway is ON — MultiIngestionSettings renders instead of read-only table',
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table'),
|
||||
tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table') ??
|
||||
undefined,
|
||||
);
|
||||
|
||||
await gotoIngestion(page);
|
||||
|
||||
const container = page.locator('.ingestion-settings-container');
|
||||
await expect(container).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByText(/start sending your telemetry data/i),
|
||||
).toBeVisible();
|
||||
|
||||
const table = container.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table.getByText('Ingestion URL')).toBeVisible();
|
||||
await expect(table.getByText('Ingestion Key')).toBeVisible();
|
||||
await expect(table.getByText('Ingestion Region')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// MCP Server settings, two variants gated by mcp_url in /api/v1/global/config:
|
||||
// full page (mcp_url present, cloud) vs NotCloudFallback (absent, community/self-hosted).
|
||||
// RISK MODE — READ-ONLY: never create a service account; copy/create/install
|
||||
// buttons asserted for presence only, never clicked.
|
||||
// mcpEndpointPresent is probed in beforeAll (real backend state) so TC-01/TC-02
|
||||
// skip via test.skip rather than branching in bodies (playwright/no-conditional-in-test).
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let mcpEndpointPresent = false;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/global/config', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const mcpUrl: unknown = body?.data?.mcp_url;
|
||||
mcpEndpointPresent = typeof mcpUrl === 'string' && mcpUrl.length > 0;
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoMcpServer(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MCP_SERVER);
|
||||
// Spinner gone => either full page or fallback has rendered.
|
||||
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe('Settings — MCP Server page', () => {
|
||||
// Locators below use CSS classes / role-text; only mcp-settings has a data-testid.
|
||||
test('TC-01 full page renders: header, client tabs, auth card, use-cases card', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
|
||||
);
|
||||
// Full-page content requires mcp_url to be configured. If not present the
|
||||
// NotCloudFallback renders instead — TC-02 covers that path.
|
||||
test.skip(
|
||||
!mcpEndpointPresent,
|
||||
'PERSONA_SKIP: mcp_url not configured on this stack — NotCloudFallback renders; see TC-02',
|
||||
);
|
||||
|
||||
await gotoMcpServer(page);
|
||||
|
||||
await expect(page.getByTestId('mcp-settings')).toBeVisible();
|
||||
|
||||
await expect(page.locator('.mcp-settings__header-title')).toContainText(
|
||||
'SigNoz MCP Server',
|
||||
);
|
||||
await expect(page.locator('.mcp-settings__header-subtitle')).toContainText(
|
||||
'Model Context Protocol',
|
||||
);
|
||||
|
||||
await expect(page.locator('.mcp-settings__card')).toBeVisible();
|
||||
await expect(page.locator('.mcp-settings__card-title')).toContainText(
|
||||
'Configure your client',
|
||||
);
|
||||
|
||||
const tabsRoot = page.locator('.mcp-client-tabs-root');
|
||||
await expect(tabsRoot).toBeVisible();
|
||||
await expect(tabsRoot.getByRole('tab', { name: /cursor/i })).toBeVisible();
|
||||
await expect(
|
||||
tabsRoot.getByRole('tab', { name: /claude code/i }),
|
||||
).toBeVisible();
|
||||
await expect(tabsRoot.getByRole('tab', { name: /vs code/i })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('.mcp-client-tabs__snippet-pre').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /copy cursor config/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const authCard = page.locator('.mcp-auth-card');
|
||||
await expect(authCard).toBeVisible();
|
||||
await expect(authCard.locator('.mcp-auth-card__title')).toContainText(
|
||||
'Authenticate from your client',
|
||||
);
|
||||
|
||||
await expect(
|
||||
authCard.locator('.mcp-auth-card__field-label').first(),
|
||||
).toContainText('SigNoz Instance URL');
|
||||
await expect(
|
||||
authCard.getByRole('button', { name: /copy signoz instance url/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
authCard.locator('.mcp-auth-card__field-label').nth(1),
|
||||
).toContainText('API Key');
|
||||
await expect(
|
||||
authCard.getByRole('button', { name: /create service account/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const useCasesCard = page.locator('.mcp-use-cases-card');
|
||||
await expect(useCasesCard).toBeVisible();
|
||||
await expect(
|
||||
useCasesCard.locator('.mcp-use-cases-card__title'),
|
||||
).toContainText('What you can do with it');
|
||||
await expect(useCasesCard.locator('.mcp-use-cases-card__list')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
useCasesCard.getByRole('button', { name: /see more use cases/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Skipped when the beforeAll probe found mcp_url — full page renders instead.
|
||||
test('TC-02 NotCloudFallback renders when MCP endpoint is not configured', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
mcpEndpointPresent,
|
||||
'PERSONA_SKIP: mcp_url is configured on this stack — NotCloudFallback does not render',
|
||||
);
|
||||
|
||||
await gotoMcpServer(page);
|
||||
|
||||
await expect(page.locator('.not-cloud-fallback')).toBeVisible();
|
||||
await expect(page.locator('.not-cloud-fallback__title')).toContainText(
|
||||
'MCP Server is available on SigNoz',
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /view mcp server docs/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('mcp-settings')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// RISK MODE: read-only plus one non-submitting invite-modal check — no member is
|
||||
// created/edited/deleted/role-changed. The fresh bootstrap stack has exactly one
|
||||
// member (seeded admin, active), so filter/search coverage is limited to that row.
|
||||
// No data-testid exists in MembersSettings/Table/InviteModal — role/placeholder/text/CSS fallback.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const ADMIN_EMAIL = process.env.SIGNOZ_E2E_USERNAME ?? 'admin@integration.test';
|
||||
const SEARCH_PLACEHOLDER = 'Search by name or email...';
|
||||
|
||||
async function gotoMembers(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MEMBERS);
|
||||
// Members list is fetched server-side; allow margin for the API response.
|
||||
await expect(page.locator('.members-table-wrapper')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Settings — Members page', () => {
|
||||
test('TC-01 list renders with columns and the bootstrap admin user row', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Members', level: 1 }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Overview of people added to this workspace.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.locator('.members-filter-trigger')).toBeVisible();
|
||||
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /invite member/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const table = page.locator('.members-table');
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Name / Email' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Status' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Joined On' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
|
||||
const adminRow = page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('.member-email', { hasText: ADMIN_EMAIL }) });
|
||||
await expect(adminRow.getByText('ACTIVE')).toBeVisible();
|
||||
});
|
||||
|
||||
// On the single-member stack, Pending/Deleted both yield the empty state.
|
||||
test('TC-02 filter dropdown — cycles All / Pending / Deleted and updates the list', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByText(/pending invites/i).click();
|
||||
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page
|
||||
.getByRole('menu')
|
||||
.getByText(/^deleted/i)
|
||||
.click();
|
||||
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page
|
||||
.getByRole('menu')
|
||||
.getByText(/all members/i)
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 search filters by email match and shows empty state on no match', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
await searchInput.fill(ADMIN_EMAIL);
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('xyznonexistentuser999@nowhere.invalid');
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('.members-empty-state__text')
|
||||
.getByText('xyznonexistentuser999@nowhere.invalid'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// RISK MODE: submit is never clicked; no invite is sent.
|
||||
test('TC-04 invite modal — renders correctly, submit disabled on untouched rows, Cancel dismisses', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await page.getByRole('button', { name: /invite member/i }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(
|
||||
modal.getByRole('heading', { name: 'Invite Team Members' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Header cells scoped to class selectors to avoid matching input placeholders.
|
||||
await expect(modal.locator('.email-header')).toBeVisible();
|
||||
await expect(modal.locator('.role-header')).toBeVisible();
|
||||
|
||||
// Modal starts with 3 empty rows.
|
||||
const emailInputs = modal.locator('input[type="email"]');
|
||||
await expect(emailInputs.first()).toBeVisible();
|
||||
await expect(emailInputs).toHaveCount(3);
|
||||
|
||||
await expect(
|
||||
modal.getByRole('button', { name: /add another/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Submit is disabled while all rows are untouched.
|
||||
const submitBtn = modal.getByRole('button', { name: 'Invite Team Members' });
|
||||
await expect(submitBtn).toBeVisible();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await modal.getByRole('button', { name: /cancel/i }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// Runtime branching lives in these helpers, not test() bodies — playwright/no-conditional-in-test.
|
||||
|
||||
async function gotoMySettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MY_SETTINGS);
|
||||
await expect(page.getByTestId('theme-selector')).toBeVisible();
|
||||
}
|
||||
|
||||
async function readThemeState(
|
||||
page: Page,
|
||||
): Promise<{ theme: string; autoSwitch: string }> {
|
||||
// globalThis cast: the evaluate callback runs in the browser, but the e2e
|
||||
// tsconfig uses the ES2020 lib (no DOM), so `localStorage` isn't typed here.
|
||||
return page.evaluate(() => ({
|
||||
theme: (globalThis as any).localStorage.getItem('THEME') ?? 'dark',
|
||||
autoSwitch:
|
||||
(globalThis as any).localStorage.getItem('THEME_AUTO_SWITCH') ?? 'false',
|
||||
}));
|
||||
}
|
||||
|
||||
async function restoreTheme(
|
||||
page: Page,
|
||||
theme: string,
|
||||
autoSwitch: string,
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
([t, a]) => {
|
||||
(globalThis as any).localStorage.setItem('THEME', t);
|
||||
(globalThis as any).localStorage.setItem('THEME_AUTO_SWITCH', a);
|
||||
},
|
||||
[theme, autoSwitch],
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreSideNavPinned(
|
||||
page: Page,
|
||||
originalChecked: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: originalChecked === 'true' },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
function flipAriaChecked(current: string): string {
|
||||
if (current === 'true') {
|
||||
return 'false';
|
||||
}
|
||||
return 'true';
|
||||
}
|
||||
|
||||
test.describe('My Settings — Account page', () => {
|
||||
test('TC-01 page renders with all expected controls', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /update name/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /reset password/i }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('theme-selector')).toBeVisible();
|
||||
await expect(page.getByTestId('timezone-adaptation-switch')).toBeVisible();
|
||||
await expect(page.getByTestId('side-nav-pinned-switch')).toBeVisible();
|
||||
|
||||
// License copy button renders because bootstrap issues an enterprise license on cloud.
|
||||
await expect(page.getByTestId('license-key-copy-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 theme toggle cycles dark → light → auto and applies', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const originalTheme = await readThemeState(page);
|
||||
|
||||
try {
|
||||
// Radix ToggleGroup renders items as role="radio" within a radiogroup.
|
||||
const selector = page.getByTestId('theme-selector');
|
||||
const darkRadio = selector.getByRole('radio', { name: /dark/i });
|
||||
const lightRadio = selector.getByRole('radio', { name: /light/i });
|
||||
const systemRadio = selector.getByRole('radio', { name: /system/i });
|
||||
|
||||
await lightRadio.click();
|
||||
await expect(lightRadio).toBeChecked();
|
||||
|
||||
await systemRadio.click();
|
||||
await expect(systemRadio).toBeChecked();
|
||||
|
||||
await darkRadio.click();
|
||||
await expect(darkRadio).toBeChecked();
|
||||
} finally {
|
||||
await restoreTheme(page, originalTheme.theme, originalTheme.autoSwitch);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-03 sidebar pin toggle flips checked state', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const switchEl = page.getByTestId('side-nav-pinned-switch');
|
||||
const originalChecked =
|
||||
(await switchEl.getAttribute('aria-checked')) ?? 'false';
|
||||
const expectedAfterToggle = flipAriaChecked(originalChecked);
|
||||
|
||||
try {
|
||||
await switchEl.click();
|
||||
// Pin state persists server-side; allow margin for the update under
|
||||
// parallel-worker CPU contention (default 5s expect timeout flakes).
|
||||
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await restoreSideNavPinned(page, originalChecked);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 timezone adaptation toggle flips checked state', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const switchEl = page.getByTestId('timezone-adaptation-switch');
|
||||
const originalChecked =
|
||||
(await switchEl.getAttribute('aria-checked')) ?? 'true';
|
||||
const expectedAfterToggle = flipAriaChecked(originalChecked);
|
||||
|
||||
try {
|
||||
await switchEl.click();
|
||||
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
// isAdaptationEnabled is not persisted — toggle back to restore session state.
|
||||
await switchEl.click();
|
||||
}
|
||||
});
|
||||
|
||||
// note: PUT /api/v2/users/me returns root_user_operation_unsupported for the
|
||||
// bootstrap admin user. Only the modal open/input/submit-button UI is tested
|
||||
// here; the "name reflects in card after save" assertion cannot be verified
|
||||
// against this stack.
|
||||
test('TC-05 update name modal — opens, pre-fills, submit button active', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const currentName = await page.locator('.user-name').first().innerText();
|
||||
|
||||
await page.getByRole('button', { name: /update name/i }).click();
|
||||
|
||||
const nameInput = page.getByPlaceholder('e.g. John Doe');
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
await expect(nameInput).toHaveValue(currentName);
|
||||
|
||||
const submitBtn = page.getByTestId('update-name-btn');
|
||||
await expect(submitBtn).toBeVisible();
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
// Close via × button — Ant Modal's Escape handler can race with input focus in headless mode.
|
||||
await page
|
||||
.locator('.update-name-modal')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click();
|
||||
await expect(nameInput).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 reset-password modal — validation only, never submits', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
// The button that OPENS the modal has no testid; reset-password-btn is the SUBMIT button inside.
|
||||
await page
|
||||
.getByRole('button', { name: /reset password/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const currentPasswordInput = page.getByTestId('current-password-textbox');
|
||||
const newPasswordInput = page.getByTestId('new-password-textbox');
|
||||
const submitBtn = page.getByTestId('reset-password-btn');
|
||||
|
||||
await expect(currentPasswordInput).toBeVisible();
|
||||
await expect(newPasswordInput).toBeVisible();
|
||||
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await currentPasswordInput.fill('somepassword');
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Same value → passwords match → validation error + disabled
|
||||
await newPasswordInput.fill('somepassword');
|
||||
await expect(page.getByText(/new password must be different/i)).toBeVisible();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Stop at enabled — clicking would rotate the admin password and break every other worker.
|
||||
await newPasswordInput.fill('differentpassword!1');
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
await page
|
||||
.locator('.reset-password-modal')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click();
|
||||
await expect(currentPasswordInput).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// OrganizationSettings (/settings/org-settings): DisplayName form + AuthDomain section.
|
||||
// Invite coverage lives in members.spec.ts — the #invite-team-members hash is ignored here.
|
||||
//
|
||||
// note: PUT /api/v2/orgs returns root_user_operation_unsupported for the bootstrap
|
||||
// admin user. TC-02 only asserts the field is editable and the Submit button enables;
|
||||
// it does NOT submit the form. The original org name is never mutated.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
async function gotoOrgSettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.ORG_SETTINGS);
|
||||
await expect(page.getByLabel('Display name')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Organization Settings — SSO & Org page', () => {
|
||||
test('TC-01 page renders display-name field and authenticated-domains section', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
await expect(page.getByLabel('Display name')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Authenticated Domains' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Add Domain' })).toBeVisible();
|
||||
});
|
||||
|
||||
// note: root_user_operation_unsupported on save (see header) — never clicks Submit; value restored in finally.
|
||||
test('TC-02 org display name — field is editable and Submit enables on change', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
const nameInput = page.getByLabel('Display name');
|
||||
const submitBtn = page.getByRole('button', { name: 'Submit' });
|
||||
|
||||
const originalValue = await nameInput.inputValue();
|
||||
|
||||
try {
|
||||
// Submit is disabled when the value equals the current saved name.
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await nameInput.fill('org-sso-spec-temp');
|
||||
await expect(nameInput).toHaveValue('org-sso-spec-temp');
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
await nameInput.fill('');
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
} finally {
|
||||
// Restored value equals the saved one, so Submit stays disabled — no API call.
|
||||
await nameInput.fill(originalValue);
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// RISK MODE: never enable SSO/SAML or click Save — that changes auth for the whole stack.
|
||||
test('TC-03 SSO config — Add Domain opens provider-selector modal, close dismisses it', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add Domain' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(
|
||||
modal.getByText('Configure Authentication Method'),
|
||||
).toBeVisible();
|
||||
await expect(modal.getByText('Google Apps Authentication')).toBeVisible();
|
||||
|
||||
// SAML/OIDC visibility depends on the SSO flag — only assert Google Auth, always enabled.
|
||||
|
||||
await modal.getByRole('button', { name: /close/i }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Roles page. RISK MODE — READ-ONLY: never create/edit/delete a role; TC-03
|
||||
// only views a managed role's detail page and navigates back.
|
||||
// rolesEnabled probes /api/v1/features for USE_FINE_GRAINED_AUTHZ — real backend
|
||||
// state, not a guess; row navigation is only wired up when it is on, so TC-03 skips otherwise.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let rolesEnabled = false;
|
||||
|
||||
async function gotoRolesList(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.ROLES);
|
||||
await expect(page.getByTestId('roles-settings')).toBeVisible();
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/features', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
|
||||
const flag = flags.find((f) => f?.name === 'use_fine_grained_authz');
|
||||
rolesEnabled = !!flag?.active;
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Settings — Roles page', () => {
|
||||
test('TC-01 list renders with container, header, search, and managed-role rows', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
await expect(page.locator('.roles-settings-header-title')).toContainText(
|
||||
'Roles',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.roles-settings-header-description'),
|
||||
).toContainText('Create and manage custom roles for your team.');
|
||||
|
||||
await expect(page.locator('input[type="search"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('input[placeholder="Search for roles..."]'),
|
||||
).toBeVisible();
|
||||
|
||||
const table = page.locator('.roles-listing-table');
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table.locator('.roles-table-header-cell--name')).toContainText(
|
||||
'Name',
|
||||
);
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--description'),
|
||||
).toContainText('Description');
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--updated-at'),
|
||||
).toContainText('Updated At');
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--created-at'),
|
||||
).toContainText('Created At');
|
||||
|
||||
await expect(
|
||||
table.locator('.roles-table-section-header', { hasText: 'Managed roles' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(table.locator('.roles-table-row').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 search filters roles by match and shows empty state on no match', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
const searchInput = page.locator('input[placeholder="Search for roles..."]');
|
||||
const table = page.locator('.roles-listing-table');
|
||||
|
||||
await searchInput.fill('Admin');
|
||||
await expect(
|
||||
table.locator('.roles-table-cell--name', { hasText: /admin/i }).first(),
|
||||
).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('xyznonexistentrole999');
|
||||
await expect(table.locator('.roles-table-empty')).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toContainText(
|
||||
'No roles match your search.',
|
||||
);
|
||||
await expect(table.locator('.roles-table-row')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(table.locator('.roles-table-row').first()).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Read-only: views a managed role, asserts no edit/delete, navigates back.
|
||||
// Skipped when USE_FINE_GRAINED_AUTHZ is off — rows have no click handler.
|
||||
test('TC-03 role detail page — clicking a managed role navigates to its detail view', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!rolesEnabled,
|
||||
'PERSONA_SKIP: USE_FINE_GRAINED_AUTHZ feature flag is off — role rows are not clickable',
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
const table = page.locator('.roles-listing-table');
|
||||
|
||||
const firstRow = table.locator('.roles-table-row').first();
|
||||
await firstRow.scrollIntoViewIfNeeded();
|
||||
await firstRow.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings\/roles\/[^/]+/);
|
||||
|
||||
const detailPage = page.locator('.role-details-page');
|
||||
await expect(detailPage).toBeVisible();
|
||||
await expect(detailPage.locator('.role-details-title')).toBeVisible();
|
||||
await expect(detailPage.locator('.role-details-title')).toContainText(
|
||||
'Role —',
|
||||
);
|
||||
|
||||
await expect(
|
||||
detailPage.getByText(
|
||||
'This is a managed role. Permissions and settings are view-only and cannot be modified.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
detailPage.getByRole('button', { name: 'Edit Role Details' }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
detailPage.locator('.role-details-section-label', {
|
||||
hasText: 'Permissions',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto(SETTINGS_ROUTES.ROLES);
|
||||
await expect(page.getByTestId('roles-settings')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Service Accounts page. RISK MODE — READ-ONLY: never create/edit/delete an
|
||||
// account or generate a token; the create modal is never opened.
|
||||
// listAccessible probes the real authz/check backend state in beforeAll (when
|
||||
// use_fine_grained_authz is on the admin may lack serviceaccount:list, rendering
|
||||
// PermissionDeniedFullPage); the functional TCs skip when it is false.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let listAccessible = false;
|
||||
|
||||
async function gotoServiceAccounts(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.SERVICE_ACCOUNTS);
|
||||
await expect(page.locator('.sa-settings__title')).toBeVisible();
|
||||
}
|
||||
|
||||
function buildSkipReason(
|
||||
persona: Parameters<typeof personaSkipReason>[0],
|
||||
env: Parameters<typeof personaSkipReason>[1],
|
||||
): string | null {
|
||||
return personaSkipReason(persona, env, SETTINGS_ROUTES.SERVICE_ACCOUNTS);
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/features', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
|
||||
const fgAuthz = flags.find((f) => f?.name === 'use_fine_grained_authz');
|
||||
|
||||
if (!fgAuthz?.active) {
|
||||
// Without fine-grained authz the SA list is always accessible.
|
||||
listAccessible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the authz check endpoint for serviceaccount:list (wildcard).
|
||||
const authzRes = await page.request.post('/api/v1/authz/check', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: [
|
||||
{
|
||||
relation: 'list',
|
||||
object: {
|
||||
resource: { kind: 'serviceaccount', type: 'serviceaccount' },
|
||||
selector: '*',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const authzBody = await authzRes.json();
|
||||
const items: { authorized?: boolean }[] = authzBody?.data ?? [];
|
||||
listAccessible = items.some((i) => i?.authorized);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Settings — Service Accounts page', () => {
|
||||
test('TC-01 page chrome and empty-state render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
await expect(page.locator('.sa-settings__title')).toContainText(
|
||||
'Service Accounts',
|
||||
);
|
||||
await expect(page.locator('.sa-settings__subtitle')).toContainText(
|
||||
'Overview of service accounts added to this workspace.',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.sa-settings__subtitle a[href*="signoz.io/docs"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const controls = page.locator('.sa-settings__controls');
|
||||
await expect(controls).toBeVisible();
|
||||
await expect(
|
||||
controls.getByRole('button', { name: /All accounts/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
controls.locator('input[placeholder="Search by name or email..."]'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
controls.getByRole('button', { name: /New Service Account/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.locator('.sa-table-wrapper')).toBeVisible();
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No service accounts.',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 filter dropdown writes URL param and shows empty-state per mode', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
const filterTrigger = page.getByRole('button', { name: /All accounts/i });
|
||||
|
||||
await filterTrigger.click();
|
||||
await page.getByText(/^Active ⎯/).click();
|
||||
await expect(page).toHaveURL(/[?&]filter=active/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Active ⎯/i }).click();
|
||||
await page.getByText(/^Deleted ⎯/).click();
|
||||
await expect(page).toHaveURL(/[?&]filter=deleted/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Deleted ⎯/i }).click();
|
||||
await page.getByText(/^All accounts ⎯/).click();
|
||||
await expect(page).not.toHaveURL(/[?&]filter=active/);
|
||||
await expect(page).not.toHaveURL(/[?&]filter=deleted/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 search updates URL and empty-state; create button enabled', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
const searchInput = page.locator(
|
||||
'input[placeholder="Search by name or email..."]',
|
||||
);
|
||||
|
||||
await searchInput.fill('xyznonexistent999');
|
||||
await expect(page).toHaveURL(/[?&]search=xyznonexistent999/);
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No results for',
|
||||
);
|
||||
await expect(page.locator('.sa-empty-state__text strong')).toContainText(
|
||||
'xyznonexistent999',
|
||||
);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(page).not.toHaveURL(/[?&]search=xyznonexistent999/);
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No service accounts.',
|
||||
);
|
||||
|
||||
const createBtn = page.getByRole('button', { name: /New Service Account/i });
|
||||
await expect(createBtn).toBeVisible();
|
||||
await expect(createBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { Persona, SettingsEnv } from '../../helpers/persona';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
registeredRoutes,
|
||||
visibleNavItems,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import {
|
||||
NAV_TESTID,
|
||||
SETTINGS_ROUTES,
|
||||
gotoSettings,
|
||||
} from '../../helpers/settings';
|
||||
|
||||
// Branching lives in module-level helpers, not test bodies — the repo's
|
||||
// playwright/no-conditional-in-test rule forbids `if` inside `test()`.
|
||||
|
||||
function partitionNavTestids(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
): { visible: string[]; hidden: string[] } {
|
||||
const all = Object.values(NAV_TESTID);
|
||||
const expected = visibleNavItems(persona, env);
|
||||
return {
|
||||
visible: all.filter((testid) => expected.has(testid)),
|
||||
hidden: all.filter((testid) => !expected.has(testid)),
|
||||
};
|
||||
}
|
||||
|
||||
// Visible nav items whose /settings route is not registered (mounted).
|
||||
// INTEGRATIONS is excluded — it is a top-level page, not a RouteTab route.
|
||||
function navRouteMismatches(persona: Persona, env: SettingsEnv): string[] {
|
||||
const visible = visibleNavItems(persona, env);
|
||||
const registered = registeredRoutes(persona, env);
|
||||
const routeByTestid = Object.fromEntries(
|
||||
Object.entries(NAV_TESTID).map(([route, testid]) => [testid, route]),
|
||||
);
|
||||
return [...visible]
|
||||
.map((testid) => routeByTestid[testid])
|
||||
.filter((route) => !!route && route !== SETTINGS_ROUTES.INTEGRATIONS)
|
||||
.filter((route) => !registered.has(route))
|
||||
.map((route) => `${route} is nav-visible but route not registered`);
|
||||
}
|
||||
|
||||
test.describe('Settings — shell, gating matrix & integrity', () => {
|
||||
test('TC-01 settings shell chrome renders with no JS pageerror', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoSettings(page);
|
||||
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
|
||||
expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 sidenav shows exactly the matrix-predicted items', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
await gotoSettings(page);
|
||||
const sidenav = page.getByTestId('settings-page-sidenav');
|
||||
const { visible, hidden } = partitionNavTestids(persona, env);
|
||||
|
||||
for (const testid of visible) {
|
||||
await expect(
|
||||
sidenav.getByTestId(testid),
|
||||
`${testid} should be visible`,
|
||||
).toBeVisible();
|
||||
}
|
||||
for (const testid of hidden) {
|
||||
await expect(
|
||||
sidenav.getByTestId(testid),
|
||||
`${testid} should be hidden`,
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-03 every registered route deep-links with no JS pageerror', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
const routes = [...registeredRoutes(persona, env)];
|
||||
for (const route of routes) {
|
||||
const errors: Error[] = [];
|
||||
const onError = (err: Error): void => {
|
||||
errors.push(err);
|
||||
};
|
||||
page.on('pageerror', onError);
|
||||
await page.goto(route);
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
page.off('pageerror', onError);
|
||||
expect(
|
||||
errors,
|
||||
`pageerror on ${route}: ${errors.map((e) => e.message).join('\n')}`,
|
||||
).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 every visible nav item resolves to a registered route', async ({
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
const mismatches = navRouteMismatches(persona, env);
|
||||
expect(mismatches, mismatches.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-05 clicking a nav item navigates and marks active', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!visibleNavItems(persona, env).has('account'),
|
||||
'PERSONA_SKIP: account nav hidden',
|
||||
);
|
||||
await gotoSettings(page);
|
||||
const sidenav = page.getByTestId('settings-page-sidenav');
|
||||
await sidenav.getByTestId('account').click();
|
||||
await expect(page).toHaveURL(/\/settings\/my-settings/);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Keyboard Shortcuts — static read-only page (RISK MODE: nothing mutated).
|
||||
// No testids here, so locators are CSS classes (.keyboard-shortcuts,
|
||||
// .shortcut-section-heading) and role/text.
|
||||
|
||||
const ROUTE = SETTINGS_ROUTES.SHORTCUTS;
|
||||
|
||||
async function gotoShortcuts(page: Page): Promise<void> {
|
||||
await page.goto(ROUTE);
|
||||
await expect(page.locator('.keyboard-shortcuts')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Settings — Keyboard Shortcuts page', () => {
|
||||
test('TC-01 shortcuts page renders all four grouped sections with entries', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, ROUTE),
|
||||
personaSkipReason(persona, env, ROUTE) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoShortcuts(page);
|
||||
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('settings-page-sidenav').getByTestId('keyboard-shortcuts'),
|
||||
).toBeVisible();
|
||||
|
||||
const sections = page.locator('.shortcut-section-heading');
|
||||
await expect(sections).toHaveCount(4);
|
||||
await expect(sections.nth(0)).toHaveText('Global Shortcuts');
|
||||
await expect(sections.nth(1)).toHaveText('Logs Explorer Shortcuts');
|
||||
await expect(sections.nth(2)).toHaveText('Query Builder Shortcuts');
|
||||
await expect(sections.nth(3)).toHaveText('Dashboard Shortcuts');
|
||||
|
||||
await expect(page.locator('.shortcut-section-table')).toHaveCount(4);
|
||||
|
||||
const firstTable = page.locator('.shortcut-section-table').first();
|
||||
await expect(
|
||||
firstTable.getByRole('columnheader', { name: 'Keyboard Shortcut' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
firstTable.getByRole('columnheader', { name: 'Description' }),
|
||||
).toBeVisible();
|
||||
|
||||
// "shift+d" chosen as it is stable across OS variants (no cmd/ctrl).
|
||||
const globalTable = page.locator('.shortcut-section-table').nth(0);
|
||||
await expect(
|
||||
globalTable.getByRole('cell', { name: 'shift+d' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
globalTable.getByRole('cell', { name: 'Navigate to Dashboards List' }),
|
||||
).toBeVisible();
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const table = page.locator('.shortcut-section-table').nth(i);
|
||||
await expect(table.locator('tbody tr').first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
233
tests/integration/tests/dashboard/05_v2_public_dashboard.py
Normal file
233
tests/integration/tests/dashboard/05_v2_public_dashboard.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker
|
||||
|
||||
V2_BASE_URL = "/api/v2/dashboards"
|
||||
PANEL_KEY = "24e2697b"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Public dashboards are a licensed feature, so a license must be present.
|
||||
"""
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_public_dashboard_v2(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Insert metric data so the panel query resolves to a result.
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name="system.cpu.time",
|
||||
labels={"service.name": "sampleapp"},
|
||||
timestamp=now - timedelta(minutes=minutes),
|
||||
value=value,
|
||||
temporality="Cumulative",
|
||||
)
|
||||
for minutes, value in ((5, 100.0), (3, 200.0), (1, 300.0))
|
||||
]
|
||||
)
|
||||
|
||||
# Create a v2 dashboard with one panel whose builder query carries a filter,
|
||||
# so we can assert the filter is redacted from the anonymous payload.
|
||||
create_response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "v2-public-sample",
|
||||
"tags": [{"key": "team", "value": "pulse"}],
|
||||
"spec": {
|
||||
"display": {"name": "Sample Dashboard", "description": "Used for integration tests"},
|
||||
"duration": "1h",
|
||||
"variables": [],
|
||||
"panels": {
|
||||
PANEL_KEY: {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "total"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"visualization": {"fillSpans": True}}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "system.cpu.time",
|
||||
"reduceTo": "sum",
|
||||
"spaceAggregation": "sum",
|
||||
"timeAggregation": "rate",
|
||||
}
|
||||
],
|
||||
"filter": {"expression": "service.name = 'sampleapp'"},
|
||||
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"content": {"$ref": f"#/spec/panels/{PANEL_KEY}"},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert create_response.status_code == HTTPStatus.CREATED, create_response.text
|
||||
dashboard_id = create_response.json()["data"]["id"]
|
||||
|
||||
# Enable public sharing (the public-config endpoint is shape-agnostic, still v1).
|
||||
public_response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
|
||||
json={"timeRangeEnabled": True, "defaultTimeRange": "10m"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert public_response.status_code == HTTPStatus.CREATED, public_response.text
|
||||
|
||||
config_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}/public"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert config_response.status_code == HTTPStatus.OK, config_response.text
|
||||
public_id = config_response.json()["data"]["publicPath"].split("/public/dashboard/")[-1]
|
||||
|
||||
# ── anonymous public data (no Authorization header) ──────────────────────
|
||||
data_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}"),
|
||||
timeout=5,
|
||||
)
|
||||
assert data_response.status_code == HTTPStatus.OK, data_response.text
|
||||
body = data_response.json()
|
||||
assert body["status"] == "success"
|
||||
|
||||
dashboard = body["data"]["dashboard"]
|
||||
assert dashboard["schemaVersion"] == "v6"
|
||||
assert dashboard["spec"]["display"]["name"] == "Sample Dashboard"
|
||||
assert {"key": "team", "value": "pulse"} in dashboard["tags"]
|
||||
|
||||
# Identity/audit fields are not exposed to anonymous viewers.
|
||||
assert dashboard["createdBy"] == ""
|
||||
assert dashboard["updatedBy"] == ""
|
||||
|
||||
# The public config is echoed back.
|
||||
assert body["data"]["publicDashboard"]["timeRangeEnabled"] is True
|
||||
|
||||
# The builder query is redacted: the filter is gone, display fields remain.
|
||||
builder = dashboard["spec"]["panels"][PANEL_KEY]["spec"]["queries"][0]["spec"]["plugin"]["spec"]
|
||||
assert "filter" not in builder
|
||||
assert builder["name"] == "A"
|
||||
assert builder["signal"] == "metrics"
|
||||
assert builder["aggregations"][0]["metricName"] == "system.cpu.time"
|
||||
assert builder["groupBy"][0]["name"] == "service.name"
|
||||
|
||||
# ── anonymous panel query range ──────────────────────────────────────────
|
||||
start_time = int((now - timedelta(minutes=15)).timestamp() * 1000)
|
||||
end_time = int(now.timestamp() * 1000)
|
||||
|
||||
query_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}/panels/{PANEL_KEY}/query_range"),
|
||||
params={"startTime": start_time, "endTime": end_time},
|
||||
timeout=10,
|
||||
)
|
||||
assert query_response.status_code == HTTPStatus.OK, query_response.text
|
||||
query_body = query_response.json()
|
||||
assert query_body["status"] == "success"
|
||||
|
||||
# The inserted metric is returned as a time series for query "A".
|
||||
result = query_body["data"]
|
||||
assert result["type"] == "time_series"
|
||||
results = result["data"]["results"]
|
||||
result_a = next((r for r in results if r.get("queryName") == "A"), None)
|
||||
assert result_a is not None, results
|
||||
series = result_a["aggregations"][0]["series"]
|
||||
assert len(series) >= 1, result_a
|
||||
assert len(series[0]["values"]) >= 1, series[0]
|
||||
|
||||
# With timeRangeEnabled, the bounds are required.
|
||||
missing_range = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}/panels/{PANEL_KEY}/query_range"),
|
||||
timeout=5,
|
||||
)
|
||||
assert missing_range.status_code == HTTPStatus.BAD_REQUEST, missing_range.text
|
||||
|
||||
# An unknown panel key is rejected.
|
||||
unknown_panel = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}/panels/does-not-exist/query_range"),
|
||||
params={"startTime": start_time, "endTime": end_time},
|
||||
timeout=5,
|
||||
)
|
||||
assert unknown_panel.status_code == HTTPStatus.BAD_REQUEST, unknown_panel.text
|
||||
|
||||
# A bogus public id is rejected before any data is served.
|
||||
bogus = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/not-a-real-id/panels/{PANEL_KEY}/query_range"),
|
||||
params={"startTime": start_time, "endTime": end_time},
|
||||
timeout=5,
|
||||
)
|
||||
assert bogus.status_code >= HTTPStatus.BAD_REQUEST
|
||||
|
||||
# ── deleting the dashboard removes public access ─────────────────────────
|
||||
# DeleteV2 drops the public-config row in the same transaction, so the public
|
||||
# id no longer resolves to a dashboard.
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{dashboard_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT, delete_response.text
|
||||
|
||||
deleted_data = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}"),
|
||||
timeout=5,
|
||||
)
|
||||
assert deleted_data.status_code == HTTPStatus.NOT_FOUND, deleted_data.text
|
||||
|
||||
deleted_query = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/public/dashboards/{public_id}/panels/{PANEL_KEY}/query_range"),
|
||||
params={"startTime": start_time, "endTime": end_time},
|
||||
timeout=5,
|
||||
)
|
||||
assert deleted_query.status_code == HTTPStatus.NOT_FOUND, deleted_query.text
|
||||
Reference in New Issue
Block a user