mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-17 22:10:34 +01:00
Compare commits
5 Commits
issue-5378
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530710b7bc | ||
|
|
4fb5eec08d | ||
|
|
f889d36f0f | ||
|
|
db12d44523 | ||
|
|
86fc0e81ba |
@@ -2591,41 +2591,6 @@ components:
|
||||
- panels
|
||||
- layouts
|
||||
type: object
|
||||
DashboardtypesDashboardView:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- data
|
||||
- orgId
|
||||
type: object
|
||||
DashboardtypesDashboardViewData:
|
||||
properties:
|
||||
order:
|
||||
$ref: '#/components/schemas/DashboardtypesListOrder'
|
||||
query:
|
||||
type: string
|
||||
sort:
|
||||
$ref: '#/components/schemas/DashboardtypesListSort'
|
||||
version:
|
||||
type: string
|
||||
required:
|
||||
- version
|
||||
type: object
|
||||
DashboardtypesDatasourcePlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -2910,15 +2875,6 @@ components:
|
||||
- total
|
||||
- tags
|
||||
type: object
|
||||
DashboardtypesListableDashboardView:
|
||||
properties:
|
||||
views:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardView'
|
||||
type: array
|
||||
required:
|
||||
- views
|
||||
type: object
|
||||
DashboardtypesListedDashboardForUserV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -3223,16 +3179,6 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPostableDashboardView:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- data
|
||||
type: object
|
||||
DashboardtypesPostablePublicDashboard:
|
||||
properties:
|
||||
defaultTimeRange:
|
||||
@@ -13382,231 +13328,6 @@ paths:
|
||||
summary: Update user preference
|
||||
tags:
|
||||
- preferences
|
||||
/api/v2/dashboard_views:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns every saved view in the calling user's org. Saved views
|
||||
are shared org-wide.
|
||||
operationId: ListDashboardViews
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesListableDashboardView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List dashboard saved views
|
||||
tags:
|
||||
- dashboard
|
||||
post:
|
||||
deprecated: false
|
||||
description: Persists the calling user's dashboard listing state (query, sort,
|
||||
order) as a named, reusable view shared across the org.
|
||||
operationId: CreateDashboardView
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Create dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboard_views/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Removes a saved view. Saved views are shared org-wide. Deleting
|
||||
a non-existent view returns 404.
|
||||
operationId: DeleteDashboardView
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Delete dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
operationId: UpdateDashboardView
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update dashboard saved view
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -266,22 +266,6 @@ func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer
|
||||
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
|
||||
return module.pkgDashboardModule.CreateView(ctx, orgID, postable)
|
||||
}
|
||||
|
||||
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
|
||||
return module.pkgDashboardModule.ListViews(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdatableDashboardView) (*dashboardtypes.DashboardView, error) {
|
||||
return module.pkgDashboardModule.UpdateView(ctx, orgID, id, updateable)
|
||||
}
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return module.pkgDashboardModule.DeleteView(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/app/api"
|
||||
"github.com/SigNoz/signoz/ee/query-service/usage"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
@@ -57,12 +59,25 @@ type Server struct {
|
||||
|
||||
// NewServer creates and initializes Server
|
||||
func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
|
||||
Provider: "memory",
|
||||
Memory: cache.Memory{
|
||||
NumCounters: 10 * 10000,
|
||||
MaxCost: 1 << 27, // 128 MB
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader := clickhouseReader.NewReader(
|
||||
signoz.Instrumentation.Logger(),
|
||||
signoz.SQLStore,
|
||||
signoz.TelemetryStore,
|
||||
signoz.Prometheus,
|
||||
signoz.TelemetryStore.Cluster(),
|
||||
config.Querier.FluxInterval,
|
||||
cacheForTraceDetail,
|
||||
signoz.Cache,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -21,17 +21,14 @@ import type {
|
||||
CloneDashboardV2201,
|
||||
CloneDashboardV2PathParameters,
|
||||
CreateDashboardV2201,
|
||||
CreateDashboardView201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
DashboardtypesPatchableDashboardV2DTO,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostableDashboardViewDTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatableDashboardV2DTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
DeleteDashboardV2PathParameters,
|
||||
DeleteDashboardViewPathParameters,
|
||||
DeletePublicDashboardPathParameters,
|
||||
GetDashboardV2200,
|
||||
GetDashboardV2PathParameters,
|
||||
@@ -41,7 +38,6 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
ListDashboardViews200,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListDashboardsV2200,
|
||||
@@ -55,8 +51,6 @@ import type {
|
||||
UnpinDashboardV2PathParameters,
|
||||
UpdateDashboardV2200,
|
||||
UpdateDashboardV2PathParameters,
|
||||
UpdateDashboardView200,
|
||||
UpdateDashboardViewPathParameters,
|
||||
UpdatePublicDashboardPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
@@ -656,354 +650,6 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
|
||||
* @summary List dashboard saved views
|
||||
*/
|
||||
export const listDashboardViews = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListDashboardViews200>({
|
||||
url: `/api/v2/dashboard_views`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListDashboardViewsQueryKey = () => {
|
||||
return [`/api/v2/dashboard_views`] as const;
|
||||
};
|
||||
|
||||
export const getListDashboardViewsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListDashboardViewsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>
|
||||
> = ({ signal }) => listDashboardViews(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListDashboardViewsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>
|
||||
>;
|
||||
export type ListDashboardViewsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List dashboard saved views
|
||||
*/
|
||||
|
||||
export function useListDashboardViews<
|
||||
TData = Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListDashboardViewsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List dashboard saved views
|
||||
*/
|
||||
export const invalidateListDashboardViews = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListDashboardViewsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.
|
||||
* @summary Create dashboard saved view
|
||||
*/
|
||||
export const createDashboardView = (
|
||||
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateDashboardView201>({
|
||||
url: `/api/v2/dashboard_views`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateDashboardViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createDashboardView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createDashboardView(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateDashboardViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createDashboardView>>
|
||||
>;
|
||||
export type CreateDashboardViewMutationBody =
|
||||
| BodyType<DashboardtypesPostableDashboardViewDTO>
|
||||
| undefined;
|
||||
export type CreateDashboardViewMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create dashboard saved view
|
||||
*/
|
||||
export const useCreateDashboardView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createDashboardView>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateDashboardViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
|
||||
* @summary Delete dashboard saved view
|
||||
*/
|
||||
export const deleteDashboardView = (
|
||||
{ id }: DeleteDashboardViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/dashboard_views/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteDashboardViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDashboardViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDashboardViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteDashboardView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
{ pathParams: DeleteDashboardViewPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteDashboardView(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteDashboardViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>
|
||||
>;
|
||||
|
||||
export type DeleteDashboardViewMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete dashboard saved view
|
||||
*/
|
||||
export const useDeleteDashboardView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDashboardViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteDashboardView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDashboardViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteDashboardViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
* @summary Update dashboard saved view
|
||||
*/
|
||||
export const updateDashboardView = (
|
||||
{ id }: UpdateDashboardViewPathParameters,
|
||||
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateDashboardView200>({
|
||||
url: `/api/v2/dashboard_views/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateDashboardViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateDashboardView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateDashboardView(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateDashboardViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>
|
||||
>;
|
||||
export type UpdateDashboardViewMutationBody =
|
||||
| BodyType<DashboardtypesPostableDashboardViewDTO>
|
||||
| undefined;
|
||||
export type UpdateDashboardViewMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update dashboard saved view
|
||||
*/
|
||||
export const useUpdateDashboardView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateDashboardView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardViewPathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
|
||||
* @summary List dashboards (v2)
|
||||
|
||||
@@ -4633,54 +4633,6 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
}
|
||||
|
||||
export enum DashboardtypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum DashboardtypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export interface DashboardtypesDashboardViewDataDTO {
|
||||
order?: DashboardtypesListOrderDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
query?: string;
|
||||
sort?: DashboardtypesListSortDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDashboardViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
data: DashboardtypesDashboardViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginKindDTO {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
@@ -4792,6 +4744,15 @@ export interface DashboardtypesJSONPatchOperationDTO {
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum DashboardtypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export interface DashboardtypesListedDashboardV2SpecDTO {
|
||||
display?: DashboardtypesDisplayDTO;
|
||||
}
|
||||
@@ -4934,13 +4895,6 @@ export interface DashboardtypesListableDashboardV2DTO {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesListableDashboardViewDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
views: DashboardtypesDashboardViewDTO[];
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
@@ -4992,14 +4946,6 @@ export interface DashboardtypesPostableDashboardV2DTO {
|
||||
tags: TagtypesPostableTagDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPostableDashboardViewDTO {
|
||||
data: DashboardtypesDashboardViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPostablePublicDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9891,36 +9837,6 @@ export type GetUserPreference200 = {
|
||||
export type UpdateUserPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListDashboardViews200 = {
|
||||
data: DashboardtypesListableDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateDashboardView201 = {
|
||||
data: DashboardtypesDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteDashboardViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardView200 = {
|
||||
data: DashboardtypesDashboardViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListDashboardsV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
|
||||
@@ -63,6 +63,5 @@
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import PieArc from './PieArc';
|
||||
import PieCenterLabel from './PieCenterLabel';
|
||||
import styles from './Pie.module.scss';
|
||||
import { PieTooltipData } from './types';
|
||||
import { getDonutGeometry, getFillColor } from './utils';
|
||||
import { getFillColor } from './utils';
|
||||
|
||||
/**
|
||||
* Donut chart rendered with @visx. Splits its area into chart + legend with the
|
||||
@@ -78,12 +78,16 @@ export default function Pie({
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
|
||||
// Donut geometry derived from the allocated chart box, sized to leave room
|
||||
// for the external leader labels (see getDonutGeometry).
|
||||
const { size, radius, innerRadius } = useMemo(
|
||||
() => getDonutGeometry(width, height),
|
||||
[width, height],
|
||||
);
|
||||
// Donut geometry derived from the allocated chart box.
|
||||
const { size, radius, innerRadius } = useMemo(() => {
|
||||
const nextSize = Math.min(width, height);
|
||||
const nextRadius = nextSize * 0.35;
|
||||
return {
|
||||
size: nextSize,
|
||||
radius: nextRadius,
|
||||
innerRadius: nextRadius * 0.6,
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
const totalValue = useMemo(
|
||||
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
import {
|
||||
getArcGeometry,
|
||||
getDonutGeometry,
|
||||
getFillColor,
|
||||
getScaledFontSize,
|
||||
lightenColor,
|
||||
} from '../utils';
|
||||
|
||||
describe('Pie utils', () => {
|
||||
describe('getDonutGeometry', () => {
|
||||
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
|
||||
const { radius } = getDonutGeometry(400, 300);
|
||||
const half = Math.min(400, 300) / 2; // 150
|
||||
// The label anchor sits at radius * 1.3 and must stay within the box
|
||||
// half-extent so labels are not clipped.
|
||||
expect(radius * 1.3).toBeLessThanOrEqual(half);
|
||||
// And it should use the available room (anchor = half - 22 allowance).
|
||||
expect(radius * 1.3).toBeCloseTo(half - 22);
|
||||
});
|
||||
|
||||
it('derives size and inner radius from the outer radius', () => {
|
||||
const { size, radius, innerRadius } = getDonutGeometry(300, 300);
|
||||
expect(size).toBeCloseTo(radius * 2);
|
||||
expect(innerRadius).toBeCloseTo(radius * 0.6);
|
||||
});
|
||||
|
||||
it('sizes off the smaller dimension so it fits both axes', () => {
|
||||
expect(getDonutGeometry(1000, 200)).toStrictEqual(
|
||||
getDonutGeometry(200, 1000),
|
||||
);
|
||||
});
|
||||
|
||||
it('never returns a negative radius for a box too small for labels', () => {
|
||||
expect(getDonutGeometry(20, 20).radius).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScaledFontSize', () => {
|
||||
it('returns the base size for empty text', () => {
|
||||
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(
|
||||
|
||||
@@ -10,16 +10,6 @@ export interface ScaledFontSizeArgs {
|
||||
innerRadius: number;
|
||||
}
|
||||
|
||||
/** Donut sizing for a given chart box: the outer/inner radii and the square it spans. */
|
||||
export interface DonutGeometry {
|
||||
/** Outer diameter — feeds the visx Pie width/height and the render guard. */
|
||||
size: number;
|
||||
/** Outer radius of the donut ring. */
|
||||
radius: number;
|
||||
/** Inner radius (the hole) — also bounds the centre-total font. */
|
||||
innerRadius: number;
|
||||
}
|
||||
|
||||
export interface ArcGeometry {
|
||||
/** Outer point where the leader label sits. */
|
||||
labelX: number;
|
||||
|
||||
@@ -3,37 +3,7 @@
|
||||
* so the renderer stays declarative (per the one-component-per-file rule).
|
||||
*/
|
||||
|
||||
import {
|
||||
ArcGeometry,
|
||||
DonutGeometry,
|
||||
ParsedRgb,
|
||||
ScaledFontSizeArgs,
|
||||
} from './types';
|
||||
|
||||
// Leader-line + two-line label/value drawn outside the donut. `getArcGeometry`
|
||||
// anchors the label at `radius * LABEL_RADIUS_RATIO`; `LABEL_TEXT_ALLOWANCE` is
|
||||
// the px reserved beyond that anchor for the (10px, two-line) text so it never
|
||||
// clips against the SVG edge.
|
||||
const LABEL_RADIUS_RATIO = 1.3;
|
||||
const LABEL_TEXT_ALLOWANCE = 22;
|
||||
const INNER_RADIUS_RATIO = 0.6;
|
||||
|
||||
/**
|
||||
* Sizes the donut to fit inside a `width × height` box *with room for the
|
||||
* external leader labels*. The label anchor sits at `radius * 1.3`, so we solve
|
||||
* the outer radius back from the box's half-extent minus the text allowance —
|
||||
* guaranteeing the labels stay inside the SVG instead of being clipped (V1 used
|
||||
* a flat `0.35 * min(w,h)`, which left too little margin on small panels).
|
||||
*/
|
||||
export function getDonutGeometry(width: number, height: number): DonutGeometry {
|
||||
const half = Math.min(width, height) / 2;
|
||||
const radius = Math.max(0, (half - LABEL_TEXT_ALLOWANCE) / LABEL_RADIUS_RATIO);
|
||||
return {
|
||||
size: radius * 2,
|
||||
radius,
|
||||
innerRadius: radius * INNER_RADIUS_RATIO,
|
||||
};
|
||||
}
|
||||
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
|
||||
|
||||
/**
|
||||
* Shrinks the centre-total font as the text gets longer so it never overflows
|
||||
@@ -67,7 +37,7 @@ export function getArcGeometry(
|
||||
radius: number,
|
||||
): ArcGeometry {
|
||||
const angle = (startAngle + endAngle) / 2;
|
||||
const labelRadius = radius * LABEL_RADIUS_RATIO;
|
||||
const labelRadius = radius * 1.3;
|
||||
const lineEndRadius = radius * 1.1;
|
||||
return {
|
||||
labelX: Math.sin(angle) * labelRadius,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { calculateChartDimensions } from '../utils';
|
||||
|
||||
const labels = (count: number, length = 20): string[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
`label-${i}`.padEnd(length, 'x').slice(0, length),
|
||||
);
|
||||
|
||||
describe('calculateChartDimensions', () => {
|
||||
it('returns all zeros when the container has no space', () => {
|
||||
expect(
|
||||
calculateChartDimensions({
|
||||
containerWidth: 0,
|
||||
containerHeight: 300,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
}),
|
||||
).toStrictEqual({
|
||||
width: 0,
|
||||
height: 0,
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
|
||||
expect(dims.legendWidth).toBe(240);
|
||||
expect(dims.width).toBe(760);
|
||||
expect(dims.height).toBe(400);
|
||||
expect(dims.legendHeight).toBe(400);
|
||||
});
|
||||
|
||||
it('BOTTOM: a single row of items reserves one legend row', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
});
|
||||
// One row = line height (28) + padding (12).
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(460);
|
||||
expect(dims.legendWidth).toBe(1000);
|
||||
});
|
||||
|
||||
it('BOTTOM: many items cap at two rows on a tall container', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
|
||||
expect(dims.legendHeight).toBe(68);
|
||||
expect(dims.height).toBe(432);
|
||||
});
|
||||
|
||||
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 160,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Without the height-relative cap the legend would take 68px of a 160px
|
||||
// panel and the chart (pie especially) collapses to a sliver.
|
||||
expect(dims.legendHeight).toBe(48); // 30% of 160
|
||||
expect(dims.height).toBe(112);
|
||||
});
|
||||
});
|
||||
@@ -116,15 +116,7 @@ export function calculateChartDimensions({
|
||||
? legendRowCount * legendRowHeight - LEGEND_PADDING
|
||||
: legendRowHeight;
|
||||
|
||||
// Cap at two rows / 80px, and never more than 30% of the container height
|
||||
// (the doc above always promised the %-cap; without it, short grid panels
|
||||
// hand most of their area to the legend and the chart — the pie donut
|
||||
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
|
||||
const maxAllowedLegendHeight = Math.min(
|
||||
2 * legendRowHeight,
|
||||
80,
|
||||
Math.floor(containerHeight * 0.3),
|
||||
);
|
||||
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
|
||||
|
||||
const bottomLegendHeight = Math.min(
|
||||
idealBottomLegendHeight,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -123,14 +124,24 @@ function ServiceOverview({
|
||||
/>
|
||||
<Card data-testid="service_latency">
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
onDragSelect={onDragSelect}
|
||||
widget={latencyWidget}
|
||||
onClickHandler={handleGraphClick('Service')}
|
||||
isQueryEnabled={isQueryEnabled}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
{topLevelOperationsIsLoading && (
|
||||
<Skeleton
|
||||
style={{
|
||||
height: '100%',
|
||||
padding: '16px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!topLevelOperationsIsLoading && (
|
||||
<Graph
|
||||
onDragSelect={onDragSelect}
|
||||
widget={latencyWidget}
|
||||
onClickHandler={handleGraphClick('Service')}
|
||||
isQueryEnabled={isQueryEnabled}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
)}
|
||||
</GraphContainer>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -28,14 +29,24 @@ function TopLevelOperation({
|
||||
</Typography>
|
||||
) : (
|
||||
<GraphContainer>
|
||||
<Graph
|
||||
widget={widget}
|
||||
onClickHandler={handleGraphClick(opName)}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={!topLevelOperationsIsLoading}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
{topLevelOperationsIsLoading && (
|
||||
<Skeleton
|
||||
style={{
|
||||
height: '100%',
|
||||
padding: '16px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!topLevelOperationsIsLoading && (
|
||||
<Graph
|
||||
widget={widget}
|
||||
onClickHandler={handleGraphClick(opName)}
|
||||
onDragSelect={onDragSelect}
|
||||
isQueryEnabled={!topLevelOperationsIsLoading}
|
||||
version={ENTITY_VERSION_V4}
|
||||
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
|
||||
/>
|
||||
)}
|
||||
</GraphContainer>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
auto-fill,
|
||||
minmax(var(--legend-average-width, 240px), 1fr)
|
||||
);
|
||||
row-gap: 4px;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
|
||||
interface NoDataProps {
|
||||
/** Message to display. Defaults to "No data". */
|
||||
label?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared empty-state for panel renderers, shown when a query resolves but
|
||||
* returns nothing to plot. Centred in the panel body so every panel kind
|
||||
* surfaces the same "No data" affordance instead of each renderer (or its
|
||||
* underlying chart) inventing its own copy and casing.
|
||||
*/
|
||||
function NoData({
|
||||
label = 'No data',
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.noData} data-testid={testId}>
|
||||
<Typography.Text className={styles.noDataText}>{label}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Builds a record keyed by builder-query name to that query's groupBy keys
|
||||
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
|
||||
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
|
||||
* call site that needs the V1 shape; the rest of V2 panel code stays on
|
||||
* v5 types.
|
||||
*/
|
||||
export function useGroupByPerQuery(
|
||||
builderQueries: BuilderQuery[],
|
||||
): Record<string, BaseAutocompleteData[]> {
|
||||
return useMemo(() => {
|
||||
const result: Record<string, BaseAutocompleteData[]> = {};
|
||||
builderQueries.forEach((q) => {
|
||||
if (!q.name) {
|
||||
return;
|
||||
}
|
||||
result[q.name] = (q.groupBy ?? []).map((g) => ({
|
||||
key: g.name,
|
||||
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
|
||||
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
|
||||
id: '',
|
||||
}));
|
||||
});
|
||||
return result;
|
||||
}, [builderQueries]);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const MIN_FONT_PX = 16;
|
||||
const MAX_FONT_PX = 60;
|
||||
// The value font is sized to a fraction of the container's smaller dimension so
|
||||
// it scales with the panel without overflowing.
|
||||
const FONT_SCALE_DIVISOR = 5;
|
||||
|
||||
/**
|
||||
* Sizes a single large value to its container, recomputing on resize via a
|
||||
* ResizeObserver. Returns the ref to attach to the container and the current
|
||||
* font size (px) to apply to the value text.
|
||||
*/
|
||||
export function useResponsiveFontSize(): {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
fontSize: string;
|
||||
} {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSize, setFontSize] = useState('2.5vw');
|
||||
|
||||
useEffect(() => {
|
||||
const updateFontSize = (): void => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
const minDimension = Math.min(width, height);
|
||||
const newSize = Math.max(
|
||||
Math.min(minDimension / FONT_SCALE_DIVISOR, MAX_FONT_PX),
|
||||
MIN_FONT_PX,
|
||||
);
|
||||
setFontSize(`${newSize}px`);
|
||||
};
|
||||
|
||||
updateFontSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateFontSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { containerRef, fontSize };
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildBarChartConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data (falls back
|
||||
// to the global picker inside the helper). The generated request DTO is
|
||||
// structurally the hand-written V5 request; the cast is the boundary.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
const groupByPerQuery = useGroupByPerQuery(builderQueries);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildBarChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
stepIntervals: getExecStats(data.response)?.stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
flatSeries,
|
||||
data.response,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(() => {
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
// The uPlot key prop is the only way to force a full teardown and re-mount
|
||||
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
|
||||
// to these preferences trigger a fresh chart instance, preventing stale
|
||||
// sync wiring from being inherited.
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={key}
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarPanelRenderer;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { stacked: true } },
|
||||
];
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { toClickPluginPayload } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface BuildBarChartConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
/**
|
||||
* Flat list of builder queries on this panel (see `getBuilderQueries`).
|
||||
* Powers per-query legend resolution; empty for non-builder panels.
|
||||
*/
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
|
||||
*
|
||||
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
|
||||
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
|
||||
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
|
||||
* one bar series per result row.
|
||||
*/
|
||||
export function buildBarChartConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
isLogScale: spec.axes?.isLogScale,
|
||||
softMin: spec.axes?.softMin ?? undefined,
|
||||
softMax: spec.axes?.softMax ?? undefined,
|
||||
formatting: spec.formatting,
|
||||
thresholds: spec.thresholds,
|
||||
stepIntervals,
|
||||
clickPayload: toClickPluginPayload(series),
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
});
|
||||
|
||||
addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
|
||||
* when `spec.visualization.stackedBarChart` is set. Each series receives its
|
||||
* own per-query step interval so bar widths line up with the actual
|
||||
* sampling cadence reported by the backend.
|
||||
*
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
|
||||
if (spec.visualization?.stackedBarChart) {
|
||||
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
|
||||
// `+1` keeps the band targets aligned with the series we're about to add.
|
||||
builder.setBands(getInitialStackedBands(series.length + 1));
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
const stepInterval = s.queryName ? stepIntervals?.[s.queryName] : undefined;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping,
|
||||
isDarkMode,
|
||||
stepInterval,
|
||||
metric: s.labels,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { resolveLegendPosition } from '../../utils/chartAppearance/resolvers';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildHistogramConfig } from './utils/buildConfig';
|
||||
import { prepareHistogramData } from './prepareData';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildHistogramConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
}),
|
||||
[panelId, spec, builderQueries, flatSeries, isDarkMode, timezone, panelMode],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
prepareHistogramData({
|
||||
series: flatSeries,
|
||||
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
|
||||
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
|
||||
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
|
||||
}),
|
||||
[
|
||||
flatSeries,
|
||||
spec.histogramBuckets?.bucketWidth,
|
||||
spec.histogramBuckets?.bucketCount,
|
||||
spec.histogramBuckets?.mergeAllActiveQueries,
|
||||
],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(
|
||||
() => resolveLegendPosition(spec.legend?.position),
|
||||
[spec.legend?.position],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter
|
||||
id={panelId}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
canDrilldown={false}
|
||||
/>
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
<Histogram
|
||||
key={panelId}
|
||||
config={config}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
canPinTooltip
|
||||
isQueriesMerged={isQueriesMerged}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanelRenderer;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
import { histogramBucketSizes } from '@grafana/data';
|
||||
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
|
||||
import {
|
||||
buildHistogramBuckets,
|
||||
mergeAlignedDataTables,
|
||||
prependNullBinToFirstHistogramSeries,
|
||||
replaceUndefinedWithNullInAlignedData,
|
||||
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { AlignedData } from 'uplot';
|
||||
import { incrRoundDn, roundDecimals } from 'utils/round';
|
||||
|
||||
export interface PrepareHistogramDataArgs {
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
bucketWidth?: number;
|
||||
bucketCount?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
}
|
||||
|
||||
const BUCKET_OFFSET = 0;
|
||||
const sortAscending = (a: number, b: number): number => a - b;
|
||||
|
||||
/**
|
||||
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
|
||||
* either from `bucketWidth` (explicit override) or the smallest predefined
|
||||
* Grafana bucket that fits the data's `range / bucketCount` target while
|
||||
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
|
||||
* the resolution of the input).
|
||||
*
|
||||
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
|
||||
*/
|
||||
export function prepareHistogramData({
|
||||
series,
|
||||
bucketWidth,
|
||||
bucketCount = DEFAULT_BUCKET_COUNT,
|
||||
mergeAllActiveQueries = false,
|
||||
}: PrepareHistogramDataArgs): AlignedData {
|
||||
const values = extractNumericValues(series);
|
||||
if (values.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const sorted = [...values].sort(sortAscending);
|
||||
const range = sorted[sorted.length - 1] - sorted[0];
|
||||
const smallestDelta = computeSmallestDelta(sorted);
|
||||
let bucketSize = selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride: bucketWidth,
|
||||
});
|
||||
if (bucketSize <= 0) {
|
||||
bucketSize = range > 0 ? range / bucketCount : 1;
|
||||
}
|
||||
|
||||
const getBucket = (v: number): number =>
|
||||
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
|
||||
|
||||
const frames = buildFrames(series, mergeAllActiveQueries);
|
||||
// Merged mode folds every query into frame 0 and leaves trailing empty
|
||||
// frames — drop those. Per-query mode must keep one column per result row
|
||||
// (even empty queries), or the data column count drifts below the series
|
||||
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
|
||||
const histograms: AlignedData[] = frames
|
||||
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
|
||||
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
|
||||
|
||||
if (histograms.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const merged = mergeAlignedDataTables(histograms);
|
||||
replaceUndefinedWithNullInAlignedData(merged);
|
||||
prependNullBinToFirstHistogramSeries(merged, bucketSize);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
|
||||
function toBinnableValue(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function extractNumericValues(series: PanelSeries[]): number[] {
|
||||
const values: number[] = [];
|
||||
for (const s of series) {
|
||||
for (const point of s.values) {
|
||||
values.push(toBinnableValue(point.value));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function computeSmallestDelta(sortedValues: number[]): number {
|
||||
if (sortedValues.length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
let smallest = Infinity;
|
||||
for (let i = 1; i < sortedValues.length; i++) {
|
||||
const delta = sortedValues[i] - sortedValues[i - 1];
|
||||
if (delta > 0) {
|
||||
smallest = Math.min(smallest, delta);
|
||||
}
|
||||
}
|
||||
return smallest === Infinity ? 0 : smallest;
|
||||
}
|
||||
|
||||
function selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride,
|
||||
}: {
|
||||
range: number;
|
||||
bucketCount: number;
|
||||
smallestDelta: number;
|
||||
bucketWidthOverride?: number;
|
||||
}): number {
|
||||
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
|
||||
return bucketWidthOverride;
|
||||
}
|
||||
const targetSize = range / bucketCount;
|
||||
for (const candidate of histogramBucketSizes) {
|
||||
if (targetSize < candidate && candidate >= smallestDelta) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// When merging is on, fold all frames into the first; the trailing empty
|
||||
// frames stay in the array so downstream `.filter(length > 0)` drops them.
|
||||
function buildFrames(
|
||||
series: PanelSeries[],
|
||||
mergeAllActiveQueries: boolean,
|
||||
): number[][] {
|
||||
const frames: number[][] = series.map((s) =>
|
||||
s.values.map((point) => toBinnableValue(point.value)),
|
||||
);
|
||||
if (mergeAllActiveQueries && frames.length > 1) {
|
||||
const first = frames[0];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
first.push(...frames[i]);
|
||||
frames[i] = [];
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'buckets', controls: { count: true } },
|
||||
];
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const POINT_SIZE = 5;
|
||||
const BAR_WIDTH_FACTOR = 1;
|
||||
// Merged-series colors mirror the V1 default — single histogram bin gets a
|
||||
// fixed blue-ish pair so the merged view looks the same as before.
|
||||
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
|
||||
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
|
||||
|
||||
export interface BuildHistogramConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesHistogramPanelSpecDTO;
|
||||
/** Builder queries on this panel — used to resolve per-series labels. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
|
||||
*
|
||||
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
|
||||
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
|
||||
* axes, click plugin) but then override the X/Y scales to be auto-linear
|
||||
* (`time: false, auto: true`) and install a histogram-specific cursor that
|
||||
* disables drag-pan and tightens focus proximity.
|
||||
*/
|
||||
export function buildHistogramConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
|
||||
// Histograms have no time axis — no stepIntervals, and no click plugin
|
||||
// (the renderer passes no onClick), so the base config needs no response.
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
});
|
||||
|
||||
builder.setCursor({
|
||||
drag: { x: false, y: false, setScale: true },
|
||||
focus: { prox: 1e3 },
|
||||
});
|
||||
|
||||
// Override the time-axis scales from `buildBaseConfig` — histograms are
|
||||
// distribution plots, not time series.
|
||||
builder.addScale({ scaleKey: 'x', time: false, auto: true });
|
||||
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
|
||||
|
||||
addSeries({ builder, spec, builderQueries, series, isDarkMode });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesHistogramPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
|
||||
* set, `prepareHistogramData` produces a single Y column, so we add exactly
|
||||
* one series with the fixed merged-mode colors. Otherwise one series per
|
||||
* result row, with labels resolved via the standard legend matrix.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
const mergeAllActiveQueries =
|
||||
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
if (mergeAllActiveQueries) {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label: '',
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
colorMapping,
|
||||
barWidthFactor: BAR_WIDTH_FACTOR,
|
||||
pointSize: POINT_SIZE,
|
||||
lineColor: MERGED_SERIES_LINE_COLOR,
|
||||
fillColor: MERGED_SERIES_FILL_COLOR,
|
||||
isDarkMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label,
|
||||
drawStyle: DrawStyle.Histogram,
|
||||
colorMapping,
|
||||
barWidthFactor: BAR_WIDTH_FACTOR,
|
||||
pointSize: POINT_SIZE,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { formatPanelValue } from '../../utils/formatPanelValue';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
|
||||
import { prepareNumberData } from './prepareData';
|
||||
import { mapNumberThresholds } from './utils';
|
||||
import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() =>
|
||||
prepareNumberData(
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
),
|
||||
[data.response, data.legendMap, data.requestPayload],
|
||||
);
|
||||
|
||||
const thresholds = useMemo(
|
||||
() => mapNumberThresholds(spec.thresholds),
|
||||
[spec.thresholds],
|
||||
);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const unit = spec.formatting?.unit;
|
||||
|
||||
// Precision is applied regardless of whether a unit is set (see
|
||||
// `formatPanelValue`), so decimal-precision changes always take effect.
|
||||
const formattedValue = useMemo(
|
||||
() => (value === null ? '' : formatPanelValue(value, unit, decimalPrecision)),
|
||||
[value, unit, decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="number-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" />
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
rawValue={value}
|
||||
thresholds={thresholds}
|
||||
unit={unit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberPanelRenderer;
|
||||
@@ -1,163 +0,0 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesNumberPanelSpecDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import BaseNumberPanelRenderer from '../Renderer';
|
||||
|
||||
// The kind's interaction map is `Record<string, never>`, which makes the strict
|
||||
// `PanelRendererProps<'signoz/NumberPanel'>` intersection impossible to satisfy
|
||||
// with a literal. NumberPanel reads no interaction props, so render it against
|
||||
// the base prop surface.
|
||||
const NumberPanelRenderer =
|
||||
BaseNumberPanelRenderer as React.FC<BaseRendererProps>;
|
||||
|
||||
// ValueDisplay observes its container to size the font.
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesNumberPanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/NumberPanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
// V5 scalar response: one table per query, value in the aggregation column.
|
||||
function dataWith(value: string | number): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: [
|
||||
{
|
||||
name: '__result',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: [[value]],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyData: PanelQueryData = {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: { type: 'scalar', data: { results: [] } },
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
|
||||
// `data` is always present per the renderer contract; an absent fetch surfaces
|
||||
// as a missing `response`, not a missing `data`.
|
||||
const absentResponseData: PanelQueryData = {
|
||||
response: undefined,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
|
||||
// NumberPanel adds no interaction props (its interaction map is
|
||||
// `Record<string, never>`), so the base renderer props fully describe it.
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
...props,
|
||||
};
|
||||
return render(<NumberPanelRenderer {...baseProps} />);
|
||||
}
|
||||
|
||||
describe('NumberPanelRenderer', () => {
|
||||
it('renders the value with its y-axis unit', () => {
|
||||
const { getByText } = renderPanel({
|
||||
panel: panelWith({ formatting: { unit: 'ms' } }),
|
||||
data: dataWith('295.4299833508185'),
|
||||
});
|
||||
|
||||
expect(getByText('295.43')).toBeInTheDocument();
|
||||
expect(getByText('ms')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression: with no unit configured, decimal precision must still apply.
|
||||
// Previously the renderer fell back to `value.toString()` whenever the unit
|
||||
// was empty, so precision changes had no effect on unitless panels.
|
||||
it('applies decimal precision even when no unit is set', () => {
|
||||
const { getByText, queryByText } = renderPanel({
|
||||
panel: panelWith({}),
|
||||
data: dataWith('3.14159'),
|
||||
});
|
||||
|
||||
expect(getByText('3.14')).toBeInTheDocument();
|
||||
expect(queryByText('3.14159')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders No Data when the response has no scalar results', () => {
|
||||
const { getByTestId } = renderPanel({ data: emptyData });
|
||||
|
||||
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders No Data when the response is absent', () => {
|
||||
const { getByTestId } = renderPanel({ data: absentResponseData });
|
||||
|
||||
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the conflicting-thresholds indicator when a value matches multiple thresholds', () => {
|
||||
const { getByTestId } = renderPanel({
|
||||
panel: panelWith({
|
||||
thresholds: [
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 0,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
{
|
||||
color: '#0f0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 100,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
],
|
||||
}),
|
||||
data: dataWith('295.4299833508185'),
|
||||
});
|
||||
|
||||
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { prepareNumberData } from '../prepareData';
|
||||
|
||||
function tableWith(
|
||||
columns: PanelTable['columns'],
|
||||
rows: PanelTable['rows'],
|
||||
): PanelTable {
|
||||
return { queryName: 'A', legend: '', columns, rows };
|
||||
}
|
||||
|
||||
describe('prepareNumberData', () => {
|
||||
it('returns null for no tables', () => {
|
||||
expect(prepareNumberData([])).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the first row of the value column', () => {
|
||||
const table = tableWith(
|
||||
[
|
||||
{ name: 'group', queryName: 'A', isValueColumn: false, id: 'group' },
|
||||
{ name: 'value', queryName: 'A', isValueColumn: true, id: 'val' },
|
||||
],
|
||||
[
|
||||
{ data: { group: 'prod', val: '295.4299833508185' } },
|
||||
{ data: { group: 'dev', val: '7' } },
|
||||
],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBeCloseTo(295.43, 2);
|
||||
});
|
||||
|
||||
it('falls back to the row first value when no column is tagged isValueColumn', () => {
|
||||
const table = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: false, id: 'value' }],
|
||||
[{ data: { value: '7' } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBe(7);
|
||||
});
|
||||
|
||||
it('skips empty tables and reads the first one with rows', () => {
|
||||
const empty = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
|
||||
[],
|
||||
);
|
||||
const filled = tableWith(
|
||||
[{ name: 'value', queryName: 'B', isValueColumn: true, id: 'B' }],
|
||||
[{ data: { B: 42 } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([empty, filled])).toBe(42);
|
||||
});
|
||||
|
||||
it('returns null when the value is non-numeric', () => {
|
||||
const table = tableWith(
|
||||
[{ name: 'value', queryName: 'A', isValueColumn: true, id: 'A' }],
|
||||
[{ data: { A: 'n/a' } }],
|
||||
);
|
||||
|
||||
expect(prepareNumberData([table])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { mapNumberThresholds } from '../utils';
|
||||
|
||||
describe('mapNumberThresholds', () => {
|
||||
it('returns [] for null / undefined / empty', () => {
|
||||
expect(mapNumberThresholds(null)).toStrictEqual([]);
|
||||
expect(mapNumberThresholds(undefined)).toStrictEqual([]);
|
||||
expect(mapNumberThresholds([])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('maps comparison operators to symbol operators', () => {
|
||||
const thresholds: DashboardtypesComparisonThresholdDTO[] = [
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
color: '#0f0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
color: '#00f',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above_or_equal,
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
color: '#ff0',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below_or_equal,
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
color: '#0ff',
|
||||
operator: DashboardtypesComparisonOperatorDTO.equal,
|
||||
value: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const mapped = mapNumberThresholds(thresholds);
|
||||
|
||||
expect(mapped.map((t) => t.operator)).toStrictEqual([
|
||||
'>',
|
||||
'<',
|
||||
'>=',
|
||||
'<=',
|
||||
'=',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps not_equal to !=', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#f00',
|
||||
operator: DashboardtypesComparisonOperatorDTO.not_equal,
|
||||
value: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0].operator).toBe('!=');
|
||||
});
|
||||
|
||||
it('maps format and carries value/unit/color', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#abcdef',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 100,
|
||||
unit: 'ms',
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0]).toStrictEqual({
|
||||
color: '#abcdef',
|
||||
operator: '>',
|
||||
value: 100,
|
||||
unit: 'ms',
|
||||
format: 'background',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps text format to text', () => {
|
||||
const mapped = mapNumberThresholds([
|
||||
{
|
||||
color: '#000',
|
||||
value: 1,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mapped[0].format).toBe('text');
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.valueText {
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.conflictBackground {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
|
||||
.conflictText {
|
||||
margin-left: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.conflictIcon {
|
||||
color: var(--warning-background);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from 'antd';
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { PanelThreshold } from '../../../../types/threshold';
|
||||
import { resolveActiveThreshold } from '../../../../utils/evaluateThresholds';
|
||||
|
||||
import { parseFormattedValue } from '../../../../utils/parseFormattedValue';
|
||||
import styles from './ValueDisplay.module.scss';
|
||||
import { useResponsiveFontSize } from '../../../../hooks/useResponsiveFontSize';
|
||||
import ValueUnit from '../ValueUnit/ValueUnit';
|
||||
|
||||
interface ValueDisplayProps {
|
||||
/** The pre-formatted value string (may include a unit label). */
|
||||
value: string;
|
||||
/** The raw numeric value, used for threshold evaluation. */
|
||||
rawValue: number;
|
||||
thresholds: PanelThreshold[];
|
||||
/** The panel's unit, used to convert threshold units before comparison. */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single large scalar with optional prefix/suffix units and threshold
|
||||
* recoloring (text or background). A V2-native replacement for the V1
|
||||
* `ValueGraph` — depends only on V2 threshold utilities and the shared icon/
|
||||
* typography primitives.
|
||||
*/
|
||||
function ValueDisplay({
|
||||
value,
|
||||
rawValue,
|
||||
thresholds,
|
||||
unit,
|
||||
}: ValueDisplayProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
const { containerRef, fontSize } = useResponsiveFontSize();
|
||||
|
||||
const { numericValue, prefixUnit, suffixUnit } = useMemo(
|
||||
() => parseFormattedValue(value),
|
||||
[value],
|
||||
);
|
||||
|
||||
const { threshold, isConflicting } = useMemo(
|
||||
() => resolveActiveThreshold(thresholds, rawValue, unit),
|
||||
[thresholds, rawValue, unit],
|
||||
);
|
||||
|
||||
const isBackground = threshold?.format === 'background';
|
||||
const textColor = threshold?.format === 'text' ? threshold.color : undefined;
|
||||
const backgroundColor = isBackground ? threshold?.color : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.container}
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<div className={styles.textContainer}>
|
||||
{prefixUnit && (
|
||||
<ValueUnit
|
||||
type="prefix"
|
||||
unit={prefixUnit}
|
||||
color={textColor}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
)}
|
||||
<Typography.Text
|
||||
className={styles.valueText}
|
||||
data-testid="number-panel-value"
|
||||
style={{ color: textColor, fontSize }}
|
||||
>
|
||||
{numericValue}
|
||||
</Typography.Text>
|
||||
{suffixUnit && (
|
||||
<ValueUnit
|
||||
type="suffix"
|
||||
unit={suffixUnit}
|
||||
color={textColor}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isConflicting && (
|
||||
<div
|
||||
className={isBackground ? styles.conflictBackground : styles.conflictText}
|
||||
>
|
||||
<Tooltip title={t('this_value_satisfies_multiple_thresholds')}>
|
||||
<CircleAlert
|
||||
className={styles.conflictIcon}
|
||||
data-testid="conflicting-thresholds"
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueDisplay;
|
||||
@@ -1,5 +0,0 @@
|
||||
.unit {
|
||||
margin-left: 4px;
|
||||
font-weight: 300;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ValueUnit.module.scss';
|
||||
|
||||
interface ValueUnitProps {
|
||||
type: 'prefix' | 'suffix';
|
||||
unit: string;
|
||||
/** Text color, set only when a "text" threshold is active. */
|
||||
color?: string;
|
||||
fontSize: string;
|
||||
}
|
||||
|
||||
/** A prefix/suffix unit label rendered alongside the numeric value. */
|
||||
function ValueUnit({
|
||||
type,
|
||||
unit,
|
||||
color,
|
||||
fontSize,
|
||||
}: ValueUnitProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text
|
||||
className={styles.unit}
|
||||
data-testid={`value-display-${type}-unit`}
|
||||
style={{ color, fontSize: `calc(${fontSize} * 0.7)` }}
|
||||
>
|
||||
{unit}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueUnit;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
displayName: 'Number',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
/**
|
||||
* Reduces the scalar tables of a V5 response to the single number a
|
||||
* NumberPanel renders.
|
||||
*
|
||||
* V2 always issues `requestType: 'scalar'` for VALUE panels, so the response
|
||||
* is a scalar table per query (see `prepareScalarTables`). The value is the
|
||||
* first row's `isValueColumn` cell of the first table that has rows —
|
||||
* falling back to the row's first cell when no column is marked as the
|
||||
* value (mirrors the V1 `formatForWeb` fallback read).
|
||||
*
|
||||
* Returns `null` when there is no numeric value to show, which the renderer
|
||||
* maps to the "No Data" state.
|
||||
*/
|
||||
export function prepareNumberData(tables: PanelTable[]): number | null {
|
||||
for (const table of tables) {
|
||||
if (table.rows.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const row = table.rows[0].data;
|
||||
const valueColumn = table.columns.find((column) => column.isValueColumn);
|
||||
const raw = valueColumn
|
||||
? row[valueColumn.id || valueColumn.name]
|
||||
: Object.values(row)[0];
|
||||
const value = Number(raw);
|
||||
if (Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// A number panel renders one scalar — no axes, legend, or stacking. Just value
|
||||
// formatting and thresholds that recolor the value/background.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
];
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
ThresholdDisplayFormat,
|
||||
} from '../../types/threshold';
|
||||
|
||||
// Perses comparison operators → the symbol operators V2 threshold evaluation
|
||||
// uses.
|
||||
const OPERATOR_MAP: Record<
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
ThresholdComparisonOperator
|
||||
> = {
|
||||
[DashboardtypesComparisonOperatorDTO.above]: '>',
|
||||
[DashboardtypesComparisonOperatorDTO.below]: '<',
|
||||
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '>=',
|
||||
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '<=',
|
||||
[DashboardtypesComparisonOperatorDTO.equal]: '=',
|
||||
[DashboardtypesComparisonOperatorDTO.not_equal]: '!=',
|
||||
};
|
||||
|
||||
const FORMAT_MAP: Record<
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
ThresholdDisplayFormat
|
||||
> = {
|
||||
[DashboardtypesThresholdFormatDTO.text]: 'text',
|
||||
[DashboardtypesThresholdFormatDTO.background]: 'background',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps the panel-spec threshold shape (`ComparisonThresholdDTO`) onto the
|
||||
* V2-native `PanelThreshold` consumed by `ValueDisplay` / threshold
|
||||
* evaluation. No dependency on the V1 `ThresholdProps` shape.
|
||||
*/
|
||||
export function mapNumberThresholds(
|
||||
thresholds: DashboardtypesComparisonThresholdDTO[] | null | undefined,
|
||||
): PanelThreshold[] {
|
||||
if (!thresholds || thresholds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return thresholds.map((threshold) => ({
|
||||
color: threshold.color,
|
||||
operator: threshold.operator ? OPERATOR_MAP[threshold.operator] : undefined,
|
||||
value: threshold.value,
|
||||
unit: threshold.unit,
|
||||
format: threshold.format ? FORMAT_MAP[threshold.format] : undefined,
|
||||
}));
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
|
||||
import { preparePieData } from './prepareData';
|
||||
|
||||
function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
|
||||
// documented boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const slices = useMemo(
|
||||
() =>
|
||||
preparePieData({
|
||||
tables: prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
customColors: spec.legend?.customColors,
|
||||
isDarkMode,
|
||||
}),
|
||||
[
|
||||
data.response,
|
||||
data.legendMap,
|
||||
data.requestPayload,
|
||||
spec.legend?.customColors,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(
|
||||
() => resolveLegendPosition(spec.legend?.position),
|
||||
[spec.legend?.position],
|
||||
);
|
||||
|
||||
const handleSliceClick = useCallback(
|
||||
(slice: PieSlice) => {
|
||||
onClick?.({ label: slice.label, value: slice.value });
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
isDarkMode={isDarkMode}
|
||||
position={legendPosition}
|
||||
id={panelId}
|
||||
onSliceClick={handleSliceClick}
|
||||
data-testid="pie-chart"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PiePanelRenderer;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
export interface PreparePieDataArgs {
|
||||
/** Scalar tables from the V5 response (see `prepareScalarTables`). */
|
||||
tables: PanelTable[];
|
||||
/** Per-label colour overrides from `spec.legend.customColors`. */
|
||||
customColors?: Record<string, string> | null;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the scalar tables of a V5 response into pie slices: one slice per
|
||||
* group row. The aggregation column holds the value, the group column(s)
|
||||
* form the label. Colours honour `customColors` then fall back to a
|
||||
* deterministic palette colour; non-positive / non-numeric values are
|
||||
* dropped.
|
||||
*/
|
||||
export function preparePieData({
|
||||
tables,
|
||||
customColors,
|
||||
isDarkMode,
|
||||
}: PreparePieDataArgs): PieSlice[] {
|
||||
const colorMap = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
|
||||
const slices: PieSlice[] = [];
|
||||
tables.forEach((table) => {
|
||||
const valueColumn = table.columns.find((column) => column.isValueColumn);
|
||||
if (!valueColumn) {
|
||||
return;
|
||||
}
|
||||
const valueKey = valueColumn.id || valueColumn.name;
|
||||
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
|
||||
|
||||
table.rows.forEach((row) => {
|
||||
const value = Number(row.data[valueKey]);
|
||||
const label =
|
||||
labelColumns
|
||||
.map((column) => row.data[column.id || column.name])
|
||||
.filter((part) => part != null)
|
||||
.map(String)
|
||||
.join(', ') ||
|
||||
table.legend ||
|
||||
table.queryName ||
|
||||
'';
|
||||
const color = customColors?.[label] ?? generateColor(label, colorMap);
|
||||
slices.push({ label, value, color });
|
||||
});
|
||||
});
|
||||
|
||||
return slices.filter(
|
||||
(slice) => Number.isFinite(slice.value) && slice.value > 0,
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a
|
||||
// legend. `mode` is omitted: the pie legend is always interactive swatches.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
];
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildTimeSeriesConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
function TimeSeriesPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
|
||||
// documented boundary narrowing — not a blind assertion. Memoized so the
|
||||
// `?? {}` fallback doesn't produce a fresh object on each render.
|
||||
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// X-scale clamps come from the request that produced the data, so each
|
||||
// panel pins to the window it actually fetched — important during
|
||||
// drag-zoom transitions when the time picker has moved but new data
|
||||
// hasn't arrived yet. Falls back to the global picker inside the helper.
|
||||
// The generated request DTO is structurally the hand-written V5 request;
|
||||
// the cast is the documented boundary.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
const groupByPerQuery = useGroupByPerQuery(builderQueries);
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildTimeSeriesConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series: flatSeries,
|
||||
stepIntervals: getExecStats(data.response)?.stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
flatSeries,
|
||||
data.response,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
dashboardPreference?.syncMode,
|
||||
],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(() => {
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
/**
|
||||
* The uPlot key prop is the only way to force a full teardown and re-mount
|
||||
* of the chart. By including the syncMode and syncFilterMode in the key,
|
||||
* we ensure that changes to these preferences trigger a fresh chart instance,
|
||||
* preventing stale sync settings from being inherited.
|
||||
*/
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="time-series-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
<TimeSeries
|
||||
key={key}
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeSeriesPanelRenderer;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'formatting',
|
||||
controls: {
|
||||
unit: true,
|
||||
decimals: true,
|
||||
},
|
||||
},
|
||||
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, mode: true } },
|
||||
{ kind: 'thresholds', controls: { list: true } },
|
||||
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
|
||||
];
|
||||
@@ -1,159 +0,0 @@
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
FILL_MODE_MAP,
|
||||
LINE_INTERPOLATION_MAP,
|
||||
LINE_STYLE_MAP,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
|
||||
import { resolveSpanGaps } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/chartAppearance/resolvers';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
hasSingleVisiblePoint,
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import {
|
||||
DrawStyle,
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const DEFAULT_POINT_SIZE = 5;
|
||||
|
||||
export interface BuildTimeSeriesConfigArgs {
|
||||
panelId: string;
|
||||
spec: DashboardtypesTimeSeriesPanelSpecDTO;
|
||||
/**
|
||||
* Flat list of builder queries on this panel (see `getBuilderQueries`).
|
||||
* Powers per-query legend resolution; empty for non-builder panels.
|
||||
*/
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
|
||||
*
|
||||
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
|
||||
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
|
||||
* in the TimeSeries-specific concern: one series per result, with visuals
|
||||
* resolved from `spec.chartAppearance`.
|
||||
*/
|
||||
export function buildTimeSeriesConfig({
|
||||
panelId,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
stepIntervals,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
isLogScale: spec.axes?.isLogScale,
|
||||
softMin: spec.axes?.softMin ?? undefined,
|
||||
softMax: spec.axes?.softMax ?? undefined,
|
||||
formatting: spec.formatting,
|
||||
thresholds: spec.thresholds,
|
||||
stepIntervals,
|
||||
clickPayload: toClickPluginPayload(series),
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
});
|
||||
|
||||
addSeries({ builder, spec, builderQueries, series, isDarkMode });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
interface AddSeriesArgs {
|
||||
builder: UPlotConfigBuilder;
|
||||
spec: DashboardtypesTimeSeriesPanelSpecDTO;
|
||||
builderQueries: BuilderQuery[];
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one uPlot series per flattened V5 series to the scaffolded builder.
|
||||
* The visual resolution (line style, interpolation, fill mode, span gaps)
|
||||
* reads from `spec.chartAppearance`; the label is resolved via the legend
|
||||
* matrix in `resolveSeriesLabelV5`. Mutates the builder in place.
|
||||
*
|
||||
* Order must match `prepareAlignedData` — both iterate the same flat list.
|
||||
*/
|
||||
function addSeries({
|
||||
builder,
|
||||
spec,
|
||||
builderQueries,
|
||||
series,
|
||||
isDarkMode,
|
||||
}: AddSeriesArgs): void {
|
||||
const chartAppearance = spec.chartAppearance;
|
||||
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
|
||||
// a defined record (it dereferences keys without a guard).
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
|
||||
|
||||
const lineStyle = chartAppearance?.lineStyle
|
||||
? LINE_STYLE_MAP[chartAppearance.lineStyle]
|
||||
: LineStyle.Solid;
|
||||
const lineInterpolation = chartAppearance?.lineInterpolation
|
||||
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
|
||||
: LineInterpolation.Spline;
|
||||
const fillMode = chartAppearance?.fillMode
|
||||
? FILL_MODE_MAP[chartAppearance.fillMode]
|
||||
: FillMode.None;
|
||||
|
||||
series.forEach((s) => {
|
||||
const hasSingleValidPoint = hasSingleVisiblePoint(s.values);
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
// A single visible point can't be drawn as a line — degrade to points
|
||||
// so the user still sees the datum (matches V1 behavior).
|
||||
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
|
||||
label,
|
||||
colorMapping,
|
||||
spanGaps,
|
||||
lineStyle,
|
||||
lineInterpolation,
|
||||
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
|
||||
pointSize: DEFAULT_POINT_SIZE,
|
||||
fillMode,
|
||||
isDarkMode,
|
||||
metric: s.labels,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.panelContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { definition as BarChart } from './kinds/BarChartPanel/definition';
|
||||
import { definition as Histogram } from './kinds/HistogramPanel/definition';
|
||||
import { definition as NumberValue } from './kinds/NumberPanel/definition';
|
||||
import { definition as PieChart } from './kinds/PieChartPanel/definition';
|
||||
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
|
||||
import type {
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
} from './types/panelDefinition';
|
||||
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Pure assembly: each kind owns its own PanelDefinition (see
|
||||
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
|
||||
// single entry below — no other central file needs editing.
|
||||
export const PANELS: PanelRegistry = {
|
||||
[TimeSeries.kind]: TimeSeries,
|
||||
[BarChart.kind]: BarChart,
|
||||
[Histogram.kind]: Histogram,
|
||||
[NumberValue.kind]: NumberValue,
|
||||
[PieChart.kind]: PieChart,
|
||||
};
|
||||
|
||||
export function getPanelDefinition(
|
||||
kind: PanelKind,
|
||||
): RenderablePanelDefinition | undefined {
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
// The registry is correlated by kind, so a string lookup yields a union over
|
||||
// every kind's exactly-typed definition. The renderer cannot be validated
|
||||
// against that union at the JSX boundary, so widen to the kind-agnostic
|
||||
// surface here — the single, intentional cast for the whole panel system.
|
||||
return PANELS[kind] as unknown as RenderablePanelDefinition | undefined;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/**
|
||||
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
|
||||
* each non-chart kind carries the context its drill-down needs. The `source`
|
||||
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
|
||||
* handler) discriminate without assuming a chart shape.
|
||||
*/
|
||||
export type ChartClickEvent = ChartClickData;
|
||||
export type TableClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
columnId?: string;
|
||||
};
|
||||
export type ListClickEvent = {
|
||||
rowData: Record<string, unknown>;
|
||||
};
|
||||
export type PieClickEvent = { label: string; value: number };
|
||||
|
||||
/** Union of every panel click event — switched on by `source` at the boundary. */
|
||||
export type PanelClickEvent =
|
||||
| ChartClickEvent
|
||||
| TableClickEvent
|
||||
| ListClickEvent
|
||||
| PieClickEvent;
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
/**
|
||||
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
|
||||
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
|
||||
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
|
||||
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
|
||||
* rendererProps.ts indexes this map, so a missing kind is a compile error there.
|
||||
*/
|
||||
export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
};
|
||||
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
|
||||
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
|
||||
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
|
||||
'signoz/NumberPanel': Record<string, never>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Widest interaction surface — used where the panel kind is not known
|
||||
* statically (the registry render boundary; see `getPanelDefinition`). It is
|
||||
* the structural supertype the per-kind shapes are cast to exactly once.
|
||||
*/
|
||||
export interface AnyPanelInteractionProps {
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
kind: K;
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
supportedSignals: DataSource[];
|
||||
}
|
||||
|
||||
// Keyed registry that preserves the kind ↔ definition correlation: indexing
|
||||
// with a literal kind yields that kind's exactly-typed PanelDefinition.
|
||||
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
|
||||
|
||||
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
|
||||
// At the render boundary the concrete kind isn't known statically (a registry
|
||||
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
|
||||
// concentrating the single unavoidable cast in one place instead of leaking it
|
||||
// to every call site.
|
||||
export interface RenderablePanelDefinition extends Omit<
|
||||
PanelDefinition,
|
||||
'Renderer'
|
||||
> {
|
||||
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { DashboardtypesPanelPluginKindDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* String-literal union of every panel kind, derived from the generated enum so
|
||||
* the contract stays the single source of truth. Kept as a `${enum}` union
|
||||
* (not the nominal enum) so plain string-literal kinds — `PanelRendererProps<
|
||||
* 'signoz/TimeSeriesPanel'>`, registry keys, `PanelInteractionMap` keys —
|
||||
* remain assignable without enum-member ceremony at every call site.
|
||||
*/
|
||||
export type PanelKind = `${DashboardtypesPanelPluginKindDTO}`;
|
||||
|
||||
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
|
||||
'signoz/BarChartPanel': PANEL_TYPES.BAR,
|
||||
'signoz/NumberPanel': PANEL_TYPES.VALUE,
|
||||
'signoz/PieChartPanel': PANEL_TYPES.PIE,
|
||||
'signoz/TablePanel': PANEL_TYPES.TABLE,
|
||||
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
|
||||
'signoz/ListPanel': PANEL_TYPES.LIST,
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelInteractionMap } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/**
|
||||
* Dashboard-wide rendering preferences propagated down to every panel renderer
|
||||
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
|
||||
* sync, tooltip filter mode, dashboard id for scoped state) without each
|
||||
* renderer rediscovering them via hooks.
|
||||
*/
|
||||
export interface DashboardPreference {
|
||||
/**
|
||||
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
|
||||
* hovering one panel highlights the corresponding x on every other panel.
|
||||
* Always present — `DashboardCursorSync.None` is the off state.
|
||||
*/
|
||||
syncMode: DashboardCursorSync;
|
||||
/**
|
||||
* Filter applied to the synced tooltip across panels (e.g. only show series
|
||||
* whose label matches the hovered series).
|
||||
*/
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
/**
|
||||
* Dashboard id — useful for renderers that scope per-dashboard state
|
||||
* (e.g. pinned-tooltip persistence, drill-down history).
|
||||
*/
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
// Kind-agnostic props every renderer receives, regardless of panel kind. The
|
||||
// kind-specific interaction props (onClick payload, onDragSelect) are layered
|
||||
// on per-kind by PanelRendererProps<K>.
|
||||
export interface BaseRendererProps {
|
||||
panelId: string;
|
||||
/**
|
||||
* The whole perses panel — renderers derive their concrete `spec` and the
|
||||
* perses-shaped `queries` from this. Passing the full panel keeps the prop
|
||||
* surface stable as new panel-level fields are added to the wire format.
|
||||
* Required: the render boundary (`Panel`) only mounts a renderer once the
|
||||
* panel and its kind are resolved, so a renderer never sees an absent panel.
|
||||
*/
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Gate for the drill-down right-click menu. Off by default in V2. */
|
||||
enableDrillDown?: boolean;
|
||||
/**
|
||||
* Render context — varies behavior (e.g. dashboard widget vs. standalone
|
||||
* full-screen vs. inside the editor). See PanelMode for the contract.
|
||||
*/
|
||||
panelMode: PanelMode;
|
||||
/**
|
||||
* Dashboard-level preferences that should propagate to every panel
|
||||
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
|
||||
* resolving these; the renderer just consumes them.
|
||||
*/
|
||||
dashboardPreference?: DashboardPreference;
|
||||
}
|
||||
|
||||
// Renderer props for a specific panel kind: the shared base plus that kind's
|
||||
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
|
||||
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
|
||||
// only reference the gestures that kind supports. Indexing PanelInteractionMap
|
||||
// here forces the map to cover every PanelKind. The default K = PanelKind
|
||||
// yields the widest surface (a union over all kinds).
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> =
|
||||
BaseRendererProps & PanelInteractionMap[K];
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
BarChart,
|
||||
Columns3,
|
||||
Hash,
|
||||
ListEnd,
|
||||
Palette,
|
||||
Ruler,
|
||||
SlidersHorizontal,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
// Derived from an actual icon component so the type stays exact (size is a
|
||||
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
|
||||
export type SectionIcon = typeof Hash;
|
||||
|
||||
export interface SectionMetadata {
|
||||
title: string;
|
||||
icon: SectionIcon;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
|
||||
// Section components type their controls prop via `SectionControls['axes']`.
|
||||
export type SectionControls = {
|
||||
formatting: { unit?: boolean; decimals?: boolean };
|
||||
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
|
||||
legend: { position?: boolean; mode?: boolean };
|
||||
thresholds: { list?: boolean };
|
||||
chartAppearance: {
|
||||
lineStyle?: boolean;
|
||||
fillOpacity?: boolean;
|
||||
stacked?: boolean;
|
||||
};
|
||||
columnUnits: { perColumnUnit?: boolean };
|
||||
buckets: { count?: boolean; min?: boolean; max?: boolean };
|
||||
};
|
||||
|
||||
// Source of truth for sections. Its keys define SectionKind; its values are the
|
||||
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
|
||||
// one entry here + one entry in SectionControls.
|
||||
export const SECTIONS = {
|
||||
formatting: { title: 'Formatting', icon: Hash },
|
||||
axes: { title: 'Axes', icon: Ruler },
|
||||
legend: { title: 'Legend', icon: ListEnd },
|
||||
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
|
||||
chartAppearance: { title: 'Chart appearance', icon: Palette },
|
||||
columnUnits: { title: 'Column units', icon: Columns3 },
|
||||
buckets: { title: 'Buckets', icon: BarChart },
|
||||
} as const satisfies Record<string, SectionMetadata>;
|
||||
|
||||
export type SectionKind = keyof typeof SECTIONS;
|
||||
|
||||
// Discriminated union derived from SectionControls — kept in lockstep automatically.
|
||||
export type SectionConfig = {
|
||||
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
|
||||
}[SectionKind];
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* V2-native threshold model.
|
||||
*
|
||||
* The panel spec carries thresholds as `DashboardtypesComparisonThresholdDTO`
|
||||
* (operator/format expressed as `above`/`below`/`text`/`background`). For
|
||||
* evaluation and rendering we work with the symbol operators and lowercase
|
||||
* display formats, kept here so V2 panels never reach into the V1
|
||||
* `container/NewWidget` `ThresholdProps` shape.
|
||||
*/
|
||||
|
||||
/** Comparison operators a threshold can use, as evaluable symbols. */
|
||||
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
|
||||
|
||||
/** How a matched threshold recolors the panel. */
|
||||
export type ThresholdDisplayFormat = 'text' | 'background';
|
||||
|
||||
/**
|
||||
* A threshold normalized for evaluation/rendering. `operator`/`format` are
|
||||
* optional because the spec allows partially-configured thresholds; a
|
||||
* threshold with no operator never matches.
|
||||
*/
|
||||
export interface PanelThreshold {
|
||||
color: string;
|
||||
operator?: ThresholdComparisonOperator;
|
||||
value: number;
|
||||
/** Unit the threshold value is expressed in; converted to the panel unit before comparison. */
|
||||
unit?: string;
|
||||
format?: ThresholdDisplayFormat;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
import {
|
||||
doesValueMatchThreshold,
|
||||
resolveActiveThreshold,
|
||||
} from '../evaluateThresholds';
|
||||
|
||||
const threshold = (overrides: Partial<PanelThreshold>): PanelThreshold => ({
|
||||
color: '#f00',
|
||||
value: 100,
|
||||
operator: '>',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('doesValueMatchThreshold', () => {
|
||||
it.each([
|
||||
['>', 150, 100, true],
|
||||
['>', 50, 100, false],
|
||||
['<', 50, 100, true],
|
||||
['>=', 100, 100, true],
|
||||
['<=', 100, 100, true],
|
||||
['=', 100, 100, true],
|
||||
['!=', 150, 100, true],
|
||||
] as const)('evaluates %s (%d vs %d)', (operator, value, target, expected) => {
|
||||
expect(
|
||||
doesValueMatchThreshold(value, threshold({ operator, value: target })),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it('never matches a threshold without an operator', () => {
|
||||
expect(doesValueMatchThreshold(150, threshold({ operator: undefined }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('compares the raw value when units are in different categories', () => {
|
||||
// 'bytes' vs 'ms' belong to different categories, so conversion is invalid
|
||||
// and the comparison falls back to the raw value (150 > 100).
|
||||
expect(
|
||||
doesValueMatchThreshold(150, threshold({ value: 100, unit: 'bytes' }), 'ms'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActiveThreshold', () => {
|
||||
it('returns no threshold when none match', () => {
|
||||
const result = resolveActiveThreshold([threshold({ value: 1000 })], 10);
|
||||
expect(result.threshold).toBeNull();
|
||||
expect(result.isConflicting).toBe(false);
|
||||
});
|
||||
|
||||
it('flags a conflict and picks the earliest-declared match', () => {
|
||||
const first = threshold({ color: '#aaa', operator: '>', value: 0 });
|
||||
const second = threshold({ color: '#bbb', operator: '>', value: 100 });
|
||||
|
||||
const result = resolveActiveThreshold([first, second], 150);
|
||||
|
||||
expect(result.isConflicting).toBe(true);
|
||||
expect(result.threshold).toBe(first);
|
||||
});
|
||||
|
||||
it('returns the single matching threshold without a conflict', () => {
|
||||
const only = threshold({ color: '#abc', operator: '>', value: 100 });
|
||||
const result = resolveActiveThreshold(
|
||||
[only, threshold({ value: 9999 })],
|
||||
150,
|
||||
);
|
||||
|
||||
expect(result.threshold).toBe(only);
|
||||
expect(result.isConflicting).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
|
||||
import { formatPanelValue } from '../formatPanelValue';
|
||||
|
||||
describe('formatPanelValue', () => {
|
||||
it('applies the configured precision and appends the unit label', () => {
|
||||
// The unit-aware formatter returns value + label as one string; the
|
||||
// ValueDisplay splits it into numeric/suffix parts when rendering.
|
||||
expect(formatPanelValue(295.4299833508185, 'ms', 2)).toBe('295.43 ms');
|
||||
});
|
||||
|
||||
// Regression: precision must apply even with no unit. The old gate
|
||||
// (`unit ? format() : value.toString()`) dropped precision on unitless
|
||||
// panels, so decimal-precision changes had no visible effect.
|
||||
it('applies precision when NO unit is set', () => {
|
||||
expect(formatPanelValue(3.14159, undefined, 2)).toBe('3.14');
|
||||
expect(formatPanelValue(3.14159, '', 2)).toBe('3.14');
|
||||
});
|
||||
|
||||
it('honors full precision without a unit', () => {
|
||||
expect(formatPanelValue(3.14159, undefined, PrecisionOptionsEnum.FULL)).toBe(
|
||||
'3.14159',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the fractional part at precision 0', () => {
|
||||
expect(formatPanelValue(3.14159, undefined, 0)).toBe('3');
|
||||
});
|
||||
|
||||
it('renders whole numbers without a trailing decimal', () => {
|
||||
expect(formatPanelValue(5, undefined, 2)).toBe('5');
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import { resolveSeriesLabelV5 } from '../resolveSeriesLabel';
|
||||
|
||||
// Fixtures cast at the boundary; the v5 BuilderQuery union is too verbose to
|
||||
// construct field-typed inline.
|
||||
|
||||
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
|
||||
return spec as unknown as BuilderQuery;
|
||||
}
|
||||
|
||||
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
labels: { host: 'h1' },
|
||||
kind: 'series',
|
||||
values: [],
|
||||
aggregation: { index: 0, alias: '' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveSeriesLabelV5', () => {
|
||||
it('returns baseLabel for panels without builder queries (promql/clickhouse)', () => {
|
||||
expect(resolveSeriesLabelV5(panelSeries(), [], 'base')).toBe('base');
|
||||
});
|
||||
|
||||
it('returns baseLabel when no query matches the series queryName', () => {
|
||||
expect(
|
||||
resolveSeriesLabelV5(
|
||||
panelSeries({ queryName: 'Z' }),
|
||||
[builderQuery({ name: 'A' })],
|
||||
'base',
|
||||
),
|
||||
).toBe('base');
|
||||
});
|
||||
|
||||
it('falls back to baseLabel || queryName when the aggregation has no alias/expression (metrics)', () => {
|
||||
const queries = [
|
||||
builderQuery({ name: 'A', aggregations: [{ metricName: 'cpu' }] }),
|
||||
];
|
||||
expect(resolveSeriesLabelV5(panelSeries(), queries, 'base')).toBe('base');
|
||||
expect(resolveSeriesLabelV5(panelSeries(), queries, '')).toBe('A');
|
||||
});
|
||||
|
||||
it('single query + groupBy + single aggregation → baseLabel', () => {
|
||||
const queries = [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
groupBy: [{ name: 'host' }],
|
||||
aggregations: [{ expression: 'count()', alias: '' }],
|
||||
}),
|
||||
];
|
||||
expect(resolveSeriesLabelV5(panelSeries(), queries, 'h1')).toBe('h1');
|
||||
});
|
||||
|
||||
it('single query + groupBy + multiple aggregations → "alias-or-expression"-baseLabel', () => {
|
||||
const queries = [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
groupBy: [{ name: 'host' }],
|
||||
aggregations: [
|
||||
{ expression: 'count()', alias: '' },
|
||||
{ expression: 'avg(x)', alias: 'mean' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
resolveSeriesLabelV5(
|
||||
panelSeries({ aggregation: { index: 1, alias: 'mean' } }),
|
||||
queries,
|
||||
'h1',
|
||||
),
|
||||
).toBe('mean-h1');
|
||||
});
|
||||
|
||||
it('single query, no groupBy, single aggregation → alias || legend || expression', () => {
|
||||
const queries = [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
legend: 'My legend',
|
||||
aggregations: [{ expression: 'count()', alias: '' }],
|
||||
}),
|
||||
];
|
||||
expect(resolveSeriesLabelV5(panelSeries({ labels: {} }), queries, 'A')).toBe(
|
||||
'My legend',
|
||||
);
|
||||
});
|
||||
|
||||
it('multiple queries, no groupBy, single aggregation → alias || baseLabel', () => {
|
||||
const queries = [
|
||||
builderQuery({ name: 'A', aggregations: [{ expression: 'count()' }] }),
|
||||
builderQuery({ name: 'B', aggregations: [{ expression: 'sum(y)' }] }),
|
||||
];
|
||||
expect(
|
||||
resolveSeriesLabelV5(panelSeries({ labels: {} }), queries, 'base'),
|
||||
).toBe('base');
|
||||
});
|
||||
|
||||
it('resolves via the aggregation index carried on the series', () => {
|
||||
const queries = [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
aggregations: [
|
||||
{ expression: 'count()', alias: 'hits' },
|
||||
{ expression: 'avg(x)', alias: 'mean' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
expect(
|
||||
resolveSeriesLabelV5(
|
||||
panelSeries({ labels: {}, aggregation: { index: 1, alias: 'mean' } }),
|
||||
queries,
|
||||
'',
|
||||
),
|
||||
).toBe('mean');
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesPanelFormattingDTO,
|
||||
DashboardtypesThresholdWithLabelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import onClickPlugin, {
|
||||
OnClickPluginOpts,
|
||||
} from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
resolveSelectionPreferencesSource,
|
||||
shouldSaveSelectionPreference,
|
||||
} from './selectionPreferences';
|
||||
|
||||
/**
|
||||
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
|
||||
* name but accepts perses-shaped inputs directly (so callers don't translate
|
||||
* once per panel). The series-rendering step is panel-specific and lives in
|
||||
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
|
||||
* thresholds, axes, drag-to-zoom, click plugin).
|
||||
*/
|
||||
export interface BuildBaseConfigArgs {
|
||||
panelId: string;
|
||||
panelType: PANEL_TYPES;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
|
||||
/** From `spec.axes` — drives the Y scale and (when log) both scales' base. */
|
||||
isLogScale?: boolean;
|
||||
softMin?: number;
|
||||
softMax?: number;
|
||||
|
||||
/** From `spec.formatting.unit` — drives Y axis tick formatting + threshold formatting. */
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
|
||||
/** From `spec.thresholds` — perses shape, mapped to the draw-hook shape internally. */
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
/**
|
||||
* Tuple-shaped payload for the shared click plugin (see
|
||||
* `toClickPluginPayload`). Omitted by panels without click interactions.
|
||||
*/
|
||||
clickPayload?: MetricRangePayloadProps;
|
||||
|
||||
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
|
||||
/** Optional — histogram and other non-time panels omit drag-to-zoom. */
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
|
||||
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
|
||||
* then call `addSeries`/`addPlugin` on the returned builder for their own
|
||||
* panel-specific rendering.
|
||||
*/
|
||||
export function buildBaseConfig({
|
||||
panelId,
|
||||
panelType,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
isLogScale,
|
||||
softMin,
|
||||
softMax,
|
||||
formatting,
|
||||
thresholds,
|
||||
stepIntervals,
|
||||
clickPayload,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
}: BuildBaseConfigArgs): UPlotConfigBuilder {
|
||||
const yAxisUnit = formatting?.unit;
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id: panelId,
|
||||
onDragSelect,
|
||||
tzDate: makeTzDate(timezone),
|
||||
shouldSaveSelectionPreference: shouldSaveSelectionPreference(panelMode),
|
||||
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
|
||||
stepInterval: stepIntervals ? minStepInterval(stepIntervals) : undefined,
|
||||
});
|
||||
|
||||
const thresholdOptions: ThresholdsDrawHookOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: mapThresholds(thresholds),
|
||||
yAxisUnit,
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdOptions);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin,
|
||||
softMax,
|
||||
thresholds: thresholdOptions,
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
|
||||
if (typeof onClick === 'function') {
|
||||
builder.addPlugin(onClickPlugin({ onClick, apiResponse: clickPayload }));
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
panelType,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeTzDate(
|
||||
timezone: Timezone,
|
||||
): ((timestamp: number) => Date) | undefined {
|
||||
if (!timezone) {
|
||||
return undefined;
|
||||
}
|
||||
return (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
}
|
||||
|
||||
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
|
||||
// panels that need to feed the same threshold list elsewhere (e.g. to a series
|
||||
// `addSeries` thresholds hook) don't have to redo the mapping.
|
||||
export function mapThresholds(
|
||||
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
|
||||
): ThresholdsDrawHookOptions['thresholds'] {
|
||||
if (!thresholds || thresholds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return thresholds.map((t) => ({
|
||||
thresholdValue: t.value,
|
||||
thresholdColor: t.color,
|
||||
thresholdUnit: t.unit,
|
||||
thresholdLabel: t.label,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* V5 backend reports per-query step intervals; we feed the smallest one through
|
||||
* to uPlot so the X-axis tick density matches the densest query. An empty map
|
||||
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math —
|
||||
* fall back to `undefined` (uPlot's "auto") in that case.
|
||||
*/
|
||||
function minStepInterval(
|
||||
stepIntervals: Record<string, number>,
|
||||
): number | undefined {
|
||||
const values = Object.values(stepIntervals);
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
return Number.isFinite(min) ? min : undefined;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
|
||||
/**
|
||||
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
|
||||
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
|
||||
* two — don't coerce, map.
|
||||
*
|
||||
* Kept as a single source of truth so every panel that reads chart-appearance
|
||||
* fields stays in sync as either side's enum evolves.
|
||||
*/
|
||||
|
||||
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {
|
||||
[DashboardtypesLineStyleDTO.solid]: LineStyle.Solid,
|
||||
[DashboardtypesLineStyleDTO.dashed]: LineStyle.Dashed,
|
||||
};
|
||||
|
||||
export const LINE_INTERPOLATION_MAP: Record<
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
LineInterpolation
|
||||
> = {
|
||||
[DashboardtypesLineInterpolationDTO.linear]: LineInterpolation.Linear,
|
||||
[DashboardtypesLineInterpolationDTO.spline]: LineInterpolation.Spline,
|
||||
[DashboardtypesLineInterpolationDTO.step_after]: LineInterpolation.StepAfter,
|
||||
[DashboardtypesLineInterpolationDTO.step_before]: LineInterpolation.StepBefore,
|
||||
};
|
||||
|
||||
export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
|
||||
[DashboardtypesFillModeDTO.solid]: FillMode.Solid,
|
||||
[DashboardtypesFillModeDTO.gradient]: FillMode.Gradient,
|
||||
[DashboardtypesFillModeDTO.none]: FillMode.None,
|
||||
};
|
||||
|
||||
export const LEGEND_POSITION_MAP: Record<
|
||||
DashboardtypesLegendPositionDTO,
|
||||
LegendPosition
|
||||
> = {
|
||||
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
|
||||
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesPrecisionOptionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { LEGEND_POSITION_MAP } from './enumMaps';
|
||||
|
||||
/**
|
||||
* Resolvers that turn raw `spec` chart-appearance fields into the chart's
|
||||
* runtime values, falling back to the chart defaults for missing/unknown input.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
|
||||
* (`'0'`–`'4'` plus the sentinel `'full'`). The chart consumes a numeric
|
||||
* `PrecisionOption` (`0`–`4`) or the same `'full'` sentinel from its own
|
||||
* enum. Missing / unknown → `undefined` (chart uses its default).
|
||||
*/
|
||||
export function resolveDecimalPrecision(
|
||||
precision: DashboardtypesPrecisionOptionDTO | undefined,
|
||||
): PrecisionOption | undefined {
|
||||
if (!precision) {
|
||||
return undefined;
|
||||
}
|
||||
if (precision === DashboardtypesPrecisionOptionDTO.full) {
|
||||
return PrecisionOptionsEnum.FULL;
|
||||
}
|
||||
const parsed = Number(precision);
|
||||
if (
|
||||
parsed === 0 ||
|
||||
parsed === 1 ||
|
||||
parsed === 2 ||
|
||||
parsed === 3 ||
|
||||
parsed === 4
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
|
||||
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
|
||||
* the threshold so uPlot only bridges short runs of nulls.
|
||||
*/
|
||||
export function resolveSpanGaps(
|
||||
fillLessThan: string | undefined,
|
||||
): boolean | number {
|
||||
if (!fillLessThan) {
|
||||
return true;
|
||||
}
|
||||
const parsed = Number(fillLessThan);
|
||||
return Number.isFinite(parsed) ? parsed : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the legend position for a panel. Missing / unknown values fall
|
||||
* back to `BOTTOM` to match the chart's default and the V1 behavior.
|
||||
*/
|
||||
export function resolveLegendPosition(
|
||||
position: DashboardtypesLegendPositionDTO | undefined,
|
||||
): LegendPosition {
|
||||
if (position && position in LEGEND_POSITION_MAP) {
|
||||
return LEGEND_POSITION_MAP[position];
|
||||
}
|
||||
return LegendPosition.BOTTOM;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
UniversalUnitToGrafanaUnit,
|
||||
YAxisCategoryNames,
|
||||
} from 'components/YAxisUnitSelector/constants';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
|
||||
import { convertValue } from 'lib/getConvertedValue';
|
||||
|
||||
import type {
|
||||
PanelThreshold,
|
||||
ThresholdComparisonOperator,
|
||||
} from '../types/threshold';
|
||||
|
||||
/**
|
||||
* Threshold evaluation for V2 panels — a self-contained port of the V1
|
||||
* `GridTableComponent`/`ValueGraph` logic that depends only on shared,
|
||||
* non-V1 primitives (`convertValue`, the Y-axis unit catalog). No imports
|
||||
* from `container/NewWidget`, `container/GridTableComponent`, or
|
||||
* `components/ValueGraph`.
|
||||
*/
|
||||
|
||||
/** Resolves which unit category a unit id belongs to, or null if unknown. */
|
||||
function getCategoryName(unitId: string): YAxisCategoryNames | null {
|
||||
const categories = getYAxisCategories(YAxisSource.DASHBOARDS);
|
||||
|
||||
const foundCategory = categories.find((category) =>
|
||||
category.units.some((unit) => {
|
||||
// Category units use universal ids; thresholds/panel units may use
|
||||
// Grafana-style ids. Match either the universal id directly or its
|
||||
// mapped Grafana id.
|
||||
if (unit.id === unitId) {
|
||||
return true;
|
||||
}
|
||||
return UniversalUnitToGrafanaUnit[unit.id] === unitId;
|
||||
}),
|
||||
);
|
||||
|
||||
return foundCategory ? foundCategory.name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts `value` from `fromUnit` to `toUnit`, returning null when the
|
||||
* conversion is invalid (unknown unit, or units in different categories).
|
||||
*/
|
||||
function convertUnit(
|
||||
value: number,
|
||||
fromUnit?: string,
|
||||
toUnit?: string,
|
||||
): number | null {
|
||||
if (!fromUnit || !toUnit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromCategory = getCategoryName(fromUnit);
|
||||
const toCategory = getCategoryName(toUnit);
|
||||
|
||||
if (!fromCategory || !toCategory || fromCategory !== toCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return convertValue(value, fromUnit, toUnit);
|
||||
}
|
||||
|
||||
function evaluateCondition(
|
||||
operator: ThresholdComparisonOperator | undefined,
|
||||
value: number,
|
||||
thresholdValue: number,
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case '>':
|
||||
return value > thresholdValue;
|
||||
case '<':
|
||||
return value < thresholdValue;
|
||||
case '>=':
|
||||
return value >= thresholdValue;
|
||||
case '<=':
|
||||
return value <= thresholdValue;
|
||||
case '=':
|
||||
return value === thresholdValue;
|
||||
case '!=':
|
||||
return value !== thresholdValue;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` (expressed in `panelUnit`) satisfies `threshold`. When the
|
||||
* threshold declares its own unit, the panel value is converted into that unit
|
||||
* before comparing; if the conversion is invalid we compare the raw value.
|
||||
*/
|
||||
export function doesValueMatchThreshold(
|
||||
value: number,
|
||||
threshold: PanelThreshold,
|
||||
panelUnit?: string,
|
||||
): boolean {
|
||||
if (threshold.operator === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const convertedValue = convertUnit(value, panelUnit, threshold.unit);
|
||||
const comparable = convertedValue ?? value;
|
||||
|
||||
return evaluateCondition(threshold.operator, comparable, threshold.value);
|
||||
}
|
||||
|
||||
export interface ActiveThreshold {
|
||||
/** The matched threshold to apply, or null when none match. */
|
||||
threshold: PanelThreshold | null;
|
||||
/** True when more than one threshold matched the value. */
|
||||
isConflicting: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the threshold to apply for `value`. Among matching thresholds the
|
||||
* one declared earliest (lowest index) wins, mirroring V1 precedence; a match
|
||||
* count greater than one flags a conflict.
|
||||
*/
|
||||
export function resolveActiveThreshold(
|
||||
thresholds: PanelThreshold[],
|
||||
value: number,
|
||||
panelUnit?: string,
|
||||
): ActiveThreshold {
|
||||
const matching = thresholds.filter((threshold) =>
|
||||
doesValueMatchThreshold(value, threshold, panelUnit),
|
||||
);
|
||||
|
||||
if (matching.length === 0) {
|
||||
return { threshold: null, isConflicting: false };
|
||||
}
|
||||
|
||||
const highestPrecedence = matching.reduce((winner, candidate) =>
|
||||
thresholds.indexOf(candidate) < thresholds.indexOf(winner)
|
||||
? candidate
|
||||
: winner,
|
||||
);
|
||||
|
||||
return { threshold: highestPrecedence, isConflicting: matching.length > 1 };
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
/**
|
||||
* Formats a scalar for display in a V2 panel, honoring the configured decimal
|
||||
* precision. The shared, unit-aware `getYAxisFormattedValue` is the single
|
||||
* formatting helper across V2 panels (number/table/list/pie); this wrapper is
|
||||
* the only seam through which panels touch it.
|
||||
*
|
||||
* Precision is applied REGARDLESS of whether a unit is set. When no unit is
|
||||
* configured we format through the `'none'` unit, which still respects
|
||||
* precision — this is the fix for decimal precision being silently dropped on
|
||||
* unitless panels (the old `unit ? format() : value.toString()` gate threw the
|
||||
* precision away whenever the unit was empty).
|
||||
*/
|
||||
export function formatPanelValue(
|
||||
value: number,
|
||||
unit?: string,
|
||||
precision?: PrecisionOption,
|
||||
): string {
|
||||
return getYAxisFormattedValue(String(value), unit || 'none', precision);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Flattens a panel's queries into the list of builder queries it contains —
|
||||
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
|
||||
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
|
||||
* carry the legend / groupBy / aggregation context downstream code needs.
|
||||
*
|
||||
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
|
||||
* summary type — so callers consume the same type the wire format defines.
|
||||
*/
|
||||
export function getBuilderQueries(
|
||||
queries: DashboardtypesQueryDTO[] | null | undefined,
|
||||
): BuilderQuery[] {
|
||||
if (!queries) {
|
||||
return [];
|
||||
}
|
||||
const flattened: BuilderQuery[] = [];
|
||||
queries.forEach((envelope) => {
|
||||
const plugin = envelope?.spec?.plugin;
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/BuilderQuery') {
|
||||
flattened.push(plugin.spec as BuilderQuery);
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
(plugin.spec.queries ?? []).forEach((sub) => {
|
||||
if (sub.type === 'builder_query') {
|
||||
flattened.push(sub.spec as BuilderQuery);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return flattened;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export interface ParsedFormattedValue {
|
||||
/** The numeric portion (e.g. "295.43", "1.2K"). */
|
||||
numericValue: string;
|
||||
/** A leading unit symbol such as a currency prefix, if any. */
|
||||
prefixUnit: string;
|
||||
/** A trailing unit label such as "ms" or "MB", if any. */
|
||||
suffixUnit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a formatted value string (e.g. "$ 1.2K", "295.43 ms") into its
|
||||
* numeric core and any prefix/suffix unit so each part can be styled
|
||||
* independently. Falls back to treating the whole string as the numeric value
|
||||
* when it doesn't match the expected shape.
|
||||
*/
|
||||
export function parseFormattedValue(value: string): ParsedFormattedValue {
|
||||
const matches = value.match(
|
||||
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
|
||||
);
|
||||
|
||||
return {
|
||||
numericValue: matches?.[2] || value,
|
||||
prefixUnit: matches?.[1]?.trim() || '',
|
||||
suffixUnit: matches?.[3]?.trim() || '',
|
||||
};
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
/**
|
||||
* Identity of one series for label resolution: which query produced it and
|
||||
* which of that query's aggregations.
|
||||
*/
|
||||
interface SeriesIdentity {
|
||||
queryName: string;
|
||||
/** Index into the matched query's aggregation list. */
|
||||
aggregationIndex: number;
|
||||
/** Fallback when the base label is empty and the aggregation is bare. */
|
||||
fallbackName?: string;
|
||||
}
|
||||
|
||||
/** Resolves the display label for one flattened V5 series. */
|
||||
export function resolveSeriesLabelV5(
|
||||
series: PanelSeries,
|
||||
builderQueries: BuilderQuery[],
|
||||
baseLabel: string,
|
||||
): string {
|
||||
return resolveLabel(
|
||||
{
|
||||
queryName: series.queryName,
|
||||
aggregationIndex: series.aggregation.index,
|
||||
fallbackName: series.queryName,
|
||||
},
|
||||
builderQueries,
|
||||
baseLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the V1 legend matrix: `single-vs-many builder queries ×
|
||||
* with/without groupBy × single-vs-many aggregations`. Returns `baseLabel`
|
||||
* unchanged for panels without builder queries (PromQL, ClickHouseSQL) and
|
||||
* for builder series whose aggregation carries no alias/expression — metric
|
||||
* aggregations don't have those fields, so they naturally short-circuit to
|
||||
* the base label here.
|
||||
*/
|
||||
function resolveLabel(
|
||||
identity: SeriesIdentity,
|
||||
builderQueries: BuilderQuery[],
|
||||
baseLabel: string,
|
||||
): string {
|
||||
if (builderQueries.length === 0) {
|
||||
return baseLabel;
|
||||
}
|
||||
|
||||
const matching = builderQueries.find((q) => q.name === identity.queryName);
|
||||
if (!matching) {
|
||||
return baseLabel;
|
||||
}
|
||||
|
||||
const aggIndex = identity.aggregationIndex;
|
||||
const aggregations = matching.aggregations ?? [];
|
||||
const aggregation = aggregations[aggIndex];
|
||||
|
||||
// `alias` / `expression` exist on Log/Trace aggregations only —
|
||||
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
|
||||
// `in` guards narrow the union without a cast.
|
||||
const aggregationAlias =
|
||||
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
|
||||
const aggregationExpression =
|
||||
aggregation && 'expression' in aggregation
|
||||
? (aggregation.expression ?? '')
|
||||
: '';
|
||||
|
||||
if (!aggregationAlias && !aggregationExpression) {
|
||||
return baseLabel || identity.fallbackName || matching.name || '';
|
||||
}
|
||||
|
||||
const ctx: FormatContext = {
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
baseLabel,
|
||||
legend: matching.legend ?? '',
|
||||
hasGroupBy: (matching.groupBy?.length ?? 0) > 0,
|
||||
singleAggregation: aggregations.length === 1,
|
||||
};
|
||||
|
||||
return builderQueries.length === 1
|
||||
? formatForSinglePanelQuery(ctx)
|
||||
: formatForMultiplePanelQueries(ctx);
|
||||
}
|
||||
|
||||
interface FormatContext {
|
||||
aggregationAlias: string;
|
||||
aggregationExpression: string;
|
||||
baseLabel: string;
|
||||
legend: string;
|
||||
hasGroupBy: boolean;
|
||||
singleAggregation: boolean;
|
||||
}
|
||||
|
||||
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
|
||||
function formatForSinglePanelQuery({
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
baseLabel,
|
||||
legend,
|
||||
hasGroupBy,
|
||||
singleAggregation,
|
||||
}: FormatContext): string {
|
||||
if (hasGroupBy) {
|
||||
if (singleAggregation) {
|
||||
return baseLabel;
|
||||
}
|
||||
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
|
||||
}
|
||||
if (singleAggregation) {
|
||||
return aggregationAlias || legend || aggregationExpression;
|
||||
}
|
||||
return aggregationAlias || aggregationExpression;
|
||||
}
|
||||
|
||||
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
|
||||
// Differs from the single-query path in two cells: the no-groupBy / single-agg
|
||||
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
|
||||
// multi-agg cell prepends the base label.
|
||||
function formatForMultiplePanelQueries({
|
||||
aggregationAlias,
|
||||
aggregationExpression,
|
||||
baseLabel,
|
||||
hasGroupBy,
|
||||
singleAggregation,
|
||||
}: FormatContext): string {
|
||||
if (hasGroupBy) {
|
||||
if (singleAggregation) {
|
||||
return baseLabel;
|
||||
}
|
||||
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
|
||||
}
|
||||
if (singleAggregation) {
|
||||
return aggregationAlias || baseLabel || aggregationExpression;
|
||||
}
|
||||
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { SelectionPreferencesSource } from 'lib/uPlotV2/config/types';
|
||||
|
||||
/**
|
||||
* Drag-to-zoom "selection preference" wiring, grouped on its own so the base
|
||||
* config builder stays focused on assembling the chart. Both helpers are driven
|
||||
* purely by the render context (`PanelMode`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether a chart's drag-selection preference should be persisted. Only the
|
||||
* read-only dashboard view persists it; editor/preview contexts keep it
|
||||
* ephemeral so an in-progress edit doesn't mutate saved state.
|
||||
*/
|
||||
export function shouldSaveSelectionPreference(panelMode: PanelMode): boolean {
|
||||
return panelMode === PanelMode.DASHBOARD_VIEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the chart reads/writes its selection preference: localStorage for the
|
||||
* persisted view contexts, in-memory otherwise.
|
||||
*/
|
||||
export function resolveSelectionPreferencesSource(
|
||||
panelMode: PanelMode,
|
||||
): SelectionPreferencesSource {
|
||||
return panelMode === PanelMode.DASHBOARD_VIEW ||
|
||||
panelMode === PanelMode.STANDALONE_VIEW
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
: SelectionPreferencesSource.IN_MEMORY;
|
||||
}
|
||||
@@ -3,8 +3,50 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bodyKind {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import type { Warning } from 'types/api';
|
||||
import type { DashboardtypesPanelPluginKindDTO as PanelKind } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import type { DeletePanelArgs } from './hooks/useDeletePanel';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
|
||||
import PanelBody from './PanelBody/PanelBody';
|
||||
import UnsupportedPanelBody from './PanelBody/UnsupportedPanelBody';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
/** Panel action context — present together only in editable sectioned mode. */
|
||||
@@ -24,43 +21,28 @@ export interface PanelActionsConfig {
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
/** True once this panel's section enters the viewport — gates the fetch. */
|
||||
/**
|
||||
* Placeholder: true once this panel's section enters the viewport. The panel
|
||||
* query-loading implementation (later PR) will consume this to lazily fetch
|
||||
* data. Currently unused on purpose.
|
||||
*/
|
||||
isVisible?: boolean;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single dashboard panel: chrome (header) + content (body). Thin orchestrator
|
||||
* — data fetching lives in `usePanelQuery`, cross-panel interactions in
|
||||
* `usePanelInteractions`, and the loading/error/chart state machine in
|
||||
* `PanelBody`.
|
||||
*/
|
||||
function Panel({
|
||||
panel,
|
||||
panelId,
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
const name = panel.spec.display?.name;
|
||||
const description = panel.spec.display?.description;
|
||||
const fullKind = panel.spec.plugin?.kind as unknown as PanelKind;
|
||||
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel.spec.queries?.length ?? 0;
|
||||
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
// Lazy: only fetch once the section is on screen (undefined → treat as
|
||||
// visible) and a renderer exists for the kind.
|
||||
enabled: !!panelDefinition && isVisible !== false,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
|
||||
const description = panel?.spec?.display?.description;
|
||||
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel?.spec?.queries?.length ?? 0;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) {
|
||||
@@ -78,33 +60,35 @@ function Panel({
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<PanelHeader
|
||||
title={headerTitle}
|
||||
panelId={panelId}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
// The V5 response `warning` is the same object the legacy chain
|
||||
// surfaced as `Warning` — passed through untouched; the cast is the
|
||||
// generated-DTO → hand-written-type boundary.
|
||||
warning={data.response?.data?.warning as Warning | undefined}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
{panelDefinition ? (
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
dashboardPreference={dashboardPreference}
|
||||
/>
|
||||
) : (
|
||||
// TODO: remove this after all panel kinds are supported
|
||||
<UnsupportedPanelBody kind={kind} queryCount={queryCount} />
|
||||
)}
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>
|
||||
{headerTitle}
|
||||
</Typography.Text>
|
||||
<Badge className={styles.badge}>{kind}</Badge>
|
||||
</div>
|
||||
{panelActions ? (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={panelActions.currentLayoutIndex}
|
||||
sections={panelActions.sections}
|
||||
onMovePanel={panelActions.onMovePanel}
|
||||
onDeletePanel={panelActions.onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<EllipsisVertical size={14} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
|
||||
coming next
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Generic centred body — used by the loading indicator and the
|
||||
// unsupported-kind fallback.
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bodyKind {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// Container for the rendered chart — fills the panel below the header and lets
|
||||
// the chart shrink (min-* 0) so it resizes with the grid cell.
|
||||
.chartContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Error state — shown only when there's no stale data to fall back to.
|
||||
.error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
max-width: 90%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Spin } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Loader, TriangleAlert } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import styles from './PanelBody.module.scss';
|
||||
|
||||
interface PanelBodyProps {
|
||||
/** Resolved renderer for the panel kind — always present (`Panel` renders the
|
||||
* unsupported fallback itself when no renderer is registered). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
dashboardPreference: DashboardPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content of a panel whose kind has a registered renderer, as an
|
||||
* explicit state machine so each state is handled deliberately (no implicit
|
||||
* fall-through):
|
||||
*
|
||||
* error + no data → error message with retry
|
||||
* first load (no data) → loading indicator
|
||||
* otherwise → the kind's renderer (which owns its own "No Data" state, and
|
||||
* keeps stale data mounted during background refetches)
|
||||
*/
|
||||
function PanelBody({
|
||||
panelDefinition,
|
||||
panel,
|
||||
panelId,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// Surface a hard failure only when there's no (stale) data to show; otherwise
|
||||
// keep the last-good chart and let the header indicate the refresh.
|
||||
// react-query keeps the previous response during background refetches, so
|
||||
// `data.response` presence is the "have something to show" signal.
|
||||
const hasData = !!data.response;
|
||||
|
||||
if (error && !hasData) {
|
||||
return (
|
||||
<div className={styles.error} data-testid="panel-error">
|
||||
<TriangleAlert size={20} className={styles.errorIcon} />
|
||||
<Typography.Text className={styles.errorMessage}>
|
||||
{error.message || 'Failed to load panel data'}
|
||||
</Typography.Text>
|
||||
<Button variant="outlined" color="secondary" onClick={refetch}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// First load only — background refetches keep the response populated so the
|
||||
// chart stays mounted instead of blinking.
|
||||
if (isLoading && !hasData) {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.chartContainer}>
|
||||
<panelDefinition.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
enableDrillDown={false}
|
||||
dashboardPreference={dashboardPreference}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelBody;
|
||||
@@ -1,32 +0,0 @@
|
||||
import styles from './PanelBody.module.scss';
|
||||
|
||||
interface UnsupportedPanelBodyProps {
|
||||
/** Short, signoz-prefix-stripped panel kind (e.g. "TablePanel"). */
|
||||
kind: string;
|
||||
queryCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body shown when no renderer is registered for the panel's kind. Split out from
|
||||
* `PanelBody` so that `PanelBody` only ever runs with a resolved renderer — the
|
||||
* "kind not yet supported" path is handled here, before any data fetching is
|
||||
* surfaced.
|
||||
*/
|
||||
function UnsupportedPanelBody({
|
||||
kind,
|
||||
queryCount,
|
||||
}: UnsupportedPanelBodyProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.body} data-testid="panel-unknown-kind-fallback">
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet supported
|
||||
in V2
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnsupportedPanelBody;
|
||||
@@ -1,41 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
// Actions sit inside the drag-handle row but opt out of dragging
|
||||
// (`panel-no-drag`); reset the grab cursor so the menu reads as clickable.
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// Subtle background-refetch spinner in the header (chart stays mounted).
|
||||
.refetchIndicator {
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
|
||||
import {
|
||||
panelStatusFromError,
|
||||
panelStatusFromWarning,
|
||||
} from '../PanelStatus/utils';
|
||||
import styles from './PanelHeader.module.scss';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
panelId: string;
|
||||
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error, if any — surfaced as a header error indicator. */
|
||||
error?: Error | null;
|
||||
/** Non-fatal query warning lifted from the response payload. */
|
||||
warning?: Warning;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
|
||||
function PanelHeader({
|
||||
title,
|
||||
panelId,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
panelActions,
|
||||
}: PanelHeaderProps): JSX.Element {
|
||||
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
|
||||
|
||||
const warningDetail = useMemo(
|
||||
() => panelStatusFromWarning(warning),
|
||||
[warning],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
|
||||
{isFetching && (
|
||||
<Loader
|
||||
size={12}
|
||||
className={cx('animate-spin', styles.refetchIndicator)}
|
||||
data-testid="panel-refetching"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* `panel-no-drag` opts this region out of the grid drag handle so the
|
||||
actions menu is clickable instead of starting a panel drag. */}
|
||||
<div className={cx('panel-no-drag', styles.actions)}>
|
||||
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
|
||||
{warningDetail && (
|
||||
<PanelStatusPopover variant="warning" detail={warningDetail} />
|
||||
)}
|
||||
{panelActions && (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={panelActions.currentLayoutIndex}
|
||||
sections={panelActions.sections}
|
||||
onMovePanel={panelActions.onMovePanel}
|
||||
onDeletePanel={panelActions.onDeletePanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelHeader;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { BookOpenText } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
import styles from './PanelStatusPopover.module.scss';
|
||||
|
||||
interface PanelStatusContentProps {
|
||||
detail: PanelStatusDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover body for a panel status (error or warning): a code + summary header
|
||||
* with an optional docs link, followed by any per-item messages. Pure
|
||||
* presentation — the variant's icon/colour is owned by `PanelStatusPopover`.
|
||||
*/
|
||||
function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
|
||||
const { code, message, docsUrl, messages } = detail;
|
||||
|
||||
return (
|
||||
<section className={styles.content} data-testid="panel-status-content">
|
||||
<header className={styles.summary}>
|
||||
<div className={styles.summaryText}>
|
||||
<h2 className={styles.code}>{code}</h2>
|
||||
<p className={styles.message}>{message}</p>
|
||||
</div>
|
||||
{docsUrl && (
|
||||
<Typography.Link
|
||||
className={styles.docsLink}
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="panel-status-docs"
|
||||
>
|
||||
<BookOpenText size={14} />
|
||||
<span>Open Docs</span>
|
||||
</Typography.Link>
|
||||
)}
|
||||
</header>
|
||||
{messages.length > 0 && (
|
||||
<ul className={styles.messageList}>
|
||||
{messages.map((m) => (
|
||||
<li key={m} className={styles.messageItem}>
|
||||
{m}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelStatusContent;
|
||||
@@ -1,65 +0,0 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 600px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.messageItem {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CircleX, TriangleAlert } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import PanelStatusContent from './PanelStatusContent';
|
||||
import type { PanelStatusDetail, PanelStatusVariant } from './types';
|
||||
import styles from './PanelStatusPopover.module.scss';
|
||||
|
||||
const VARIANT_CONFIG: Record<
|
||||
PanelStatusVariant,
|
||||
{ color: string; ariaLabel: string }
|
||||
> = {
|
||||
error: { color: Color.BG_CHERRY_500, ariaLabel: 'Panel error' },
|
||||
warning: { color: Color.BG_AMBER_500, ariaLabel: 'Panel warning' },
|
||||
};
|
||||
|
||||
interface PanelStatusPopoverProps {
|
||||
variant: PanelStatusVariant;
|
||||
detail: PanelStatusDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header status indicator: a variant-coloured icon (error → CircleX,
|
||||
* warning → TriangleAlert) that opens a tooltip with the status detail. One
|
||||
* component drives both variants so error and warning surfacing stay in lockstep.
|
||||
*/
|
||||
function PanelStatusPopover({
|
||||
variant,
|
||||
detail,
|
||||
}: PanelStatusPopoverProps): JSX.Element {
|
||||
const { color, ariaLabel } = VARIANT_CONFIG[variant];
|
||||
const Icon = variant === 'error' ? CircleX : TriangleAlert;
|
||||
|
||||
return (
|
||||
<TooltipSimple title={<PanelStatusContent detail={detail} />} arrow>
|
||||
{/* Wrapping span gives a ref-able, hoverable trigger (icon
|
||||
components don't forward refs) and a stable testid anchor. */}
|
||||
<span
|
||||
className={styles.trigger}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={`panel-status-${variant}`}
|
||||
>
|
||||
<Icon size={16} color={color} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelStatusPopover;
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
|
||||
|
||||
// The query layer rejects with the raw AxiosError from the generated client
|
||||
// (it is not pre-converted to APIError), so the tests mirror that wire shape.
|
||||
function axiosErrorWith(
|
||||
error: RenderErrorResponseDTO['error'],
|
||||
status: number = StatusCodes.BAD_REQUEST,
|
||||
): AxiosError<RenderErrorResponseDTO> {
|
||||
return {
|
||||
response: { status, data: { error } },
|
||||
} as AxiosError<RenderErrorResponseDTO>;
|
||||
}
|
||||
|
||||
describe('panelStatusFromError', () => {
|
||||
it('returns null when there is no error', () => {
|
||||
expect(panelStatusFromError(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a structured API error to code/message/docs/sub-messages', () => {
|
||||
const error = axiosErrorWith({
|
||||
code: 'invalid_query',
|
||||
message: 'Query is invalid',
|
||||
url: 'https://docs/err',
|
||||
errors: [{ message: 'missing aggregation' }, { message: 'bad filter' }],
|
||||
});
|
||||
|
||||
expect(panelStatusFromError(error)).toStrictEqual({
|
||||
code: 'invalid_query',
|
||||
message: 'Query is invalid',
|
||||
docsUrl: 'https://docs/err',
|
||||
messages: ['missing aggregation', 'bad filter'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the error message when there is no structured body', () => {
|
||||
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
|
||||
code: 'unknown_error',
|
||||
message: 'boom',
|
||||
docsUrl: undefined,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits docsUrl when the API error has no url', () => {
|
||||
const error = axiosErrorWith(
|
||||
{ code: 'x', message: 'y', url: '', errors: [] },
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
|
||||
expect(panelStatusFromError(error)?.docsUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panelStatusFromWarning', () => {
|
||||
it('returns null when there is no warning', () => {
|
||||
expect(panelStatusFromWarning(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a warning to the normalized status shape', () => {
|
||||
const warning: Warning = {
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
url: 'https://docs/warn',
|
||||
warnings: [{ message: 'series A truncated' }],
|
||||
};
|
||||
|
||||
expect(panelStatusFromWarning(warning)).toStrictEqual({
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
docsUrl: 'https://docs/warn',
|
||||
messages: ['series A truncated'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
/** Which kind of non-fatal panel status is being surfaced in the header. */
|
||||
export type PanelStatusVariant = 'error' | 'warning';
|
||||
|
||||
/**
|
||||
* Normalized status shape that both an API error and a query warning adapt into,
|
||||
* so a single popover can render either. Mirrors the fields the backend supplies
|
||||
* on its `ErrorV2` / `Warning` envelopes (code + summary + optional docs link +
|
||||
* per-item messages).
|
||||
*/
|
||||
export interface PanelStatusDetail {
|
||||
/** Short status code (e.g. an error/warning code) shown as the heading. */
|
||||
code: string;
|
||||
/** Human-readable summary line. */
|
||||
message: string;
|
||||
/** Optional docs link; renders an "Open Docs" action when present. */
|
||||
docsUrl?: string;
|
||||
/** Additional per-item messages listed under the summary. */
|
||||
messages: string[];
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
|
||||
/**
|
||||
* Adapts a query failure into the normalized status shape.
|
||||
*
|
||||
* The generated `queryRangeV5` client's reject interceptor passes the raw
|
||||
* `AxiosError` through untouched — it is NOT pre-converted to `APIError` — so
|
||||
* the error arriving here is an axios error. `convertToApiError` is the
|
||||
* app-standard normalizer for generated-API axios errors: it pulls the backend
|
||||
* `code / message / url / errors` envelope off the response and supplies
|
||||
* sensible fallbacks for anything missing, so there's always a structured
|
||||
* detail to surface.
|
||||
*/
|
||||
export function panelStatusFromError(
|
||||
error: Error | null | undefined,
|
||||
): PanelStatusDetail | null {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
if (!apiError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { error: detail } = apiError.getErrorDetails();
|
||||
return {
|
||||
code: detail.code,
|
||||
message: detail.message,
|
||||
docsUrl: detail.url || undefined,
|
||||
messages: (detail.errors ?? []).map((e) => e.message),
|
||||
};
|
||||
}
|
||||
|
||||
/** Adapts a query warning into the normalized status shape. */
|
||||
export function panelStatusFromWarning(
|
||||
warning: Warning | null | undefined,
|
||||
): PanelStatusDetail | null {
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: warning.code,
|
||||
message: warning.message,
|
||||
docsUrl: warning.url || undefined,
|
||||
messages: (warning.warnings ?? []).map((w) => w.message),
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import PanelHeader from '../PanelHeader/PanelHeader';
|
||||
|
||||
// PanelHeader's status indicators render a radix tooltip, which needs a
|
||||
// TooltipProvider ancestor (supplied globally by AppLayout at runtime).
|
||||
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
|
||||
render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
const baseProps = {
|
||||
title: 'My panel',
|
||||
kind: 'TimeSeries',
|
||||
panelId: 'panel-1',
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
const warning: Warning = {
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
url: '',
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe('PanelHeader status indicators', () => {
|
||||
it('shows the error indicator whenever an error is present', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} error={new Error('boom')} />);
|
||||
expect(screen.getByTestId('panel-status-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the warning indicator whenever a warning is present', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} warning={warning} />);
|
||||
expect(screen.getByTestId('panel-status-warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no status indicators when there is no error or warning', () => {
|
||||
renderWithProvider(<PanelHeader {...baseProps} />);
|
||||
expect(screen.queryByTestId('panel-status-error')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
|
||||
export interface PanelInteractions {
|
||||
/**
|
||||
* Drag-select a time range on a chart → write the window to the URL + global
|
||||
* time so every panel re-fetches against the same range.
|
||||
*/
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/**
|
||||
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
|
||||
* off the dashboard id from the route.
|
||||
*/
|
||||
dashboardPreference: DashboardPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the cross-panel interactions shared by every dashboard-view
|
||||
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
|
||||
* preferences. Keeping this out of the `Panel` component keeps the component a
|
||||
* thin render orchestrator and lets the wiring be unit-tested in isolation.
|
||||
*/
|
||||
export function usePanelInteractions(): PanelInteractions {
|
||||
const dispatch = useDispatch();
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
|
||||
const [syncMode] = useDashboardCursorSyncMode(
|
||||
dashboardId,
|
||||
PanelMode.DASHBOARD_VIEW,
|
||||
);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
const dashboardPreference = useMemo<DashboardPreference>(
|
||||
() => ({ syncMode, syncFilterMode, dashboardId }),
|
||||
[syncMode, syncFilterMode, dashboardId],
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
safeNavigate(`${pathname}?${urlQuery.toString()}`);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
return { onDragSelect, dashboardPreference };
|
||||
}
|
||||
@@ -9,14 +9,4 @@
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// react-resizable draws the bottom-right resize grip as a ::after chevron
|
||||
// with a hard-coded light-grey border that is invisible on the dark theme.
|
||||
// Recolour it with the theme border token so the grip is visible in both.
|
||||
:global(.react-resizable-handle) {
|
||||
&::after {
|
||||
border-right-color: var(--l2-border) !important;
|
||||
border-bottom-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ function SectionGrid({
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
draggableHandle=".panel-drag-handle"
|
||||
draggableCancel=".panel-no-drag"
|
||||
isDraggable={isEditable}
|
||||
isResizable={isEditable}
|
||||
onDragStop={handleLayoutChange}
|
||||
@@ -63,23 +62,21 @@ function SectionGrid({
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
{item.panel && (
|
||||
<Panel
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
panelActions={
|
||||
isEditable && onMovePanel && onDeletePanel
|
||||
? {
|
||||
currentLayoutIndex: layoutIndex,
|
||||
sections: sections ?? [],
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Panel
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
panelActions={
|
||||
isEditable && onMovePanel && onDeletePanel
|
||||
? {
|
||||
currentLayoutIndex: layoutIndex,
|
||||
sections: sections ?? [],
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelQuery } from '../usePanelQuery';
|
||||
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../useGetQueryRangeV5', () => ({
|
||||
useGetQueryRangeV5: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseSelector = useSelector as unknown as jest.Mock;
|
||||
const mockUseGetQueryRangeV5 = useGetQueryRangeV5 as unknown as jest.Mock;
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// Test fixtures are cast at the outer boundary; the perses-generated panel and
|
||||
// query plugin unions are too verbose to construct field-typed inline.
|
||||
|
||||
function panelWith(
|
||||
panelKind: string,
|
||||
querySpec: Record<string, unknown>,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
plugin: { kind: panelKind, spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/BuilderQuery', spec: querySpec },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
function builderPanel(): DashboardtypesPanelDTO {
|
||||
return panelWith('signoz/TimeSeriesPanel', {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
filter: { expression: '' },
|
||||
});
|
||||
}
|
||||
|
||||
function emptyPanel(): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
// Nanoseconds, as redux globalTime stores them. 1e15 ns = 1e9 ms.
|
||||
const DEFAULT_GLOBAL_TIME = {
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
minTime: 1_000_000_000_000_000,
|
||||
maxTime: 2_000_000_000_000_000,
|
||||
isAutoRefreshDisabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseSelector.mockImplementation((selector: unknown) => {
|
||||
// usePanelQuery passes a selector `(state) => state.globalTime`.
|
||||
return (
|
||||
selector as (state: { globalTime: typeof DEFAULT_GLOBAL_TIME }) => unknown
|
||||
)({ globalTime: DEFAULT_GLOBAL_TIME });
|
||||
});
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- tests -----------------------------------------------------------------
|
||||
|
||||
describe('usePanelQuery', () => {
|
||||
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.schemaVersion).toBe('v1');
|
||||
expect(requestPayload.compositeQuery.queries).toStrictEqual([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', signal: 'logs', filter: { expression: '' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts redux nanosecond time to epoch ms on the request', () => {
|
||||
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.start).toBe(1_000_000_000);
|
||||
expect(requestPayload.end).toBe(2_000_000_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['signoz/TimeSeriesPanel', 'time_series'],
|
||||
['signoz/ListPanel', 'raw'],
|
||||
// HISTOGRAM and BAR panels bin/derive from raw time-series data
|
||||
// client-side, so the backend must receive `time_series` (V1 parity).
|
||||
['signoz/HistogramPanel', 'time_series'],
|
||||
['signoz/BarChartPanel', 'time_series'],
|
||||
['signoz/NumberPanel', 'scalar'],
|
||||
['signoz/PieChartPanel', 'scalar'],
|
||||
])('%s panel sends requestType=%s', (panelKind, requestType) => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
|
||||
panelId: 'p1',
|
||||
}),
|
||||
);
|
||||
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.requestType).toBe(requestType);
|
||||
});
|
||||
|
||||
it('exposes the raw V5 response, request payload, and legend map on data', () => {
|
||||
const v5Response = { status: 'success', data: { type: 'time_series' } };
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: v5Response,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
|
||||
expect(result.current.data.response).toBe(v5Response);
|
||||
expect(result.current.data.legendMap).toStrictEqual({ A: '' });
|
||||
expect(result.current.data.requestPayload?.schemaVersion).toBe('v1');
|
||||
});
|
||||
|
||||
it('exposes an undefined response before data arrives', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.data.response).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes error from the fetch hook', () => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: new Error('boom'),
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.error?.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('combines isLoading and isFetching into a single isLoading flag', () => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces a missing/undefined error to null', () => {
|
||||
mockUseGetQueryRangeV5.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: undefined,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
|
||||
);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('passes enabled=false to the fetch hook when the caller disables it', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-disables the fetch when every metrics query is missing a metric name', () => {
|
||||
renderHook(() =>
|
||||
usePanelQuery({
|
||||
panel: panelWith('signoz/TimeSeriesPanel', {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [{}],
|
||||
}),
|
||||
panelId: 'p1',
|
||||
}),
|
||||
);
|
||||
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
|
||||
const panel = builderPanel();
|
||||
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
|
||||
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(queryKey).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'p1',
|
||||
DEFAULT_GLOBAL_TIME.minTime,
|
||||
DEFAULT_GLOBAL_TIME.maxTime,
|
||||
DEFAULT_GLOBAL_TIME.selectedTime,
|
||||
'signoz/TimeSeriesPanel',
|
||||
panel.spec?.queries,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('builds an empty composite and disables the fetch when panel is undefined (no crash)', () => {
|
||||
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
|
||||
const [{ requestPayload, enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
|
||||
expect(requestPayload.compositeQuery.queries).toStrictEqual([]);
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { queryRangeV5 } from 'api/generated/services/querier';
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
|
||||
export interface UseGetQueryRangeV5Args {
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO;
|
||||
queryKey: unknown[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// 4xx responses are deterministic (bad query, auth) — retrying re-sends a
|
||||
// request that will fail identically. Same policy as V1's useGetQueryRange.
|
||||
// react-query hands the retry callback the *raw* thrown value, which on this
|
||||
// path is the AxiosError the generated client rejects with (it is not yet
|
||||
// normalized to APIError) — so we inspect it at the axios level for the cancel
|
||||
// signal and the HTTP status. Normalization to APIError happens later, at the
|
||||
// display boundary (see PanelStatus `panelStatusFromError`).
|
||||
function retryUnlessClientError(failureCount: number, error: Error): boolean {
|
||||
if (isAxiosError(error)) {
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
const status = error.response?.status;
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-V5 query-range fetch: posts the generated request DTO via the
|
||||
* generated `queryRangeV5` call and returns the raw generated response —
|
||||
* no V1 `Query` shape on either leg. Wrapped in `useQuery` (not the
|
||||
* generated `useQueryRangeV5` mutation hook) because panel fetches need
|
||||
* caching, `enabled` gating, and refetch semantics.
|
||||
*/
|
||||
export function useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey,
|
||||
enabled,
|
||||
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
|
||||
return useQuery<QueryRangeV5200, Error>({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
|
||||
enabled,
|
||||
retry: retryUnlessClientError,
|
||||
});
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
hasRunnableQueries,
|
||||
} from '../queryV5/buildQueryRangeRequest';
|
||||
import type { PanelQueryData } from '../queryV5/types';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
|
||||
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
|
||||
|
||||
export interface UsePanelQueryArgs {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
/**
|
||||
* Gate the underlying fetch. Defaults to true. PanelV2 sets this false when
|
||||
* no plugin is registered for the panel's kind so the unknown-kind fallback
|
||||
* UI doesn't trigger a wasted API call.
|
||||
*
|
||||
* The hook *also* auto-disables internally when the panel has no runnable
|
||||
* queries — callers don't need to compute that themselves.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UsePanelQueryResult {
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
/** Combines `isLoading` (first fetch) and `isFetching` (background refresh). */
|
||||
isLoading: boolean;
|
||||
/** Background refresh in flight while data is already present. */
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (e.g. a retry button on the error state). */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the query-range data for a V2 panel over the pure-V5 contract.
|
||||
*
|
||||
* 1. Request — `buildQueryRangeRequest` assembles the generated
|
||||
* `Querybuildertypesv5QueryRangeRequestDTO` directly from the panel's
|
||||
* perses queries (a CompositeQuery plugin already nests the V5
|
||||
* envelope list). No V1 `Query` intermediary.
|
||||
* 2. Time + variables — reads the global time selection from Redux
|
||||
* (variables substitution is intentionally deferred until V2 has its
|
||||
* own variable plumbing).
|
||||
* 3. Fetch — `useGetQueryRangeV5` posts via the generated `queryRangeV5`
|
||||
* call with a react-query cache key composed from panel identity +
|
||||
* time range + kind + queries.
|
||||
*
|
||||
* Renderers consume the raw V5 response through the `queryV5` prep utils
|
||||
* (`flattenTimeSeries`, `prepareScalarTables`, …).
|
||||
*
|
||||
* The hook is consumed today by PanelV2 (renderer dispatch) and will be
|
||||
* consumed by PanelEditor (1.8) for "preview as you edit."
|
||||
*/
|
||||
export function usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
enabled = true,
|
||||
}: UsePanelQueryArgs): UsePanelQueryResult {
|
||||
const fullKind = panel?.spec?.plugin?.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
|
||||
const queries = panel?.spec?.queries;
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedInterval,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// Redux global time is in nanoseconds; the V5 API takes epoch ms.
|
||||
const startMs = Math.floor(minTime / 1e6);
|
||||
const endMs = Math.floor(maxTime / 1e6);
|
||||
|
||||
const requestPayload = useMemo(
|
||||
() =>
|
||||
buildQueryRangeRequest({
|
||||
queries: queries ?? [],
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
}),
|
||||
[queries, panelType, startMs, endMs],
|
||||
);
|
||||
|
||||
const legendMap = useMemo(() => extractLegendMap(queries ?? []), [queries]);
|
||||
|
||||
const runnable = useMemo(() => hasRunnableQueries(queries ?? []), [queries]);
|
||||
|
||||
const response = useGetQueryRangeV5({
|
||||
requestPayload,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
|
||||
panelId,
|
||||
minTime,
|
||||
maxTime,
|
||||
globalSelectedInterval,
|
||||
fullKind,
|
||||
queries,
|
||||
],
|
||||
enabled: enabled && runnable,
|
||||
});
|
||||
|
||||
const data = useMemo<PanelQueryData>(
|
||||
() => ({ response: response.data, requestPayload, legendMap }),
|
||||
[response.data, requestPayload, legendMap],
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: response.isLoading || response.isFetching,
|
||||
isFetching: response.isFetching,
|
||||
// Coerce undefined → null so the contract is `Error | null`, not
|
||||
// `Error | null | undefined`. Consumers can rely on a single
|
||||
// "no error" sentinel.
|
||||
error: (response.error as Error | null) ?? null,
|
||||
refetch: response.refetch,
|
||||
};
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
getBarStepIntervalSeconds,
|
||||
hasRunnableQueries,
|
||||
panelTypeToRequestType,
|
||||
toQueryEnvelopes,
|
||||
} from '../buildQueryRangeRequest';
|
||||
|
||||
// Test fixtures are cast at the outer boundary; the perses-generated query
|
||||
// plugin unions are too verbose to construct field-typed inline.
|
||||
|
||||
function bareBuilderQuery(
|
||||
spec: Record<string, unknown>,
|
||||
): DashboardtypesQueryDTO[] {
|
||||
return [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec } },
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
function compositeQuery(
|
||||
envelopes: Record<string, unknown>[],
|
||||
): DashboardtypesQueryDTO[] {
|
||||
return [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const START_MS = 1_700_000_000_000;
|
||||
|
||||
describe('panelTypeToRequestType', () => {
|
||||
it.each([
|
||||
[PANEL_TYPES.TIME_SERIES, 'time_series'],
|
||||
// HISTOGRAM and BAR bin client-side from time-series data; sending
|
||||
// 'distribution' would return a shape the renderers can't bin.
|
||||
[PANEL_TYPES.BAR, 'time_series'],
|
||||
[PANEL_TYPES.HISTOGRAM, 'time_series'],
|
||||
[PANEL_TYPES.TABLE, 'scalar'],
|
||||
[PANEL_TYPES.PIE, 'scalar'],
|
||||
[PANEL_TYPES.VALUE, 'scalar'],
|
||||
[PANEL_TYPES.LIST, 'raw'],
|
||||
[PANEL_TYPES.TRACE, 'trace'],
|
||||
])('%s → %s', (panelType, requestType) => {
|
||||
expect(panelTypeToRequestType(panelType)).toBe(requestType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toQueryEnvelopes', () => {
|
||||
it('wraps a bare BuilderQuery into a single builder_query envelope', () => {
|
||||
const envelopes = toQueryEnvelopes(
|
||||
bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
);
|
||||
expect(envelopes).toStrictEqual([
|
||||
{ type: 'builder_query', spec: { name: 'A', signal: 'metrics' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a CompositeQuery envelope list through verbatim', () => {
|
||||
const subqueries = [
|
||||
{ type: 'builder_query', spec: { name: 'A' } },
|
||||
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A*2' } },
|
||||
];
|
||||
expect(toQueryEnvelopes(compositeQuery(subqueries))).toStrictEqual(
|
||||
subqueries,
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps PromQL and ClickHouse plugins with their envelope types', () => {
|
||||
const prom = [
|
||||
{
|
||||
kind: 'PromQuery',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/PromQLQuery', spec: { name: 'A', query: 'up' } },
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
expect(toQueryEnvelopes(prom)).toStrictEqual([
|
||||
{ type: 'promql', spec: { name: 'A', query: 'up' } },
|
||||
]);
|
||||
|
||||
const ch = [
|
||||
{
|
||||
kind: 'ClickHouseQuery',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/ClickHouseSQL',
|
||||
spec: { name: 'A', query: 'SELECT 1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
expect(toQueryEnvelopes(ch)).toStrictEqual([
|
||||
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops invalid top-level Formula with a warning instead of crashing', () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
const formula = [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/Formula', spec: { name: 'F1', expression: 'A' } },
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
expect(toQueryEnvelopes(formula)).toStrictEqual([]);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns empty for missing plugin or empty queries', () => {
|
||||
expect(toQueryEnvelopes([])).toStrictEqual([]);
|
||||
expect(
|
||||
toQueryEnvelopes([
|
||||
{ kind: 'TimeSeriesQuery', spec: {} },
|
||||
] as unknown as DashboardtypesQueryDTO[]),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQueryRangeRequest', () => {
|
||||
it('assembles the full request DTO', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
expect(request).toStrictEqual({
|
||||
schemaVersion: 'v1',
|
||||
start: START_MS,
|
||||
end: START_MS + HOUR_MS,
|
||||
requestType: 'time_series',
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{ type: 'builder_query', spec: { name: 'A', signal: 'metrics' } },
|
||||
],
|
||||
},
|
||||
formatOptions: { formatTableResultForUI: false, fillGaps: false },
|
||||
variables: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets formatTableResultForUI only for TABLE panels', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
expect(request.formatOptions?.formatTableResultForUI).toBe(true);
|
||||
});
|
||||
|
||||
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
|
||||
stepInterval?: number;
|
||||
};
|
||||
expect(spec.stepInterval).toBe(
|
||||
getBarStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a user-set stepInterval on BAR builder queries', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
|
||||
stepInterval?: number;
|
||||
};
|
||||
expect(spec.stepInterval).toBe(300);
|
||||
});
|
||||
|
||||
it('does not touch stepInterval for non-BAR panels', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
|
||||
stepInterval?: number;
|
||||
};
|
||||
expect(spec.stepInterval).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBarStepIntervalSeconds', () => {
|
||||
// V1 parity: getBarStepIntervalPoints in container/GridCardLayout/utils.ts
|
||||
it.each([
|
||||
[30, 60],
|
||||
[60, 60],
|
||||
[120, 120],
|
||||
[180, 120],
|
||||
[300, 180],
|
||||
])('%s min range → %s s step', (minutes, step) => {
|
||||
expect(getBarStepIntervalSeconds(0, minutes * 60 * 1000)).toBe(step);
|
||||
});
|
||||
|
||||
it('caps long ranges at ~80 bars, rounded to 5-minute steps', () => {
|
||||
// 24h = 1440 min → 1440/80 = 18 → rounded up to 20 min → 1200 s
|
||||
expect(getBarStepIntervalSeconds(0, 24 * HOUR_MS)).toBe(1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractLegendMap', () => {
|
||||
it('maps query names to legends across composite subqueries', () => {
|
||||
const legendMap = extractLegendMap(
|
||||
compositeQuery([
|
||||
{ type: 'builder_query', spec: { name: 'A', legend: 'CPU {{host}}' } },
|
||||
{ type: 'builder_query', spec: { name: 'B' } },
|
||||
{ type: 'builder_formula', spec: { name: 'F1', legend: 'sum' } },
|
||||
]),
|
||||
);
|
||||
expect(legendMap).toStrictEqual({ A: 'CPU {{host}}', B: '', F1: 'sum' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRunnableQueries', () => {
|
||||
it('false when the panel has no queries', () => {
|
||||
expect(hasRunnableQueries([])).toBe(false);
|
||||
});
|
||||
|
||||
it('true for non-metrics builder queries', () => {
|
||||
expect(
|
||||
hasRunnableQueries(bareBuilderQuery({ name: 'A', signal: 'logs' })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('false when every metrics query is missing a metric name', () => {
|
||||
expect(
|
||||
hasRunnableQueries(
|
||||
bareBuilderQuery({
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [{ metricName: ' ' }],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('true when at least one metrics query has a metric name', () => {
|
||||
expect(
|
||||
hasRunnableQueries(
|
||||
compositeQuery([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', signal: 'metrics', aggregations: [{}] },
|
||||
},
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'B',
|
||||
signal: 'metrics',
|
||||
aggregations: [{ metricName: 'system_cpu' }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5ScalarDataDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
extractAggregationsPerQuery,
|
||||
prepareScalarTables,
|
||||
} from '../prepareScalarTables';
|
||||
|
||||
// Test fixtures are cast at the outer boundary; the generated envelope union
|
||||
// erases spec to unknown anyway.
|
||||
|
||||
function requestWith(
|
||||
envelopes: Record<string, unknown>[],
|
||||
): Querybuildertypesv5QueryRangeRequestDTO {
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
compositeQuery: { queries: envelopes },
|
||||
} as unknown as Querybuildertypesv5QueryRangeRequestDTO;
|
||||
}
|
||||
|
||||
function scalarResult(
|
||||
columns: Record<string, unknown>[],
|
||||
data: unknown[][],
|
||||
): Querybuildertypesv5ScalarDataDTO {
|
||||
return { columns, data } as unknown as Querybuildertypesv5ScalarDataDTO;
|
||||
}
|
||||
|
||||
const SINGLE_AGG_REQUEST = requestWith([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
aggregations: [{ expression: 'count()', alias: '' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
describe('extractAggregationsPerQuery', () => {
|
||||
it('maps builder query names to their aggregations, ignoring other envelope types', () => {
|
||||
const request = requestWith([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', aggregations: [{ expression: 'count()' }] },
|
||||
},
|
||||
{ type: 'promql', spec: { name: 'P', query: 'up' } },
|
||||
]);
|
||||
expect(extractAggregationsPerQuery(request)).toStrictEqual({
|
||||
A: [{ expression: 'count()' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty for an undefined payload', () => {
|
||||
expect(extractAggregationsPerQuery(undefined)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareScalarTables', () => {
|
||||
it('builds keyed rows with group + aggregation columns (V1 getColName/getColId parity)', () => {
|
||||
const [table] = prepareScalarTables({
|
||||
results: [
|
||||
scalarResult(
|
||||
[
|
||||
{ name: 'service.name', queryName: 'A', columnType: 'group' },
|
||||
{
|
||||
name: '__result_0',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
['frontend', 42],
|
||||
['backend', 7],
|
||||
],
|
||||
),
|
||||
],
|
||||
legendMap: { A: '' },
|
||||
requestPayload: SINGLE_AGG_REQUEST,
|
||||
});
|
||||
|
||||
expect(table.queryName).toBe('A');
|
||||
expect(table.columns).toStrictEqual([
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
// Single aggregation, no alias/legend → expression as display name,
|
||||
// queryName as id.
|
||||
{ name: 'count()', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
]);
|
||||
expect(table.rows).toStrictEqual([
|
||||
{ data: { 'service.name': 'frontend', A: 42 } },
|
||||
{ data: { 'service.name': 'backend', A: 7 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('single aggregation resolves name as alias > legend > expression', () => {
|
||||
const columns = [
|
||||
{
|
||||
name: '__result_0',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
];
|
||||
const withAlias = prepareScalarTables({
|
||||
results: [scalarResult(columns, [])],
|
||||
legendMap: { A: 'My legend' },
|
||||
requestPayload: requestWith([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
aggregations: [{ expression: 'count()', alias: 'hits' }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
expect(withAlias[0].columns[0].name).toBe('hits');
|
||||
|
||||
const withLegend = prepareScalarTables({
|
||||
results: [scalarResult(columns, [])],
|
||||
legendMap: { A: 'My legend' },
|
||||
requestPayload: SINGLE_AGG_REQUEST,
|
||||
});
|
||||
expect(withLegend[0].columns[0].name).toBe('My legend');
|
||||
});
|
||||
|
||||
it('multiple aggregations skip the legend and key columns by queryName.expression', () => {
|
||||
const [table] = prepareScalarTables({
|
||||
results: [
|
||||
scalarResult(
|
||||
[
|
||||
{
|
||||
name: '__result_0',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
{
|
||||
name: '__result_1',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 1,
|
||||
},
|
||||
],
|
||||
[[10, 20]],
|
||||
),
|
||||
],
|
||||
legendMap: { A: 'Ignored legend' },
|
||||
requestPayload: requestWith([
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
aggregations: [{ expression: 'count()' }, { expression: 'avg(x)' }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(table.columns.map((col) => col.name)).toStrictEqual([
|
||||
'count()',
|
||||
'avg(x)',
|
||||
]);
|
||||
expect(table.columns.map((col) => col.id)).toStrictEqual([
|
||||
'A.count()',
|
||||
'A.avg(x)',
|
||||
]);
|
||||
expect(table.rows).toStrictEqual([
|
||||
{ data: { 'A.count()': 10, 'A.avg(x)': 20 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('one table per scalar result (multi-query separation)', () => {
|
||||
const tables = prepareScalarTables({
|
||||
results: [
|
||||
scalarResult(
|
||||
[{ name: '__result_0', queryName: 'A', columnType: 'aggregation' }],
|
||||
[[1]],
|
||||
),
|
||||
scalarResult(
|
||||
[{ name: '__result_0', queryName: 'B', columnType: 'aggregation' }],
|
||||
[[2]],
|
||||
),
|
||||
],
|
||||
legendMap: {},
|
||||
requestPayload: requestWith([]),
|
||||
});
|
||||
expect(tables.map((t) => t.queryName)).toStrictEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('queries without aggregation metadata fall back to legend || queryName', () => {
|
||||
const [table] = prepareScalarTables({
|
||||
results: [
|
||||
scalarResult(
|
||||
[{ name: '__result_0', queryName: 'A', columnType: 'aggregation' }],
|
||||
[],
|
||||
),
|
||||
],
|
||||
legendMap: { A: 'Legend' },
|
||||
requestPayload: requestWith([]),
|
||||
});
|
||||
expect(table.columns[0].name).toBe('Legend');
|
||||
expect(table.columns[0].id).toBe('A');
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { PanelSeries } from '../types';
|
||||
import {
|
||||
hasSingleVisiblePoint,
|
||||
prepareAlignedData,
|
||||
toClickPluginPayload,
|
||||
} from '../uplotData';
|
||||
|
||||
function makeSeries(
|
||||
values: { timestamp: number; value: number }[],
|
||||
overrides: Partial<PanelSeries> = {},
|
||||
): PanelSeries {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
labels: {},
|
||||
kind: 'series',
|
||||
values,
|
||||
aggregation: { index: 0, alias: '' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('prepareAlignedData', () => {
|
||||
it('converts ms timestamps to seconds and aligns series on the union x-axis', () => {
|
||||
const aligned = prepareAlignedData([
|
||||
makeSeries([
|
||||
{ timestamp: 1_000, value: 1 },
|
||||
{ timestamp: 2_000, value: 2 },
|
||||
]),
|
||||
makeSeries([
|
||||
{ timestamp: 2_000, value: 20 },
|
||||
{ timestamp: 3_000, value: 30 },
|
||||
]),
|
||||
]);
|
||||
expect(aligned).toStrictEqual([
|
||||
[1, 2, 3],
|
||||
[1, 2, null],
|
||||
[null, 20, 30],
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces non-finite values with null', () => {
|
||||
const aligned = prepareAlignedData([
|
||||
makeSeries([
|
||||
{ timestamp: 1_000, value: NaN },
|
||||
{ timestamp: 2_000, value: Infinity },
|
||||
{ timestamp: 3_000, value: 3 },
|
||||
]),
|
||||
]);
|
||||
expect(aligned[1]).toStrictEqual([null, null, 3]);
|
||||
});
|
||||
|
||||
it('yields an empty y-array for a series with no values (legacy parity)', () => {
|
||||
const aligned = prepareAlignedData([
|
||||
makeSeries([{ timestamp: 1_000, value: 1 }]),
|
||||
makeSeries([]),
|
||||
]);
|
||||
expect(aligned[2]).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns a lone timestamp axis for no series', () => {
|
||||
expect(prepareAlignedData([])).toStrictEqual([[]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSingleVisiblePoint', () => {
|
||||
it('true for zero or one finite point', () => {
|
||||
expect(hasSingleVisiblePoint([])).toBe(true);
|
||||
expect(hasSingleVisiblePoint([{ timestamp: 1, value: 5 }])).toBe(true);
|
||||
expect(
|
||||
hasSingleVisiblePoint([
|
||||
{ timestamp: 1, value: NaN },
|
||||
{ timestamp: 2, value: 5 },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('false once two finite points exist', () => {
|
||||
expect(
|
||||
hasSingleVisiblePoint([
|
||||
{ timestamp: 1, value: 1 },
|
||||
{ timestamp: 2, value: 2 },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toClickPluginPayload', () => {
|
||||
it('produces the tuple-shaped legacy result the shared click plugin reads', () => {
|
||||
const payload = toClickPluginPayload([
|
||||
makeSeries([{ timestamp: 5_000, value: 1.5 }], {
|
||||
labels: { host: 'h1' },
|
||||
legend: 'L',
|
||||
aggregation: { index: 1, alias: 'p99' },
|
||||
}),
|
||||
]);
|
||||
expect(payload.data.result).toStrictEqual([
|
||||
{
|
||||
metric: { host: 'h1' },
|
||||
queryName: 'A',
|
||||
legend: 'L',
|
||||
values: [[5, '1.5']],
|
||||
metaData: { alias: 'p99', index: 1, queryName: 'A' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { QueryRangeV5200 } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
getRawResults,
|
||||
getScalarResults,
|
||||
getTimeSeriesResults,
|
||||
} from '../v5ResponseData';
|
||||
|
||||
// Test fixtures are cast at the outer boundary; the generated response union
|
||||
// erases `results` to unknown[] anyway.
|
||||
|
||||
function timeSeriesResponse(
|
||||
results: Record<string, unknown>[],
|
||||
meta?: Record<string, unknown>,
|
||||
): QueryRangeV5200 {
|
||||
return {
|
||||
status: 'success',
|
||||
data: { type: 'time_series', data: { results }, meta },
|
||||
} as unknown as QueryRangeV5200;
|
||||
}
|
||||
|
||||
const SERIES_A = {
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: 'p99',
|
||||
meta: { unit: 'ms' },
|
||||
series: [
|
||||
{
|
||||
labels: [{ key: { name: 'host' }, value: 'h1' }],
|
||||
values: [
|
||||
{ timestamp: 1000, value: 1.5 },
|
||||
{ timestamp: 2000, value: 2.5, partial: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('narrowing accessors', () => {
|
||||
it('getTimeSeriesResults returns results only for time_series responses', () => {
|
||||
expect(getTimeSeriesResults(timeSeriesResponse([SERIES_A]))).toHaveLength(1);
|
||||
expect(
|
||||
getTimeSeriesResults({
|
||||
status: 'success',
|
||||
data: { type: 'scalar', data: { results: [{}] } },
|
||||
} as unknown as QueryRangeV5200),
|
||||
).toStrictEqual([]);
|
||||
expect(getTimeSeriesResults(undefined)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('getScalarResults returns results only for scalar responses', () => {
|
||||
const scalar = {
|
||||
status: 'success',
|
||||
data: { type: 'scalar', data: { results: [{ queryName: 'A' }] } },
|
||||
} as unknown as QueryRangeV5200;
|
||||
expect(getScalarResults(scalar)).toStrictEqual([{ queryName: 'A' }]);
|
||||
expect(getScalarResults(timeSeriesResponse([SERIES_A]))).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('getRawResults accepts both raw and trace responses', () => {
|
||||
const make = (type: string): QueryRangeV5200 =>
|
||||
({
|
||||
status: 'success',
|
||||
data: { type, data: { results: [{ queryName: 'A', rows: [] }] } },
|
||||
}) as unknown as QueryRangeV5200;
|
||||
expect(getRawResults(make('raw'))).toHaveLength(1);
|
||||
expect(getRawResults(make('trace'))).toHaveLength(1);
|
||||
expect(getRawResults(make('time_series'))).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('getExecStats surfaces top-level meta (incl. stepIntervals)', () => {
|
||||
const response = timeSeriesResponse([], { stepIntervals: { A: 60 } });
|
||||
expect(getExecStats(response)?.stepIntervals).toStrictEqual({ A: 60 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenTimeSeries', () => {
|
||||
it('flattens aggregations × series with numeric ms values and labels record', () => {
|
||||
const [series] = flattenTimeSeries(
|
||||
getTimeSeriesResults(timeSeriesResponse([SERIES_A])),
|
||||
{ A: 'CPU {{host}}' },
|
||||
);
|
||||
expect(series).toStrictEqual({
|
||||
queryName: 'A',
|
||||
legend: 'CPU {{host}}',
|
||||
labels: { host: 'h1' },
|
||||
kind: 'series',
|
||||
values: [
|
||||
{ timestamp: 1000, value: 1.5 },
|
||||
{ timestamp: 2000, value: 2.5, partial: true },
|
||||
],
|
||||
aggregation: { index: 0, alias: 'p99', unit: 'ms' },
|
||||
});
|
||||
});
|
||||
|
||||
it('tags anomaly companion series with their kind', () => {
|
||||
const result = {
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
series: [{ labels: [], values: [] }],
|
||||
predictedSeries: [{ labels: [], values: [] }],
|
||||
upperBoundSeries: [{ labels: [], values: [] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const kinds = flattenTimeSeries(
|
||||
getTimeSeriesResults(timeSeriesResponse([result])),
|
||||
{},
|
||||
).map((s) => s.kind);
|
||||
expect(kinds.sort()).toStrictEqual(['predicted', 'series', 'upperBound']);
|
||||
});
|
||||
|
||||
// V1 parity: convertV5ResponseToLegacy + GetMetricQueryRange backfill.
|
||||
it('falls back legend to queryName and mirrors it into labels when the series has no labels', () => {
|
||||
const result = {
|
||||
queryName: 'A',
|
||||
aggregations: [{ index: 0, series: [{ labels: [], values: [] }] }],
|
||||
};
|
||||
const [series] = flattenTimeSeries(
|
||||
getTimeSeriesResults(timeSeriesResponse([result])),
|
||||
{},
|
||||
);
|
||||
expect(series.legend).toBe('A');
|
||||
expect(series.labels).toStrictEqual({ A: 'A' });
|
||||
});
|
||||
|
||||
it('keeps a user legend without touching labels when labels exist', () => {
|
||||
const [series] = flattenTimeSeries(
|
||||
getTimeSeriesResults(timeSeriesResponse([SERIES_A])),
|
||||
{},
|
||||
);
|
||||
expect(series.legend).toBe('');
|
||||
expect(series.labels).toStrictEqual({ host: 'h1' });
|
||||
});
|
||||
|
||||
it('handles multiple aggregation buckets per query', () => {
|
||||
const result = {
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{ index: 0, alias: 'avg', series: [{ labels: [], values: [] }] },
|
||||
{ index: 1, alias: 'max', series: [{ labels: [], values: [] }] },
|
||||
],
|
||||
};
|
||||
const flattened = flattenTimeSeries(
|
||||
getTimeSeriesResults(timeSeriesResponse([result])),
|
||||
{},
|
||||
);
|
||||
expect(flattened.map((s) => s.aggregation)).toStrictEqual([
|
||||
{ index: 0, alias: 'avg', unit: undefined },
|
||||
{ index: 1, alias: 'max', unit: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
/**
|
||||
* Narrow view over the builder-query / promql / clickhouse spec variants of
|
||||
* the generated query-envelope union. The generated envelope types erase
|
||||
* `spec` to `unknown` (Orval limitation on the discriminated union), so the
|
||||
* fields shared by every spec variant are read through this view with a
|
||||
* localized cast at the envelope boundary.
|
||||
*/
|
||||
interface QuerySpecView {
|
||||
name?: string;
|
||||
legend?: string;
|
||||
signal?: string;
|
||||
stepInterval?: number | string;
|
||||
aggregations?: { metricName?: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a V2 panel type to the V5 `requestType` the backend expects.
|
||||
*
|
||||
* HISTOGRAM and BAR panels bin/derive from raw time-series data client-side,
|
||||
* so the backend request type for them is `time_series` — the same effective
|
||||
* mapping the V1 path produced via `getGraphType` + `mapPanelTypeToRequestType`.
|
||||
*/
|
||||
export function panelTypeToRequestType(
|
||||
panelType: PANEL_TYPES,
|
||||
): Querybuildertypesv5RequestTypeDTO {
|
||||
switch (panelType) {
|
||||
case PANEL_TYPES.TIME_SERIES:
|
||||
case PANEL_TYPES.BAR:
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return Querybuildertypesv5RequestTypeDTO.time_series;
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
return Querybuildertypesv5RequestTypeDTO.scalar;
|
||||
case PANEL_TYPES.LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.raw;
|
||||
case PANEL_TYPES.TRACE:
|
||||
return Querybuildertypesv5RequestTypeDTO.trace;
|
||||
default:
|
||||
return Querybuildertypesv5RequestTypeDTO.time_series;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps the perses query envelope into the V5 `compositeQuery.queries`
|
||||
* list. A CompositeQuery plugin already carries the V5 envelope list and is
|
||||
* passed through verbatim; bare plugins are wrapped into a single envelope.
|
||||
*
|
||||
* Top-level Formula and TraceOperator are invalid — they reference builder
|
||||
* queries by name and can only travel inside a CompositeQuery. Defensive
|
||||
* read: warn and drop, don't crash dashboard load.
|
||||
*/
|
||||
export function toQueryEnvelopes(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
// Backend invariant: panel.queries.length === 1. Only the first entry is
|
||||
// consumed — extras (a malformed payload) are ignored.
|
||||
const plugin = queries[0]?.spec?.plugin;
|
||||
if (!plugin?.spec) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (plugin.kind) {
|
||||
case 'signoz/CompositeQuery':
|
||||
return (plugin.spec as Querybuildertypesv5CompositeQueryDTO).queries ?? [];
|
||||
case 'signoz/BuilderQuery':
|
||||
return [
|
||||
{
|
||||
type: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
spec: plugin.spec,
|
||||
},
|
||||
];
|
||||
case 'signoz/PromQLQuery':
|
||||
return [{ type: Querybuildertypesv5QueryTypeDTO.promql, spec: plugin.spec }];
|
||||
case 'signoz/ClickHouseSQL':
|
||||
return [
|
||||
{
|
||||
type: Querybuildertypesv5QueryTypeDTO.clickhouse_sql,
|
||||
spec: plugin.spec,
|
||||
},
|
||||
];
|
||||
case 'signoz/Formula':
|
||||
case 'signoz/TraceOperator':
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`buildQueryRangeRequest: top-level ${plugin.kind} is invalid ` +
|
||||
'(formulas and trace operators must travel inside a ' +
|
||||
'CompositeQuery alongside the builder query they reference). ' +
|
||||
'Dropping.',
|
||||
);
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step interval (seconds) for BAR panels so the bar count stays readable
|
||||
* (~80 bars max). Duplicated from V1 `getBarStepIntervalPoints`
|
||||
* (container/GridCardLayout/utils.ts) per the V1/V2 split policy.
|
||||
*/
|
||||
export function getBarStepIntervalSeconds(
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): number {
|
||||
const durationMs = endMs - startMs;
|
||||
const durationMin = durationMs / (60 * 1000);
|
||||
|
||||
if (durationMin <= 60) {
|
||||
return 60;
|
||||
}
|
||||
if (durationMin <= 180) {
|
||||
return 120;
|
||||
}
|
||||
if (durationMin <= 300) {
|
||||
return 180;
|
||||
}
|
||||
|
||||
const totalPoints = Math.ceil(durationMs / (1000 * 60));
|
||||
const interval = Math.ceil(totalPoints / 80);
|
||||
const roundedInterval = Math.ceil(interval / 5) * 5;
|
||||
return roundedInterval * 60;
|
||||
}
|
||||
|
||||
// BAR panels: builder queries without a user-set stepInterval get the
|
||||
// range-derived interval so bars align (V1 parity: `updateBarStepInterval`).
|
||||
function withBarStepInterval(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
|
||||
return envelopes.map((envelope) => {
|
||||
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
|
||||
return envelope;
|
||||
}
|
||||
const spec = envelope.spec as QuerySpecView;
|
||||
if (spec.stepInterval) {
|
||||
return envelope;
|
||||
}
|
||||
return { ...envelope, spec: { ...spec, stepInterval } };
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildQueryRangeRequestArgs {
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
panelType: PANEL_TYPES;
|
||||
/** Epoch milliseconds. */
|
||||
startMs: number;
|
||||
/** Epoch milliseconds. */
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the generated V5 query-range request DTO directly from the panel's
|
||||
* perses queries — no V1 `Query` intermediary.
|
||||
*
|
||||
* Dashboard variables are intentionally absent (`variables: {}`) until V2
|
||||
* grows its own variable plumbing; this matches what usePanelQuery sent
|
||||
* through the V1 path.
|
||||
*/
|
||||
export function buildQueryRangeRequest({
|
||||
queries,
|
||||
panelType,
|
||||
startMs,
|
||||
endMs,
|
||||
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
|
||||
let envelopes = toQueryEnvelopes(queries);
|
||||
if (panelType === PANEL_TYPES.BAR) {
|
||||
envelopes = withBarStepInterval(envelopes, startMs, endMs);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
start: startMs,
|
||||
end: endMs,
|
||||
requestType: panelTypeToRequestType(panelType),
|
||||
compositeQuery: { queries: envelopes },
|
||||
formatOptions: {
|
||||
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
|
||||
fillGaps: false,
|
||||
},
|
||||
variables: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* queryName → legend for every envelope that carries one. Replaces the
|
||||
* legendMap `prepareQueryRangePayloadV5` derived from the V1 query; consumed
|
||||
* by the legacy-response bridge for legend resolution.
|
||||
*/
|
||||
export function extractLegendMap(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): Record<string, string> {
|
||||
const legendMap: Record<string, string> = {};
|
||||
toQueryEnvelopes(queries).forEach((envelope) => {
|
||||
const spec = envelope.spec as QuerySpecView | undefined;
|
||||
if (spec?.name) {
|
||||
legendMap[spec.name] = spec.legend ?? '';
|
||||
}
|
||||
});
|
||||
return legendMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch gate. False when the panel has no queries, or when every metrics
|
||||
* builder query is missing a metric name (the V1 path short-circuited those
|
||||
* to an empty response via `validateMetricNameForMetricsDataSource` to avoid
|
||||
* a guaranteed 400; here the fetch is skipped outright).
|
||||
*/
|
||||
export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
|
||||
const envelopes = toQueryEnvelopes(queries);
|
||||
if (envelopes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metricsSpecs = envelopes
|
||||
.filter(
|
||||
(envelope) =>
|
||||
envelope.type === Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
)
|
||||
.map((envelope) => envelope.spec as QuerySpecView)
|
||||
.filter((spec) => spec.signal === 'metrics');
|
||||
|
||||
if (metricsSpecs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !metricsSpecs.every((spec) => {
|
||||
const metricName = spec.aggregations?.[0]?.metricName;
|
||||
return !metricName || metricName.trim() === '';
|
||||
});
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type {
|
||||
Querybuildertypesv5ColumnDescriptorDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5ScalarDataDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5QueryTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelTable, PanelTableColumn } from './types';
|
||||
|
||||
/**
|
||||
* Narrow view over a builder-query aggregation; the generated envelope spec
|
||||
* is `unknown`, so the fields column naming needs are read through this view
|
||||
* with a localized cast at the envelope boundary.
|
||||
*/
|
||||
interface AggregationView {
|
||||
alias?: string;
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
type AggregationsPerQuery = Record<string, AggregationView[]>;
|
||||
|
||||
/**
|
||||
* queryName → aggregations, recovered from the request payload's builder
|
||||
* envelopes. Column display names depend on the aggregation alias/expression
|
||||
* the query was sent with (V1 parity: `convertV5ResponseToLegacy` derived the
|
||||
* same map from `params.compositeQuery`).
|
||||
*/
|
||||
export function extractAggregationsPerQuery(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): AggregationsPerQuery {
|
||||
const perQuery: AggregationsPerQuery = {};
|
||||
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
|
||||
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.builder_query) {
|
||||
return;
|
||||
}
|
||||
const spec = envelope.spec as {
|
||||
name?: string;
|
||||
aggregations?: AggregationView[];
|
||||
};
|
||||
if (spec?.name && spec.aggregations) {
|
||||
perQuery[spec.name] = spec.aggregations;
|
||||
}
|
||||
});
|
||||
return perQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column display name. Group columns keep their field name; aggregation
|
||||
* columns resolve alias > legend > expression > queryName — with the legend
|
||||
* skipped when the query has multiple aggregations, because one legend can't
|
||||
* label several value columns. (Port of V1 `getColName`.)
|
||||
*/
|
||||
function getColName(
|
||||
col: Querybuildertypesv5ColumnDescriptorDTO,
|
||||
legendMap: Record<string, string>,
|
||||
aggregationsPerQuery: AggregationsPerQuery,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const queryName = col.queryName ?? '';
|
||||
const aggregations = aggregationsPerQuery[queryName];
|
||||
const aggregation = aggregations?.[col.aggregationIndex ?? 0];
|
||||
const legend = legendMap[queryName];
|
||||
const alias = aggregation?.alias;
|
||||
const expression = aggregation?.expression || '';
|
||||
const aggregationsCount = aggregations?.length || 0;
|
||||
|
||||
if (aggregationsCount > 0) {
|
||||
if (aggregationsCount === 1) {
|
||||
return alias || legend || expression || queryName;
|
||||
}
|
||||
return alias || expression || queryName;
|
||||
}
|
||||
|
||||
return legend || queryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable row-data key for a column. Multi-aggregation queries need
|
||||
* `queryName.expression` so the value columns don't collide. (Port of V1
|
||||
* `getColId`.)
|
||||
*/
|
||||
function getColId(
|
||||
col: Querybuildertypesv5ColumnDescriptorDTO,
|
||||
aggregationsPerQuery: AggregationsPerQuery,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const queryName = col.queryName ?? '';
|
||||
const aggregations = aggregationsPerQuery[queryName];
|
||||
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
|
||||
|
||||
if ((aggregations?.length || 0) > 1 && expression) {
|
||||
return `${queryName}.${expression}`;
|
||||
}
|
||||
return queryName;
|
||||
}
|
||||
|
||||
export interface PrepareScalarTablesArgs {
|
||||
results: Querybuildertypesv5ScalarDataDTO[];
|
||||
legendMap: Record<string, string>;
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 scalar results (`{columns, data[][]}`) into the keyed
|
||||
* table shape Number/Pie/Table panels render: columns with resolved display
|
||||
* names + `isValueColumn`, rows keyed by column id. (Port of V1
|
||||
* `convertScalarDataArrayToTable`; the `formatForWeb` variant produced the
|
||||
* same structure and is collapsed into this one.)
|
||||
*/
|
||||
export function prepareScalarTables({
|
||||
results,
|
||||
legendMap,
|
||||
requestPayload,
|
||||
}: PrepareScalarTablesArgs): PanelTable[] {
|
||||
const aggregationsPerQuery = extractAggregationsPerQuery(requestPayload);
|
||||
|
||||
return results.map((scalarData) => {
|
||||
if (!scalarData) {
|
||||
return {
|
||||
queryName: '',
|
||||
legend: '',
|
||||
columns: [],
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
const queryName = scalarData.columns?.[0]?.queryName ?? '';
|
||||
|
||||
const columns: PanelTableColumn[] = (scalarData.columns ?? []).map((col) => ({
|
||||
name: getColName(col, legendMap, aggregationsPerQuery),
|
||||
queryName: col.queryName ?? '',
|
||||
isValueColumn: col.columnType === 'aggregation',
|
||||
id: getColId(col, aggregationsPerQuery),
|
||||
}));
|
||||
|
||||
const rows = (scalarData.data ?? []).map((dataRow) => {
|
||||
const rowData: Record<string, unknown> = {};
|
||||
columns.forEach((col, colIndex) => {
|
||||
rowData[col.id || col.name] = dataRow[colIndex];
|
||||
});
|
||||
return { data: rowData };
|
||||
});
|
||||
|
||||
return {
|
||||
queryName,
|
||||
legend: legendMap[queryName] || '',
|
||||
columns,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* V5-native shapes the panel renderers consume, produced by the queryV5 prep
|
||||
* utils from the raw generated query-range response. These replace the legacy
|
||||
* tuple/`newResult` shapes renderers used to read off the bridged response.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Raw V5 fetch result threaded from usePanelQuery to the renderers. Carries
|
||||
* the request alongside the response because column naming (scalar) and the
|
||||
* X-scale clamps both depend on what was actually sent.
|
||||
*/
|
||||
export interface PanelQueryData {
|
||||
response: QueryRangeV5200 | undefined;
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined;
|
||||
/** queryName → user legend, from the panel's queries. */
|
||||
legendMap: Record<string, string>;
|
||||
}
|
||||
|
||||
/** One data point. `timestamp` is epoch milliseconds (V5 wire native). */
|
||||
export interface PanelSeriesPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
/** True when the bucket at the range edge is incomplete. */
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguishes the plain series list from the anomaly-detector companions
|
||||
* carried on the same aggregation bucket.
|
||||
*/
|
||||
export type PanelSeriesKind =
|
||||
| 'series'
|
||||
| 'predicted'
|
||||
| 'upperBound'
|
||||
| 'lowerBound'
|
||||
| 'anomalyScores';
|
||||
|
||||
/** One flattened time series (one aggregation bucket × one label set). */
|
||||
export interface PanelSeries {
|
||||
queryName: string;
|
||||
/**
|
||||
* Resolved legend: the user-set legend for the query, falling back to the
|
||||
* query name when the series carries no labels (V1 parity).
|
||||
*/
|
||||
legend: string;
|
||||
/**
|
||||
* Label name → value. Empty object when the series has no group-by.
|
||||
* Values are stringified — uPlot series config and `getLabelName` both
|
||||
* consume string-valued label records.
|
||||
*/
|
||||
labels: Record<string, string>;
|
||||
values: PanelSeriesPoint[];
|
||||
kind: PanelSeriesKind;
|
||||
aggregation: {
|
||||
index: number;
|
||||
alias: string;
|
||||
unit?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PanelTableColumn {
|
||||
/** Display name (alias/legend/expression resolution applied). */
|
||||
name: string;
|
||||
queryName: string;
|
||||
/** True for aggregation columns, false for group-by columns. */
|
||||
isValueColumn: boolean;
|
||||
/** Key into `PanelTableRow.data`. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PanelTableRow {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One scalar result rendered as a table (Number/Pie/Table panels). */
|
||||
export interface PanelTable {
|
||||
queryName: string;
|
||||
legend: string;
|
||||
columns: PanelTableColumn[];
|
||||
rows: PanelTableRow[];
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import type { QueryData } from 'types/api/widgets/getQuery';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { PanelSeries, PanelSeriesPoint } from './types';
|
||||
|
||||
/**
|
||||
* uPlot data prep over the flat `PanelSeries[]`, V5-native counterpart of the
|
||||
* legacy `prepareChartData` chain. uPlot's x values are epoch seconds;
|
||||
* `PanelSeriesPoint.timestamp` is epoch ms, so the conversion happens here —
|
||||
* the single seam between wire time and chart time.
|
||||
*/
|
||||
|
||||
function toPlotValue(value: number): number | null {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns all series onto one shared x-axis: the union of every series'
|
||||
* timestamps, sorted ascending, with `null` filling the slots a series has no
|
||||
* sample for. A series with no values at all yields an empty array (legacy
|
||||
* `fillMissingXAxisTimestamps` parity).
|
||||
*/
|
||||
export function prepareAlignedData(series: PanelSeries[]): uPlot.AlignedData {
|
||||
const timestampSet = new Set<number>();
|
||||
series.forEach((s) => {
|
||||
s.values.forEach((point) => {
|
||||
timestampSet.add(Math.floor(point.timestamp / 1000));
|
||||
});
|
||||
});
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
|
||||
const yValues = series.map((s) => {
|
||||
if (!s.values.length) {
|
||||
return [];
|
||||
}
|
||||
const valueByTimestamp = new Map<number, number | null>();
|
||||
s.values.forEach((point) => {
|
||||
valueByTimestamp.set(
|
||||
Math.floor(point.timestamp / 1000),
|
||||
toPlotValue(point.value),
|
||||
);
|
||||
});
|
||||
return timestamps.map((ts) => valueByTimestamp.get(ts) ?? null);
|
||||
});
|
||||
|
||||
return [timestamps, ...yValues] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the series has at most one finite point — such a series can't be
|
||||
* drawn as a line, so renderers degrade it to points (V1
|
||||
* `hasSingleVisiblePoint` parity).
|
||||
*/
|
||||
export function hasSingleVisiblePoint(values: PanelSeriesPoint[]): boolean {
|
||||
let validPointCount = 0;
|
||||
for (const point of values) {
|
||||
if (Number.isFinite(point.value)) {
|
||||
validPointCount += 1;
|
||||
if (validPointCount > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* LEGACY-SHAPE adapter for the shared `onClickPlugin` (lib/uPlotLib), which
|
||||
* maps pixel coordinates back to data through the old tuple-shaped
|
||||
* `payload.data.result`. The plugin is V1-shared and can't take `PanelSeries`;
|
||||
* this thin mapper is the only place the tuple shape survives in V2. Remove
|
||||
* when V2 grows its own click plugin.
|
||||
*/
|
||||
export function toClickPluginPayload(
|
||||
series: PanelSeries[],
|
||||
): MetricRangePayloadProps {
|
||||
const result = series.map(
|
||||
(s) =>
|
||||
({
|
||||
metric: s.labels,
|
||||
queryName: s.queryName,
|
||||
legend: s.legend,
|
||||
values: s.values.map(
|
||||
(point) =>
|
||||
[Math.floor(point.timestamp / 1000), String(point.value)] as [
|
||||
number,
|
||||
string,
|
||||
],
|
||||
),
|
||||
metaData: {
|
||||
alias: s.aggregation.alias,
|
||||
index: s.aggregation.index,
|
||||
queryName: s.queryName,
|
||||
},
|
||||
}) as unknown as QueryData,
|
||||
);
|
||||
|
||||
// `newResult` is declared required on the legacy type but the click plugin
|
||||
// never reads it — omit and cast through unknown.
|
||||
return {
|
||||
data: { result, resultType: '' },
|
||||
} as unknown as MetricRangePayloadProps;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import type {
|
||||
Querybuildertypesv5AggregationBucketDTO,
|
||||
Querybuildertypesv5ExecStatsDTO,
|
||||
Querybuildertypesv5RawDataDTO,
|
||||
Querybuildertypesv5ScalarDataDTO,
|
||||
Querybuildertypesv5TimeSeriesDataDTO,
|
||||
Querybuildertypesv5TimeSeriesDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import type { PanelSeries, PanelSeriesKind } from './types';
|
||||
|
||||
/**
|
||||
* The generated `Querybuildertypesv5QueryDataDTO` union erases the `results`
|
||||
* element type to `unknown[]` (Orval limitation), so each accessor here
|
||||
* narrows it once — guarded by the response `type` discriminator — to the
|
||||
* generated per-element DTO. This is the single place that cast lives.
|
||||
*/
|
||||
|
||||
export function getTimeSeriesResults(
|
||||
response: QueryRangeV5200 | undefined,
|
||||
): Querybuildertypesv5TimeSeriesDataDTO[] {
|
||||
if (response?.data?.type !== 'time_series') {
|
||||
return [];
|
||||
}
|
||||
return (response.data.data?.results ??
|
||||
[]) as Querybuildertypesv5TimeSeriesDataDTO[];
|
||||
}
|
||||
|
||||
export function getScalarResults(
|
||||
response: QueryRangeV5200 | undefined,
|
||||
): Querybuildertypesv5ScalarDataDTO[] {
|
||||
if (response?.data?.type !== 'scalar') {
|
||||
return [];
|
||||
}
|
||||
return (response.data.data?.results ??
|
||||
[]) as Querybuildertypesv5ScalarDataDTO[];
|
||||
}
|
||||
|
||||
export function getRawResults(
|
||||
response: QueryRangeV5200 | undefined,
|
||||
): Querybuildertypesv5RawDataDTO[] {
|
||||
const data = response?.data;
|
||||
if (data?.type !== 'raw' && data?.type !== 'trace') {
|
||||
return [];
|
||||
}
|
||||
return (data.data?.results ?? []) as Querybuildertypesv5RawDataDTO[];
|
||||
}
|
||||
|
||||
/** Exec stats (incl. per-query `stepIntervals`) from the response top level. */
|
||||
export function getExecStats(
|
||||
response: QueryRangeV5200 | undefined,
|
||||
): Querybuildertypesv5ExecStatsDTO | undefined {
|
||||
return response?.data?.meta;
|
||||
}
|
||||
|
||||
// V5 labels are `{key: {name}, value}` pairs; renderers want a flat
|
||||
// name → value record with string values (uPlot/getLabelName contract).
|
||||
function labelsToRecord(
|
||||
series: Querybuildertypesv5TimeSeriesDTO,
|
||||
): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
(series.labels ?? []).forEach((label) => {
|
||||
if (label.key?.name) {
|
||||
record[label.key.name] = String(label.value);
|
||||
}
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legend/labels backfill, V1 parity (`convertV5ResponseToLegacy` +
|
||||
* `GetMetricQueryRange`'s post pass): a series with no labels falls back to
|
||||
* the query name as its legend, and mirrors the name into `labels` so
|
||||
* downstream label-driven naming has something to show.
|
||||
*/
|
||||
function resolveLegendAndLabels(
|
||||
queryName: string,
|
||||
labels: Record<string, string>,
|
||||
legendMap: Record<string, string>,
|
||||
): { legend: string; labels: Record<string, string> } {
|
||||
let legend = legendMap[queryName] ?? '';
|
||||
if (isEmpty(labels)) {
|
||||
if (!legend) {
|
||||
legend = queryName;
|
||||
}
|
||||
if (legend === queryName) {
|
||||
return { legend, labels: { [queryName]: queryName } };
|
||||
}
|
||||
}
|
||||
return { legend, labels };
|
||||
}
|
||||
|
||||
const BUCKET_FIELD_TO_KIND: Record<
|
||||
PanelSeriesKind,
|
||||
keyof Querybuildertypesv5AggregationBucketDTO
|
||||
> = {
|
||||
series: 'series',
|
||||
predicted: 'predictedSeries',
|
||||
upperBound: 'upperBoundSeries',
|
||||
lowerBound: 'lowerBoundSeries',
|
||||
anomalyScores: 'anomalyScores',
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens the V5 time-series result tree
|
||||
* (`results[].aggregations[].series[]` + anomaly companions) into the flat
|
||||
* `PanelSeries[]` the chart renderers iterate. Values stay numeric and
|
||||
* timestamps stay epoch-ms (V5 wire native) — no legacy stringification.
|
||||
*/
|
||||
export function flattenTimeSeries(
|
||||
results: Querybuildertypesv5TimeSeriesDataDTO[],
|
||||
legendMap: Record<string, string>,
|
||||
): PanelSeries[] {
|
||||
const flattened: PanelSeries[] = [];
|
||||
|
||||
// Kind-outer iteration so the flat order matches what the legacy chain
|
||||
// produced (per query: all plain series across aggregations, then the
|
||||
// anomaly companions) — series order drives uPlot color assignment.
|
||||
results.forEach((result) => {
|
||||
const queryName = result.queryName ?? '';
|
||||
(Object.keys(BUCKET_FIELD_TO_KIND) as PanelSeriesKind[]).forEach((kind) => {
|
||||
(result.aggregations ?? []).forEach((bucket) => {
|
||||
const seriesList = bucket[BUCKET_FIELD_TO_KIND[kind]] as
|
||||
| Querybuildertypesv5TimeSeriesDTO[]
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
(seriesList ?? []).forEach((series) => {
|
||||
const { legend, labels } = resolveLegendAndLabels(
|
||||
queryName,
|
||||
labelsToRecord(series),
|
||||
legendMap,
|
||||
);
|
||||
flattened.push({
|
||||
queryName,
|
||||
legend,
|
||||
labels,
|
||||
kind,
|
||||
values: (series.values ?? []).map((value) => ({
|
||||
timestamp: value.timestamp ?? 0,
|
||||
value: value.value ?? 0,
|
||||
...(value.partial ? { partial: true } : {}),
|
||||
})),
|
||||
aggregation: {
|
||||
index: bucket.index ?? 0,
|
||||
alias: bucket.alias ?? '',
|
||||
unit: bucket.meta?.unit,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return flattened;
|
||||
}
|
||||
@@ -229,74 +229,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListViews), handler.OpenAPIDef{
|
||||
ID: "ListDashboardViews",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "List dashboard saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.ListableDashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateView), handler.OpenAPIDef{
|
||||
ID: "CreateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Create dashboard saved view",
|
||||
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(dashboardtypes.PostableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateView), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(dashboardtypes.UpdatableDashboardView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.DashboardView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboard_views/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteView), handler.OpenAPIDef{
|
||||
ID: "DeleteDashboardView",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Delete dashboard saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
|
||||
@@ -22,7 +22,7 @@ func newConfig() factory.Config {
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.13",
|
||||
Version: "v0.0.12",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,14 +80,6 @@ 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
|
||||
|
||||
CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error)
|
||||
|
||||
ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error)
|
||||
|
||||
UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdatableDashboardView) (*dashboardtypes.DashboardView, error)
|
||||
|
||||
DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
@@ -137,12 +129,4 @@ type Handler interface {
|
||||
UnpinV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreateView(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListViews(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateView(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteView(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user