mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-02 17:40:42 +01:00
Compare commits
32 Commits
chore/dash
...
qf-sidebar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542563cb63 | ||
|
|
f7dffd3012 | ||
|
|
c9f66c037a | ||
|
|
45ce679014 | ||
|
|
3a730e3674 | ||
|
|
45a8bb424c | ||
|
|
17afa7a3cf | ||
|
|
c6fd0a6ccf | ||
|
|
14f728ae68 | ||
|
|
095821264e | ||
|
|
5069bf80b0 | ||
|
|
1086914a3b | ||
|
|
3e2daaea0e | ||
|
|
1b8155fea9 | ||
|
|
dac406eb93 | ||
|
|
6dc9bf7b16 | ||
|
|
d15a1452f8 | ||
|
|
f69bce998a | ||
|
|
ae9f58ba44 | ||
|
|
a930da0901 | ||
|
|
5314cb7828 | ||
|
|
2fe033ec78 | ||
|
|
02a2800f89 | ||
|
|
02c5555a48 | ||
|
|
915aa2eb70 | ||
|
|
518caff0f2 | ||
|
|
0d3ac28286 | ||
|
|
47beef07de | ||
|
|
5f2891bd6c | ||
|
|
97f0e832ab | ||
|
|
0bdc7bf6a1 | ||
|
|
770a8f7b0a |
28
.github/CODEOWNERS
vendored
28
.github/CODEOWNERS
vendored
@@ -152,29 +152,39 @@ go.mod @therealpandey
|
||||
|
||||
## Dashboard Types
|
||||
|
||||
/frontend/src/types/api/dashboard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/types/api/widgets/ @SigNoz/pulse-frontend
|
||||
/frontend/src/api/types/dashboard/ @SigNoz/pulse-frontend
|
||||
|
||||
## Widget Card
|
||||
## Dashboard List
|
||||
|
||||
/frontend/src/container/WidgetCard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
|
||||
|
||||
# Dashboard Widget Page
|
||||
|
||||
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard Page
|
||||
|
||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
|
||||
|
||||
## Public Dashboard Page
|
||||
|
||||
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard Libs + Components
|
||||
|
||||
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/lib/visualization/ @SigNoz/pulse-frontend
|
||||
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard Pages
|
||||
|
||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
|
||||
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -50,6 +50,7 @@ jobs:
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
- quickfilter
|
||||
- querierlogs
|
||||
- queriertraces
|
||||
- queriermetrics
|
||||
@@ -62,6 +63,7 @@ jobs:
|
||||
- role
|
||||
- rootuser
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
|
||||
@@ -2559,6 +2559,7 @@ components:
|
||||
- factor-api-key
|
||||
- license
|
||||
- subscription
|
||||
- deployment-host
|
||||
- logs
|
||||
- traces
|
||||
- metrics
|
||||
@@ -7382,6 +7383,26 @@ components:
|
||||
- custom
|
||||
- text
|
||||
type: string
|
||||
QuickfiltertypesSignalFilters:
|
||||
properties:
|
||||
filters:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
type: string
|
||||
type: object
|
||||
QuickfiltertypesUpdatableQuickFilters:
|
||||
properties:
|
||||
filters:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
type: string
|
||||
type: object
|
||||
RenderErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
@@ -8010,6 +8031,7 @@ components:
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
- ai_observability
|
||||
type: string
|
||||
SavedviewtypesUpdatableSavedView:
|
||||
properties:
|
||||
@@ -8800,6 +8822,7 @@ components:
|
||||
- span
|
||||
- trace
|
||||
- resource
|
||||
- scope
|
||||
- attribute
|
||||
- body
|
||||
- ""
|
||||
@@ -15467,10 +15490,8 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
summary: Get features
|
||||
tags:
|
||||
- features
|
||||
@@ -18318,6 +18339,165 @@ paths:
|
||||
summary: Get query range result (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/quick_filters:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for every signal, each filter as
|
||||
a telemetry field key.
|
||||
operationId: ListQuickFilters
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:list
|
||||
- tokenizer:
|
||||
- quick-filter:list
|
||||
summary: List quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces the org's quick filters for the signal named in the body.
|
||||
operationId: UpdateQuickFilters
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/QuickfiltertypesUpdatableQuickFilters'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:update
|
||||
- tokenizer:
|
||||
- quick-filter:update
|
||||
summary: Update quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/quick_filters/{signal_name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for one signal, each filter as
|
||||
a telemetry field key.
|
||||
operationId: GetQuickFilters
|
||||
parameters:
|
||||
- in: path
|
||||
name: signal_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:read
|
||||
- tokenizer:
|
||||
- quick-filter:read
|
||||
summary: Get a signal's quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/readyz:
|
||||
get:
|
||||
operationId: Readyz
|
||||
@@ -23942,9 +24122,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- deployment-host:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- deployment-host:list
|
||||
summary: Get host info from Zeus.
|
||||
tags:
|
||||
- zeus
|
||||
@@ -23998,9 +24178,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- deployment-host:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- deployment-host:update
|
||||
summary: Put host in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
|
||||
@@ -67,7 +67,7 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// note: add ee override methods first
|
||||
|
||||
// routes available only in ee version
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
298
frontend/src/api/generated/services/quick-filter/index.ts
Normal file
298
frontend/src/api/generated/services/quick-filter/index.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetQuickFilters200,
|
||||
GetQuickFiltersPathParameters,
|
||||
ListQuickFilters200,
|
||||
QuickfiltertypesUpdatableQuickFiltersDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns the org's quick filters for every signal, each filter as a telemetry field key.
|
||||
* @summary List quick filters
|
||||
*/
|
||||
export const listQuickFilters = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListQuickFilters200>({
|
||||
url: `/api/v2/quick_filters`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListQuickFiltersQueryKey = () => {
|
||||
return [`/api/v2/quick_filters`] as const;
|
||||
};
|
||||
|
||||
export const getListQuickFiltersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListQuickFiltersQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listQuickFilters>>> = ({
|
||||
signal,
|
||||
}) => listQuickFilters(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListQuickFiltersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>
|
||||
>;
|
||||
export type ListQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List quick filters
|
||||
*/
|
||||
|
||||
export function useListQuickFilters<
|
||||
TData = Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListQuickFiltersQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List quick filters
|
||||
*/
|
||||
export const invalidateListQuickFilters = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListQuickFiltersQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the org's quick filters for the signal named in the body.
|
||||
* @summary Update quick filters
|
||||
*/
|
||||
export const updateQuickFilters = (
|
||||
quickfiltertypesUpdatableQuickFiltersDTO?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/quick_filters`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: quickfiltertypesUpdatableQuickFiltersDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateQuickFiltersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateQuickFilters'];
|
||||
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 updateQuickFilters>>,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateQuickFilters(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateQuickFiltersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>
|
||||
>;
|
||||
export type UpdateQuickFiltersMutationBody =
|
||||
| BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>
|
||||
| undefined;
|
||||
export type UpdateQuickFiltersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update quick filters
|
||||
*/
|
||||
export const useUpdateQuickFilters = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateQuickFiltersMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the org's quick filters for one signal, each filter as a telemetry field key.
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
export const getQuickFilters = (
|
||||
{ signalName }: GetQuickFiltersPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetQuickFilters200>({
|
||||
url: `/api/v2/quick_filters/${signalName}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetQuickFiltersQueryKey = ({
|
||||
signalName,
|
||||
}: GetQuickFiltersPathParameters) => {
|
||||
return [`/api/v2/quick_filters/${signalName}`] as const;
|
||||
};
|
||||
|
||||
export const getGetQuickFiltersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ signalName }: GetQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetQuickFiltersQueryKey({ signalName });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getQuickFilters>>> = ({
|
||||
signal,
|
||||
}) => getQuickFilters({ signalName }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!signalName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetQuickFiltersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>
|
||||
>;
|
||||
export type GetQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
|
||||
export function useGetQuickFilters<
|
||||
TData = Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ signalName }: GetQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetQuickFiltersQueryOptions({ signalName }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
export const invalidateGetQuickFilters = async (
|
||||
queryClient: QueryClient,
|
||||
{ signalName }: GetQuickFiltersPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetQuickFiltersQueryKey({ signalName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
@@ -2175,6 +2175,7 @@ export enum CoretypesKindDTO {
|
||||
'factor-api-key' = 'factor-api-key',
|
||||
license = 'license',
|
||||
subscription = 'subscription',
|
||||
'deployment-host' = 'deployment-host',
|
||||
logs = 'logs',
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
@@ -3492,6 +3493,7 @@ export enum TelemetrytypesFieldContextDTO {
|
||||
span = 'span',
|
||||
trace = 'trace',
|
||||
resource = 'resource',
|
||||
scope = 'scope',
|
||||
attribute = 'attribute',
|
||||
body = 'body',
|
||||
'' = '',
|
||||
@@ -8435,6 +8437,28 @@ export enum Querybuildertypesv5QueryTypeDTO {
|
||||
clickhouse_sql = 'clickhouse_sql',
|
||||
promql = 'promql',
|
||||
}
|
||||
export interface QuickfiltertypesSignalFiltersDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface QuickfiltertypesUpdatableQuickFiltersDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface RenderErrorResponseDTO {
|
||||
error: ErrorsJSONDTO;
|
||||
/**
|
||||
@@ -9021,6 +9045,7 @@ export enum SavedviewtypesSourceDTO {
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
ai_observability = 'ai_observability',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display?: SavedviewtypesDisplayDTO;
|
||||
@@ -11824,6 +11849,28 @@ export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListQuickFilters200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: QuickfiltertypesSignalFiltersDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetQuickFiltersPathParameters = {
|
||||
signalName: string;
|
||||
};
|
||||
export type GetQuickFilters200 = {
|
||||
data: QuickfiltertypesSignalFiltersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Readyz200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
const getCustomFilters = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
const { signal } = props;
|
||||
try {
|
||||
const response = await axios.get(`/orgs/me/filters/${signal}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getCustomFilters;
|
||||
@@ -1,13 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
|
||||
|
||||
const updateCustomFiltersAPI = async (
|
||||
props: UpdateCustomFiltersProps,
|
||||
): Promise<SuccessResponse<void> | AxiosError> =>
|
||||
axios.put(`/orgs/me/filters`, {
|
||||
...props.data,
|
||||
});
|
||||
|
||||
export default updateCustomFiltersAPI;
|
||||
@@ -7,9 +7,8 @@ import axios from 'axios';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
@@ -69,9 +68,7 @@ function ExplorerCard({
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
export const ExploreHeaderToolTip = {
|
||||
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
|
||||
text: 'More details on how to use query builder',
|
||||
@@ -9,5 +7,3 @@ export const SaveButtonText = {
|
||||
SAVE_AS_NEW_VIEW: 'Save as new view',
|
||||
SAVE_VIEW: 'Save view',
|
||||
};
|
||||
|
||||
export type QuerySearchParamNames = QueryParams.viewName | QueryParams.viewKey;
|
||||
|
||||
@@ -241,28 +241,29 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
))
|
||||
)}
|
||||
|
||||
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
{!showOnlyWhereClause &&
|
||||
currentQuery.builder.queryFormulas?.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowFooter && (
|
||||
<QueryFooter
|
||||
@@ -290,7 +291,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentQuery.builder.queryFormulas.map((formula) => (
|
||||
{currentQuery.builder.queryFormulas?.map((formula) => (
|
||||
<div key={formula.queryName} className="formula-name">
|
||||
{formula.queryName}
|
||||
</div>
|
||||
|
||||
@@ -212,6 +212,32 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
expect(handleRunQueryMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not crash when builder.queryFormulas/queryTraceOperator are missing (partial/legacy query)', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
...baseQBContext,
|
||||
currentQuery: {
|
||||
...currentQueryBase,
|
||||
builder: {
|
||||
queryData: currentQueryBase.builder.queryData,
|
||||
queryFormulas: undefined as unknown as [],
|
||||
queryTraceOperator: undefined as unknown as [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />),
|
||||
).not.toThrow();
|
||||
|
||||
// query list still renders from queryData, formulas block is skipped
|
||||
expect(document.querySelector('.query-names-section')).toBeInTheDocument();
|
||||
expect(
|
||||
document.querySelector('.qb-formulas-container'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fx button is disabled when functions already exist', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
const supersetQueryBase = baseQBContext.supersetQuery as Query;
|
||||
|
||||
@@ -101,45 +101,6 @@ export function deriveCheckboxState({
|
||||
return filterState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new query with every clause for this attribute key removed, both
|
||||
* from the structured filter items and the raw filter expression.
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
|
||||
@@ -7,10 +7,8 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
import { applyCheckboxToggle } from './checkboxFilterQuery';
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
@@ -94,7 +92,13 @@ function useCheckboxFilterActions({
|
||||
};
|
||||
|
||||
const onClear = (): void => {
|
||||
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
|
||||
dispatch(
|
||||
clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey: filter.attributeKey.key,
|
||||
activeQueryIndex,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return { onChange, onClear };
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterChangeEventData,
|
||||
@@ -44,8 +45,14 @@ export default function CheckboxFilterV2(
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const handleToggleSearch = (): void => {
|
||||
setIsSearchOpen((prev) => !prev);
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
@@ -74,6 +81,10 @@ export default function CheckboxFilterV2(
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
source:
|
||||
source === QuickFiltersSource.METER_EXPLORER
|
||||
? TelemetrytypesSourceDTO.meter
|
||||
: undefined,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
@@ -162,12 +173,9 @@ export default function CheckboxFilterV2(
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
onClear={onClear}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
<section>
|
||||
@@ -176,23 +184,26 @@ export default function CheckboxFilterV2(
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
{isSearchOpen && (
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalCount > 0 && (
|
||||
<section className={styles.values}>
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.leftAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
// The collapse chevron must keep its size; only the title absorbs the squeeze.
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -18,16 +26,31 @@
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: capitalize;
|
||||
// Always ellipsize a long name; on hover the actions take width and it
|
||||
// compresses further.
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rightAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 48px;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
// Always laid out so the header height stays constant (no shift on hover);
|
||||
// collapsed to zero width until hover, so the title uses the full width and
|
||||
// reflows/ellipsizes when the actions appear.
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clearAll {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
.header:hover .rightAction {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
|
||||
|
||||
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onToggleSearch: () => void;
|
||||
onClear: () => void;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onToggleSearch,
|
||||
onClear,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section
|
||||
@@ -42,21 +42,22 @@ export function CheckboxFilterV2Header({
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
|
||||
<Typography.Text
|
||||
className={styles.clearAll}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
data-testid="checkbox-filter-clear-all"
|
||||
>
|
||||
Clear
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
{isOpen && (
|
||||
<section className={styles.rightAction}>
|
||||
<SectionActionButton
|
||||
icon={<Search size={14} />}
|
||||
tooltip="Search"
|
||||
onClick={onToggleSearch}
|
||||
testId="checkbox-filter-search-toggle"
|
||||
/>
|
||||
<SectionActionButton
|
||||
icon={<Undo2 size={14} />}
|
||||
tooltip="Reset"
|
||||
onClick={onClear}
|
||||
testId="checkbox-filter-clear-all"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -144,6 +145,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
// Related values now appear in "Related" section (no badge, uses divider instead)
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -193,6 +195,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -237,6 +240,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'xyz-no-match');
|
||||
|
||||
@@ -344,6 +348,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-v1');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'pod-a');
|
||||
|
||||
@@ -518,7 +523,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter applied for attribute', async () => {
|
||||
it('shows the reset action when expanded even with no active filter', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
@@ -533,9 +538,9 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
// Reset is always available on an expanded section now (hover-gated via
|
||||
// CSS), not conditional on an active filter.
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
showClearAll: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onToggleSearch: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -31,11 +30,12 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not show clear button when collapsed', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
|
||||
);
|
||||
it('does not render the section actions when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-search-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -50,36 +50,13 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('shows clear button when expanded + showClearAll=true', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
|
||||
it('renders both search and reset actions when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-toggle'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when showClearAll=false', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter present for attribute', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
showClearAll
|
||||
isSomeFilterPresentForCurrentAttribute={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,28 +99,35 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClear on clear button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clear button click does not trigger onToggleOpen', async () => {
|
||||
it('calls onToggleSearch on search click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleSearch = jest.fn();
|
||||
const onToggleOpen = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleSearch={onToggleSearch}
|
||||
onToggleOpen={onToggleOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
|
||||
expect(onToggleSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClear on reset click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onClear={onClear}
|
||||
onToggleOpen={onToggleOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
|
||||
@@ -10,6 +14,7 @@ interface UseFieldValuesProps {
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
source?: TelemetrytypesSourceDTO;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
@@ -33,6 +38,7 @@ export function useFieldValues({
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
@@ -46,6 +52,7 @@ export function useFieldValues({
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
@@ -75,6 +82,12 @@ export function useFieldValues({
|
||||
}, [data]);
|
||||
|
||||
const allValues: string[] = useMemo(() => {
|
||||
// Bool fields should always offer true/false.
|
||||
// The values api returns nothing for them.
|
||||
if (filter.attributeKey.dataType === DataTypes.bool) {
|
||||
return ['true', 'false'];
|
||||
}
|
||||
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
@@ -91,7 +104,7 @@ export function useFieldValues({
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues];
|
||||
}, [data]);
|
||||
}, [data, filter.attributeKey.dataType]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
padding-right: 9px !important;
|
||||
}
|
||||
|
||||
.duration-reset {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ant-collapse-header:hover .duration-reset {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ant-collapse-header-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
@@ -105,11 +115,6 @@
|
||||
.section-body-header {
|
||||
display: flex;
|
||||
|
||||
> button {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
padding-top: 13px;
|
||||
}
|
||||
.ant-collapse {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Collapse } from 'antd';
|
||||
import { Collapse } from 'antd';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
@@ -14,12 +15,16 @@ import {
|
||||
AllTraceFilterKeys,
|
||||
AllTraceFilterKeyValue,
|
||||
HandleRunProps,
|
||||
traceFilterKeys,
|
||||
unionTagFilterItems,
|
||||
} from 'pages/TracesExplorer/Filter/filterUtils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { clearFilterFromQuery } from '../shared/filterQuery';
|
||||
import { SectionActionButton } from '../shared/SectionActionButton/SectionActionButton';
|
||||
|
||||
import './Duration.styles.scss';
|
||||
|
||||
export type FilterType = Record<
|
||||
@@ -268,12 +273,19 @@ function Duration({
|
||||
handleRun();
|
||||
}, [selectedFilters]);
|
||||
|
||||
const onClearHandler = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedFilters?.durationNanoMin || selectedFilters?.durationNanoMax) {
|
||||
handleRun({ clearByType: 'durationNano' });
|
||||
const onClearHandler = (): void => {
|
||||
if (!selectedFilters?.durationNanoMin && !selectedFilters?.durationNanoMax) {
|
||||
return;
|
||||
}
|
||||
const clearedQuery = clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey: traceFilterKeys.durationNano.key,
|
||||
activeQueryIndex,
|
||||
});
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(clearedQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(clearedQuery);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,18 +306,19 @@ function Duration({
|
||||
/>
|
||||
),
|
||||
label: 'Duration',
|
||||
extra: activeKeys.includes('durationNano') ? (
|
||||
<div className="duration-reset">
|
||||
<SectionActionButton
|
||||
icon={<Undo2 size={14} />}
|
||||
tooltip="Reset"
|
||||
onClick={onClearHandler}
|
||||
testId="collapse-duration-clearBtn"
|
||||
/>
|
||||
</div>
|
||||
) : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{activeKeys.includes('durationNano') && (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={onClearHandler}
|
||||
data-testid="collapse-duration-clearBtn"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.iconBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
import styles from './SectionActionButton.module.scss';
|
||||
|
||||
interface SectionActionButtonProps {
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export function SectionActionButton({
|
||||
icon,
|
||||
tooltip,
|
||||
onClick,
|
||||
testId,
|
||||
}: SectionActionButtonProps): JSX.Element {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.iconBtn}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { isKeyMatch } from '../Checkbox/utils';
|
||||
|
||||
/**
|
||||
* Returns a new query with every clause for this attribute key removed, both
|
||||
* from the structured filter items and the raw filter expression.
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filterKey,
|
||||
activeQueryIndex,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filterKey: string;
|
||||
activeQueryIndex: number;
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filterKey,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filterKey),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
function SortableFilter({
|
||||
filter,
|
||||
@@ -25,13 +25,13 @@ function SortableFilter({
|
||||
allowDrag,
|
||||
allowRemove,
|
||||
}: {
|
||||
filter: FilterType;
|
||||
onRemove: (filter: FilterType) => void;
|
||||
filter: TelemetryFieldKey;
|
||||
onRemove: (filter: TelemetryFieldKey) => void;
|
||||
allowDrag: boolean;
|
||||
allowRemove: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: filter.key });
|
||||
useSortable({ id: filter.key as string });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -46,14 +46,14 @@ function SortableFilter({
|
||||
>
|
||||
<div {...attributes} {...listeners} className="drag-handle">
|
||||
{allowDrag && <GripVertical size={16} />}
|
||||
{filter.key}
|
||||
{filter.name}
|
||||
</div>
|
||||
{allowRemove && (
|
||||
<Button
|
||||
className="remove-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => {
|
||||
onRemove(filter as FilterType);
|
||||
onRemove(filter);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -69,8 +69,8 @@ function AddedFilters({
|
||||
setAddedFilters,
|
||||
}: {
|
||||
inputValue: string;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
@@ -90,12 +90,12 @@ function AddedFilters({
|
||||
const filteredAddedFilters = useMemo(
|
||||
() =>
|
||||
addedFilters.filter((filter) =>
|
||||
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
),
|
||||
[addedFilters, inputValue],
|
||||
);
|
||||
|
||||
const handleRemoveFilter = (filter: FilterType): void => {
|
||||
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ function AddedFilters({
|
||||
<div className="no-values-found">No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={addedFilters.map((f) => f.key)}
|
||||
items={addedFilters.map((f) => f.key as string)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
|
||||
@@ -4,14 +4,9 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FieldContext, TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
function OtherFiltersSkeleton(): JSX.Element {
|
||||
return (
|
||||
@@ -37,106 +32,49 @@ function OtherFilters({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
inputValue: string;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const isLogDataSource = useMemo(
|
||||
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
|
||||
[signal],
|
||||
);
|
||||
const isMeterDataSource = useMemo(
|
||||
() => signal && signal === SignalType.METER_EXPLORER,
|
||||
[signal],
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
|
||||
const { data, isFetching } = useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
signalSource: isMeterDataSource ? 'meter' : '',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, signal, inputValue],
|
||||
enabled: !!signal,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
|
||||
useGetAttributeSuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
filters: {} as TagFilter,
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isLogDataSource,
|
||||
},
|
||||
);
|
||||
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data?.data?.keys || {}).flat();
|
||||
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
|
||||
// add, render) can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
name: attr.name,
|
||||
signal: attr.signal,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
}));
|
||||
|
||||
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
|
||||
useGetAggregateKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
|
||||
},
|
||||
const addedKeys = new Set(
|
||||
addedFilters.map((filter) =>
|
||||
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
|
||||
),
|
||||
);
|
||||
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
|
||||
}, [data, addedFilters]);
|
||||
|
||||
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
|
||||
useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
signalSource: 'meter',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
const otherFilters = useMemo(() => {
|
||||
let filterAttributes;
|
||||
if (isLogDataSource) {
|
||||
filterAttributes = suggestionsData?.payload?.attributes || [];
|
||||
} else if (isMeterDataSource) {
|
||||
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
|
||||
fieldKeysData?.data?.data?.keys || {},
|
||||
)?.flat();
|
||||
filterAttributes = fieldKeys.map(
|
||||
(attr) =>
|
||||
({
|
||||
key: attr.name,
|
||||
dataType: attr.fieldDataType,
|
||||
type: attr.fieldContext,
|
||||
signal: attr.signal,
|
||||
}) as BaseAutocompleteData,
|
||||
);
|
||||
} else {
|
||||
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
|
||||
}
|
||||
return filterAttributes?.filter(
|
||||
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
|
||||
);
|
||||
}, [
|
||||
suggestionsData,
|
||||
aggregateKeysData,
|
||||
addedFilters,
|
||||
isLogDataSource,
|
||||
fieldKeysData,
|
||||
isMeterDataSource,
|
||||
]);
|
||||
|
||||
const handleAddFilter = (filter: FilterType): void => {
|
||||
setAddedFilters((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: filter.key,
|
||||
dataType: filter.dataType,
|
||||
type: filter.type,
|
||||
},
|
||||
]);
|
||||
const handleAddFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => [...prev, filter]);
|
||||
};
|
||||
|
||||
const renderFilters = (): React.ReactNode => {
|
||||
const isLoading =
|
||||
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
|
||||
if (isLoading) {
|
||||
if (isFetching) {
|
||||
return <OtherFiltersSkeleton />;
|
||||
}
|
||||
if (!otherFilters?.length) {
|
||||
@@ -145,11 +83,11 @@ function OtherFilters({
|
||||
|
||||
return otherFilters.map((filter) => (
|
||||
<div key={filter.key} className="qf-filter-item other-filters-item">
|
||||
<div className="qf-filter-key">{filter.key}</div>
|
||||
<div className="qf-filter-key">{filter.name}</div>
|
||||
<Button
|
||||
className="add-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => handleAddFilter(filter as FilterType)}
|
||||
onClick={(): void => handleAddFilter(filter)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { SignalType } from '../types';
|
||||
import AddedFilters from './AddedFilters';
|
||||
@@ -19,7 +18,7 @@ function QuickFiltersSettings({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
@@ -28,6 +27,7 @@ function QuickFiltersSettings({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
handleInputChange,
|
||||
@@ -39,18 +39,6 @@ function QuickFiltersSettings({
|
||||
signal,
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
// check if both arrays have the same length and same order of elements
|
||||
!(
|
||||
addedFilters.length === customFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === customFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, customFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="qf-header">
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
signal?: SignalType;
|
||||
}
|
||||
|
||||
interface UseQuickFilterSettingsReturn {
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
handleSettingsClose: () => void;
|
||||
handleDiscardChanges: () => void;
|
||||
handleSaveChanges: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
isUpdatingCustomFilters: boolean;
|
||||
inputValue: string;
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -37,27 +41,43 @@ const useQuickFilterSettings = ({
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
|
||||
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
|
||||
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() =>
|
||||
customFilters.map((filter) => ({
|
||||
...filter,
|
||||
key: buildCompositeKey(
|
||||
filter.name,
|
||||
filter.fieldContext,
|
||||
filter.fieldDataType,
|
||||
),
|
||||
})),
|
||||
[customFilters],
|
||||
);
|
||||
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
|
||||
normalizedCustomFilters,
|
||||
);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
|
||||
useMutation(updateCustomFiltersAPI, {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
notifications.error({
|
||||
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
useUpdateQuickFilters({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
void logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.error({
|
||||
message: error.message || SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
@@ -78,17 +98,30 @@ const useQuickFilterSettings = ({
|
||||
}, [setIsSettingsOpen]);
|
||||
|
||||
const handleDiscardChanges = useCallback((): void => {
|
||||
setAddedFilters(customFilters);
|
||||
}, [customFilters, setAddedFilters]);
|
||||
setAddedFilters(normalizedCustomFilters);
|
||||
}, [normalizedCustomFilters, setAddedFilters]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
addedFilters.length === normalizedCustomFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === normalizedCustomFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, normalizedCustomFilters],
|
||||
);
|
||||
|
||||
const handleSaveChanges = useCallback((): void => {
|
||||
if (signal) {
|
||||
updateCustomFilters({
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
key: filter.key,
|
||||
datatype: filter.dataType,
|
||||
type: filter.type,
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
})),
|
||||
signal,
|
||||
},
|
||||
@@ -102,6 +135,7 @@ const useQuickFilterSettings = ({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
@@ -13,7 +11,7 @@ interface UseFilterConfigProps {
|
||||
}
|
||||
interface UseFilterConfigReturn {
|
||||
filterConfig: IQuickFiltersConfig[];
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
isCustomFiltersLoading: boolean;
|
||||
isDynamicFilters: boolean;
|
||||
refetchCustomFilters: () => void;
|
||||
@@ -25,17 +23,16 @@ const useFilterConfig = ({
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const {
|
||||
isFetching: isCustomFiltersLoading,
|
||||
data: customFilters = [],
|
||||
data,
|
||||
refetch,
|
||||
} = useQuery<FilterType[], Error>(
|
||||
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
|
||||
async () => {
|
||||
const res = await getCustomFilters({ signal: signal || '' });
|
||||
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
|
||||
},
|
||||
{
|
||||
enabled: !!signal,
|
||||
},
|
||||
} = useGetQuickFilters(
|
||||
{ signalName: signal ?? '' },
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const customFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
|
||||
[data],
|
||||
);
|
||||
|
||||
const isDynamicFilters = useMemo(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
useLastComputedMinMax,
|
||||
} from 'store/globalTime';
|
||||
|
||||
import { QuickFilterCheckboxUseFieldApis } from '../types';
|
||||
|
||||
/**
|
||||
* Builds the `useFieldApis` config for a signal quick-filter page.
|
||||
* if existingQuery is sent null, related values are not fetched
|
||||
*/
|
||||
export function useSignalFieldApis(): QuickFilterCheckboxUseFieldApis {
|
||||
const { minTime, maxTime } = useLastComputedMinMax();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
existingQuery: null,
|
||||
}),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -338,6 +338,63 @@ describe('Quick Filters with custom filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps same-name fields with different context as distinct entries', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(
|
||||
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
level: [
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
||||
// Both `level` variants are shown despite sharing a name.
|
||||
await waitFor(() =>
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
|
||||
);
|
||||
|
||||
// Adding one variant removes only that one; the other stays.
|
||||
const firstLevel = within(otherSection).getAllByText('level')[0];
|
||||
const addButton = firstLevel.parentElement?.querySelector('button');
|
||||
await user.click(addButton as HTMLButtonElement);
|
||||
|
||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||
await waitFor(() => {
|
||||
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -458,7 +515,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
const requestBody = putHandler.mock.calls[0][0];
|
||||
expect(requestBody.filters).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
|
||||
expect.not.objectContaining({ name: FILTER_OS_DESCRIPTION }),
|
||||
]),
|
||||
);
|
||||
expect(requestBody.signal).toBe(SIGNAL);
|
||||
@@ -612,9 +669,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
filters: [
|
||||
...(quickFiltersListResponse.data.filters ?? []),
|
||||
{
|
||||
key: 'new.custom.filter',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'new.custom.filter',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
} as const,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
|
||||
|
||||
@@ -12,6 +17,39 @@ const FILTER_TYPE_MAP: Record<string, FiltersType> = {
|
||||
duration_nano: FiltersType.DURATION,
|
||||
};
|
||||
|
||||
// Both maps below (and mapFieldDataType/mapFieldContext) exist only for the old
|
||||
// v3 attribute-values fetch in useCheckboxFilterValues, which is the sole reader
|
||||
// of attributeKey.dataType/type. Query/list endpoints don't consume them (v5 and
|
||||
// the API-monitoring/exceptions/infra paths all send a name-based expression).
|
||||
// Once the values fetch moves to fields/values (by name) in Phase A, this whole
|
||||
// mapping can be removed and attributeKey reduced to { id, key }.
|
||||
|
||||
// The new field data types are rendered down to the v3 spellings the
|
||||
// attribute-values call expects, matching the backend's legacy conversion
|
||||
// (number -> float64).
|
||||
const FIELD_DATA_TYPE_TO_DATA_TYPE: Record<string, DataTypes> = {
|
||||
[TelemetrytypesFieldDataTypeDTO.string]: DataTypes.String,
|
||||
[TelemetrytypesFieldDataTypeDTO.bool]: DataTypes.bool,
|
||||
[TelemetrytypesFieldDataTypeDTO.float64]: DataTypes.Float64,
|
||||
[TelemetrytypesFieldDataTypeDTO.int64]: DataTypes.Int64,
|
||||
[TelemetrytypesFieldDataTypeDTO.number]: DataTypes.Float64,
|
||||
};
|
||||
|
||||
// Only tag and resource exist in the v3 attribute-type enum; other contexts
|
||||
// render as empty so the still-live v3 values path never sees a spelling it
|
||||
// can't use, matching the backend's legacy conversion.
|
||||
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
};
|
||||
|
||||
const mapFieldDataType = (fieldDataType?: string): DataTypes =>
|
||||
(fieldDataType && FIELD_DATA_TYPE_TO_DATA_TYPE[fieldDataType]) ||
|
||||
DataTypes.EMPTY;
|
||||
|
||||
const mapFieldContext = (fieldContext?: string): string =>
|
||||
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
|
||||
|
||||
const getFilterName = (str: string): string => {
|
||||
if (FILTER_TITLE_MAP[str]) {
|
||||
return FILTER_TITLE_MAP[str];
|
||||
@@ -26,16 +64,16 @@ const getFilterName = (str: string): string => {
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getFilterType = (att: FilterType): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.key]) {
|
||||
return FILTER_TYPE_MAP[att.key];
|
||||
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.name]) {
|
||||
return FILTER_TYPE_MAP[att.name];
|
||||
}
|
||||
return FiltersType.CHECKBOX;
|
||||
};
|
||||
|
||||
export const getFilterConfig = (
|
||||
signal?: SignalType,
|
||||
customFilters?: FilterType[],
|
||||
customFilters?: TelemetryFieldKey[],
|
||||
config?: IQuickFiltersConfig[],
|
||||
): IQuickFiltersConfig[] => {
|
||||
if (!customFilters?.length || !signal) {
|
||||
@@ -46,13 +84,13 @@ export const getFilterConfig = (
|
||||
(att, index) =>
|
||||
({
|
||||
type: getFilterType(att),
|
||||
title: getFilterName(att.key),
|
||||
title: getFilterName(att.name),
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
|
||||
attributeKey: {
|
||||
id: att.key,
|
||||
key: att.key,
|
||||
dataType: att.dataType,
|
||||
type: att.type,
|
||||
id: att.name,
|
||||
key: att.name,
|
||||
dataType: mapFieldDataType(att.fieldDataType),
|
||||
type: mapFieldContext(att.fieldContext),
|
||||
},
|
||||
defaultOpen: index < 2,
|
||||
}) as IQuickFiltersConfig,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
@@ -110,10 +110,7 @@ describe('ServiceAccountDrawer — permissions', () => {
|
||||
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
|
||||
server.use(setupAuthzDeny(APIKeyListPermission));
|
||||
|
||||
renderDrawer();
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
|
||||
renderDrawer({ account: 'sa-1', tab: 'keys' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();
|
||||
|
||||
@@ -10,6 +10,7 @@ const fieldContextToSuggestionMap: Record<
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.trace]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.scope]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
|
||||
@@ -11,6 +12,8 @@ import DomainList from './Domains/DomainList';
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
@@ -26,6 +29,7 @@ function Explorer(): JSX.Element {
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<DomainList />
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { useSaveView } from 'hooks/saveViews/useSaveView';
|
||||
@@ -287,8 +287,7 @@ function ExplorerOptions({
|
||||
|
||||
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
|
||||
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
|
||||
const extraData = viewsData?.data?.data?.find(
|
||||
(view) => view.id === viewKey,
|
||||
|
||||
@@ -53,17 +53,24 @@ export const getUpdatedStepInterval = (evalWindow?: string): number => {
|
||||
};
|
||||
|
||||
export const getSelectedQueryOptions = (
|
||||
queries: Array<
|
||||
| IBuilderQuery
|
||||
| IBuilderTraceOperator
|
||||
| IBuilderFormula
|
||||
| IClickHouseQuery
|
||||
| IPromQLQuery
|
||||
>,
|
||||
): SelectProps['options'] =>
|
||||
queries
|
||||
queries:
|
||||
| Array<
|
||||
| IBuilderQuery
|
||||
| IBuilderTraceOperator
|
||||
| IBuilderFormula
|
||||
| IClickHouseQuery
|
||||
| IPromQLQuery
|
||||
>
|
||||
| undefined
|
||||
| null,
|
||||
): SelectProps['options'] => {
|
||||
if (!queries) {
|
||||
return [];
|
||||
}
|
||||
return queries
|
||||
.filter((query) => !query.disabled)
|
||||
.map((query) => ({
|
||||
label: 'queryName' in query ? query.queryName : query.name,
|
||||
value: 'queryName' in query ? query.queryName : query.name,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-0);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
.trace-explorer-header {
|
||||
.trace-explorer-run-query {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
margin: 8px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-outlined-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-header.single-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.traces-explorer-views {
|
||||
padding: 8px;
|
||||
padding-bottom: 60px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.qb-search-view-container {
|
||||
padding: 8px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l2-background) !important;
|
||||
height: 34px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-list-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-traces-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-table-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-time-series-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,373 @@
|
||||
import styles from './Explorer.module.scss';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
getExportQueryData,
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
ICurrentQueryData,
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import {
|
||||
tracesAddFilterAction,
|
||||
tracesChangeViewAction,
|
||||
tracesRunQueryAction,
|
||||
tracesSaveViewAction,
|
||||
} from 'pages/TracesExplorer/aiActions';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
import TracesView from './TracesView/TracesView';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
// Shell for the AI Observability Explorer tab. Owns the
|
||||
// /ai-observability/explorer route and is intentionally empty for now: the
|
||||
// query builder + results surface land in a follow-up.
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
handleSetConfig,
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// Reset loading state — the active view unmounts when cancelled, so no
|
||||
// child will call setIsLoadingQueries(false) otherwise.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
);
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
const aiActions = useMemo(
|
||||
() =>
|
||||
isAIAssistantEnabled
|
||||
? [
|
||||
tracesRunQueryAction({
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
tracesAddFilterAction({
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
tracesChangeViewAction({
|
||||
onChangeView: (view) => handleChangeSelectedView(view as ExplorerViews),
|
||||
}),
|
||||
tracesSaveViewAction({
|
||||
// POC stub — logs a save request; wire to real API when available
|
||||
onSaveView: async (name) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[AI Assistant] Save view requested:', name);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
isAIAssistantEnabled,
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
const widgetId = v4();
|
||||
|
||||
const query = getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
panelTypeParam,
|
||||
options,
|
||||
);
|
||||
|
||||
logEvent('Traces Explorer: Add to dashboard successful', {
|
||||
panelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
if (dashboardEditView) {
|
||||
safeNavigate(dashboardEditView);
|
||||
}
|
||||
},
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
logEvent('Traces Explorer: Page visited', {});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isFilterApplied = useMemo(() => {
|
||||
// if any of the non-disabled queries has filters applied, return true
|
||||
const result = stagedQuery?.builder?.queryData?.filter(
|
||||
(item) => !isEmpty(item.filters?.items) && !item.disabled,
|
||||
);
|
||||
return !!result?.length;
|
||||
}, [stagedQuery]);
|
||||
|
||||
return (
|
||||
<div className={styles.explorer} data-testid="llm-observability-explorer">
|
||||
<div className={styles.placeholder}>Explorer coming soon.</div>
|
||||
</div>
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className="trace-explorer-page"
|
||||
data-testid="llm-observability-explorer"
|
||||
>
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
leftActions={
|
||||
<LeftToolbarActions
|
||||
showFilter={isOpen}
|
||||
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
|
||||
items={TOOLBAR_VIEWS}
|
||||
selectedView={selectedView}
|
||||
onChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
}
|
||||
warningElement={
|
||||
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>
|
||||
<div className="query-section-container">
|
||||
<QuerySection />
|
||||
</div>
|
||||
</ExplorerCard>
|
||||
|
||||
<div className="traces-explorer-views">
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
<div className="trace-explorer-list-view">
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
<div className="trace-explorer-traces-view">
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
<div className="trace-explorer-time-series-view">
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
<div className="trace-explorer-table-view">
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.trace-explorer-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.order-by-select {
|
||||
width: 100px;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: requestQuery,
|
||||
graphType: panelType,
|
||||
selectedTime: 'GLOBAL_TIME' as const,
|
||||
globalSelectedInterval: globalSelectedTime as CustomTimeType,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows, panelType]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(QuerySection);
|
||||
@@ -0,0 +1,7 @@
|
||||
.traces-table-view-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Space } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TableView.styles.scss';
|
||||
|
||||
function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap.traces,
|
||||
graphType: panelType || PANEL_TYPES.TABLE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableData = useMemo(
|
||||
() =>
|
||||
data?.payload?.data?.newResult?.data?.result ||
|
||||
data?.payload.data.result ||
|
||||
[],
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
<div className="traces-table-view-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
<QueryTable
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
queryTableData={queryTableData as QueryDataV3[]}
|
||||
loading={isLoading}
|
||||
sticky
|
||||
/>
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
@@ -0,0 +1,8 @@
|
||||
.trace-explorer-time-series-view-container {
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TimeSeriesView.styles.scss';
|
||||
|
||||
function TimeSeriesViewContainer({
|
||||
dataSource = DataSource.TRACES,
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap[dataSource],
|
||||
graphType: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
return (
|
||||
<div className="trace-explorer-time-series-view-container">
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
onYAxisUnitChange={onUnitChange}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimeSeriesViewProps {
|
||||
dataSource?: DataSource;
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
@@ -0,0 +1,15 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
|
||||
const rows = useMemo<TracesTableRow[]>(
|
||||
() =>
|
||||
(responseData ?? []).map((item) => {
|
||||
const row = item.data;
|
||||
return { ...row, id: row.trace_id };
|
||||
}) as TracesTableRow[],
|
||||
[responseData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
show: true,
|
||||
key: 'list',
|
||||
},
|
||||
timeseries: {
|
||||
name: 'timeseries',
|
||||
label: 'Timeseries',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'table',
|
||||
},
|
||||
clickhouse: {
|
||||
name: 'clickhouse',
|
||||
label: 'Clickhouse',
|
||||
disabled: false,
|
||||
show: false,
|
||||
key: 'clickhouse',
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,12 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
|
||||
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
|
||||
}));
|
||||
|
||||
// Same data-router gap as the dashboard above: the Explorer toolbar calls useNavigationType.
|
||||
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
.logs-frequency-chart {
|
||||
.ant-card-body {
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
padding: 0 16px 22px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -50,7 +49,7 @@ function BodyTitleRenderer({
|
||||
const { featureFlags } = useAppContext();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const { viewName } = useGetSavedViewParams();
|
||||
|
||||
const cleanedNodeKey = removeObjectFromString(nodeKey);
|
||||
const isBodyJsonQueryEnabled =
|
||||
|
||||
@@ -7,13 +7,12 @@ import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import {
|
||||
@@ -141,7 +140,7 @@ export default function TableViewActions(
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const { viewName } = useGetSavedViewParams();
|
||||
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
|
||||
|
||||
// there is no option for where clause in old logs explorer and live logs page or infra monitoring
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
@@ -88,7 +88,7 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
jest.mock('hooks/queryBuilder/useGetSearchQueryParam');
|
||||
jest.mock('hooks/saveViews/useGetSavedViewParams');
|
||||
|
||||
describe('TableViewActions', () => {
|
||||
const TEST_VALUE = 'test value';
|
||||
@@ -140,8 +140,10 @@ describe('TableViewActions', () => {
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// Default mock for useGetSearchQueryParam
|
||||
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
|
||||
// Default mock for useGetSavedViewParams
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
@@ -249,7 +251,9 @@ describe('TableViewActions', () => {
|
||||
updateQueriesData: mockUpdateQueriesData,
|
||||
} as any);
|
||||
|
||||
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
|
||||
render(
|
||||
<TableViewActions
|
||||
|
||||
@@ -3,10 +3,9 @@ import { useLocation } from 'react-router-dom';
|
||||
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
@@ -58,7 +57,7 @@ export function useLogAttributeActions({
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { featureFlags } = useAppContext();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const { viewName } = useGetSavedViewParams();
|
||||
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
min-height: 200px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.ant-card-body {
|
||||
height: 200px;
|
||||
min-height: 200px;
|
||||
padding: 0 16px 16px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
.logs-frequency-chart-loading {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Graph from 'components/Graph';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getChartData, { GetChartDataProps } from 'lib/getChartData';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { colors } from 'lib/getRandomColor';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces';
|
||||
import { getColorsForSeverityLabels } from './utils';
|
||||
import { useLogsExplorerChartConfig } from './useLogsExplorerChartConfig';
|
||||
|
||||
import './LogsExplorerChart.styles.scss';
|
||||
|
||||
// Axis and tooltip format separately; both need this or only one abbreviates.
|
||||
const Y_AXIS_UNIT = 'short';
|
||||
|
||||
function LogsExplorerChart({
|
||||
data,
|
||||
isLoading,
|
||||
@@ -37,24 +41,6 @@ function LogsExplorerChart({
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] =
|
||||
useCallback(
|
||||
(element, index, allLabels) => ({
|
||||
data: element,
|
||||
backgroundColor: isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(allLabels[index], index)
|
||||
: colors[index % colors.length] || themeColors.red,
|
||||
borderColor: isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(allLabels[index], index)
|
||||
: colors[index % colors.length] || themeColors.red,
|
||||
...(isLabelEnabled
|
||||
? {
|
||||
label: allLabels[index],
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[isLabelEnabled, isLogsExplorerViews],
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
@@ -86,44 +72,47 @@ function LogsExplorerChart({
|
||||
[dispatch, location.pathname, safeNavigate, urlQuery, isShowingLiveLogs],
|
||||
);
|
||||
|
||||
const graphData = useMemo(
|
||||
() =>
|
||||
getChartData({
|
||||
queryData: [
|
||||
{
|
||||
queryData: data,
|
||||
},
|
||||
],
|
||||
createDataset: handleCreateDatasets,
|
||||
}),
|
||||
[data, handleCreateDatasets],
|
||||
);
|
||||
|
||||
// Convert nanosecond timestamps to milliseconds for Chart.js
|
||||
const { chartMinTime, chartMaxTime } = useMemo(
|
||||
// uPlot plots the series on a seconds-based x scale
|
||||
const { minTimeScale, maxTimeScale } = useMemo(
|
||||
() => ({
|
||||
chartMinTime: minTime ? Math.floor(minTime / 1e6) : undefined,
|
||||
chartMaxTime: maxTime ? Math.floor(maxTime / 1e6) : undefined,
|
||||
minTimeScale: minTime ? Math.floor(minTime / 1e9) : undefined,
|
||||
maxTimeScale: maxTime ? Math.floor(maxTime / 1e9) : undefined,
|
||||
}),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
|
||||
const { config, chartData } = useLogsExplorerChartConfig({
|
||||
data,
|
||||
isLogsExplorerViews,
|
||||
isLabelEnabled,
|
||||
onDragSelect,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit: Y_AXIS_UNIT,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${className} logs-frequency-chart-container`}>
|
||||
<div ref={graphRef} className={`${className} logs-frequency-chart-container`}>
|
||||
{isLoading ? (
|
||||
<div className="logs-frequency-chart-loading">
|
||||
<Spinner size="default" height="100%" />
|
||||
</div>
|
||||
) : (
|
||||
<Graph
|
||||
name="logsExplorerChart"
|
||||
data={graphData.data}
|
||||
isStacked={isLogsExplorerViews}
|
||||
type="bar"
|
||||
animate
|
||||
onDragSelect={onDragSelect}
|
||||
minTime={chartMinTime}
|
||||
maxTime={chartMaxTime}
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
stack={isLogsExplorerViews ? StackMode.Normal : StackMode.None}
|
||||
showLegend={isLabelEnabled}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
timezone={timezone}
|
||||
data-testid="logs-frequency-chart"
|
||||
yAxisUnit={Y_AXIS_UNIT}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { colors } from 'lib/getRandomColor';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { getColorsForSeverityLabels } from './utils';
|
||||
|
||||
export interface UseLogsExplorerChartConfigParams {
|
||||
data: QueryData[];
|
||||
isLogsExplorerViews?: boolean;
|
||||
isLabelEnabled?: boolean;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
yAxisUnit?: string;
|
||||
}
|
||||
|
||||
export interface UseLogsExplorerChartConfigResult {
|
||||
config: UPlotConfigBuilder;
|
||||
chartData: uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export function useLogsExplorerChartConfig({
|
||||
data,
|
||||
isLogsExplorerViews = false,
|
||||
isLabelEnabled = true,
|
||||
onDragSelect,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit,
|
||||
}: UseLogsExplorerChartConfigParams): UseLogsExplorerChartConfigResult {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// getUPlotChartData / buildBaseConfig both consume the legacy query-range payload
|
||||
// shape, so the raw series list is wrapped instead of being plotted directly.
|
||||
const apiResponse = useMemo(
|
||||
() =>
|
||||
({
|
||||
data: { result: data, resultType: '' },
|
||||
}) as unknown as MetricRangePayloadProps,
|
||||
[data],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => getUPlotChartData(apiResponse), [apiResponse]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
const builder = buildBaseConfig({
|
||||
id: 'logs-explorer-frequency-chart',
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
data.forEach((series, index) => {
|
||||
const label = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '',
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const color = isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(label, index)
|
||||
: colors[index % colors.length] || themeColors.red;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
// No group-by yields query name "A"; use ' ' not '' so uPlot does not default the label to "Value".
|
||||
label: isLabelEnabled && label.trim() ? label : ' ',
|
||||
lineColor: color,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}, [
|
||||
data,
|
||||
isDarkMode,
|
||||
isLabelEnabled,
|
||||
isLogsExplorerViews,
|
||||
maxTimeScale,
|
||||
minTimeScale,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
]);
|
||||
|
||||
return { config, chartData };
|
||||
}
|
||||
@@ -217,13 +217,6 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
.logs-frequency-chart {
|
||||
.ant-card-body {
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
padding: 0 16px 22px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -31,6 +32,7 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
const {
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
@@ -141,6 +143,7 @@ function Explorer(): JSX.Element {
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Add default values for optional fields if empty
|
||||
if (parsedCompositeQuery?.builder) {
|
||||
parsedCompositeQuery.builder.queryFormulas =
|
||||
parsedCompositeQuery.builder.queryFormulas ?? [];
|
||||
parsedCompositeQuery.builder.queryTraceOperator =
|
||||
parsedCompositeQuery.builder.queryTraceOperator ?? [];
|
||||
}
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { QuerySearchParamNames } from 'components/ExplorerCard/constants';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
export const useGetSearchQueryParam = (
|
||||
searchParams: QuerySearchParamNames,
|
||||
): string | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useMemo(() => {
|
||||
const searchQuery = urlQuery.get(searchParams);
|
||||
|
||||
return searchQuery ? JSON.parse(searchQuery) : null;
|
||||
}, [urlQuery, searchParams]);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { useGetSavedViewParams } from '../useGetSavedViewParams';
|
||||
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
|
||||
const mockedUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
|
||||
const setSearch = (search: string): void => {
|
||||
mockedUseUrlQuery.mockReturnValue(new URLSearchParams(search));
|
||||
};
|
||||
|
||||
describe('useGetSavedViewParams', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns empty strings when no params are present', () => {
|
||||
setSearch('');
|
||||
|
||||
const { result } = renderHook(() => useGetSavedViewParams());
|
||||
|
||||
expect(result.current).toStrictEqual({ viewName: '', viewKey: '' });
|
||||
});
|
||||
|
||||
it('parses JSON-stringified values', () => {
|
||||
setSearch(
|
||||
`viewName=${encodeURIComponent(
|
||||
JSON.stringify('Hindsight'),
|
||||
)}&viewKey=${encodeURIComponent(JSON.stringify('abc-123'))}`,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGetSavedViewParams());
|
||||
|
||||
expect(result.current).toStrictEqual({
|
||||
viewName: 'Hindsight',
|
||||
viewKey: 'abc-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the raw string when a value is not valid JSON', () => {
|
||||
setSearch('viewName=Hindsight&viewKey=some-uuid-value');
|
||||
|
||||
const { result } = renderHook(() => useGetSavedViewParams());
|
||||
|
||||
expect(result.current).toStrictEqual({
|
||||
viewName: 'Hindsight',
|
||||
viewKey: 'some-uuid-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw and keeps the raw string for non-string JSON', () => {
|
||||
setSearch('viewName=123');
|
||||
|
||||
const { result } = renderHook(() => useGetSavedViewParams());
|
||||
|
||||
expect(result.current).toStrictEqual({ viewName: '123', viewKey: '' });
|
||||
});
|
||||
});
|
||||
33
frontend/src/hooks/saveViews/useGetSavedViewParams.ts
Normal file
33
frontend/src/hooks/saveViews/useGetSavedViewParams.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
interface SavedViewParams {
|
||||
viewName: string;
|
||||
viewKey: string;
|
||||
}
|
||||
|
||||
const parseViewParam = (value: string | null): string => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === 'string' ? parsed : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const useGetSavedViewParams = (): SavedViewParams => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
viewName: parseViewParam(urlQuery.get(QueryParams.viewName)),
|
||||
viewKey: parseViewParam(urlQuery.get(QueryParams.viewKey)),
|
||||
}),
|
||||
[urlQuery],
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/const
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useGetSearchQueryParam } from './queryBuilder/useGetSearchQueryParam';
|
||||
import { useGetSavedViewParams } from './saveViews/useGetSavedViewParams';
|
||||
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
|
||||
|
||||
export interface ICurrentQueryData {
|
||||
@@ -31,9 +31,7 @@ export const useHandleExplorerTabChange = (): {
|
||||
updateQueriesData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
|
||||
const getUpdateQuery = useCallback(
|
||||
(newPanelType: PANEL_TYPES): Query => {
|
||||
|
||||
@@ -4,114 +4,85 @@ export const quickFiltersListResponse = {
|
||||
signal: 'logs',
|
||||
filters: [
|
||||
{
|
||||
key: 'os.description',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'os.description',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'duration_nano',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
name: 'duration_nano',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
name: 'quantity',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
key: 'body',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'deployment.environment',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.namespace',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.namespace',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'k8s.namespace.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.instance.id',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'k8s.pod.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'process.owner',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'process.owner',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const otherFilterName = (name: string): { [k: string]: unknown[] } => ({
|
||||
[name]: [
|
||||
{ name, fieldContext: 'resource', fieldDataType: 'string', signal: 'logs' },
|
||||
],
|
||||
});
|
||||
|
||||
export const otherFiltersResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
attributes: [
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.deployment.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.namespace',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.uid',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'os.description',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
],
|
||||
complete: true,
|
||||
keys: {
|
||||
...otherFilterName('service.name'),
|
||||
...otherFilterName('k8s.deployment.name'),
|
||||
...otherFilterName('deployment.environment'),
|
||||
...otherFilterName('service.namespace'),
|
||||
...otherFilterName('k8s.namespace.name'),
|
||||
...otherFilterName('service.instance.id'),
|
||||
...otherFilterName('k8s.pod.name'),
|
||||
...otherFilterName('k8s.pod.uid'),
|
||||
...otherFilterName('os.description'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
|
||||
@@ -55,6 +56,8 @@ function AllErrors(): JSX.Element {
|
||||
setShowFilters((prev) => !prev);
|
||||
};
|
||||
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
@@ -64,6 +67,7 @@ function AllErrors(): JSX.Element {
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -74,6 +75,8 @@ function LogsExplorer(): JSX.Element {
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
@@ -232,6 +235,7 @@ function LogsExplorer(): JSX.Element {
|
||||
signal={SignalType.LOGS}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -504,7 +504,7 @@ jest.mock('hooks/useHandleExplorerTabChange', () => ({
|
||||
let capturedPayload: QueryRangePayloadV5;
|
||||
|
||||
describe('TracesExplorer -', () => {
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/traces`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/traces`;
|
||||
|
||||
const setupServer = (): void => {
|
||||
server.use(
|
||||
|
||||
@@ -8,6 +8,7 @@ import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -128,6 +129,8 @@ function TracesExplorer(): JSX.Element {
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
@@ -267,6 +270,7 @@ function TracesExplorer(): JSX.Element {
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
|
||||
@@ -163,20 +163,23 @@ export function QueryBuilderProvider({
|
||||
const prepareQueryBuilderData = useCallback(
|
||||
(query: Query): Query => {
|
||||
const builder: QueryBuilderData = {
|
||||
queryData: query.builder.queryData?.map((item) => ({
|
||||
...initialQueryBuilderFormValuesMap[
|
||||
initialDataSource || DataSource.METRICS
|
||||
],
|
||||
...item,
|
||||
})),
|
||||
queryFormulas: query.builder.queryFormulas?.map((item) => ({
|
||||
...initialFormulaBuilderFormValues,
|
||||
...item,
|
||||
})),
|
||||
queryTraceOperator: query.builder.queryTraceOperator?.map((item) => ({
|
||||
...initialQueryBuilderFormTraceOperatorValues,
|
||||
...item,
|
||||
})),
|
||||
queryData:
|
||||
query.builder.queryData?.map((item) => ({
|
||||
...initialQueryBuilderFormValuesMap[
|
||||
initialDataSource || DataSource.METRICS
|
||||
],
|
||||
...item,
|
||||
})) ?? [],
|
||||
queryFormulas:
|
||||
query.builder.queryFormulas?.map((item) => ({
|
||||
...initialFormulaBuilderFormValues,
|
||||
...item,
|
||||
})) ?? [],
|
||||
queryTraceOperator:
|
||||
query.builder.queryTraceOperator?.map((item) => ({
|
||||
...initialQueryBuilderFormTraceOperatorValues,
|
||||
...item,
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
const setupedQueryData = builder.queryData.map((item) => {
|
||||
@@ -209,15 +212,17 @@ export function QueryBuilderProvider({
|
||||
return currentElement;
|
||||
});
|
||||
|
||||
const promql: IPromQLQuery[] = query.promql.map((item) => ({
|
||||
...initialQueryPromQLData,
|
||||
...item,
|
||||
}));
|
||||
const promql: IPromQLQuery[] =
|
||||
query.promql?.map((item) => ({
|
||||
...initialQueryPromQLData,
|
||||
...item,
|
||||
})) ?? [];
|
||||
|
||||
const clickHouse: IClickHouseQuery[] = query.clickhouse_sql.map((item) => ({
|
||||
...initialClickHouseData,
|
||||
...item,
|
||||
}));
|
||||
const clickHouse: IClickHouseQuery[] =
|
||||
query.clickhouse_sql?.map((item) => ({
|
||||
...initialClickHouseData,
|
||||
...item,
|
||||
})) ?? [];
|
||||
|
||||
const newQueryState: QueryState = {
|
||||
clickhouse_sql: clickHouse,
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"factor-api-key",
|
||||
"license",
|
||||
"subscription",
|
||||
"deployment-host",
|
||||
"logs",
|
||||
"traces",
|
||||
"metrics",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export interface Filter {
|
||||
key: string;
|
||||
dataType: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export type PayloadProps = {
|
||||
filters: Filter[];
|
||||
signal: string;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
|
||||
interface FilterType {
|
||||
key: string;
|
||||
datatype: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface UpdateCustomFiltersProps {
|
||||
data: {
|
||||
filters: FilterType[];
|
||||
signal: SignalType;
|
||||
};
|
||||
}
|
||||
@@ -4,13 +4,12 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addFlaggerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.ViewAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.OpenAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{
|
||||
ID: "GetFeatures",
|
||||
Tags: []string{"features"},
|
||||
Summary: "Get features",
|
||||
@@ -22,7 +21,7 @@ func (provider *provider) addFlaggerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
SecuritySchemes: newScopedSecuritySchemes(nil),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
@@ -80,6 +81,7 @@ type provider struct {
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
quickFilterHandler quickfilter.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -118,6 +120,7 @@ func NewFactory(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
quickFilterHandler quickfilter.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -159,6 +162,7 @@ func NewFactory(
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
quickFilterHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -202,6 +206,7 @@ func newProvider(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
quickFilterHandler quickfilter.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -244,6 +249,7 @@ func newProvider(
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
quickFilterHandler: quickFilterHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -384,6 +390,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addQuickFilterRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
93
pkg/apiserver/signozapiserver/quickfilter.go
Normal file
93
pkg/apiserver/signozapiserver/quickfilter.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/quick_filters", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.ListQuickFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "List quick filters",
|
||||
Description: "Returns the org's quick filters for every signal, each filter as a telemetry field key.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new([]*quickfiltertypes.SignalFilters),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceQuickFilter,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/quick_filters/{signal_name}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.GetQuickFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "Get a signal's quick filters",
|
||||
Description: "Returns the org's quick filters for one signal, each filter as a telemetry field key.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(quickfiltertypes.SignalFilters),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceQuickFilter,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/quick_filters", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.UpdateQuickFiltersV2, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "Update quick filters",
|
||||
Description: "Replaces the org's quick filters for the signal named in the body.",
|
||||
Request: new(quickfiltertypes.UpdatableQuickFilters),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceQuickFilter,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -27,7 +29,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.ViewAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.CheckResources(provider.zeusHandler.GetHosts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
|
||||
ID: "GetHosts",
|
||||
Tags: []string{"zeus"},
|
||||
Summary: "Get host info from Zeus.",
|
||||
@@ -39,12 +41,17 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDeploymentHost.Scope(coretypes.VerbList)}),
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.AdminAccess(provider.zeusHandler.PutHost), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.CheckResources(provider.zeusHandler.PutHost, authtypes.SigNozAdminRoleName), handler.OpenAPIDef{
|
||||
ID: "PutHost",
|
||||
Tags: []string{"zeus"},
|
||||
Summary: "Put host in Zeus for a deployment.",
|
||||
@@ -56,8 +63,14 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDeploymentHost.Scope(coretypes.VerbUpdate)}),
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.BodyJSONPath("name"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -20,6 +23,89 @@ func NewHandler(module quickfilter.Module) quickfilter.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
// legacySignalFilters is the v1 API shape: filters as v3 attribute keys.
|
||||
type legacySignalFilters struct {
|
||||
Signal quickfiltertypes.Signal `json:"signal"`
|
||||
Filters []v3.AttributeKey `json:"filters"`
|
||||
}
|
||||
|
||||
// newTelemetryFieldKeysFromLegacy converts a v1 write payload with the same
|
||||
// normalizations as the storage migration: alias contexts, numerics to number.
|
||||
// The v1 shape carries no per filter signal, so meter keys get it restored.
|
||||
func newTelemetryFieldKeysFromLegacy(signal quickfiltertypes.Signal, filters []v3.AttributeKey) ([]telemetrytypes.TelemetryFieldKey, error) {
|
||||
var fieldSignal telemetrytypes.Signal
|
||||
if signal == quickfiltertypes.SignalMeter {
|
||||
fieldSignal = telemetrytypes.SignalMetrics
|
||||
}
|
||||
|
||||
fieldKeys := make([]telemetrytypes.TelemetryFieldKey, 0, len(filters))
|
||||
for _, filter := range filters {
|
||||
if err := filter.Validate(); err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err)
|
||||
}
|
||||
|
||||
fieldContext, ok := telemetrytypes.FieldContextFromText(string(filter.Type))
|
||||
if !ok {
|
||||
fieldContext = telemetrytypes.FieldContextUnspecified
|
||||
}
|
||||
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
if err := fieldDataType.Scan(string(filter.DataType)); err != nil {
|
||||
fieldDataType = telemetrytypes.FieldDataTypeUnspecified
|
||||
}
|
||||
if fieldDataType == telemetrytypes.FieldDataTypeInt64 {
|
||||
fieldDataType = telemetrytypes.FieldDataTypeNumber
|
||||
}
|
||||
|
||||
fieldKeys = append(fieldKeys, telemetrytypes.TelemetryFieldKey{
|
||||
Name: filter.Key,
|
||||
Signal: fieldSignal,
|
||||
FieldContext: fieldContext,
|
||||
FieldDataType: fieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
return fieldKeys, nil
|
||||
}
|
||||
|
||||
// newLegacySignalFiltersFromSignalFilters renders stored telemetry field keys
|
||||
// back into the v1 shape, restoring the legacy spellings v1 clients expect.
|
||||
func newLegacySignalFiltersFromSignalFilters(signalFilters *quickfiltertypes.SignalFilters) *legacySignalFilters {
|
||||
filters := make([]v3.AttributeKey, 0, len(signalFilters.Filters))
|
||||
for _, fieldKey := range signalFilters.Filters {
|
||||
// Only tag and resource exist in the v3 enum; other contexts render as
|
||||
// unspecified so v1 clients never see spellings their queries can't use.
|
||||
var attributeType v3.AttributeKeyType
|
||||
switch fieldKey.FieldContext {
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
attributeType = v3.AttributeKeyTypeTag
|
||||
case telemetrytypes.FieldContextResource:
|
||||
attributeType = v3.AttributeKeyTypeResource
|
||||
default:
|
||||
attributeType = v3.AttributeKeyTypeUnspecified
|
||||
}
|
||||
|
||||
var dataType v3.AttributeKeyDataType
|
||||
switch fieldKey.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeNumber:
|
||||
dataType = v3.AttributeKeyDataTypeFloat64
|
||||
default:
|
||||
dataType = v3.AttributeKeyDataType(fieldKey.FieldDataType.StringValue())
|
||||
}
|
||||
|
||||
filters = append(filters, v3.AttributeKey{
|
||||
Key: fieldKey.Name,
|
||||
Type: attributeType,
|
||||
DataType: dataType,
|
||||
})
|
||||
}
|
||||
|
||||
return &legacySignalFilters{
|
||||
Signal: signalFilters.Signal,
|
||||
Filters: filters,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
@@ -27,36 +113,18 @@ func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID))
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Signal{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, filters)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
legacyFilters := make([]*legacySignalFilters, 0, len(filters))
|
||||
for _, signalFilters := range filters {
|
||||
legacyFilters = append(legacyFilters, newLegacySignalFiltersFromSignalFilters(signalFilters))
|
||||
}
|
||||
|
||||
var req quickfiltertypes.UpdatableQuickFilters
|
||||
decodeErr := json.NewDecoder(r.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
render.Error(rw, decodeErr)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
render.Success(rw, http.StatusOK, legacyFilters)
|
||||
}
|
||||
|
||||
func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -73,7 +141,51 @@ func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetSignalFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, newLegacySignalFiltersFromSignalFilters(filters[0]))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req legacySignalFilters
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(req.Signal, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, fieldKeys)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Signal{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -81,3 +193,48 @@ func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request
|
||||
|
||||
render.Success(rw, http.StatusOK, filters)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req quickfiltertypes.UpdatableQuickFilters
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
signal := mux.Vars(r)["signal_name"]
|
||||
validatedSignal, err := quickfiltertypes.NewSignal(signal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, filters[0])
|
||||
}
|
||||
|
||||
62
pkg/modules/quickfilter/implquickfilter/handler_test.go
Normal file
62
pkg/modules/quickfilter/implquickfilter/handler_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package implquickfilter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewTelemetryFieldKeysFromLegacy(t *testing.T) {
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalTraces, []v3.AttributeKey{
|
||||
{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString},
|
||||
{Key: "http.method", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeString},
|
||||
{Key: "duration_nano", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeFloat64},
|
||||
{Key: "code_line", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeInt64},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fieldKeys, 4)
|
||||
|
||||
assert.Equal(t, telemetrytypes.TelemetryFieldKey{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}, fieldKeys[0])
|
||||
assert.Equal(t, telemetrytypes.FieldContextAttribute, fieldKeys[1].FieldContext)
|
||||
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[2].FieldDataType)
|
||||
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[3].FieldDataType)
|
||||
|
||||
t.Run("meter writes restore the per-filter signal", func(t *testing.T) {
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalMeter, []v3.AttributeKey{
|
||||
{Key: "host.name", DataType: v3.AttributeKeyDataTypeString},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fieldKeys, 1)
|
||||
assert.Equal(t, telemetrytypes.SignalMetrics, fieldKeys[0].Signal)
|
||||
})
|
||||
|
||||
t.Run("rejects a filter without a key", func(t *testing.T) {
|
||||
_, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalTraces, []v3.AttributeKey{{DataType: v3.AttributeKeyDataTypeString}})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewLegacySignalFiltersFromSignalFilters(t *testing.T) {
|
||||
legacy := newLegacySignalFiltersFromSignalFilters(&quickfiltertypes.SignalFilters{
|
||||
Signal: quickfiltertypes.SignalLogs,
|
||||
Filters: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
||||
{Name: "severity_text", FieldContext: telemetrytypes.FieldContextLog, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "host.name", Signal: telemetrytypes.SignalMetrics},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, quickfiltertypes.SignalLogs, legacy.Signal)
|
||||
require.Len(t, legacy.Filters, 5)
|
||||
assert.Equal(t, v3.AttributeKey{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString}, legacy.Filters[0])
|
||||
assert.Equal(t, v3.AttributeKeyTypeTag, legacy.Filters[1].Type)
|
||||
assert.Equal(t, v3.AttributeKeyDataTypeFloat64, legacy.Filters[2].DataType)
|
||||
assert.Equal(t, v3.AttributeKeyTypeUnspecified, legacy.Filters[3].Type, "contexts outside the v3 enum must render as unspecified")
|
||||
assert.Equal(t, v3.AttributeKey{Key: "host.name"}, legacy.Filters[4])
|
||||
}
|
||||
@@ -2,12 +2,11 @@ package implquickfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -19,91 +18,50 @@ func NewModule(store quickfiltertypes.QuickFilterStore) quickfilter.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
// GetQuickFilters returns all quick filters for an organization.
|
||||
func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error) {
|
||||
storedFilters, err := module.store.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters")
|
||||
}
|
||||
|
||||
result := make([]*quickfiltertypes.SignalFilters, 0, len(storedFilters))
|
||||
for _, storedFilter := range storedFilters {
|
||||
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
|
||||
// GetQuickFilters returns quick filters for a signal, or for every signal when signal is zero.
|
||||
func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) ([]*quickfiltertypes.SignalFilters, error) {
|
||||
if signal.IsZero() {
|
||||
storedFilters, err := module.store.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters")
|
||||
}
|
||||
result = append(result, signalFilter)
|
||||
|
||||
result := make([]*quickfiltertypes.SignalFilters, 0, len(storedFilters))
|
||||
for _, storedFilter := range storedFilters {
|
||||
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
|
||||
}
|
||||
result = append(result, signalFilter)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetSignalFilters returns quick filters for a specific signal in an organization.
|
||||
func (m *module) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) {
|
||||
storedFilter, err := m.store.GetBySignal(ctx, orgID, signal.StringValue())
|
||||
storedFilter, err := module.store.GetBySignal(ctx, orgID, signal.StringValue())
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
return []*quickfiltertypes.SignalFilters{quickfiltertypes.NewSignalFiltersFromSignal(signal)}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no filter exists for this signal, return empty filters with the requested signal
|
||||
if storedFilter == nil {
|
||||
return &quickfiltertypes.SignalFilters{
|
||||
Signal: signal,
|
||||
Filters: []v3.AttributeKey{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Convert stored filter to signal filter
|
||||
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
|
||||
}
|
||||
|
||||
return signalFilter, nil
|
||||
return []*quickfiltertypes.SignalFilters{signalFilter}, nil
|
||||
}
|
||||
|
||||
// UpdateQuickFilters updates quick filters for a specific signal in an organization.
|
||||
func (module *module) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error {
|
||||
// Validate each filter
|
||||
for _, filter := range filters {
|
||||
if err := filter.Validate(); err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal filters to JSON
|
||||
filterJSON, err := json.Marshal(filters)
|
||||
// UpsertQuickFilters replaces quick filters for a specific signal in an organization, creating them if absent.
|
||||
func (module *module) UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []telemetrytypes.TelemetryFieldKey) error {
|
||||
filter, err := quickfiltertypes.NewStorableQuickFilter(orgID, signal, filters)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters")
|
||||
}
|
||||
|
||||
// Check if filter exists
|
||||
existingFilter, err := module.store.GetBySignal(ctx, orgID, signal.StringValue())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error checking existing filters")
|
||||
}
|
||||
|
||||
var filter *quickfiltertypes.StorableQuickFilter
|
||||
if existingFilter != nil {
|
||||
// Update in place
|
||||
if err := existingFilter.Update(filterJSON); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error updating existing filter")
|
||||
}
|
||||
filter = existingFilter
|
||||
} else {
|
||||
// Create new
|
||||
filter, err = quickfiltertypes.NewStorableQuickFilter(orgID, signal, filterJSON)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error creating new filter")
|
||||
}
|
||||
}
|
||||
|
||||
// Persist filter
|
||||
if err := module.store.Upsert(ctx, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return module.store.Upsert(ctx, filter)
|
||||
}
|
||||
|
||||
func (module *module) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error {
|
||||
|
||||
@@ -62,7 +62,7 @@ func (s *store) Upsert(ctx context.Context, filter *quickfiltertypes.StorableQui
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(filter).
|
||||
On("CONFLICT (id) DO UPDATE").
|
||||
On("CONFLICT (org_id, signal) DO UPDATE").
|
||||
Set("filter = EXCLUDED.filter").
|
||||
Set("updated_at = EXCLUDED.updated_at").
|
||||
Exec(ctx)
|
||||
|
||||
@@ -4,20 +4,25 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error)
|
||||
UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error
|
||||
GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error)
|
||||
// GetQuickFilters returns quick filters for a signal, or for every signal when signal is zero.
|
||||
GetQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) ([]*quickfiltertypes.SignalFilters, error)
|
||||
UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []telemetrytypes.TelemetryFieldKey) error
|
||||
SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
// Legacy v1 endpoints, served by converting to and from the v3 attribute key shape.
|
||||
GetQuickFilters(http.ResponseWriter, *http.Request)
|
||||
UpdateQuickFilters(http.ResponseWriter, *http.Request)
|
||||
GetSignalFilters(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListQuickFiltersV2(http.ResponseWriter, *http.Request)
|
||||
GetQuickFiltersV2(http.ResponseWriter, *http.Request)
|
||||
UpdateQuickFiltersV2(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,12 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// AI trace aggregations qualify and rank traces on whole-window per-trace
|
||||
// values, which do not decompose into cacheable time buckets.
|
||||
if q.queryType == qbtypes.QueryTypeBuilderAI {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a deterministic fingerprint for builder queries
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
|
||||
@@ -117,8 +117,7 @@ func TestBuilderQueryFingerprintQueryType(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.True(t, strings.HasPrefix(regular.Fingerprint(), qbtypes.QueryTypeBuilder.StringValue()+"&"))
|
||||
assert.True(t, strings.HasPrefix(ai.Fingerprint(), qbtypes.QueryTypeBuilderAI.StringValue()+"&"))
|
||||
assert.NotEqual(t, regular.Fingerprint(), ai.Fingerprint())
|
||||
assert.Empty(t, ai.Fingerprint())
|
||||
}
|
||||
|
||||
func TestMakeBucketsOrder(t *testing.T) {
|
||||
|
||||
@@ -470,7 +470,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
continue
|
||||
}
|
||||
// Type is resolved now; validate aggregation compatibility against it.
|
||||
if err := spec.Aggregations[i].ValidateForType(); err != nil {
|
||||
if err := spec.Aggregations[i].ValidateForTypeAndTemporality(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if reducedMetricsSet[spec.Aggregations[i].MetricName] {
|
||||
|
||||
@@ -439,7 +439,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v2/traces/fields", am.EditAccess(aH.updateTraceField)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(aH.getVersion)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(aH.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.OpenAccess(aH.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/health", am.OpenAccess(aH.getHealth)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/listErrors", am.ViewAccess(aH.listErrors)).Methods(http.MethodPost)
|
||||
@@ -450,7 +450,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
router.HandleFunc("/api/v1/disks", am.ViewAccess(aH.getDisks)).Methods(http.MethodGet)
|
||||
|
||||
// Quick Filters
|
||||
// Quick Filters (v1 routes serve the legacy v3 shape; v2 lives in signozapiserver)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetQuickFilters)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSignalFilters)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.Signoz.Handlers.QuickFilter.UpdateQuickFilters)).Methods(http.MethodPut)
|
||||
@@ -1497,7 +1497,7 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
aH.HandleError(w, err, http.StatusInternalServerError)
|
||||
aH.HandleError(w, err, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -107,8 +107,8 @@ func SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telem
|
||||
fieldContext = telemetrytypes.FieldContextAttribute
|
||||
}
|
||||
fieldDataType := field.FieldDataType
|
||||
// Resource values are strings; pin the type so operand coercion applies.
|
||||
if fieldContext == telemetrytypes.FieldContextResource &&
|
||||
// Resource and scope values are strings; pin the type so operand coercion applies.
|
||||
if (fieldContext == telemetrytypes.FieldContextResource || fieldContext == telemetrytypes.FieldContextScope) &&
|
||||
fieldDataType == telemetrytypes.FieldDataTypeUnspecified {
|
||||
fieldDataType = telemetrytypes.FieldDataTypeString
|
||||
}
|
||||
|
||||
@@ -56,6 +56,17 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
|
||||
// https://github.com/SigNoz/signoz/issues/11374
|
||||
if key.FieldContext == telemetrytypes.FieldContextScope {
|
||||
keys = append(keys, &telemetrytypes.FieldKeySelector{
|
||||
Name: key.FieldContext.StringValue() + "." + key.Name,
|
||||
Signal: key.Signal,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,44 @@ func TestQueryToKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `scope.version = '1.0.0'`,
|
||||
expectedKeys: []telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "version",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
{
|
||||
Name: "scope.version",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A scope attribute whose own name carries a `scope.` prefix. `scope.prefixed`
|
||||
// normalizes to {prefixed, scope}; the second selector re-adds the prefix so the
|
||||
// metadata fetch can target the attribute's exact key `scope.prefixed` rather than
|
||||
// relying on the broad `%prefixed%` match.
|
||||
query: `scope.prefixed = 'x'`,
|
||||
expectedKeys: []telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "prefixed",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
{
|
||||
Name: "scope.prefixed",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
@@ -93,6 +94,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
struct{ quickfilter.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -244,6 +244,9 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewDeleteOrphanUserRolesFactory(),
|
||||
sqlmigration.NewMigrateLambdaDashboardsFactory(),
|
||||
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddDeploymentHostTuplesFactory(sqlstore),
|
||||
sqlmigration.NewMigrateQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddQuickFilterTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -347,6 +350,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
handlers.QuickFilter,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
161
pkg/sqlmigration/118_add_deployment_host_tuples.go
Normal file
161
pkg/sqlmigration/118_add_deployment_host_tuples.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addDeploymentHostTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddDeploymentHostTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_deployment_host_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDeploymentHostTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDeploymentHostTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDeploymentHostTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// zeus hosts moved from the legacy ViewAccess/AdminAccess role gates to
|
||||
// CheckResources, which on enterprise requires real tuples -- existing orgs
|
||||
// never had these written, only new orgs get them from the registry at bootstrap.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "deployment-host", "list"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "deployment-host", "update"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "deployment-host", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "deployment-host", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDeploymentHostTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
170
pkg/sqlmigration/120_migrate_quick_filters.go
Normal file
170
pkg/sqlmigration/120_migrate_quick_filters.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
)
|
||||
|
||||
type storableQuickFilterRow struct {
|
||||
bun.BaseModel `bun:"table:quick_filter"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
Filter string `bun:"filter"`
|
||||
}
|
||||
|
||||
// legacyQuickFilterEntry carries both shapes a stored entry can be in: the
|
||||
// legacy key/type/dataType shape and the current name-carrying shape.
|
||||
type legacyQuickFilterEntry struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
DataType string `json:"dataType"`
|
||||
Signal string `json:"signal"`
|
||||
}
|
||||
|
||||
// quickFilterLegacyTypeToFieldContext maps the v3 attribute key types the v1
|
||||
// write path could store. Materialized top-level fields carried no type, and
|
||||
// anything unknown (e.g. "Sum" in the old meter defaults) normalizes to
|
||||
// unspecified, matching what the v1 write path does at runtime.
|
||||
var quickFilterLegacyTypeToFieldContext = map[string]string{
|
||||
"tag": "attribute",
|
||||
"resource": "resource",
|
||||
"scope": "scope",
|
||||
}
|
||||
|
||||
// quickFilterLegacyDataTypeToFieldDataType maps the v3 attribute key data
|
||||
// types the v1 write path could store, with numerics collapsed to number,
|
||||
// matching the fields API and the v1 write path.
|
||||
var quickFilterLegacyDataTypeToFieldDataType = map[string]string{
|
||||
"string": "string",
|
||||
"bool": "bool",
|
||||
"int64": "number",
|
||||
"float64": "number",
|
||||
}
|
||||
|
||||
// quickFilterFieldDataType resolves legacy datatype spellings, with unknowns
|
||||
// normalized to unspecified.
|
||||
func quickFilterFieldDataType(legacyDataType string) string {
|
||||
return quickFilterLegacyDataTypeToFieldDataType[strings.ToLower(strings.TrimSpace(legacyDataType))]
|
||||
}
|
||||
|
||||
// quickFilterFieldContext resolves legacy type spellings, with unknowns
|
||||
// normalized to unspecified.
|
||||
func quickFilterFieldContext(legacyType string) string {
|
||||
return quickFilterLegacyTypeToFieldContext[strings.ToLower(strings.TrimSpace(legacyType))]
|
||||
}
|
||||
|
||||
type migrateQuickFilters struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
settings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewMigrateQuickFiltersFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("migrate_quick_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &migrateQuickFilters{sqlstore: sqlstore, settings: ps}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *migrateQuickFilters) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *migrateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var rows []*storableQuickFilterRow
|
||||
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var migrated, skipped int
|
||||
for _, row := range rows {
|
||||
migratedFilter, changed, ok := migrateQuickFilterEntries(row.Filter)
|
||||
if !ok {
|
||||
migration.settings.Logger.WarnContext(ctx, "quick filter could not be parsed, leaving it untouched", slog.String("quick_filter_id", row.ID), slog.String("raw_filter", row.Filter))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
migrated++
|
||||
if _, err := tx.NewUpdate().Model((*storableQuickFilterRow)(nil)).Set("filter = ?", migratedFilter).Where("id = ?", row.ID).Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
migration.settings.Logger.InfoContext(ctx, "migrated quick filters to telemetry field keys", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *migrateQuickFilters) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateQuickFilterEntries rewrites a stored filter list from the legacy
|
||||
// key/dataType/type shape to telemetry field keys; ok=false means unparseable.
|
||||
func migrateQuickFilterEntries(filter string) (migrated string, changed bool, ok bool) {
|
||||
var entriesRaw []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(filter), &entriesRaw); err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
migratedEntries := make([]json.RawMessage, 0, len(entriesRaw))
|
||||
for _, rawEntry := range entriesRaw {
|
||||
var entry legacyQuickFilterEntry
|
||||
if err := json.Unmarshal(rawEntry, &entry); err != nil {
|
||||
// Some stored entries are plain strings rather than objects; treat
|
||||
// the string as the filter key name, dropping empty ones.
|
||||
var name string
|
||||
if err := json.Unmarshal(rawEntry, &name); err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
entry = legacyQuickFilterEntry{Key: name}
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.Name != "":
|
||||
migratedEntries = append(migratedEntries, rawEntry)
|
||||
case entry.Key != "":
|
||||
migratedJSON, err := marshalUnescaped(telemetryFieldKeyOutput{
|
||||
Name: entry.Key,
|
||||
Signal: entry.Signal,
|
||||
FieldContext: quickFilterFieldContext(entry.Type),
|
||||
FieldDataType: quickFilterFieldDataType(entry.DataType),
|
||||
})
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
migratedEntries = append(migratedEntries, migratedJSON)
|
||||
changed = true
|
||||
default:
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return "", false, true
|
||||
}
|
||||
|
||||
migratedJSON, err := marshalUnescaped(migratedEntries)
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
return string(migratedJSON), true, true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user