mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-08 11:10:42 +01:00
Compare commits
16 Commits
feat/semco
...
13065
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae98b05c5 | ||
|
|
7c76ba8d9e | ||
|
|
104758f3cf | ||
|
|
504be0dfd7 | ||
|
|
636c023483 | ||
|
|
1d54a93e02 | ||
|
|
ea127308b6 | ||
|
|
9f4108350a | ||
|
|
32350e61e5 | ||
|
|
2bd0153a51 | ||
|
|
23776bbd26 | ||
|
|
f941d1ea11 | ||
|
|
185677634c | ||
|
|
e8c22cb259 | ||
|
|
0aeef0dc46 | ||
|
|
7663ab02d4 |
2
.github/workflows/goci.yaml
vendored
2
.github/workflows/goci.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: check-semconv-generated-files-and-product-literals
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
build:
|
||||
if: |
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -68,6 +68,7 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
4
Makefile
4
Makefile
@@ -263,8 +263,8 @@ semconv-generate: ## Regenerate semantic-convention families for Go and TypeScri
|
||||
@go run ./scripts/semconv
|
||||
|
||||
.PHONY: semconv-check
|
||||
semconv-check: ## Verify generated semantic-convention files and reject old-name product literals
|
||||
@go run ./scripts/semconv -check -lint
|
||||
semconv-check: ## Fail if the generated semantic-convention files are stale
|
||||
@go run ./scripts/semconv -check
|
||||
|
||||
.PHONY: gen-mocks
|
||||
gen-mocks:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,7 @@ A `LogicalField` is one meaning: one name, context, and data type, backed by one
|
||||
The resolution order is the same for every storage, in the filter and in every select field, group by, order by, and aggregation:
|
||||
|
||||
1. **Own context.** A key under one of the storage's own contexts (`span.x`, `log.x`) matches its own context first, then a column the storage knows under that context. Only when both miss does it look up as if it had no context. `span.http.method` then corrects to the attribute. Strict contexts (`resource.`, `attribute.`, `scope.`, `body.`) are kept as written.
|
||||
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation. A key written as `exact(key)` in an expression, or a structured key with `fieldResolution: exact`, reads its own spelling alone.
|
||||
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation.
|
||||
3. **Ambiguity.** A filter settles several interpretations by resource over attribute, with a warning. A select field, group by, order by, or aggregation keeps every interpretation in metadata order and folds them, so it shows the value wherever it is.
|
||||
4. **Intrinsic column first**, bare keys only. A column every row has leads the list. Metadata can report the column, or the storage's `Fallback` can. When only `Fallback` knows the column, its key carries the data type the column reads as (`querybuilder.ColumnDataType`). A same-named metadata key of a contradicting type then drops, and a time column merges none. When metadata reports the column too, every match reads. A metadata gap degrades to the correct column, never to a corrupt metadata key.
|
||||
5. **Fallback.** With no match, the storage's fallback keys for the key. When the storage ignores unknown keys (a side query whose main query owns the error), the key is `Skipped`. Otherwise a key nothing can serve is an error with suggestions. The not-found warning fires only when every fallback key is a guess, that is, none of them is always present.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
// Mock for uplot library used in tests
|
||||
export interface MockUPlotInstance {
|
||||
/** Consumers read `root.parentElement` to detect a re-mounted container. */
|
||||
root: HTMLDivElement;
|
||||
setData: jest.Mock;
|
||||
setSize: jest.Mock;
|
||||
destroy: jest.Mock;
|
||||
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
|
||||
const root = document.createElement('div');
|
||||
// Real uPlot mounts its root inside the target; without it a re-render reads
|
||||
// `root.parentElement` off undefined and throws.
|
||||
target?.appendChild(root);
|
||||
return {
|
||||
root,
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
|
||||
function MockUPlot(
|
||||
_options: unknown,
|
||||
_data: unknown,
|
||||
_target: HTMLElement,
|
||||
target: HTMLElement,
|
||||
): MockUPlotInstance {
|
||||
return createMockUPlotInstance();
|
||||
return createMockUPlotInstance(target);
|
||||
}
|
||||
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
// Export the constructor as default
|
||||
export default MockUPlot;
|
||||
|
||||
@@ -417,7 +417,7 @@ for (const theme of themes.length ? themes : [null]) {
|
||||
break;
|
||||
}
|
||||
|
||||
height = needed;
|
||||
height = Math.ceil(needed);
|
||||
await page.setViewportSize({ width: Number(opts.width), height });
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ import type {
|
||||
|
||||
import type {
|
||||
CreateRule201,
|
||||
CreateRuleView201,
|
||||
DeleteRuleByIDPathParameters,
|
||||
DeleteRuleViewPathParameters,
|
||||
GetRuleByID200,
|
||||
GetRuleByIDPathParameters,
|
||||
GetRuleHistoryFilterKeys200,
|
||||
@@ -40,6 +42,7 @@ import type {
|
||||
GetRuleHistoryTopContributors200,
|
||||
GetRuleHistoryTopContributorsParams,
|
||||
GetRuleHistoryTopContributorsPathParameters,
|
||||
ListRuleViews200,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
@@ -47,8 +50,11 @@ import type {
|
||||
PatchRuleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
RuletypesPostableRuleViewDTO,
|
||||
TestRule200,
|
||||
UpdateRuleByIDPathParameters,
|
||||
UpdateRuleView200,
|
||||
UpdateRuleViewPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -74,6 +80,351 @@ const withQueryKey = <T extends object, K>(
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
export const listRuleViews = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListRuleViews200>({
|
||||
url: `/api/v2/rule_views`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRuleViewsQueryKey = () => {
|
||||
return [`/api/v2/rule_views`] as const;
|
||||
};
|
||||
|
||||
export const getListRuleViewsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRuleViewsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRuleViews>>> = ({
|
||||
signal,
|
||||
}) => listRuleViews(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRuleViewsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRuleViews>>
|
||||
>;
|
||||
export type ListRuleViewsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
|
||||
export function useListRuleViews<
|
||||
TData = Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRuleViewsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
export const invalidateListRuleViews = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRuleViewsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.
|
||||
* @summary Create rule saved view
|
||||
*/
|
||||
export const createRuleView = (
|
||||
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateRuleView201>({
|
||||
url: `/api/v2/rule_views`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createRuleView'];
|
||||
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 createRuleView>>,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createRuleView(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createRuleView>>
|
||||
>;
|
||||
export type CreateRuleViewMutationBody =
|
||||
| BodyType<RuletypesPostableRuleViewDTO>
|
||||
| undefined;
|
||||
export type CreateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create rule saved view
|
||||
*/
|
||||
export const useCreateRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
|
||||
* @summary Delete rule saved view
|
||||
*/
|
||||
export const deleteRuleView = (
|
||||
{ id }: DeleteRuleViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/rule_views/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteRuleView'];
|
||||
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 deleteRuleView>>,
|
||||
{ pathParams: DeleteRuleViewPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteRuleView(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>
|
||||
>;
|
||||
|
||||
export type DeleteRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete rule saved view
|
||||
*/
|
||||
export const useDeleteRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
* @summary Update rule saved view
|
||||
*/
|
||||
export const updateRuleView = (
|
||||
{ id }: UpdateRuleViewPathParameters,
|
||||
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateRuleView200>({
|
||||
url: `/api/v2/rule_views/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateRuleView'];
|
||||
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 updateRuleView>>,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateRuleView(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateRuleView>>
|
||||
>;
|
||||
export type UpdateRuleViewMutationBody =
|
||||
| BodyType<RuletypesPostableRuleViewDTO>
|
||||
| undefined;
|
||||
export type UpdateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update rule saved view
|
||||
*/
|
||||
export const useUpdateRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
|
||||
* @deprecated
|
||||
|
||||
@@ -5291,6 +5291,87 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesTextPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
}
|
||||
export enum DashboardtypesHeatmapYScaleDTO {
|
||||
auto = 'auto',
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
symlog = 'symlog',
|
||||
}
|
||||
export interface DashboardtypesHeatmapAxisDTO {
|
||||
scale?: DashboardtypesHeatmapYScaleDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapAxesDTO {
|
||||
y?: DashboardtypesHeatmapAxisDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesHeatmapColorModeDTO {
|
||||
palette = 'palette',
|
||||
opacity = 'opacity',
|
||||
}
|
||||
export enum DashboardtypesHeatmapPaletteDTO {
|
||||
ice = 'ice',
|
||||
moss = 'moss',
|
||||
rust = 'rust',
|
||||
graphite = 'graphite',
|
||||
ember = 'ember',
|
||||
lagoon = 'lagoon',
|
||||
orchid = 'orchid',
|
||||
verdant = 'verdant',
|
||||
lava = 'lava',
|
||||
beacon = 'beacon',
|
||||
}
|
||||
export enum DashboardtypesHeatmapColorScaleDTO {
|
||||
log = 'log',
|
||||
sqrt = 'sqrt',
|
||||
linear = 'linear',
|
||||
}
|
||||
export interface DashboardtypesHeatmapColorsDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fill?: string;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
maxCount?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
minCount?: number | null;
|
||||
mode?: DashboardtypesHeatmapColorModeDTO;
|
||||
palette?: DashboardtypesHeatmapPaletteDTO;
|
||||
scale?: DashboardtypesHeatmapColorScaleDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapChartAppearanceDTO {
|
||||
colors?: DashboardtypesHeatmapColorsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapPanelSpecDTO {
|
||||
axes?: DashboardtypesHeatmapAxesDTO;
|
||||
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/HeatmapPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
|
||||
spec: DashboardtypesHeatmapPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -5300,7 +5381,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6226,6 +6308,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
@@ -10328,6 +10411,56 @@ export interface RuletypesListableRuleDTO {
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesRuleViewDataDTO {
|
||||
order?: RuletypesListOrderDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
query?: string;
|
||||
sort?: RuletypesListSortDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
states?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface RuletypesRuleViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
data: RuletypesRuleViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesListableRuleViewsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
views: RuletypesRuleViewDTO[];
|
||||
}
|
||||
|
||||
export interface RuletypesListableRulesDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -10496,6 +10629,14 @@ export interface RuletypesPostableRuleDTO {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesPostableRuleViewDTO {
|
||||
data: RuletypesRuleViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
|
||||
|
||||
export type RuletypesRuleDTOLabels = { [key: string]: string };
|
||||
@@ -11194,6 +11335,82 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAITokensDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheRead: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheWrite: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
input: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
output: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reasoning: number;
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAISummaryDTO {
|
||||
tokens: SpantypesTraceAITokensDTO;
|
||||
/**
|
||||
* @type number
|
||||
*/
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceSummaryDTO {
|
||||
ai?: SpantypesTraceAISummaryDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootSpanStatusCode: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -12882,6 +13099,17 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceSummaryPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceSummary200 = {
|
||||
data: SpantypesGettableTraceSummaryDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -13783,6 +14011,36 @@ export type GetUsersByRoleID200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRuleViews200 = {
|
||||
data: RuletypesListableRuleViewsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateRuleView201 = {
|
||||
data: RuletypesRuleViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteRuleViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRuleViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRuleView200 = {
|
||||
data: RuletypesRuleViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRules200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -16,6 +22,8 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -27,6 +35,26 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -127,6 +155,108 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const getTraceSummary = (
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceSummary200>({
|
||||
url: `/api/v1/traces/${traceID}/summary`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryKey = ({
|
||||
traceID,
|
||||
}: GetTraceSummaryPathParameters) => {
|
||||
return [`/api/v1/traces/${traceID}/summary`] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
|
||||
signal,
|
||||
}) => getTraceSummary({ traceID }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceSummaryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>
|
||||
>;
|
||||
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceSummary<
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const invalidateGetTraceSummary = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { SemconvMigrationReport } from 'types/api/semconvMigration';
|
||||
|
||||
async function getSemconvMigrationReport(): Promise<SemconvMigrationReport> {
|
||||
const response = await axios.get('/fields/semconv-migration');
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export default getSemconvMigrationReport;
|
||||
@@ -64,12 +64,12 @@ export const COMMON_FILTERS = {
|
||||
SERVER_SPANS: "kind_string = 'Server'",
|
||||
CLIENT_SPANS: "kind_string = 'Client'",
|
||||
INTERNAL_SPANS: "kind_string = 'Internal'",
|
||||
ERROR_SPANS: 'http.response.status_code >= 400',
|
||||
SUCCESS_SPANS: 'http.response.status_code < 400',
|
||||
ERROR_SPANS: 'http.status_code >= 400',
|
||||
SUCCESS_SPANS: 'http.status_code < 400',
|
||||
|
||||
// Common service filters
|
||||
EXCLUDE_HEALTH_CHECKS: "http.route != '/health' AND http.route != '/ping'",
|
||||
HTTP_REQUESTS: "http.request.method != ''",
|
||||
HTTP_REQUESTS: "http.method != ''",
|
||||
|
||||
// Log filters
|
||||
ERROR_LOGS: "severity_text = 'ERROR'",
|
||||
@@ -87,7 +87,7 @@ export const COMMON_GROUP_BY_FIELDS = {
|
||||
fieldContext: 'resource' as const,
|
||||
},
|
||||
HTTP_METHOD: {
|
||||
name: 'http.request.method',
|
||||
name: 'http.method',
|
||||
fieldDataType: 'string' as const,
|
||||
fieldContext: 'attribute' as const,
|
||||
},
|
||||
@@ -97,7 +97,7 @@ export const COMMON_GROUP_BY_FIELDS = {
|
||||
fieldContext: 'attribute' as const,
|
||||
},
|
||||
HTTP_STATUS_CODE: {
|
||||
name: 'http.response.status_code',
|
||||
name: 'http.status_code',
|
||||
fieldDataType: 'int64' as const,
|
||||
fieldContext: 'attribute' as const,
|
||||
},
|
||||
|
||||
@@ -198,6 +198,7 @@ function createBaseSpec(
|
||||
: undefined,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
having: normalizeHaving(queryData.having),
|
||||
bucketOptions: queryData.bucketOptions,
|
||||
functions: isEmpty(queryData.functions)
|
||||
? undefined
|
||||
: queryData.functions.map((func: QueryFunction): QueryFunction => {
|
||||
|
||||
@@ -145,7 +145,7 @@ function CeleryOverviewConfigOptions(): JSX.Element {
|
||||
{
|
||||
placeholder: 'Destination',
|
||||
queryParam: QueryParams.destination,
|
||||
filterType: ['messaging.destination.name'],
|
||||
filterType: ['messaging.destination.name', 'messaging.destination'],
|
||||
},
|
||||
{
|
||||
placeholder: 'Kind',
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
|
||||
.label {
|
||||
// Typography.Text takes its display from this token; at its `inline` default
|
||||
// the label blockifies as a flex item and the text rides the top of the row.
|
||||
--typography-text-display: flex;
|
||||
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { getSemconvMembers } from 'utils/semconv';
|
||||
|
||||
import styles from './LogHighlights.module.scss';
|
||||
import TraceIdField from './TraceIdField';
|
||||
@@ -80,9 +79,7 @@ export const LOG_HIGHLIGHTS: LogHighlightConfig[] = [
|
||||
key: 'environment',
|
||||
label: 'ENVIRONMENT',
|
||||
render: (log): ReactNode | null => {
|
||||
const value = getSemconvMembers('deployment.environment.name')
|
||||
.map((key) => getAttr(log, key))
|
||||
.find(Boolean);
|
||||
const value = getAttr(log, 'deployment.environment');
|
||||
return value ? valueBadge(value) : null;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Borrows the query builder's control metrics rather than the component defaults the
|
||||
* toggle group and number input ship with: 36px tall, 2px radius, and the
|
||||
* `--query-builder-v2-*` surface the selects above this row already paint on.
|
||||
*/
|
||||
.bucketOptions {
|
||||
--toggle-group-radius: var(--radius-1);
|
||||
--toggle-group-item-size: 36px;
|
||||
--toggle-group-item-font-size: 13px;
|
||||
--toggle-group-item-padding-left: var(--spacing-6);
|
||||
--toggle-group-item-padding-right: var(--spacing-6);
|
||||
|
||||
// Repeated from `.query-add-ons` rather than inherited: the section also renders on a
|
||||
// formula, which has no add-ons ancestor to pick the toggle palette up from.
|
||||
--toggle-group-secondary-bg: var(
|
||||
--query-builder-v2-toggle-group-background-color,
|
||||
var(--l1-background-hover)
|
||||
);
|
||||
--toggle-group-secondary-border: var(
|
||||
--query-builder-v2-toggle-group-border-color,
|
||||
var(--l2-border)
|
||||
);
|
||||
--toggle-group-secondary-active-bg: var(
|
||||
--query-builder-v2-toggle-group-active-background-color,
|
||||
var(--l1-background)
|
||||
);
|
||||
--toggle-group-secondary-bg-hover: var(
|
||||
--query-builder-v2-toggle-group-background-color-hover,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
--input-height: 36px;
|
||||
--input-font-size: 13px;
|
||||
--input-border-radius: var(--radius-1);
|
||||
--input-border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
--input-background: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-foreground: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
|
||||
padding: var(--spacing-4);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/** One label paired with its control, at the same 10px rhythm as the aggregate rows. */
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.bounds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
color: var(--l3-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle group exposes a font-size token but no family, so each item's label carries
|
||||
* the query builder's value type itself.
|
||||
*/
|
||||
.toggleLabel {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
|
||||
.numberInput {
|
||||
width: 104px;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Unfilled: a pill on the same row as the toggles above reads as another control,
|
||||
* and the bounds are derived rather than set here. */
|
||||
.bound {
|
||||
padding: var(--spacing-1) 0;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
.overflowBound {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.hintIcon {
|
||||
flex: none;
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
margin-left: auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { InputNumber } from '@signozhq/ui/input-number';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { ChevronUp, Info } from '@signozhq/icons';
|
||||
import {
|
||||
Querybuildertypesv5BucketOptionsDTO,
|
||||
Querybuildertypesv5BucketsKindDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
BUCKET_KIND_HINTS,
|
||||
BUCKET_KIND_OPTIONS,
|
||||
DEFAULT_NUM_BUCKETS,
|
||||
LOG_SCALE_OPTIONS,
|
||||
MAX_NUM_BUCKETS,
|
||||
} from './constants';
|
||||
import {
|
||||
formatUpperBound,
|
||||
hasBoundsBeyondPreview,
|
||||
isLinearBuckets,
|
||||
kindOptionOf,
|
||||
linearBuckets,
|
||||
logBuckets,
|
||||
logScaleOf,
|
||||
previewUpperBounds,
|
||||
} from './utils';
|
||||
|
||||
import styles from './BucketOptions.module.scss';
|
||||
|
||||
function BucketOptions({
|
||||
bucketOptions,
|
||||
unit,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/** The panel's y-axis unit, so the previewed bounds read the way the axis will. */
|
||||
unit?: string;
|
||||
onChange: (next: Querybuildertypesv5BucketOptionsDTO | undefined) => void;
|
||||
/** Omitted where the section isn't dismissable, as on a formula. */
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
// A linear axis has no bounds to describe until it has a max value, so the picked
|
||||
// kind is held here rather than read back off the emitted options: it has to survive
|
||||
// the gap between choosing Linear and filling the field in.
|
||||
const [kind, setKind] = useState<Querybuildertypesv5BucketsKindDTO>(() =>
|
||||
kindOptionOf(bucketOptions),
|
||||
);
|
||||
const linearSpec =
|
||||
bucketOptions && isLinearBuckets(bucketOptions)
|
||||
? bucketOptions.spec
|
||||
: undefined;
|
||||
const [logScale, setLogScale] = useState<number>(() =>
|
||||
logScaleOf(bucketOptions),
|
||||
);
|
||||
const [maxValue, setMaxValue] = useState<number | null>(
|
||||
linearSpec?.maxValue ?? null,
|
||||
);
|
||||
const [numBuckets, setNumBuckets] = useState<number | null>(
|
||||
linearSpec?.numBuckets ?? null,
|
||||
);
|
||||
|
||||
const emitLinear = useCallback(
|
||||
(nextMaxValue: number | null, nextNumBuckets: number | null): void => {
|
||||
// An incomplete linear axis is sent as no axis at all rather than as a spec the
|
||||
// request would reject.
|
||||
if (nextMaxValue === null || nextMaxValue <= 0) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(linearBuckets(nextMaxValue, nextNumBuckets));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleKindChange = useCallback(
|
||||
(value: string): void => {
|
||||
// Radix clears the value when the active item is clicked again; a bucket axis is
|
||||
// always one of the two, so keep the current pick instead.
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKind = value as Querybuildertypesv5BucketsKindDTO;
|
||||
setKind(nextKind);
|
||||
|
||||
if (nextKind === Querybuildertypesv5BucketsKindDTO.log) {
|
||||
onChange(logBuckets(logScale));
|
||||
} else {
|
||||
emitLinear(maxValue, numBuckets);
|
||||
}
|
||||
},
|
||||
[emitLinear, logScale, maxValue, numBuckets, onChange],
|
||||
);
|
||||
|
||||
const handleScaleChange = useCallback(
|
||||
(value: string): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScale = Number(value);
|
||||
setLogScale(nextScale);
|
||||
onChange(logBuckets(nextScale));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleMaxValueChange = useCallback(
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setMaxValue(next);
|
||||
emitLinear(next, numBuckets);
|
||||
},
|
||||
[emitLinear, numBuckets],
|
||||
);
|
||||
|
||||
const handleNumBucketsChange = useCallback(
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setNumBuckets(next);
|
||||
emitLinear(maxValue, next);
|
||||
},
|
||||
[emitLinear, maxValue],
|
||||
);
|
||||
|
||||
// The toggle group takes a ReactNode label, which is how each item picks up the
|
||||
// query builder's value type — the component exposes no font-family token.
|
||||
const kindItems = useMemo(
|
||||
() =>
|
||||
BUCKET_KIND_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label: <span className={styles.toggleLabel}>{label}</span>,
|
||||
'aria-label': label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const scaleItems = useMemo(
|
||||
() =>
|
||||
LOG_SCALE_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label: <span className={styles.toggleLabel}>{label}</span>,
|
||||
'aria-label': label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
// The kind toggle can be ahead of what has been emitted, so the preview describes
|
||||
// the picked kind rather than the emitted options.
|
||||
const previewedOptions = useMemo(():
|
||||
| Querybuildertypesv5BucketOptionsDTO
|
||||
| undefined => {
|
||||
if (kind === Querybuildertypesv5BucketsKindDTO.log) {
|
||||
return logBuckets(logScale);
|
||||
}
|
||||
if (kind === Querybuildertypesv5BucketsKindDTO.linear && maxValue !== null) {
|
||||
return linearBuckets(maxValue, numBuckets);
|
||||
}
|
||||
return undefined;
|
||||
}, [kind, logScale, maxValue, numBuckets]);
|
||||
|
||||
const bounds = useMemo(
|
||||
() =>
|
||||
kind === Querybuildertypesv5BucketsKindDTO.linear && !previewedOptions
|
||||
? undefined
|
||||
: previewUpperBounds(previewedOptions),
|
||||
[kind, previewedOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.bucketOptions} data-testid="bucket-options">
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Bucket by</span>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={kind}
|
||||
items={kindItems}
|
||||
onChange={handleKindChange}
|
||||
testId="bucket-options-kind"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kind === Querybuildertypesv5BucketsKindDTO.log && (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Scale</span>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={String(logScale)}
|
||||
items={scaleItems}
|
||||
onChange={handleScaleChange}
|
||||
testId="bucket-options-scale"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === Querybuildertypesv5BucketsKindDTO.linear && (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Max value</span>
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={0}
|
||||
value={maxValue}
|
||||
onChange={handleMaxValueChange}
|
||||
placeholder="Required"
|
||||
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
|
||||
data-testid="bucket-options-max-value"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Buckets</span>
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={1}
|
||||
max={MAX_NUM_BUCKETS}
|
||||
precision={0}
|
||||
value={numBuckets}
|
||||
onChange={handleNumBucketsChange}
|
||||
placeholder={`Default ${DEFAULT_NUM_BUCKETS}`}
|
||||
data-testid="bucket-options-num-buckets"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Close bucket options"
|
||||
className={styles.closeButton}
|
||||
prefix={<ChevronUp size={16} />}
|
||||
onClick={onClose}
|
||||
data-testid="bucket-options-close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.bounds} data-testid="bucket-options-bounds">
|
||||
<span className={styles.label}>
|
||||
Bounds
|
||||
<Tooltip title={BUCKET_KIND_HINTS[kind]} placement="top">
|
||||
<Info size={12} className={styles.hintIcon} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
{bounds ? (
|
||||
<>
|
||||
{bounds.map((bound) => (
|
||||
<span className={styles.bound} key={bound}>
|
||||
{formatUpperBound(bound, unit)}
|
||||
</span>
|
||||
))}
|
||||
{hasBoundsBeyondPreview(previewedOptions) && (
|
||||
<span className={styles.muted}>…</span>
|
||||
)}
|
||||
<span className={cx(styles.bound, styles.overflowBound)}>+Inf</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.muted}>Set a max value to see the bounds</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BucketOptions.defaultProps = {
|
||||
bucketOptions: undefined,
|
||||
unit: undefined,
|
||||
onClose: undefined,
|
||||
};
|
||||
|
||||
export default BucketOptions;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MAX_LOG_SCALE } from '../constants';
|
||||
import {
|
||||
bandsPerDoublingFromScale,
|
||||
formatUpperBound,
|
||||
hasBoundsBeyondPreview,
|
||||
kindOptionOf,
|
||||
linearBuckets,
|
||||
logBuckets,
|
||||
previewUpperBounds,
|
||||
} from '../utils';
|
||||
|
||||
describe('bucket option scales', () => {
|
||||
it.each([
|
||||
[-2, 0.25],
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 4],
|
||||
[3, 8],
|
||||
[4, 16],
|
||||
])('scale %i is %i bands per doubling', (scale, bands) => {
|
||||
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kindOptionOf', () => {
|
||||
it('reads no options as log, the server default', () => {
|
||||
expect(kindOptionOf(undefined)).toBe('log');
|
||||
});
|
||||
|
||||
it('reads the kind off the options', () => {
|
||||
expect(kindOptionOf(logBuckets(MAX_LOG_SCALE))).toBe('log');
|
||||
expect(kindOptionOf(linearBuckets(10, null))).toBe('linear');
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewUpperBounds', () => {
|
||||
it('doubles at one band per doubling', () => {
|
||||
expect(previewUpperBounds(logBuckets(0))).toStrictEqual([
|
||||
1, 2, 4, 8, 16, 32, 64, 128,
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the finest log axis when no options are set', () => {
|
||||
const bounds = previewUpperBounds(undefined);
|
||||
|
||||
expect(bounds).toHaveLength(8);
|
||||
// 16 bands per doubling: the eighth bound is 2^(7/16), still short of the first doubling.
|
||||
expect(bounds?.[7]).toBeCloseTo(2 ** (7 / 16));
|
||||
});
|
||||
|
||||
it('spaces a linear axis evenly by bucket width', () => {
|
||||
expect(previewUpperBounds(linearBuckets(100, 10))).toStrictEqual(
|
||||
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].slice(0, 8),
|
||||
);
|
||||
});
|
||||
|
||||
it('stops at the last bucket when the axis has fewer than the preview shows', () => {
|
||||
expect(previewUpperBounds(linearBuckets(9, 3))).toStrictEqual([3, 6, 9]);
|
||||
});
|
||||
|
||||
it('has no bounds to preview for a linear axis without a usable max value', () => {
|
||||
expect(previewUpperBounds(linearBuckets(0, null))).toBeUndefined();
|
||||
expect(previewUpperBounds(linearBuckets(-1, null))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasBoundsBeyondPreview', () => {
|
||||
it('is always true for a log axis, which has no top', () => {
|
||||
expect(hasBoundsBeyondPreview(logBuckets(0))).toBe(true);
|
||||
expect(hasBoundsBeyondPreview(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks whether a linear axis runs past the preview', () => {
|
||||
expect(hasBoundsBeyondPreview(linearBuckets(100, 4))).toBe(false);
|
||||
expect(hasBoundsBeyondPreview(linearBuckets(100, 20))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUpperBound', () => {
|
||||
it('reads a bound in the panel unit when one is set', () => {
|
||||
expect(formatUpperBound(1, 'ms')).toContain('ms');
|
||||
});
|
||||
|
||||
it('keeps three significant digits when unitless', () => {
|
||||
expect(formatUpperBound(128, undefined)).toBe('128');
|
||||
expect(formatUpperBound(2 ** (1 / 16), undefined)).toBe('1.04');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Querybuildertypesv5BucketsKindDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Mirrors the limits `querybuildertypesv5` validates `bucketOptions` against. The
|
||||
* builder keeps its own copy so an out-of-range axis is refused before the request
|
||||
* rather than after it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A log axis spaces bounds at 2^scale bands per doubling. MaxLogScale is the
|
||||
* resolution ClickHouse buckets at, so it is both the finest available and what an
|
||||
* absent `bucketOptions` resolves to.
|
||||
*/
|
||||
export const MAX_LOG_SCALE = 4;
|
||||
|
||||
/** One band per 16x, the coarsest axis worth rendering. */
|
||||
export const MIN_LOG_SCALE = -4;
|
||||
|
||||
export const MAX_NUM_BUCKETS = 512;
|
||||
|
||||
export const DEFAULT_NUM_BUCKETS = 60;
|
||||
|
||||
/** Every scale the request accepts, coarsest first. The bounds strip below says
|
||||
* how coarse a given one is, so the numbers stand alone. */
|
||||
export const LOG_SCALES = Array.from(
|
||||
{ length: MAX_LOG_SCALE - MIN_LOG_SCALE + 1 },
|
||||
(_, index) => MIN_LOG_SCALE + index,
|
||||
);
|
||||
|
||||
/** How many leading upper bounds the bounds strip previews before eliding. */
|
||||
export const PREVIEW_BOUND_COUNT = 8;
|
||||
|
||||
export const BUCKET_KIND_OPTIONS: {
|
||||
value: Querybuildertypesv5BucketsKindDTO;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: Querybuildertypesv5BucketsKindDTO.log, label: 'Log' },
|
||||
{ value: Querybuildertypesv5BucketsKindDTO.linear, label: 'Linear' },
|
||||
];
|
||||
|
||||
export const LOG_SCALE_OPTIONS = LOG_SCALES.map((scale) => ({
|
||||
value: String(scale),
|
||||
label: String(scale),
|
||||
}));
|
||||
|
||||
export const BUCKET_KIND_HINTS: Record<
|
||||
Querybuildertypesv5BucketsKindDTO,
|
||||
string
|
||||
> = {
|
||||
[Querybuildertypesv5BucketsKindDTO.log]:
|
||||
'Bounds are spaced evenly on a log axis, so every band is the same height on screen and the tail stays readable. A lower scale means fewer, coarser bands; scale 4, the finest, is the default.',
|
||||
[Querybuildertypesv5BucketsKindDTO.linear]:
|
||||
'Bounds are spaced evenly from 0 up to the max value, so a band covers the same width wherever it sits. Everything above the max value lands in a single overflow band.',
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Querybuildertypesv5BucketOptionsDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind,
|
||||
Querybuildertypesv5BucketOptionsLogDTO,
|
||||
Querybuildertypesv5BucketOptionsLogDTOKind,
|
||||
Querybuildertypesv5BucketsKindDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import {
|
||||
DEFAULT_NUM_BUCKETS,
|
||||
MAX_LOG_SCALE,
|
||||
PREVIEW_BOUND_COUNT,
|
||||
} from './constants';
|
||||
|
||||
export const isLinearBuckets = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO,
|
||||
): bucketOptions is Querybuildertypesv5BucketOptionsLinearDTO =>
|
||||
bucketOptions.kind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear;
|
||||
|
||||
export const linearBuckets = (
|
||||
maxValue: number,
|
||||
numBuckets: number | null,
|
||||
): Querybuildertypesv5BucketOptionsLinearDTO => ({
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
spec: { maxValue, ...(numBuckets ? { numBuckets } : {}) },
|
||||
});
|
||||
|
||||
export const logBuckets = (
|
||||
scale: number,
|
||||
): Querybuildertypesv5BucketOptionsLogDTO => ({
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale },
|
||||
});
|
||||
|
||||
/**
|
||||
* The log spec's scale, defaulted the way the server defaults it. A linear axis has
|
||||
* no scale, so it reads as the default too.
|
||||
*/
|
||||
export const logScaleOf = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): number =>
|
||||
bucketOptions && !isLinearBuckets(bucketOptions)
|
||||
? (bucketOptions.spec.scale ?? MAX_LOG_SCALE)
|
||||
: MAX_LOG_SCALE;
|
||||
|
||||
/** A log axis places `2^scale` bounds per doubling. */
|
||||
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
|
||||
|
||||
export const kindOptionOf = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): Querybuildertypesv5BucketsKindDTO =>
|
||||
bucketOptions && isLinearBuckets(bucketOptions)
|
||||
? Querybuildertypesv5BucketsKindDTO.linear
|
||||
: Querybuildertypesv5BucketsKindDTO.log;
|
||||
|
||||
/**
|
||||
* The leading upper bounds the axis will carry. A log axis is anchored at 1 — band
|
||||
* index 0's boundary — and a linear one at the top of its first band; both continue
|
||||
* past what the strip shows, and everything above the last one lands in the overflow
|
||||
* band the UI labels separately.
|
||||
*
|
||||
* `undefined` for a linear axis with no max value yet: without a top there is nothing
|
||||
* to divide.
|
||||
*/
|
||||
export function previewUpperBounds(
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): number[] | undefined {
|
||||
if (bucketOptions && isLinearBuckets(bucketOptions)) {
|
||||
const { maxValue, numBuckets = DEFAULT_NUM_BUCKETS } = bucketOptions.spec;
|
||||
|
||||
if (!Number.isFinite(maxValue) || maxValue <= 0 || numBuckets <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const width = maxValue / numBuckets;
|
||||
|
||||
return Array.from(
|
||||
{ length: Math.min(numBuckets, PREVIEW_BOUND_COUNT) },
|
||||
(_, index) => (index + 1) * width,
|
||||
);
|
||||
}
|
||||
|
||||
const bands = bandsPerDoublingFromScale(logScaleOf(bucketOptions));
|
||||
|
||||
return Array.from(
|
||||
{ length: PREVIEW_BOUND_COUNT },
|
||||
(_, index) => 2 ** (index / bands),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the strip elides bounds after the ones it shows. A linear axis with no more
|
||||
* buckets than the strip holds ends where the strip does.
|
||||
*/
|
||||
export function hasBoundsBeyondPreview(
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): boolean {
|
||||
if (!bucketOptions || !isLinearBuckets(bucketOptions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(bucketOptions.spec.numBuckets ?? DEFAULT_NUM_BUCKETS) > PREVIEW_BOUND_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bound is a value on the panel's own axis, so it reads in the panel's unit when one
|
||||
* is set. Unitless, three significant digits keep the tightly spaced bounds of a fine
|
||||
* log axis distinguishable without printing the float in full.
|
||||
*/
|
||||
export function formatUpperBound(bound: number, unit?: string): string {
|
||||
if (unit) {
|
||||
return getYAxisFormattedValue(String(bound), unit);
|
||||
}
|
||||
|
||||
return Number(bound.toPrecision(3)).toString();
|
||||
}
|
||||
@@ -3,14 +3,21 @@ import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { BarChart, ChevronUp, ExternalLink, ScrollText } from '@signozhq/icons';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Grid3X3,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
resolveQueryBuilderFields,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
|
||||
import BucketOptions from './BucketOptions/BucketOptions';
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
@@ -57,6 +65,7 @@ const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.Legend]: 'legend',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
[QueryBuilderField.BucketOptions]: 'bucketOptions',
|
||||
};
|
||||
|
||||
const ADD_ONS: AddOn[] = [
|
||||
@@ -116,6 +125,22 @@ const REDUCE_TO: AddOn = {
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
};
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const BUCKET_OPTIONS: AddOn = {
|
||||
icon: <Grid3X3 size={14} />,
|
||||
label: 'Bucket by',
|
||||
key: QueryBuilderField.BucketOptions,
|
||||
description:
|
||||
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||
|
||||
@@ -196,7 +221,7 @@ function QueryAddOns({
|
||||
isForTraceOperator,
|
||||
});
|
||||
|
||||
const { handleSetQueryData } = useQueryBuilder();
|
||||
const { handleSetQueryData, currentQuery } = useQueryBuilder();
|
||||
|
||||
const supportedAddOns = useMemo((): AddOn[] => {
|
||||
let addOns: AddOn[];
|
||||
@@ -210,8 +235,25 @@ function QueryAddOns({
|
||||
addOns = [...ADD_ONS];
|
||||
}
|
||||
|
||||
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
|
||||
}, [panelType, query.dataSource, showReduceTo]);
|
||||
if (showReduceTo) {
|
||||
addOns = [...addOns, REDUCE_TO];
|
||||
}
|
||||
|
||||
if (
|
||||
panelType === PANEL_TYPES.HEATMAP &&
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
addOns = [...addOns, BUCKET_OPTIONS];
|
||||
}
|
||||
|
||||
return addOns;
|
||||
}, [
|
||||
panelType,
|
||||
query.dataSource,
|
||||
query.aggregateAttribute?.type,
|
||||
showReduceTo,
|
||||
]);
|
||||
|
||||
const resolvedFields = useMemo(
|
||||
() =>
|
||||
@@ -392,6 +434,13 @@ function QueryAddOns({
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeBucketOptions = useCallback(
|
||||
(value: Querybuildertypesv5BucketOptionsDTO | undefined) => {
|
||||
handleChangeQueryData('bucketOptions', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="query-add-ons" data-testid="query-add-ons">
|
||||
{selectedViews.length > 0 && (
|
||||
@@ -559,6 +608,19 @@ function QueryAddOns({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find(
|
||||
(view) => view.key === QueryBuilderField.BucketOptions,
|
||||
) && (
|
||||
<div className="add-on-content" data-testid="bucket-options-content">
|
||||
<BucketOptions
|
||||
bucketOptions={query.bucketOptions}
|
||||
unit={currentQuery.unit}
|
||||
onChange={handleChangeBucketOptions}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.BucketOptions)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1758,7 +1758,7 @@ QuerySearch.defaultProps = {
|
||||
signalSource: '',
|
||||
hardcodedAttributeKeys: undefined,
|
||||
placeholder:
|
||||
"Enter your filter query (e.g., http.response.status_code >= 500 AND service.name = 'frontend')",
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
|
||||
showFilterSuggestionsWithoutMetric: false,
|
||||
initialExpression: undefined,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -26,8 +26,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
handleSetQueryData: typeof mockHandleSetQueryData;
|
||||
currentQuery: { unit: string | undefined };
|
||||
} => ({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
currentQuery: { unit: undefined },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -330,4 +332,175 @@ describe('QueryAddOns', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
describe('bucket options', () => {
|
||||
function renderHeatmap(overrides: Partial<any> = {}): void {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS, ...overrides })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.HEATMAP}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('is offered on a metrics heatmap only', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('query-add-on-bucket_options'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on other panel types', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on a heatmap over another signal', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.LOGS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.HEATMAP}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([ATTRIBUTE_TYPES.HISTOGRAM, ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM])(
|
||||
'is not offered for a %s metric, which carries its own buckets',
|
||||
(type) => {
|
||||
renderHeatmap({
|
||||
aggregateAttribute: { key: 'http_duration_bucket', type },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("auto-opens on the query's own kind", () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('shows the server default, log at scale 4, without sending it', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap();
|
||||
|
||||
await user.click(screen.getByTestId('query-add-on-bucket_options'));
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '4' })).toBeChecked();
|
||||
expect(
|
||||
screen.queryByRole('radio', { name: 'Auto' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockHandleChangeQueryData).not.toHaveBeenCalledWith(
|
||||
'bucketOptions',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the picked scale', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '0' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the coarser scales the request accepts below one band per doubling', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '-4' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: -4 },
|
||||
});
|
||||
});
|
||||
|
||||
it('previews the bounds the picked axis will carry', () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
const bounds = within(screen.getByTestId('bucket-options-bounds'));
|
||||
['1', '2', '4', '8', '16', '32', '64', '128', '+Inf'].forEach((bound) => {
|
||||
expect(bounds.getByText(bound)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends nothing for a linear axis until it has a max value', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Linear' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
'bucketOptions',
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Set a max value to see the bounds'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the linear axis once a max value is filled in', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Linear' }));
|
||||
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
'bucketOptions',
|
||||
{ kind: 'linear', spec: { maxValue: 500 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes back to the toggle bar', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByTestId('bucket-options-close'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('bucket-options-content'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -995,16 +995,6 @@ describe('removeKeysFromExpression', () => {
|
||||
expect(result).toBe("status = 'success'");
|
||||
});
|
||||
|
||||
it('should remove a comparison that uses the exact field wrapper', () => {
|
||||
const expression =
|
||||
"exact(resource.deployment.environment) EXISTS AND service.name = 'api-gateway'";
|
||||
const result = removeKeysFromExpression(expression, [
|
||||
'resource.deployment.environment',
|
||||
]);
|
||||
|
||||
expect(result).toBe("service.name = 'api-gateway'");
|
||||
});
|
||||
|
||||
it('should remove multiple keys from expression', () => {
|
||||
const expression =
|
||||
"service.name = 'api-gateway' AND status = 'success' AND region = 'us-east-1'";
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum QueryBuilderField {
|
||||
Limit = 'limit',
|
||||
Legend = 'legend_format',
|
||||
ReduceTo = 'reduce_to',
|
||||
BucketOptions = 'bucket_options',
|
||||
// Builder level
|
||||
Formula = 'formula',
|
||||
AdditionalQueries = 'additional_queries',
|
||||
|
||||
@@ -76,6 +76,7 @@ export const RAW_QUERY_FIELDS: Omit<
|
||||
[QueryBuilderField.Limit]: { state: 'hidden' },
|
||||
[QueryBuilderField.Legend]: { state: 'hidden' },
|
||||
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
|
||||
[QueryBuilderField.BucketOptions]: { state: 'hidden' },
|
||||
[QueryBuilderField.Formula]: { state: 'hidden' },
|
||||
[QueryBuilderField.OrderBy]: { state: 'pinned' },
|
||||
};
|
||||
|
||||
@@ -687,16 +687,7 @@ export const removeKeysFromExpression = (
|
||||
}
|
||||
|
||||
function visitComparison(ctx: ComparisonContext): string | null {
|
||||
const field = ctx.field();
|
||||
// The runtime returns null for the inactive field alternative even though
|
||||
// the generated TypeScript signature is non-nullable.
|
||||
const exactCall = field.exactCall() as unknown as ReturnType<
|
||||
typeof field.exactCall
|
||||
> | null;
|
||||
const keyText = (exactCall ? exactCall.key() : field.key())
|
||||
.getText()
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const keyText = ctx.key().getText().trim().toLowerCase();
|
||||
|
||||
if (!keysSet.has(keyText)) {
|
||||
return src(ctx);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTER_DOC_PATHS: Record<string, string> = {
|
||||
severity_text: 'severity-text',
|
||||
'deployment.environment.name': 'environment',
|
||||
'deployment.environment': 'environment',
|
||||
'service.name': 'service-name',
|
||||
'host.name': 'hostname',
|
||||
'k8s.cluster.name': 'k8s-cluster-name',
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
clip-path: inset(0 -100vw 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
savedViewsHeader,
|
||||
} = props;
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
@@ -379,6 +380,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<div className="quick-filters-container">
|
||||
<div className="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
<section className="header">
|
||||
{renderLeftActions()}
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -21,6 +19,44 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.savedViews {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--l1-background);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.quickFilters {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
z-index: 1;
|
||||
|
||||
&.isOpen {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// No quick filters: the header sits in flow and the list stays under it
|
||||
.isStatic {
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
.quickFilters {
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.savedViews {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
@@ -31,3 +67,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import { ComponentProps, ReactNode, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
|
||||
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
|
||||
@@ -12,8 +15,15 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
// What the page configures; the layout adds the open / close wiring.
|
||||
type SavedViewsElementProps = Omit<
|
||||
ComponentProps<typeof SavedViewsPanel>,
|
||||
'onClose'
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
quickFilterProps?: QuickFiltersElementProps;
|
||||
savedViewProps?: SavedViewsElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
@@ -23,20 +33,60 @@ export interface QuickFiltersLayoutProps {
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
savedViewProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
const [isViewsOpen, setIsViewsOpen] = useState(false);
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const hasQuickFilters = !!quickFilterProps;
|
||||
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
|
||||
|
||||
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
|
||||
|
||||
const isPanelPinned = hasSavedViews && !hasQuickFilters;
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsOpen;
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showFilters && (
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={styles.filters}
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
<div
|
||||
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
|
||||
data-testid="quick-filters-layout-drawer"
|
||||
>
|
||||
{hasQuickFilters ? (
|
||||
<QuickFilters
|
||||
{...quickFilterProps}
|
||||
savedViewsHeader={savedViewsHeader}
|
||||
/>
|
||||
) : (
|
||||
savedViewsHeader
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
@@ -44,7 +94,7 @@ function QuickFiltersLayout({
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<div>{children}</div>
|
||||
<div className={styles.contentInner}>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
@@ -7,15 +9,33 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
filters: 'filters',
|
||||
sidebar: 'sidebar',
|
||||
isStatic: 'isStatic',
|
||||
savedViews: 'savedViews',
|
||||
quickFilters: 'quickFilters',
|
||||
isOpen: 'isOpen',
|
||||
content: 'content',
|
||||
contentInner: 'contentInner',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSavedViewEnabled', () => ({
|
||||
useSavedViewEnabled: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
default: ({
|
||||
source,
|
||||
savedViewsHeader,
|
||||
}: {
|
||||
source: string;
|
||||
savedViewsHeader?: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -23,8 +43,16 @@ const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
const drawer = (): HTMLElement =>
|
||||
screen.getByTestId('quick-filters-layout-drawer');
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
@@ -40,11 +68,16 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<QuickFiltersLayout
|
||||
showFilters={false}
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
@@ -56,24 +89,138 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no sidebar at all without quick filters or saved views', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
className="root-extra"
|
||||
contentClassName="content-extra"
|
||||
testId="layout"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'page-content',
|
||||
'content-extra',
|
||||
);
|
||||
});
|
||||
|
||||
describe('saved views', () => {
|
||||
it('ignores savedViewProps while the flag is off', () => {
|
||||
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
|
||||
const { unmount } = render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
|
||||
// Saved views only, so nothing is left to show.
|
||||
unmount();
|
||||
render(
|
||||
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const quickFilters = screen.getByTestId('quick-filters');
|
||||
|
||||
expect(quickFilters).toContainElement(
|
||||
screen.getByTestId('saved-views-header'),
|
||||
);
|
||||
expect(drawer()).toContainElement(quickFilters);
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-open'));
|
||||
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-close'));
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
|
||||
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
});
|
||||
|
||||
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const sidebar = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(sidebar).toHaveClass('isStatic');
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,20 @@
|
||||
width: 342px;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
transition: width 0.05s ease-in-out;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
transition:
|
||||
transform 0.2s ease-in-out,
|
||||
visibility 0s;
|
||||
|
||||
&.hidden {
|
||||
width: 0;
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform 0.2s ease-in-out,
|
||||
visibility 0s 0.2s;
|
||||
}
|
||||
.qf-header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -61,6 +62,7 @@ export interface IQuickFiltersProps {
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
useFieldApis?: QuickFilterCheckboxUseFieldApis;
|
||||
savedViewsHeader?: ReactNode;
|
||||
}
|
||||
|
||||
export enum QuickFiltersSource {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Alert } from 'antd';
|
||||
import { findOldSemconvNames } from 'utils/semconv';
|
||||
|
||||
interface SemconvEditorWarningProps {
|
||||
value: unknown;
|
||||
editor: string;
|
||||
}
|
||||
|
||||
function SemconvEditorWarning({
|
||||
value,
|
||||
editor,
|
||||
}: SemconvEditorWarningProps): JSX.Element | null {
|
||||
const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
|
||||
const renames = findOldSemconvNames(text);
|
||||
if (renames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
data-testid="semconv-editor-warning"
|
||||
message={`${editor} contains renamed OpenTelemetry fields`}
|
||||
description={renames
|
||||
.map(({ old, current }) => `${old} → ${current}`)
|
||||
.join(', ')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SemconvEditorWarning;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { getSemconvRename } from 'utils/semconv';
|
||||
|
||||
interface SemconvOldNameBadgeProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function SemconvOldNameBadge({
|
||||
name,
|
||||
}: SemconvOldNameBadgeProps): JSX.Element | null {
|
||||
const rename = getSemconvRename(name);
|
||||
if (!rename || rename.family.kind !== 'attribute') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="amber" variant="outline" data-testid="semconv-old-name-badge">
|
||||
old name, renamed to {rename.current}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default SemconvOldNameBadge;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import SemconvEditorWarning from '../SemconvEditorWarning';
|
||||
import SemconvOldNameBadge from '../SemconvOldNameBadge';
|
||||
|
||||
describe('semantic convention product hints', () => {
|
||||
it('badges an old raw attribute with its current name', () => {
|
||||
render(<SemconvOldNameBadge name="deployment.environment" />);
|
||||
|
||||
expect(screen.getByTestId('semconv-old-name-badge')).toHaveTextContent(
|
||||
'old name, renamed to deployment.environment.name',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not badge a current raw attribute', () => {
|
||||
render(<SemconvOldNameBadge name="deployment.environment.name" />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('semconv-old-name-badge'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an informational editor warning without disabling the editor', () => {
|
||||
render(
|
||||
<>
|
||||
<input aria-label="query" defaultValue="deployment.environment = 'prod'" />
|
||||
<SemconvEditorWarning
|
||||
value="deployment.environment = 'prod'"
|
||||
editor="ClickHouse SQL"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('query')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('semconv-editor-warning')).toHaveTextContent(
|
||||
'deployment.environment → deployment.environment.name',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as SemconvEditorWarning } from './SemconvEditorWarning';
|
||||
export { default as SemconvOldNameBadge } from './SemconvOldNameBadge';
|
||||
@@ -19,51 +19,6 @@ export type SemconvFamily = {
|
||||
};
|
||||
|
||||
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
|
||||
{
|
||||
current: 'code.file.path',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'code.filepath',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'code.function.name',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'code.function',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'code.line.number',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'code.lineno',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'container.cpu.usage',
|
||||
kind: 'metric',
|
||||
@@ -79,84 +34,6 @@ export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'container.runtime.name',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'container.runtime',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'db.namespace',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'db.elasticsearch.cluster.name',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'db.name',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'db.cassandra.keyspace',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'db.hbase.namespace',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'db.operation.name',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'db.operation',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'db.query.text',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'db.statement',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'deployment.environment.name',
|
||||
kind: 'attribute',
|
||||
@@ -172,36 +49,6 @@ export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
|
||||
signals: ['logs', 'metrics', 'traces'],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'http.request.method',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'http.method',
|
||||
contexts: ['attribute'],
|
||||
signals: ['logs', 'traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'http.response.status_code',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'http.status_code',
|
||||
contexts: ['attribute'],
|
||||
signals: ['logs', 'traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'k8s.node.cpu.usage',
|
||||
kind: 'metric',
|
||||
@@ -232,175 +79,4 @@ export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'messaging.client.id',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'messaging.client_id',
|
||||
contexts: ['attribute'],
|
||||
signals: ['metrics', 'traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'messaging.kafka.client_id',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'messaging.rocketmq.client_id',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'messaging.consumer.group.name',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'messaging.eventhubs.consumer.group',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'messaging.kafka.consumer.group',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'messaging.rocketmq.client_group',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'messaging.kafka.consumer_group',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'messaging.destination.name',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'messaging.destination',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'messaging.operation.type',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'messaging.operation',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'rpc.system.name',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'rpc.system',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'service.peer.name',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'peer.service',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'url.full',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'http.url',
|
||||
contexts: ['attribute'],
|
||||
signals: ['logs', 'traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'url.scheme',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'http.scheme',
|
||||
contexts: ['attribute'],
|
||||
signals: ['logs', 'traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'user_agent.original',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'browser.user_agent',
|
||||
contexts: ['resource'],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
{
|
||||
name: 'http.user_agent',
|
||||
contexts: ['attribute'],
|
||||
signals: ['traces'],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: ['attribute'],
|
||||
signals: ['logs', 'traces'],
|
||||
valueMap: {},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -32,6 +32,8 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.AREA]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
|
||||
@@ -339,6 +339,7 @@ export enum PANEL_TYPES {
|
||||
AREA = 'area',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -527,6 +527,21 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
|
||||
* count already draws. Sum is also what the statement builder forces on a histogram
|
||||
* heatmap whatever is asked for, so it is the only honest option to offer.
|
||||
*/
|
||||
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT,
|
||||
label: 'Count',
|
||||
},
|
||||
];
|
||||
|
||||
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
|
||||
@@ -10,7 +10,7 @@ export const AllTraceFilterKeyValue: Record<string, string> = {
|
||||
durationNano: 'Duration',
|
||||
duration_nano: 'Duration',
|
||||
durationNanoMax: 'Duration',
|
||||
'deployment.environment.name': 'Environment',
|
||||
'deployment.environment': 'Environment',
|
||||
hasError: 'Status',
|
||||
has_error: 'Status',
|
||||
serviceName: 'Service Name',
|
||||
@@ -55,11 +55,11 @@ export const traceFilterKeys: Record<AllTraceFilterKeys, BaseAutocompleteData> =
|
||||
id: 'serviceName--string--tag--true',
|
||||
},
|
||||
|
||||
'deployment.environment.name': {
|
||||
key: 'deployment.environment.name',
|
||||
'deployment.environment': {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: 'deployment.environment.name--string--resource--false',
|
||||
id: 'deployment.environment--string--resource--false',
|
||||
},
|
||||
name: {
|
||||
key: 'name',
|
||||
|
||||
@@ -155,7 +155,7 @@ function DomainList(): JSX.Element {
|
||||
dataSource={DataSource.TRACES}
|
||||
queryData={query}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Enter your filter query (e.g., deployment.environment.name = 'otel-demo' AND service.name = 'frontend')"
|
||||
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
|
||||
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
@@ -180,8 +180,9 @@ function DomainList(): JSX.Element {
|
||||
</div>
|
||||
<div className="no-domain-subtitle">
|
||||
Ensure all HTTP client spans are being sent with kind as{' '}
|
||||
<span className="attribute">Client</span> and the URL set in the{' '}
|
||||
<span className="attribute">url.full</span> attribute.
|
||||
<span className="attribute">Client</span> and url set in{' '}
|
||||
<span className="attribute">url.full</span> or{' '}
|
||||
<span className="attribute">http.url</span> attribute.
|
||||
</div>
|
||||
<a
|
||||
href={DOCLINKS.EXTERNAL_API_MONITORING}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
|
||||
export const ApiMonitoringHardcodedAttributeKeys: QueryKeyDataSuggestionsProps[] =
|
||||
[
|
||||
{
|
||||
label: 'deployment.environment.name',
|
||||
label: 'deployment.environment',
|
||||
type: 'resource',
|
||||
name: 'deployment.environment.name',
|
||||
name: 'deployment.environment',
|
||||
signal: 'traces',
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
title: 'Environment',
|
||||
|
||||
attributeKey: {
|
||||
key: 'deployment.environment.name',
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
|
||||
@@ -467,6 +467,7 @@ describe('Footer utils', () => {
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
bucketOptions: undefined,
|
||||
disabled: false,
|
||||
filter: {
|
||||
expression: '',
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { getSemconvMembers } from 'utils/semconv';
|
||||
import styles from './traceListColumns.module.scss';
|
||||
|
||||
const keyToLabelMap: Record<string, string> = {
|
||||
@@ -28,12 +27,7 @@ const keyToLabelMap: Record<string, string> = {
|
||||
const keyAliases: Record<string, string[]> = {
|
||||
serviceName: ['serviceName', 'service.name', 'service_name'],
|
||||
durationNano: ['durationNano', 'duration.nano', 'duration_nano'],
|
||||
httpMethod: [
|
||||
'httpMethod',
|
||||
...getSemconvMembers('http.request.method'),
|
||||
'http_request_method',
|
||||
'http_method',
|
||||
],
|
||||
httpMethod: ['httpMethod', 'http.method', 'http_method'],
|
||||
responseStatusCode: [
|
||||
'response_status_code',
|
||||
'response.status.code',
|
||||
|
||||
@@ -128,7 +128,7 @@ export const INFRA_MONITORING_ATTR_KEYS = {
|
||||
K8S_OBJECT_NAME: 'k8s.object.name',
|
||||
|
||||
// Environment
|
||||
DEPLOYMENT_ENVIRONMENT: 'deployment.environment.name',
|
||||
DEPLOYMENT_ENVIRONMENT: 'deployment.environment',
|
||||
|
||||
// Host System
|
||||
OS_TYPE: 'os.type',
|
||||
@@ -907,7 +907,7 @@ export const ENTITY_FILTER_PLACEHOLDERS: Record<InfraMonitoringEntity, string> =
|
||||
[InfraMonitoringEntity.NAMESPACES]:
|
||||
"Enter your filter query (e.g., k8s.namespace.name = 'production' AND k8s.cluster.name = 'prod-cluster')",
|
||||
[InfraMonitoringEntity.CLUSTERS]:
|
||||
"Enter your filter query (e.g., k8s.cluster.name = 'prod-cluster' AND deployment.environment.name = 'production')",
|
||||
"Enter your filter query (e.g., k8s.cluster.name = 'prod-cluster' AND deployment.environment = 'production')",
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]:
|
||||
"Enter your filter query (e.g., k8s.deployment.name = 'api-server' AND k8s.namespace.name = 'production')",
|
||||
[InfraMonitoringEntity.STATEFULSETS]:
|
||||
|
||||
@@ -2,17 +2,6 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.semconv-migration-report {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.ant-table-wrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ingestion-key-container {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -5,8 +5,6 @@ import getIngestionData from 'api/settings/getIngestionData';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IngestionDataType } from 'types/api/settings/ingestion';
|
||||
|
||||
import SemconvMigrationReport from './SemconvMigrationReport';
|
||||
|
||||
import './IngestionSettings.styles.scss';
|
||||
|
||||
export default function IngestionSettings(): JSX.Element {
|
||||
@@ -86,7 +84,6 @@ export default function IngestionSettings(): JSX.Element {
|
||||
dataSource={data}
|
||||
bordered
|
||||
/>
|
||||
<SemconvMigrationReport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,8 +83,6 @@ import { MeterAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getDaysUntilExpiry } from 'utils/timeUtils';
|
||||
|
||||
import SemconvMigrationReport from './SemconvMigrationReport';
|
||||
|
||||
import './IngestionSettings.styles.scss';
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -1707,7 +1705,6 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
}}
|
||||
className="ingestion-keys-table"
|
||||
/>
|
||||
<SemconvMigrationReport />
|
||||
</div>
|
||||
|
||||
{/* Delete Key Modal */}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import { Alert, Table, TableColumnsType } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getSemconvMigrationReport from 'api/semconv/getMigrationReport';
|
||||
import dayjs from 'dayjs';
|
||||
import { SemconvMigrationReportEntry } from 'types/api/semconvMigration';
|
||||
|
||||
function SemconvMigrationReport(): JSX.Element {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['semconv-migration-report'],
|
||||
queryFn: getSemconvMigrationReport,
|
||||
});
|
||||
|
||||
const columns: TableColumnsType<SemconvMigrationReportEntry> = [
|
||||
{
|
||||
title: 'Old name',
|
||||
dataIndex: 'old',
|
||||
key: 'old',
|
||||
},
|
||||
{
|
||||
title: 'Current name',
|
||||
dataIndex: 'current',
|
||||
key: 'current',
|
||||
},
|
||||
{
|
||||
title: 'Signal',
|
||||
dataIndex: 'signal',
|
||||
key: 'signal',
|
||||
},
|
||||
{
|
||||
title: 'Services still sending only the old name',
|
||||
dataIndex: 'services',
|
||||
key: 'services',
|
||||
render: (services: string[]): string => services.join(', '),
|
||||
},
|
||||
{
|
||||
title: 'Last seen',
|
||||
dataIndex: 'lastSeenUnixMilli',
|
||||
key: 'lastSeenUnixMilli',
|
||||
render: (value: number): string =>
|
||||
dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="semconv-migration-report">
|
||||
<Typography.Title level={4}>Semantic convention migration</Typography.Title>
|
||||
<Typography.Text>
|
||||
Services in this report sent an old OpenTelemetry field during the last 24
|
||||
hours without sending its current replacement. Update their SDK or
|
||||
instrumentation when practical; SigNoz queries remain backward compatible.
|
||||
</Typography.Text>
|
||||
{isError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Could not load the semantic convention migration report"
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
dataSource={data?.entries ?? []}
|
||||
rowKey={(entry): string => `${entry.current}-${entry.old}-${entry.signal}`}
|
||||
pagination={false}
|
||||
locale={{ emptyText: 'No old-only services found in the last 24 hours' }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SemconvMigrationReport;
|
||||
@@ -18,7 +18,7 @@ export const SAMPLE_SPAN_JSON = `{
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "llm-gateway",
|
||||
"deployment.environment.name": "production"
|
||||
"deployment.environment": "production"
|
||||
}
|
||||
}`;
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getSemconvMembers } from 'utils/semconv';
|
||||
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
|
||||
@@ -14,10 +12,12 @@ export const TIMESTAMP_FIELD_NAMES = new Set([
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
...getSemconvMembers('http.request.method'),
|
||||
'http.method',
|
||||
'http.request.method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
...getSemconvMembers('http.response.status_code'),
|
||||
'http.status_code',
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
|
||||
|
||||
@@ -74,8 +74,7 @@ export function tracesRunQueryAction(
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Attribute key, e.g. service.name, http.response.status_code',
|
||||
description: 'Attribute key, e.g. service.name, http.status_code',
|
||||
},
|
||||
op: {
|
||||
type: 'string',
|
||||
@@ -144,7 +143,7 @@ export function tracesAddFilterAction(
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Attribute key, e.g. service.name, http.response.status_code',
|
||||
description: 'Attribute key, e.g. service.name, http.status_code',
|
||||
},
|
||||
op: {
|
||||
type: 'string',
|
||||
|
||||
@@ -5,22 +5,9 @@
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
|
||||
color: var(--bg-amber-300, var(--bg-amber-400));
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.bannerIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
border-radius: var(--radius-2);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -80,13 +80,10 @@ function UnpricedModelsTab(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.unpricedModelsTab}>
|
||||
<div className={styles.banner}>
|
||||
<TriangleAlert size="sm" className={styles.bannerIcon} />
|
||||
<Typography.Text as="span" size="small" color="warning">
|
||||
Models detected in traces without pricing. Map each to a billing model or
|
||||
create pricing so estimated cost can be computed.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Callout type="warning" size="small" showIcon className={styles.banner}>
|
||||
Models detected in traces without pricing. Map each to a billing model or
|
||||
create pricing so estimated cost can be computed.
|
||||
</Callout>
|
||||
|
||||
{isError && (
|
||||
<div className={styles.error}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SemconvOldNameBadge } from 'components/Semconv';
|
||||
|
||||
import { TagContainer, TagLabel, TagValue } from './FieldRenderer.styles';
|
||||
import { FieldRendererProps } from './LogDetailedView.types';
|
||||
@@ -29,7 +28,6 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
|
||||
<Typography.Text truncate={1} className="label">
|
||||
{newField}{' '}
|
||||
</Typography.Text>
|
||||
<SemconvOldNameBadge name={newField} />
|
||||
</TooltipSimple>
|
||||
|
||||
<div className="tags">
|
||||
@@ -49,10 +47,7 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="label">{field}</span>
|
||||
<SemconvOldNameBadge name={field} />
|
||||
</>
|
||||
<span className="label">{field}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -164,9 +164,7 @@ describe('useInitialQuery - Priority-Based Resource Filtering', () => {
|
||||
value: 'frontend-service',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({
|
||||
key: 'deployment.environment.name',
|
||||
}),
|
||||
key: expect.objectContaining({ key: 'deployment.environment' }),
|
||||
value: 'production',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -288,9 +286,7 @@ describe('useInitialQuery - Priority-Based Resource Filtering', () => {
|
||||
value: 'legacy-app',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({
|
||||
key: 'deployment.environment.name',
|
||||
}),
|
||||
key: expect.objectContaining({ key: 'deployment.environment' }),
|
||||
value: 'production',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { getSemconvRename } from 'utils/semconv';
|
||||
|
||||
const FALLBACK_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
|
||||
const FALLBACK_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the specified keywords
|
||||
|
||||
// Priority categories for filter selection
|
||||
// Strategy:
|
||||
// - Always include: service.name, deployment.environment.name, env, environment
|
||||
// - Always include: service.name, deployment.environment, env, environment
|
||||
// - Select ONE category only: stops at the first category with a matching attribute
|
||||
// - Within category: picks the first available attribute by order
|
||||
// - Order (highest to lowest priority): Kubernetes > Cloud > Host > Container
|
||||
@@ -27,36 +26,27 @@ const PRIORITY_CATEGORIES = [
|
||||
|
||||
const SERVICE_AND_ENVIRONMENT_KEYS = [
|
||||
'service.name',
|
||||
'deployment.environment.name',
|
||||
'deployment.environment',
|
||||
'env',
|
||||
'environment',
|
||||
];
|
||||
|
||||
export const getFiltersFromResources = (
|
||||
resources: ILog['resources_string'],
|
||||
): TagFilterItem[] => {
|
||||
const items = new Map<string, TagFilterItem>();
|
||||
Object.keys(resources).forEach((key: string) => {
|
||||
const currentKey = getSemconvRename(key)?.current ?? key;
|
||||
): TagFilterItem[] =>
|
||||
Object.keys(resources).map((key: string) => {
|
||||
const resourceValue = resources[key] as string;
|
||||
const item = {
|
||||
return {
|
||||
id: uuid(),
|
||||
key: {
|
||||
key: currentKey,
|
||||
key,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: OPERATORS['='],
|
||||
value: resourceValue,
|
||||
};
|
||||
// If raw data contains both names, retain the current value just like the
|
||||
// backend's current-first resolver.
|
||||
if (!items.has(currentKey) || key === currentKey) {
|
||||
items.set(currentKey, item);
|
||||
}
|
||||
});
|
||||
return Array.from(items.values());
|
||||
};
|
||||
|
||||
export const isServiceOrEnvironmentAttribute = (key: string): boolean =>
|
||||
SERVICE_AND_ENVIRONMENT_KEYS.includes(key);
|
||||
|
||||
@@ -28,7 +28,7 @@ import { v4 as uuid } from 'uuid';
|
||||
|
||||
export const dbSystemTags: Tags[] = [
|
||||
{
|
||||
Key: 'db.system.name.(string)',
|
||||
Key: 'db.system.(string)',
|
||||
StringValues: [''],
|
||||
NumberValues: [],
|
||||
BoolValues: [],
|
||||
|
||||
@@ -10,6 +10,8 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -347,57 +349,62 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
title={oneChartPerQueryDisabledTooltip}
|
||||
>
|
||||
<Switch
|
||||
value={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
|
||||
>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
title={oneChartPerQueryDisabledTooltip}
|
||||
>
|
||||
<Switch
|
||||
value={showOneChartPerQuery}
|
||||
onChange={handleToggleShowOneChartPerQuery}
|
||||
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
isMetricUnitsLoading={isMetricUnitsLoading}
|
||||
isMetricUnitsError={isMetricUnitsError}
|
||||
metricUnits={units}
|
||||
metricNames={metricNames}
|
||||
metrics={metrics}
|
||||
handleOpenMetricDetails={handleOpenMetricDetails}
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
<div className="explore-content">
|
||||
<TimeSeries
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
areAllMetricUnitsSame={areAllMetricUnitsSame}
|
||||
isMetricUnitsLoading={isMetricUnitsLoading}
|
||||
isMetricUnitsError={isMetricUnitsError}
|
||||
metricUnits={units}
|
||||
metricNames={metricNames}
|
||||
metrics={metrics}
|
||||
handleOpenMetricDetails={handleOpenMetricDetails}
|
||||
yAxisUnit={yAxisUnit}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form } from 'antd';
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import { SemconvEditorWarning } from 'components/Semconv';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -56,7 +55,6 @@ function TagFilterInputWithLogsResultPreview({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<SemconvEditorWarning value={value} editor="Pipeline filter" />
|
||||
<div className="pipeline-filter-input-preview-container">
|
||||
<LogsFilterPreview filter={value} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { DataSourceDropdown } from '../DataSourceDropdown';
|
||||
|
||||
const TEST_ID = 'query-data-source-selector';
|
||||
|
||||
async function openDropdown(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId(TEST_ID);
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
}
|
||||
|
||||
describe('DataSourceDropdown', () => {
|
||||
// antd's virtual list renders only the first couple of options into jsdom, so
|
||||
// each case asserts what the restriction admits and excludes, not the full list.
|
||||
it('offers the signals beyond the current one when nothing restricts it', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.METRICS}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Logs' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Metrics' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers only the signals the caller can visualize', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.METRICS}
|
||||
allowedDataSources={[TelemetrytypesSignalDTO.metrics]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Metrics' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Logs' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Traces' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops a signal that is not a data source a query can be built against', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.LOGS}
|
||||
allowedDataSources={[
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
TelemetrytypesSignalDTO[''],
|
||||
]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Logs' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Traces' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Metrics' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChangeEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Col, Input, Row, Select } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
|
||||
import { LEGEND } from 'constants/global';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
// ** Hooks
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
@@ -24,8 +26,12 @@ export function Formula({
|
||||
query,
|
||||
isQBV2,
|
||||
}: FormulaProps): JSX.Element {
|
||||
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
|
||||
useQueryBuilder();
|
||||
const {
|
||||
removeQueryBuilderEntityByIndex,
|
||||
handleSetFormulaData,
|
||||
panelType,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const { handleChangeFormulaData } = useQueryOperations({
|
||||
index,
|
||||
@@ -73,6 +79,13 @@ export function Formula({
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleChangeBucketOptions = useCallback(
|
||||
(value: IBuilderFormula['bucketOptions']) => {
|
||||
handleChangeFormulaData('bucketOptions', value);
|
||||
},
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleQBV2OrderByChange = useCallback(
|
||||
(value: string) => {
|
||||
const [columnName, order] = value.split(' ');
|
||||
@@ -163,6 +176,17 @@ export function Formula({
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{/* A heatmap draws its one enabled query, which is the formula when its
|
||||
inputs are disabled — so the formula states its own bucket axis. */}
|
||||
{isQBV2 && panelType === PANEL_TYPES.HEATMAP && (
|
||||
<Col span={24}>
|
||||
<BucketOptions
|
||||
bucketOptions={formula.bucketOptions}
|
||||
unit={currentQuery.unit}
|
||||
onChange={handleChangeBucketOptions}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import type { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { Formula } from '../Formula';
|
||||
|
||||
const mockHandleChangeFormulaData = jest.fn();
|
||||
let mockPanelType: PANEL_TYPES = PANEL_TYPES.HEATMAP;
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): Record<string, unknown> => ({
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
panelType: mockPanelType,
|
||||
currentQuery: { unit: undefined },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
useQueryOperations: (): Record<string, unknown> => ({
|
||||
handleChangeFormulaData: mockHandleChangeFormulaData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../QBEntityOptions/QBEntityOptions', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="qb-entity-options" />,
|
||||
}));
|
||||
|
||||
function formula(overrides: Partial<IBuilderFormula> = {}): IBuilderFormula {
|
||||
return {
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderFormula(overrides: Partial<IBuilderFormula> = {}): void {
|
||||
render(
|
||||
<Formula index={0} formula={formula(overrides)} query={{} as never} isQBV2 />,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Formula bucket options', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPanelType = PANEL_TYPES.HEATMAP;
|
||||
});
|
||||
|
||||
it('offers a bucket axis on a heatmap', () => {
|
||||
renderFormula();
|
||||
|
||||
expect(screen.getByTestId('bucket-options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not dismissable — a formula has no add-on toggle bar to collapse into', () => {
|
||||
renderFormula();
|
||||
|
||||
expect(screen.queryByTestId('bucket-options-close')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no bucket axis on other panel types', () => {
|
||||
mockPanelType = PANEL_TYPES.TIME_SERIES;
|
||||
renderFormula();
|
||||
|
||||
expect(screen.queryByTestId('bucket-options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens on the axis the formula already carries', () => {
|
||||
renderFormula({
|
||||
bucketOptions: { kind: 'log', spec: { scale: 0 } } as never,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('writes the picked axis onto the formula', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFormula();
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '0' }));
|
||||
|
||||
expect(mockHandleChangeFormulaData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { ChangeEvent, useCallback } from 'react';
|
||||
import MEditor, { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Input } from 'antd';
|
||||
import { SemconvEditorWarning } from 'components/Semconv';
|
||||
import { LEGEND } from 'constants/global';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -119,7 +118,6 @@ function ClickHouseQueryBuilder({
|
||||
theme={isDarkMode ? 'my-theme' : 'light'}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
<SemconvEditorWarning value={queryData?.query} editor="ClickHouse SQL" />
|
||||
<Input
|
||||
onChange={handleUpdateInput}
|
||||
name="legend"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChangeEvent, useCallback } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { LEGEND } from 'constants/global';
|
||||
import { SemconvEditorWarning } from 'components/Semconv';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IPromQLQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
@@ -67,7 +66,6 @@ function PromQLQueryBuilder({
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
data-testid="promql-query-input"
|
||||
/>
|
||||
<SemconvEditorWarning value={queryData?.query} editor="PromQL" />
|
||||
|
||||
<Input
|
||||
onChange={handleUpdateQuery}
|
||||
|
||||
@@ -155,6 +155,39 @@ describe('ResetPassword Component', () => {
|
||||
{ timeout: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
it('clears password mismatch error when confirm password is emptied', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<ResetPassword version="1.0.0" />, undefined, {
|
||||
initialRoute: '/password-reset?token=reset-token-123',
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /reset password/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password456');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(confirmPasswordInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/passwords don't match/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful Password Reset', () => {
|
||||
@@ -288,6 +321,65 @@ describe('ResetPassword Component', () => {
|
||||
expect(screen.queryByText(/invalid token/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears API error when the user edits the password', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'invalid_password',
|
||||
message: 'password must be at least 8 characters long',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ResetPassword version="1.0.0" />, undefined, {
|
||||
initialRoute: '/password-reset?token=reset-token-123',
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /reset password/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'weak');
|
||||
await user.type(confirmPasswordInput, 'weak');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/password must be at least 8 characters long/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'Er1!');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/password must be at least 8 characters long/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(confirmPasswordInput, 'Er1!');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useResetPassword } from 'api/generated/services/users';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
|
||||
@@ -23,10 +22,7 @@ import './ResetPassword.styles.scss';
|
||||
type FormValues = { password: string; confirmPassword: string };
|
||||
|
||||
function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
const [confirmPasswordError, setConfirmPasswordError] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [isValidPassword, setIsValidPassword] = useState(false);
|
||||
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
|
||||
const { t } = useTranslation(['common']);
|
||||
const { search } = useLocation();
|
||||
const params = new URLSearchParams(search);
|
||||
@@ -37,6 +33,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
mutate: resetPassword,
|
||||
isLoading,
|
||||
error: mutationError,
|
||||
reset: resetMutation,
|
||||
} = useResetPassword();
|
||||
|
||||
const errorMessage = useMemo(
|
||||
@@ -45,11 +42,30 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
);
|
||||
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const handleFormSubmit = (): void => {
|
||||
const { password } = form.getFieldsValue();
|
||||
const password = Form.useWatch('password', form);
|
||||
const confirmPassword = Form.useWatch('confirmPassword', form);
|
||||
|
||||
const isPasswordMismatch =
|
||||
Boolean(confirmPassword) && password !== confirmPassword;
|
||||
const showPasswordMismatchError = confirmPasswordTouched && isPasswordMismatch;
|
||||
const isValidPassword =
|
||||
Boolean(password?.trim()) &&
|
||||
Boolean(confirmPassword?.trim()) &&
|
||||
password === confirmPassword;
|
||||
|
||||
const handleValuesChange = (): void => {
|
||||
if (mutationError) {
|
||||
resetMutation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetPassword(
|
||||
{ data: { password, token: token || '' } },
|
||||
{ data: { password: form.getFieldValue('password'), token } },
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
notifications.success({
|
||||
@@ -63,80 +79,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
);
|
||||
};
|
||||
|
||||
const validatePassword = (): boolean => {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
|
||||
if (
|
||||
password &&
|
||||
confirmPassword &&
|
||||
password.trim() &&
|
||||
confirmPassword.trim() &&
|
||||
password.length > 0 &&
|
||||
confirmPassword.length > 0
|
||||
) {
|
||||
return password === confirmPassword;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleValuesChange = useDebouncedFn((): void => {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
|
||||
if (!password || !confirmPassword) {
|
||||
setIsValidPassword(false);
|
||||
}
|
||||
|
||||
// Only clear error if passwords match while typing (but don't set error until blur)
|
||||
if (
|
||||
password &&
|
||||
confirmPassword &&
|
||||
password.trim() &&
|
||||
confirmPassword.trim()
|
||||
) {
|
||||
const isValid = validatePassword();
|
||||
setIsValidPassword(isValid);
|
||||
|
||||
// Only clear error if passwords match, don't set error on mismatch
|
||||
if (isValid) {
|
||||
setConfirmPasswordError(false);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
const handlePasswordBlur = (): void => {
|
||||
const { confirmPassword } = form.getFieldsValue();
|
||||
// Only validate if confirm password has a value
|
||||
if (confirmPassword && confirmPassword.trim()) {
|
||||
const isValid = validatePassword();
|
||||
setIsValidPassword(isValid);
|
||||
setConfirmPasswordError(!isValid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPasswordBlur = (): void => {
|
||||
const { password, confirmPassword } = form.getFieldsValue();
|
||||
if (
|
||||
password &&
|
||||
password.trim() &&
|
||||
confirmPassword &&
|
||||
confirmPassword.trim()
|
||||
) {
|
||||
const isValid = validatePassword();
|
||||
setIsValidPassword(isValid);
|
||||
setConfirmPasswordError(!isValid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
const isValid = validatePassword();
|
||||
setIsValidPassword(isValid);
|
||||
|
||||
if (token) {
|
||||
handleFormSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
<div className="reset-password-card">
|
||||
@@ -158,6 +100,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
<FormContainer
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
onValuesChange={handleValuesChange}
|
||||
className="reset-password-form"
|
||||
>
|
||||
<div className="reset-password-form-container">
|
||||
@@ -171,8 +114,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
>
|
||||
<AntdInput.Password
|
||||
tabIndex={0}
|
||||
onChange={handleValuesChange}
|
||||
onBlur={handlePasswordBlur}
|
||||
id="password"
|
||||
data-testid="password"
|
||||
placeholder="Enter new password"
|
||||
@@ -189,8 +130,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
rules={[{ required: true, message: 'Please enter confirm password!' }]}
|
||||
>
|
||||
<AntdInput.Password
|
||||
onChange={handleValuesChange}
|
||||
onBlur={handleConfirmPasswordBlur}
|
||||
onBlur={(): void => setConfirmPasswordTouched(true)}
|
||||
id="confirmPassword"
|
||||
data-testid="confirmPassword"
|
||||
placeholder="Confirm your new password"
|
||||
@@ -201,7 +141,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmPasswordError && (
|
||||
{showPasswordMismatchError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
@@ -213,7 +153,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{errorMessage && !confirmPasswordError && (
|
||||
{errorMessage && !showPasswordMismatchError && (
|
||||
<AuthError error={errorMessage} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
43
frontend/src/container/SavedViews/SavedViewsHeader.tsx
Normal file
43
frontend/src/container/SavedViews/SavedViewsHeader.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Menu } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
|
||||
// Temp Placeholder data till main functionality is plugged in
|
||||
const PLACEHOLDER_VIEW_NAME = 'My view';
|
||||
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent when the list is always on screen.
|
||||
onOpenViews?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
data-testid="saved-views-header"
|
||||
data-source={source}
|
||||
>
|
||||
<Typography.Text className={styles.name}>
|
||||
{PLACEHOLDER_VIEW_NAME}
|
||||
</Typography.Text>
|
||||
{onOpenViews && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="All views"
|
||||
prefix={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
data-testid="saved-views-open"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsHeader;
|
||||
@@ -0,0 +1,21 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
40
frontend/src/container/SavedViews/SavedViewsPanel.tsx
Normal file
40
frontend/src/container/SavedViews/SavedViewsPanel.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './SavedViewsPanel.module.scss';
|
||||
|
||||
function SavedViewsPanel({
|
||||
source,
|
||||
onClose,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent when the list is always on screen.
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-testid="saved-views-panel"
|
||||
data-source={source}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.title}>All views</Typography.Text>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
data-testid="saved-views-close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsPanel;
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getSemconvMembers } from 'utils/semconv';
|
||||
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
@@ -7,10 +5,12 @@ export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
...getSemconvMembers('http.request.method'),
|
||||
'http.method',
|
||||
'http.request.method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
...getSemconvMembers('http.response.status_code'),
|
||||
'http.status_code',
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
@@ -30,6 +30,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
AREA: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -21,4 +21,6 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
[PANEL_TYPES.AREA]: TimeSeriesPanel,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
};
|
||||
|
||||
@@ -62,14 +62,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
legend: '',
|
||||
};
|
||||
|
||||
const setupMockQueryBuilder = (): void => {
|
||||
const setupMockQueryBuilder = (panelType = 'time_series'): void => {
|
||||
(useQueryBuilder as jest.Mock).mockReturnValue({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
handleSetFormulaData: mockHandleSetFormulaData,
|
||||
removeQueryBuilderEntityByIndex: mockRemoveQueryBuilderEntityByIndex,
|
||||
setLastUsedQuery: mockSetLastUsedQuery,
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
panelType: 'time_series',
|
||||
panelType,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [defaultMockQuery, defaultMockQuery],
|
||||
@@ -332,4 +332,105 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spaceAggregationOptions for a histogram metric', () => {
|
||||
const histogramQuery: IBuilderQuery = {
|
||||
...defaultMockQuery,
|
||||
aggregateAttribute: {
|
||||
key: 'signoz_latency',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
} as BaseAutocompleteData,
|
||||
};
|
||||
|
||||
it('offers the percentiles on a time series panel', () => {
|
||||
const result = renderHookWithProps({ query: histogramQuery });
|
||||
|
||||
expect(
|
||||
result.current.spaceAggregationOptions.map((o) => o.value),
|
||||
).toStrictEqual([
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P75,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P95,
|
||||
MetricAggregateOperator.P99,
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers count alone on a heatmap panel, whose Y axis is the `le` labels', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({ query: histogramQuery });
|
||||
|
||||
expect(
|
||||
result.current.spaceAggregationOptions.map((o) => o.value),
|
||||
).toStrictEqual([MetricAggregateOperator.COUNT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('picking a histogram metric', () => {
|
||||
const histogramAttribute: BaseAutocompleteData = {
|
||||
key: 'http.client.duration.bucket',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
};
|
||||
|
||||
it('defaults the spatial aggregation to p90 on a time series panel', () => {
|
||||
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregations: [
|
||||
expect.objectContaining({
|
||||
spaceAggregation: MetricAggregateOperator.P90,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults it to count on a heatmap panel, which offers nothing else', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregations: [
|
||||
expect.objectContaining({
|
||||
spaceAggregation: MetricAggregateOperator.COUNT,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the bucket axis a gauge had chosen, since `le` is the axis now', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
query: {
|
||||
...defaultMockQuery,
|
||||
bucketOptions: { kind: 'log', spec: { scale: 2 } },
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({ bucketOptions: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
metricsGaugeSpaceAggregateOperatorOptions,
|
||||
metricsHeatmapHistogramSpaceAggregateOperatorOptions,
|
||||
metricsHistogramSpaceAggregateOperatorOptions,
|
||||
metricsSumSpaceAggregateOperatorOptions,
|
||||
metricsUnknownSpaceAggregateOperatorOptions,
|
||||
@@ -176,6 +177,11 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
(aggregateAttribute?.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
|
||||
});
|
||||
|
||||
const histogramSpaceAggregationOptions =
|
||||
panelType === PANEL_TYPES.HEATMAP
|
||||
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
|
||||
: metricsHistogramSpaceAggregateOperatorOptions;
|
||||
|
||||
switch (aggregateAttribute?.type) {
|
||||
case ATTRIBUTE_TYPES.SUM:
|
||||
setSpaceAggregationOptions(metricsSumSpaceAggregateOperatorOptions);
|
||||
@@ -185,11 +191,11 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
break;
|
||||
|
||||
case ATTRIBUTE_TYPES.HISTOGRAM:
|
||||
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
|
||||
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
|
||||
break;
|
||||
|
||||
case ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM:
|
||||
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
|
||||
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
|
||||
break;
|
||||
default:
|
||||
setSpaceAggregationOptions(metricsUnknownSpaceAggregateOperatorOptions);
|
||||
@@ -298,10 +304,17 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
timeAggregation: '',
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.P90,
|
||||
// A heatmap cell holds a count of observations per `le` band, which is
|
||||
// the one option the kind offers — a percentile default would sit in the
|
||||
// selector with nothing behind it.
|
||||
spaceAggregation:
|
||||
panelType === PANEL_TYPES.HEATMAP
|
||||
? MetricAggregateOperator.COUNT
|
||||
: MetricAggregateOperator.P90,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
];
|
||||
newQuery.bucketOptions = undefined;
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
@@ -388,6 +401,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
index,
|
||||
handleMetricAggregateAtributeTypes,
|
||||
previousMetricInfo,
|
||||
panelType,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -222,6 +222,31 @@ describe('useGetYAxisUnit', () => {
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the unit on the first render, without a settling pass', () => {
|
||||
// The real `useGetMetrics` rebuilds its array on every render; a hook that
|
||||
// stored the unit would need an extra render to settle, and would schedule one
|
||||
// after every render of the panel editor.
|
||||
mockUseGetMetrics.mockImplementation(() => ({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_1],
|
||||
}));
|
||||
|
||||
let renderCount = 0;
|
||||
const { result, rerender } = renderHook(() => {
|
||||
renderCount += 1;
|
||||
return useGetYAxisUnit();
|
||||
});
|
||||
|
||||
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
|
||||
expect(renderCount).toBe(1);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
|
||||
expect(renderCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should return undefined when metrics have different units', async () => {
|
||||
mockUseGetMetrics.mockReturnValueOnce({
|
||||
isLoading: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
getMetricUnits,
|
||||
useGetMetrics,
|
||||
@@ -46,7 +46,6 @@ function useGetYAxisUnit(
|
||||
},
|
||||
): UseGetYAxisUnitResult {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
|
||||
|
||||
const metricNames: string[] | null = useMemo(() => {
|
||||
// If the query type is not QUERY_BUILDER, return null
|
||||
@@ -95,27 +94,13 @@ function useGetYAxisUnit(
|
||||
[units],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If there are no metrics, set the y-axis unit to undefined
|
||||
if (units.length === 0) {
|
||||
setYAxisUnit(undefined);
|
||||
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
|
||||
} else if (units.length === 1 && units[0] !== '') {
|
||||
setYAxisUnit(units[0]);
|
||||
// If all metrics have the same non-empty unit, set the y-axis unit to it
|
||||
} else if (areAllMetricUnitsSame) {
|
||||
if (units[0] !== '') {
|
||||
setYAxisUnit(units[0]);
|
||||
} else {
|
||||
setYAxisUnit(undefined);
|
||||
}
|
||||
// If there is more than one metric and they have different units, set the y-axis unit to undefined
|
||||
} else if (units.length > 1 && !areAllMetricUnitsSame) {
|
||||
setYAxisUnit(undefined);
|
||||
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
|
||||
} else if (units.length === 1 && units[0] === '') {
|
||||
setYAxisUnit(undefined);
|
||||
const yAxisUnit = useMemo(() => {
|
||||
// A single shared unit is the only thing a single axis can carry; metrics that
|
||||
// disagree, or that carry no unit at all, leave the axis unitless.
|
||||
if (units.length === 0 || !areAllMetricUnitsSame) {
|
||||
return undefined;
|
||||
}
|
||||
return units[0] || undefined;
|
||||
}, [units, areAllMetricUnitsSame]);
|
||||
|
||||
return { yAxisUnit, isLoading, isError };
|
||||
|
||||
78
frontend/src/lib/query/__tests__/handleQueryChange.test.ts
Normal file
78
frontend/src/lib/query/__tests__/handleQueryChange.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type {
|
||||
IBuilderFormula,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { handleQueryChange } from '../panelQuery';
|
||||
|
||||
const bucketOptions = {
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale: 0 },
|
||||
};
|
||||
|
||||
function queryWithFormulaAxis(): Query {
|
||||
const base = initialQueriesMap[DataSource.METRICS];
|
||||
const formula: IBuilderFormula = {
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
bucketOptions,
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [{ ...base.builder.queryData[0], bucketOptions }],
|
||||
queryFormulas: [formula],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('handleQueryChange bucket options', () => {
|
||||
it('keeps both axes when the panel stays a heatmap', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.HEATMAP,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
expect(result.builder.queryFormulas[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
});
|
||||
|
||||
// The request rejects an axis on any type but heatmap, so a switch has to shed it
|
||||
// from the query (via the field allowlist) and from the formula (by hand).
|
||||
it('drops both axes when switching to another panel type', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].bucketOptions).toBeUndefined();
|
||||
expect(result.builder.queryFormulas[0].bucketOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the rest of the formula intact', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryFormulas[0]).toMatchObject({
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
panelTypeDataSourceFormValuesMap,
|
||||
@@ -95,6 +99,24 @@ export const getIsQueryModified = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formulas are carried across a panel-type switch whole, not rebuilt from
|
||||
* `panelTypeDataSourceFormValuesMap` the way `queryData` is, so a bucket axis has to be
|
||||
* dropped by hand. The request rejects one on any type but heatmap.
|
||||
*/
|
||||
const withHeatmapBucketOptions = (
|
||||
formulas: IBuilderFormula[],
|
||||
newPanelType: keyof PartialPanelTypes,
|
||||
): IBuilderFormula[] => {
|
||||
if (newPanelType === PANEL_TYPES.HEATMAP) {
|
||||
return formulas;
|
||||
}
|
||||
|
||||
return (formulas ?? []).map(
|
||||
({ bucketOptions: _bucketOptions, ...rest }) => rest,
|
||||
);
|
||||
};
|
||||
|
||||
export function handleQueryChange(
|
||||
newPanelType: keyof PartialPanelTypes,
|
||||
supersetQuery: Query,
|
||||
@@ -138,6 +160,10 @@ export function handleQueryChange(
|
||||
|
||||
return tempQuery;
|
||||
}),
|
||||
queryFormulas: withHeatmapBucketOptions(
|
||||
supersetQuery.builder.queryFormulas,
|
||||
newPanelType,
|
||||
),
|
||||
queryTraceOperator:
|
||||
newPanelType === PANEL_TYPES.LIST
|
||||
? []
|
||||
|
||||
@@ -15,6 +15,7 @@ export type PartialPanelTypes = {
|
||||
[PANEL_TYPES.VALUE]: 'value';
|
||||
[PANEL_TYPES.PIE]: 'pie';
|
||||
[PANEL_TYPES.HISTOGRAM]: 'histogram';
|
||||
[PANEL_TYPES.HEATMAP]: 'heatmap';
|
||||
};
|
||||
|
||||
export type BuilderField = keyof IBuilderQuery;
|
||||
@@ -45,6 +46,8 @@ const IS_CARRIED = {
|
||||
legend: true,
|
||||
// `dataSource` is appended by the provider; the rest drive surfaces this switch
|
||||
// does not reach.
|
||||
// Heatmap only; listed for it below.
|
||||
bucketOptions: false,
|
||||
dataSource: false,
|
||||
temporality: false,
|
||||
pageSize: false,
|
||||
@@ -112,6 +115,16 @@ const RAW_ROWS_METRICS: readonly BuilderField[] = omit(
|
||||
'functions',
|
||||
);
|
||||
|
||||
// `functions` and `having` are dropped rather than carried: the heatmap request
|
||||
// rejects both. Logs and traces keep the metrics aggregations too, because the map is
|
||||
// keyed by the query's own signal, which a switch can still be holding.
|
||||
const HEATMAP: readonly BuilderField[] = omit(
|
||||
SERIES_METRICS,
|
||||
'functions',
|
||||
'having',
|
||||
);
|
||||
const HEATMAP_METRICS: readonly BuilderField[] = [...HEATMAP, 'bucketOptions'];
|
||||
|
||||
/** Each cell gets its own copy; a shared instance would let cells contaminate
|
||||
* each other. */
|
||||
function bySource(
|
||||
@@ -133,6 +146,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[PANEL_TYPES.AREA]: bySource(SERIES, SERIES_METRICS),
|
||||
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
|
||||
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
|
||||
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_METRICS),
|
||||
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 12px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-1px);
|
||||
// Reads against the panel through the 3px it overhangs the track at either end,
|
||||
// which is what carries it where the ramp happens to match it.
|
||||
background: var(--popover-foreground);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.keys {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
// Approximates the canvas hatch painted over null cells, which `createHatchPattern`
|
||||
// strokes in the theme's own direction — light on dark, dark on light.
|
||||
.hatchSwatch {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
box-sizing: border-box;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0 2px,
|
||||
var(--muted-foreground) 2px 3px
|
||||
);
|
||||
}
|
||||
76
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
76
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Styles from './ColorBar.module.scss';
|
||||
|
||||
export interface ColorBarProps {
|
||||
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
|
||||
* colours as the cells. */
|
||||
ramp: string[];
|
||||
minLabel: string;
|
||||
maxLabel: string;
|
||||
/** 0..1. `null` hides the marker. */
|
||||
markerPosition?: number | null;
|
||||
/** What the colour encodes, e.g. "count". */
|
||||
label?: string;
|
||||
/** Keys the one state a ramp cannot express: a hatched data gap. */
|
||||
showNoDataKey?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/** What a colour means, plus a marker for the value under the cursor. */
|
||||
export default function ColorBar({
|
||||
ramp,
|
||||
minLabel,
|
||||
maxLabel,
|
||||
markerPosition = null,
|
||||
label,
|
||||
showNoDataKey = false,
|
||||
'data-testid': testId = 'color-bar',
|
||||
}: ColorBarProps): JSX.Element | null {
|
||||
const gradient = useMemo(() => {
|
||||
if (ramp.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (ramp.length === 1) {
|
||||
return ramp[0];
|
||||
}
|
||||
const stops = ramp.flatMap((color, index) => {
|
||||
const from = (index / ramp.length) * 100;
|
||||
const to = ((index + 1) / ramp.length) * 100;
|
||||
return [`${color} ${from}%`, `${color} ${to}%`];
|
||||
});
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}, [ramp]);
|
||||
|
||||
if (gradient === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedMarker =
|
||||
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
|
||||
|
||||
return (
|
||||
<div className={Styles.container} data-testid={testId}>
|
||||
{label && <span className={Styles.caption}>{label}</span>}
|
||||
<span className={Styles.label}>{minLabel}</span>
|
||||
<div className={Styles.track} style={{ background: gradient }}>
|
||||
{clampedMarker !== null && (
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ left: `${clampedMarker * 100}%` }}
|
||||
data-testid={`${testId}-marker`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={Styles.label}>{maxLabel}</span>
|
||||
{showNoDataKey && (
|
||||
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.hatchSwatch} />
|
||||
no data
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import ColorBar from '../ColorBar';
|
||||
|
||||
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
|
||||
|
||||
describe('ColorBar', () => {
|
||||
it('renders the domain labels', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing without a ramp', () => {
|
||||
const { container } = render(
|
||||
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('hides the marker when nothing is hovered', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions the marker at the hovered value', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
|
||||
});
|
||||
|
||||
it('clamps a marker outside the ramp to its ends', () => {
|
||||
const { rerender } = render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
|
||||
|
||||
rerender(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
|
||||
});
|
||||
|
||||
it('keys the one state a colour ramp cannot express', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showNoDataKey={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('no data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the no-data key out unless the data has a gap', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.queryByText('no data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('captions what the colour encodes', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
|
||||
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hard-edged segments so the bar matches the drawn cells', () => {
|
||||
render(
|
||||
<ColorBar
|
||||
ramp={['#111111', '#dddddd']}
|
||||
minLabel="0"
|
||||
maxLabel="10"
|
||||
data-testid="scale"
|
||||
/>,
|
||||
);
|
||||
|
||||
const track = screen.getByTestId('scale').querySelector('div');
|
||||
expect(track).toHaveStyle({
|
||||
background:
|
||||
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import { formatCount } from './heatmapTooltipContent';
|
||||
import { HeatmapBucketRow } from './types';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** The buckets either side of the hovered one, so a mode reads as a shape rather
|
||||
* than a single number. */
|
||||
export default function HeatmapBucketList({
|
||||
rows,
|
||||
}: {
|
||||
rows: HeatmapBucketRow[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
|
||||
{rows.map((bucket) => (
|
||||
<div
|
||||
key={bucket.row}
|
||||
className={cx(Styles.row, { [Styles.rowHovered]: bucket.isHovered })}
|
||||
data-hovered={bucket.isHovered}
|
||||
data-testid="heatmap-tooltip-bucket-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{bucket.label}</span>
|
||||
<span className={Styles.rowSeparator} />
|
||||
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { formatCount, formatShare } from './heatmapTooltipContent';
|
||||
import { HeatmapContributionRow } from './types';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** Only shown when the cell sums more than one group. */
|
||||
export default function HeatmapContributionList({
|
||||
rows,
|
||||
}: {
|
||||
rows: HeatmapContributionRow[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
|
||||
<div className={Styles.columnHeader}>
|
||||
<span>Group</span>
|
||||
<span>Contribution</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="heatmap-tooltip-contribution-row"
|
||||
>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ borderColor: row.color, backgroundColor: row.color }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowSeparator} style={{ borderColor: row.color }} />
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
<span
|
||||
className={Styles.rowShare}
|
||||
data-testid="heatmap-tooltip-contribution-share"
|
||||
>
|
||||
{formatShare(row.share)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
|
||||
// shadow (the plugin's portal wrapper is transparent and paints nothing). Text
|
||||
// follows the theme through the popover/muted pair; the fixed vanilla ramp reads
|
||||
// as white-on-white in light mode.
|
||||
//
|
||||
// Padding lives on the sections rather than here, also matching the shared
|
||||
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
|
||||
// corner radius, so it has to reach the container edges.
|
||||
.container {
|
||||
// The list pays this less each row's own inset, so its text lands on it too.
|
||||
--gutter: 14px;
|
||||
--row-inset: 6px;
|
||||
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
// Separates the cell identity from whichever question the second block answers.
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-8) var(--gutter) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--gutter) 0;
|
||||
}
|
||||
|
||||
// Names the group the grid is under, as the legend draws a shown series; it is
|
||||
// not a colour key.
|
||||
.filterMarker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px solid currentColor;
|
||||
background: currentColor;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--gutter);
|
||||
}
|
||||
|
||||
.titleBucket,
|
||||
.titleCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
|
||||
.titleCount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) calc(var(--gutter) - var(--row-inset))
|
||||
var(--spacing-6);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0 var(--row-inset) var(--spacing-2);
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--row-inset);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
|
||||
.rowHovered {
|
||||
background: var(--l3-background);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rowLabel,
|
||||
.rowValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowShare {
|
||||
flex: 0 0 auto;
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 1.5px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rowSeparator,
|
||||
.titleSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
border-color: currentColor;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
resolveColumnIndex,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { HeatmapTooltipProps } from '../../../types';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
import HeatmapBucketList from './HeatmapBucketList';
|
||||
import HeatmapContributionList from './HeatmapContributionList';
|
||||
import {
|
||||
buildBucketRows,
|
||||
buildContributionRows,
|
||||
formatBucketLabel,
|
||||
formatColumnRange,
|
||||
formatCount,
|
||||
resolveTooltipBody,
|
||||
} from './heatmapTooltipContent';
|
||||
import { HeatmapTooltipBody } from './types';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The cell identity is the same in every state; the second block answers whichever
|
||||
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, which renders a flat list of
|
||||
* series values — none of these states is that shape.
|
||||
*
|
||||
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
|
||||
* the nearest timestamp, so half of every column would report its neighbour.
|
||||
*/
|
||||
export default function HeatmapTooltip({
|
||||
uPlotInstance,
|
||||
yAxis,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
groupColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: HeatmapTooltipProps): JSX.Element | null {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
// Read outside the memo: uPlot mutates the same instance on every move, so
|
||||
// keying off the instance alone would freeze the cell.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const cell = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
|
||||
const column = resolveColumnIndex(
|
||||
timestamps,
|
||||
uPlotInstance.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
row,
|
||||
column,
|
||||
timestamp: timestamps[column],
|
||||
count:
|
||||
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
] ?? null,
|
||||
};
|
||||
}, [left, top, uPlotInstance, yAxis, step]);
|
||||
|
||||
// The cell sums the enabled groups, so those are what a breakdown must cover.
|
||||
const visible = useMemo(
|
||||
() => series.filter((entry) => visibleGroups.includes(entry.label)),
|
||||
[series, visibleGroups],
|
||||
);
|
||||
const body = resolveTooltipBody(visible.length);
|
||||
|
||||
const bucketRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Buckets) {
|
||||
return [];
|
||||
}
|
||||
return buildBucketRows({
|
||||
counts: uPlotInstance.data.slice(1) as Array<
|
||||
ArrayLike<number | null> | undefined
|
||||
>,
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
column: cell.column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
});
|
||||
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
|
||||
|
||||
const contributionRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Contribution) {
|
||||
return [];
|
||||
}
|
||||
return buildContributionRows({
|
||||
series: visible,
|
||||
timestamp: cell.timestamp,
|
||||
row: cell.row,
|
||||
color: groupColor,
|
||||
});
|
||||
}, [cell, body, visible, groupColor]);
|
||||
|
||||
if (!cell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A single enabled group out of several means the legend has isolated it.
|
||||
const isolated =
|
||||
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
|
||||
const filterLabel = isolated?.label ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="heatmap-tooltip"
|
||||
>
|
||||
<div className={Styles.identity}>
|
||||
<div className={Styles.header}>
|
||||
<span data-testid="heatmap-tooltip-range">
|
||||
{formatColumnRange({
|
||||
start: cell.timestamp,
|
||||
step,
|
||||
timezone: resolvedTimezone,
|
||||
})}
|
||||
</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
|
||||
{filterLabel && (
|
||||
<div
|
||||
className={Styles.filter}
|
||||
style={{ color: groupColor }}
|
||||
data-testid="heatmap-tooltip-filter"
|
||||
>
|
||||
<span className={Styles.filterMarker} />
|
||||
<span className={Styles.filterLabel}>{filterLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={Styles.title}>
|
||||
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
|
||||
{formatBucketLabel({
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
})}
|
||||
</span>
|
||||
<span className={Styles.titleSeparator} />
|
||||
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
|
||||
{formatCount(cell.count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
|
||||
|
||||
{body === HeatmapTooltipBody.Contribution ? (
|
||||
<HeatmapContributionList rows={contributionRows} />
|
||||
) : (
|
||||
<HeatmapBucketList rows={bucketRows} />
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import HeatmapTooltip from '../HeatmapTooltip';
|
||||
|
||||
const BOUNDS = [100, 500, 1000, 2500];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
|
||||
const STEP = 300;
|
||||
const PLOT_SIZE = 500;
|
||||
const ROW_COUNT = BOUNDS.length + 1;
|
||||
|
||||
/** Row 2 is the 500ms–1s bucket the design mock hovers. */
|
||||
const HOVERED_ROW = 2;
|
||||
|
||||
function seriesFor(
|
||||
group: string,
|
||||
countsAtHoveredRow: [number, number],
|
||||
): HeatmapSeries {
|
||||
return {
|
||||
label: `{service.name="${group}"}`,
|
||||
points: TIMESTAMPS.map((timestamp, column) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
|
||||
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const GROUPED: HeatmapSeries[] = [
|
||||
seriesFor('checkout', [355, 300]),
|
||||
seriesFor('frontend', [86, 80]),
|
||||
seriesFor('cart', [14, 10]),
|
||||
seriesFor('payments', [0, 0]),
|
||||
];
|
||||
|
||||
/** Grid counts, matching what the renderer would have been handed. */
|
||||
function gridData(rowTotals: number[]): uPlot.AlignedData {
|
||||
return [
|
||||
TIMESTAMPS,
|
||||
...Array.from({ length: ROW_COUNT }, (_, row) => [
|
||||
rowTotals[row] ?? row * 40,
|
||||
rowTotals[row] ?? row * 40,
|
||||
]),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
|
||||
const ROW_TOTALS = [10, 269, 455, 92, 2];
|
||||
|
||||
function createFakePlot(): uPlot {
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
// Aim the cursor at the middle of the hovered row, first column.
|
||||
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
|
||||
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
|
||||
|
||||
return {
|
||||
data: gridData(ROW_TOTALS),
|
||||
cursor: { left: PLOT_SIZE * 0.25, top },
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(
|
||||
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
<HeatmapTooltip
|
||||
id="panel-1"
|
||||
uPlotInstance={createFakePlot()}
|
||||
dataIndexes={[]}
|
||||
seriesIndex={null}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
viaSync={false}
|
||||
yAxis={Y_AXIS}
|
||||
step={STEP}
|
||||
series={GROUPED}
|
||||
visibleGroups={GROUPED.map((entry) => entry.label)}
|
||||
groupColor="#fcfdbf"
|
||||
yAxisUnit="ms"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('HeatmapTooltip — cell identity', () => {
|
||||
it('heads with the time span the column covers, not a single instant', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
|
||||
/^\d{2}\/\d{2} \d{2}:\d{2} → \d{2}:\d{2}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('names the hovered bucket and its count', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
|
||||
'500 ms – 1 s',
|
||||
);
|
||||
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
|
||||
});
|
||||
|
||||
it('marks the surface as pinned so the border picks up the ring', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('names a pinned tooltip in its header, as the shared tooltip does', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the header unbadged while unpinned', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is unpinned by default', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('separates the cell identity from the block below it', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a footer when the panel supplies one', () => {
|
||||
renderTooltip({
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
|
||||
});
|
||||
|
||||
it('tells the footer when the tooltip is pinned', () => {
|
||||
renderTooltip({
|
||||
isPinned: true,
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
|
||||
});
|
||||
|
||||
it('renders nothing when the cursor is off the plot', () => {
|
||||
const plot = createFakePlot();
|
||||
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
|
||||
|
||||
const { container } = renderTooltip({ uPlotInstance: plot });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
it('breaks the cell down by group instead of showing neighbours', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('heatmap-tooltip-contribution'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-buckets'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each row as the legend does and orders by contribution', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-contribution-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
expect(rows[0]).toContain('checkout');
|
||||
expect(rows[0]).toContain('355');
|
||||
expect(rows[1]).toContain('frontend');
|
||||
expect(rows[2]).toContain('cart');
|
||||
});
|
||||
|
||||
it('still lists a group that contributed nothing', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
expect(rows).toHaveLength(GROUPED.length);
|
||||
expect(rows[3]).toHaveTextContent('payments');
|
||||
expect(rows[3]).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('names what the two columns hold', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByText('Group')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives each group its share of the cell alongside the count', () => {
|
||||
renderTooltip();
|
||||
|
||||
const shares = screen
|
||||
.getAllByTestId('heatmap-tooltip-contribution-share')
|
||||
.map((share) => share.textContent);
|
||||
|
||||
expect(shares).toHaveLength(GROUPED.length);
|
||||
expect(
|
||||
shares.reduce((sum, share) => sum + parseInt(share ?? '0', 10), 0),
|
||||
).toBe(100);
|
||||
expect(shares[shares.length - 1]).toBe('0%');
|
||||
});
|
||||
|
||||
it('does not name a filter when every group is enabled', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-filter'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, one enabled', () => {
|
||||
const selected = { visibleGroups: ['{service.name="checkout"}'] };
|
||||
|
||||
it('returns to neighbouring buckets, since contribution is already answered', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the active filter', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
|
||||
'{service.name="checkout"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — no grouping', () => {
|
||||
const ungrouped = {
|
||||
series: [{ label: '', points: GROUPED[0].points }],
|
||||
visibleGroups: [''],
|
||||
};
|
||||
|
||||
it('shows neighbouring buckets, highest first', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
// Two buckets either side of 500ms – 1s, reading down the y axis.
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows[0]).toContain('> 2.5 s');
|
||||
expect(rows[2]).toContain('500 ms – 1 s');
|
||||
expect(rows[4]).toContain('≤ 100 ms');
|
||||
});
|
||||
|
||||
it('marks the hovered bucket among its neighbours', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const hovered = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.filter((row) => row.dataset.hovered === 'true');
|
||||
|
||||
expect(hovered).toHaveLength(1);
|
||||
expect(hovered[0]).toHaveTextContent('500 ms – 1 s');
|
||||
});
|
||||
|
||||
it('never breaks down a single series', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import { buildBucketRows, formatColumnRange } from '../heatmapTooltipContent';
|
||||
|
||||
const TIMEZONE = 'UTC';
|
||||
/** 2026-09-02T05:30:00Z. */
|
||||
const START = 1_788_327_000;
|
||||
|
||||
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
|
||||
const CLOSE_BOUNDS = [
|
||||
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
|
||||
0.0681567332915786,
|
||||
];
|
||||
const CLOSE_Y_AXIS = resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log);
|
||||
const COUNTS = CLOSE_Y_AXIS.rows.map((_, row) => [row]);
|
||||
|
||||
describe('formatColumnRange', () => {
|
||||
it('dates the column once while it stays inside a day', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 9 * 3600, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30 → 14:30');
|
||||
});
|
||||
|
||||
it('carries the date across a column that spans days', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 2 * 86_400, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30 → 09/04 05:30');
|
||||
});
|
||||
|
||||
it('adds seconds for a sub-minute column, which times alone cannot separate', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 30, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30:00 → 05:30:30');
|
||||
});
|
||||
|
||||
it('reads the day in the panel timezone, not UTC', () => {
|
||||
// 05:30Z is the previous evening in Los Angeles, so the same column reads as
|
||||
// crossing a date boundary there and not in UTC.
|
||||
expect(
|
||||
formatColumnRange({
|
||||
start: START,
|
||||
step: 9 * 3600,
|
||||
timezone: 'America/Los_Angeles',
|
||||
}),
|
||||
).toBe('09/01 22:30 → 09/02 07:30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBucketRows', () => {
|
||||
it('identifies a row by its place on the axis, which its label cannot', () => {
|
||||
const rows = buildBucketRows({
|
||||
counts: COUNTS,
|
||||
yAxis: CLOSE_Y_AXIS,
|
||||
row: 3,
|
||||
column: 0,
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
});
|
||||
|
||||
expect(new Set(rows.map((row) => row.label)).size).toBeLessThan(rows.length);
|
||||
expect(rows.map((row) => row.row)).toStrictEqual([5, 4, 3, 2, 1]);
|
||||
expect(rows.map((row) => row.count)).toStrictEqual([5, 4, 3, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapSeries,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import 'utils/timeUtils';
|
||||
|
||||
import {
|
||||
HeatmapBucketRow,
|
||||
HeatmapContributionRow,
|
||||
HeatmapTooltipBody,
|
||||
} from './types';
|
||||
|
||||
/** Rows shown either side of the hovered one. */
|
||||
const NEIGHBOUR_SPAN = 2;
|
||||
/** Below this, the header needs seconds to distinguish columns. */
|
||||
const SUB_MINUTE_STEP = 60;
|
||||
|
||||
export const NO_DATA_LABEL = 'no data';
|
||||
|
||||
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
|
||||
// One enabled group contributes the whole cell, so there is nothing to break
|
||||
// down — whether the query is ungrouped or the legend has isolated a group.
|
||||
return visibleCount > 1
|
||||
? HeatmapTooltipBody.Contribution
|
||||
: HeatmapTooltipBody.Buckets;
|
||||
}
|
||||
|
||||
/** A cell is an interval, so a single instant would misreport what it contains.
|
||||
* The start carries the date — the x axis prints one only where the day turns
|
||||
* over — and the end repeats it only across midnight. */
|
||||
export function formatColumnRange({
|
||||
start,
|
||||
step,
|
||||
timezone,
|
||||
}: {
|
||||
/** Column start, in seconds. */
|
||||
start: number;
|
||||
/** Column width, in seconds. */
|
||||
step: number;
|
||||
timezone: string;
|
||||
}): string {
|
||||
const time =
|
||||
step < SUB_MINUTE_STEP
|
||||
? DATE_TIME_FORMATS.TIME_SECONDS
|
||||
: DATE_TIME_FORMATS.TIME;
|
||||
const dated = `${DATE_TIME_FORMATS.DATE_SHORT} ${time}`;
|
||||
const from = dayjs(start * 1000).tz(timezone);
|
||||
const to = dayjs((start + step) * 1000).tz(timezone);
|
||||
const toFormat = to.isSame(from, 'day') ? time : dated;
|
||||
return `${from.format(dated)} → ${to.format(toFormat)}`;
|
||||
}
|
||||
|
||||
/** Formatted with the panel's unit. */
|
||||
export function formatBucketLabel({
|
||||
yAxis,
|
||||
row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): string {
|
||||
const bucket = yAxis.rows[row];
|
||||
if (!bucket) {
|
||||
return '';
|
||||
}
|
||||
return formatRowLabel(bucket, (value) =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCount(count: number | null): string {
|
||||
return count === null ? NO_DATA_LABEL : count.toLocaleString();
|
||||
}
|
||||
|
||||
/** `<1%` rather than `0%`, so a group that did contribute never reads as one
|
||||
* that didn't. */
|
||||
export function formatShare(share: number): string {
|
||||
if (share > 0 && share < 0.005) {
|
||||
return '<1%';
|
||||
}
|
||||
return `${Math.round(share * 100)}%`;
|
||||
}
|
||||
|
||||
/** Highest first, so the list reads in the same direction as the y axis. */
|
||||
export function buildBucketRows({
|
||||
counts,
|
||||
yAxis,
|
||||
row,
|
||||
column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
/** Row-major, as the renderer draws them. */
|
||||
counts: Array<ArrayLike<number | null> | undefined>;
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
column: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): HeatmapBucketRow[] {
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const rows: HeatmapBucketRow[] = [];
|
||||
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
|
||||
const index = row + offset;
|
||||
const bucket = yAxis.rows[index];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
row: index,
|
||||
label: formatRowLabel(bucket, formatBucketValue),
|
||||
count: counts[index]?.[column] ?? null,
|
||||
isHovered: offset === 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest first. Groups that contributed nothing are still listed — that is an
|
||||
* answer, and dropping the row makes the list look truncated.
|
||||
*/
|
||||
export function buildContributionRows({
|
||||
series,
|
||||
timestamp,
|
||||
row,
|
||||
color,
|
||||
}: {
|
||||
/** Only the groups the legend has enabled — they are what the cell sums. */
|
||||
series: HeatmapSeries[];
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
row: number;
|
||||
/** The grid's one colour; there is no per-series hue. */
|
||||
color: string;
|
||||
}): HeatmapContributionRow[] {
|
||||
const counted = series.map((entry) => {
|
||||
const point = entry.points.find((item) => item.timestamp === timestamp);
|
||||
return {
|
||||
label: entry.label,
|
||||
color,
|
||||
// Absent or null contributed nothing to the sum this breaks down.
|
||||
count: point?.counts[row] ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const total = counted.reduce((sum, entry) => sum + entry.count, 0);
|
||||
|
||||
return counted
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
share: total > 0 ? entry.count / total : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Which question the second block answers: "which group?" for a cell summed
|
||||
* across several, "how does this bucket compare?" for one that is one series. */
|
||||
export enum HeatmapTooltipBody {
|
||||
Buckets = 'buckets',
|
||||
Contribution = 'contribution',
|
||||
}
|
||||
|
||||
export interface HeatmapBucketRow {
|
||||
/** The bucket's row on the y axis. Labels are not unique — two boundaries can
|
||||
* round to the same text — so this is what identifies a row. */
|
||||
row: number;
|
||||
label: string;
|
||||
/** `null` where the bucket has no observation in that column, never a 0. */
|
||||
count: number | null;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface HeatmapContributionRow {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Fraction of the cell this group contributed, 0..1. */
|
||||
share: number;
|
||||
}
|
||||
@@ -16,15 +16,3 @@
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--callout-primary-title);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
|
||||
import Styles from './TooltipHeader.module.scss';
|
||||
|
||||
@@ -67,14 +67,7 @@ export default function TooltipHeader({
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && (
|
||||
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
|
||||
<>
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user