mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 09:30:31 +01:00
Compare commits
15 Commits
feat/docs/
...
nv/v2-publ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbd5cc380e | ||
|
|
6cd9b5bbd6 | ||
|
|
13f6c232a1 | ||
|
|
dac1489294 | ||
|
|
1d98e9ebf6 | ||
|
|
15e99e43ff | ||
|
|
8c766f8c10 | ||
|
|
99b32f00b9 | ||
|
|
76f8646c69 | ||
|
|
28c00e298a | ||
|
|
4592b12256 | ||
|
|
b990d40c5f | ||
|
|
95a0d7c035 | ||
|
|
e678728c61 | ||
|
|
42d3e7e0e4 |
@@ -2859,6 +2859,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:
|
||||
@@ -16419,6 +16426,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 {
|
||||
|
||||
@@ -38,6 +38,10 @@ import type {
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
@@ -1799,6 +1803,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)
|
||||
|
||||
@@ -4878,6 +4878,11 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
dashboard?: DashboardtypesGettableDashboardV2DTO;
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -10500,6 +10505,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;
|
||||
/**
|
||||
|
||||
@@ -421,5 +421,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
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@ type Module interface {
|
||||
|
||||
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)
|
||||
|
||||
CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error)
|
||||
|
||||
ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error)
|
||||
@@ -99,6 +105,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")
|
||||
}
|
||||
|
||||
@@ -376,3 +376,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))
|
||||
})
|
||||
}
|
||||
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