mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-01 18:50:35 +01:00
Compare commits
38 Commits
ns/saved-v
...
issue_5601
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13003764f0 | ||
|
|
6fb7838637 | ||
|
|
9197cb8b3b | ||
|
|
a0a17a99a6 | ||
|
|
8fb5703eb1 | ||
|
|
ee06d549e6 | ||
|
|
d81ae64515 | ||
|
|
13b25a6be1 | ||
|
|
d6d09549f0 | ||
|
|
d71c0c938a | ||
|
|
e911223b1d | ||
|
|
cb3cc3de56 | ||
|
|
017e62814a | ||
|
|
f10f526249 | ||
|
|
a5350682c3 | ||
|
|
a7b74005ea | ||
|
|
787e1be974 | ||
|
|
19ee51be66 | ||
|
|
b2c5af428e | ||
|
|
093f9b41c4 | ||
|
|
596e128005 | ||
|
|
2e1da92367 | ||
|
|
1e8d72e8fa | ||
|
|
cce080e1ae | ||
|
|
deca060a4d | ||
|
|
03c7e524e7 | ||
|
|
815dc7d88b | ||
|
|
f50d9199fe | ||
|
|
31efe177a4 | ||
|
|
d502d12ac3 | ||
|
|
bd9f15a716 | ||
|
|
813ef988c9 | ||
|
|
40e6799285 | ||
|
|
1caa60a3cd | ||
|
|
3f781f0083 | ||
|
|
6aec05cf7a | ||
|
|
683a52f35a | ||
|
|
e924fa1e62 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -53,6 +53,7 @@ jobs:
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- querierauthz
|
||||
|
||||
@@ -6902,6 +6902,7 @@ components:
|
||||
Querybuildertypesv5QueryEnvelope:
|
||||
discriminator:
|
||||
mapping:
|
||||
builder_ai_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
|
||||
builder_formula: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
|
||||
builder_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
|
||||
builder_trace_operator: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
|
||||
@@ -6910,6 +6911,7 @@ components:
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
|
||||
@@ -6924,6 +6926,15 @@ components:
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAI:
|
||||
properties:
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
|
||||
properties:
|
||||
spec:
|
||||
@@ -7037,6 +7048,7 @@ components:
|
||||
Querybuildertypesv5QueryType:
|
||||
enum:
|
||||
- builder_query
|
||||
- builder_ai_query
|
||||
- builder_formula
|
||||
- builder_trace_operator
|
||||
- clickhouse_sql
|
||||
@@ -7745,101 +7757,6 @@ components:
|
||||
enum:
|
||||
- basic
|
||||
type: string
|
||||
SavedviewtypesDisplay:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
fontSize:
|
||||
type: string
|
||||
format:
|
||||
type: string
|
||||
maxLines:
|
||||
type: integer
|
||||
type: object
|
||||
SavedviewtypesGettableSavedView:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
sourcePage:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- createdAt
|
||||
- createdBy
|
||||
- updatedAt
|
||||
- updatedBy
|
||||
- sourcePage
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
SavedviewtypesPanelType:
|
||||
enum:
|
||||
- value
|
||||
- graph
|
||||
- table
|
||||
- list
|
||||
- trace
|
||||
type: string
|
||||
SavedviewtypesPostableSavedView:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
sourcePage:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
required:
|
||||
- name
|
||||
- sourcePage
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
SavedviewtypesSavedViewSpec:
|
||||
properties:
|
||||
display:
|
||||
$ref: '#/components/schemas/SavedviewtypesDisplay'
|
||||
panelType:
|
||||
$ref: '#/components/schemas/SavedviewtypesPanelType'
|
||||
queries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
|
||||
type: array
|
||||
selectedFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: array
|
||||
required:
|
||||
- panelType
|
||||
- queries
|
||||
- selectedFields
|
||||
- display
|
||||
type: object
|
||||
SavedviewtypesSourcePage:
|
||||
enum:
|
||||
- traces
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
type: string
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
@@ -22672,299 +22589,6 @@ paths:
|
||||
summary: Test alert rule
|
||||
tags:
|
||||
- rules
|
||||
/api/v2/saved_views:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns saved views, optionally filtered by source page and name.
|
||||
operationId: ListSavedViews
|
||||
parameters:
|
||||
- in: query
|
||||
name: sourcePage
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/SavedviewtypesGettableSavedView'
|
||||
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:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List saved views
|
||||
tags:
|
||||
- saved_view
|
||||
post:
|
||||
deprecated: false
|
||||
description: Persists a saved view for the explore page. Returns the id of the
|
||||
created view.
|
||||
operationId: CreateSavedView
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesPostableSavedView'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
type: string
|
||||
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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Create saved view
|
||||
tags:
|
||||
- saved_view
|
||||
/api/v2/saved_views/{viewId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Deletes a saved view by id.
|
||||
operationId: DeleteSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Delete saved view
|
||||
tags:
|
||||
- saved_view
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a saved view by id.
|
||||
operationId: GetSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SavedviewtypesGettableSavedView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get saved view
|
||||
tags:
|
||||
- saved_view
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces a saved view's name and query.
|
||||
operationId: UpdateSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesPostableSavedView'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update saved view
|
||||
tags:
|
||||
- saved_view
|
||||
/api/v2/sessions:
|
||||
delete:
|
||||
deprecated: false
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleStatsPayload } from 'types/api/alerts/def';
|
||||
import { RuleStatsProps } from 'types/api/alerts/ruleStats';
|
||||
|
||||
const ruleStats = async (
|
||||
props: RuleStatsProps,
|
||||
): Promise<SuccessResponse<AlertRuleStatsPayload> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.post(`/rules/${props.id}/history/stats`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default ruleStats;
|
||||
@@ -1,33 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def';
|
||||
import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph';
|
||||
|
||||
const timelineGraph = async (
|
||||
props: GetTimelineGraphRequestProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTimelineGraphResponsePayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/rules/${props.id}/history/overall_status`,
|
||||
{
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default timelineGraph;
|
||||
@@ -1,36 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def';
|
||||
import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable';
|
||||
|
||||
const timelineTable = async (
|
||||
props: GetTimelineTableRequestProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTimelineTableResponsePayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(`/rules/${props.id}/history/timeline`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
offset: props.offset,
|
||||
limit: props.limit,
|
||||
order: props.order,
|
||||
state: props.state,
|
||||
// TODO(shaheer): implement filters
|
||||
filters: props.filters,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default timelineTable;
|
||||
@@ -1,33 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def';
|
||||
import { TopContributorsProps } from 'types/api/alerts/topContributors';
|
||||
|
||||
const topContributors = async (
|
||||
props: TopContributorsProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTopContributorsPayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/rules/${props.id}/history/top_contributors`,
|
||||
{
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default topContributors;
|
||||
@@ -1,491 +0,0 @@
|
||||
/**
|
||||
* ! 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 {
|
||||
CreateSavedView200,
|
||||
DeleteSavedViewPathParameters,
|
||||
GetSavedView200,
|
||||
GetSavedViewPathParameters,
|
||||
ListSavedViews200,
|
||||
ListSavedViewsParams,
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesPostableSavedViewDTO,
|
||||
UpdateSavedViewPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns saved views, optionally filtered by source page and name.
|
||||
* @summary List saved views
|
||||
*/
|
||||
export const listSavedViews = (
|
||||
params?: ListSavedViewsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListSavedViews200>({
|
||||
url: `/api/v2/saved_views`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListSavedViewsQueryKey = (params?: ListSavedViewsParams) => {
|
||||
return [`/api/v2/saved_views`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListSavedViewsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSavedViewsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListSavedViewsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSavedViews>>> = ({
|
||||
signal,
|
||||
}) => listSavedViews(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListSavedViewsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listSavedViews>>
|
||||
>;
|
||||
export type ListSavedViewsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List saved views
|
||||
*/
|
||||
|
||||
export function useListSavedViews<
|
||||
TData = Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSavedViewsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListSavedViewsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List saved views
|
||||
*/
|
||||
export const invalidateListSavedViews = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListSavedViewsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListSavedViewsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a saved view for the explore page. Returns the id of the created view.
|
||||
* @summary Create saved view
|
||||
*/
|
||||
export const createSavedView = (
|
||||
savedviewtypesPostableSavedViewDTO?: BodyType<SavedviewtypesPostableSavedViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateSavedView200>({
|
||||
url: `/api/v2/saved_views`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: savedviewtypesPostableSavedViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createSavedView'];
|
||||
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 createSavedView>>,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createSavedView(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createSavedView>>
|
||||
>;
|
||||
export type CreateSavedViewMutationBody =
|
||||
| BodyType<SavedviewtypesPostableSavedViewDTO>
|
||||
| undefined;
|
||||
export type CreateSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create saved view
|
||||
*/
|
||||
export const useCreateSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateSavedViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Deletes a saved view by id.
|
||||
* @summary Delete saved view
|
||||
*/
|
||||
export const deleteSavedView = (
|
||||
{ viewId }: DeleteSavedViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteSavedView'];
|
||||
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 deleteSavedView>>,
|
||||
{ pathParams: DeleteSavedViewPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteSavedView(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>
|
||||
>;
|
||||
|
||||
export type DeleteSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete saved view
|
||||
*/
|
||||
export const useDeleteSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteSavedViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a saved view by id.
|
||||
* @summary Get saved view
|
||||
*/
|
||||
export const getSavedView = (
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSavedView200>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSavedViewQueryKey = ({
|
||||
viewId,
|
||||
}: GetSavedViewPathParameters) => {
|
||||
return [`/api/v2/saved_views/${viewId}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSavedViewQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSavedViewQueryKey({ viewId });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSavedView>>> = ({
|
||||
signal,
|
||||
}) => getSavedView({ viewId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!viewId,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSavedViewQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSavedView>>
|
||||
>;
|
||||
export type GetSavedViewQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get saved view
|
||||
*/
|
||||
|
||||
export function useGetSavedView<
|
||||
TData = Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSavedViewQueryOptions({ viewId }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get saved view
|
||||
*/
|
||||
export const invalidateGetSavedView = async (
|
||||
queryClient: QueryClient,
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSavedViewQueryKey({ viewId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces a saved view's name and query.
|
||||
* @summary Update saved view
|
||||
*/
|
||||
export const updateSavedView = (
|
||||
{ viewId }: UpdateSavedViewPathParameters,
|
||||
savedviewtypesPostableSavedViewDTO?: BodyType<SavedviewtypesPostableSavedViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: savedviewtypesPostableSavedViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateSavedView'];
|
||||
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 updateSavedView>>,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateSavedView(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateSavedView>>
|
||||
>;
|
||||
export type UpdateSavedViewMutationBody =
|
||||
| BodyType<SavedviewtypesPostableSavedViewDTO>
|
||||
| undefined;
|
||||
export type UpdateSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update saved view
|
||||
*/
|
||||
export const useUpdateSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateSavedViewMutationOptions(options));
|
||||
};
|
||||
@@ -4301,6 +4301,18 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopeBuilderAIDTOType {
|
||||
builder_ai_query = 'builder_ai_query',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum builder_ai_query
|
||||
*/
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4484,6 +4496,7 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
|
||||
|
||||
export type Querybuildertypesv5QueryEnvelopeDTO =
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderDTO
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
|
||||
| Querybuildertypesv5QueryEnvelopeFormulaDTO
|
||||
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
|
||||
| Querybuildertypesv5QueryEnvelopePromQLDTO
|
||||
@@ -8287,6 +8300,7 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
|
||||
export enum Querybuildertypesv5QueryTypeDTO {
|
||||
builder_query = 'builder_query',
|
||||
builder_ai_query = 'builder_ai_query',
|
||||
builder_formula = 'builder_formula',
|
||||
builder_trace_operator = 'builder_trace_operator',
|
||||
clickhouse_sql = 'clickhouse_sql',
|
||||
@@ -8842,99 +8856,6 @@ export interface RuletypesRuleDTO {
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
basic = 'basic',
|
||||
}
|
||||
export interface SavedviewtypesDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fontSize?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
format?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
export enum SavedviewtypesSourcePageDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
}
|
||||
export enum SavedviewtypesPanelTypeDTO {
|
||||
value = 'value',
|
||||
graph = 'graph',
|
||||
table = 'table',
|
||||
list = 'list',
|
||||
trace = 'trace',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display: SavedviewtypesDisplayDTO;
|
||||
panelType: SavedviewtypesPanelTypeDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
queries: Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectedFields: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
export interface SavedviewtypesGettableSavedViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
sourcePage: SavedviewtypesSourcePageDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface SavedviewtypesPostableSavedViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
sourcePage: SavedviewtypesSourcePageDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -12122,57 +12043,6 @@ export type TestRule200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListSavedViewsParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sourcePage?: SavedviewtypesSourcePageDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type ListSavedViews200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: SavedviewtypesGettableSavedViewDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateSavedView200 = {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
data: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSavedView200 = {
|
||||
data: SavedviewtypesGettableSavedViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSessionContext200 = {
|
||||
data: AuthtypesSessionContextDTO;
|
||||
/**
|
||||
|
||||
@@ -102,6 +102,15 @@ interface QuerySearchProps {
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
|
||||
initialExpression?: string;
|
||||
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
|
||||
valueSuggestionsOverride?: (
|
||||
key: string,
|
||||
searchText: string,
|
||||
) => Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
@@ -115,6 +124,7 @@ function QuerySearch({
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
metricNamespace,
|
||||
valueSuggestionsOverride,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
@@ -484,13 +494,24 @@ function QuerySearch({
|
||||
const sanitizedSearchText = searchText ? searchText?.trim() : '';
|
||||
|
||||
try {
|
||||
const response = await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
});
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
const responseData = response.data as any;
|
||||
const data = responseData.data || {};
|
||||
const values = data.values || {};
|
||||
return {
|
||||
stringValues: values.stringValues || [],
|
||||
numberValues: values.numberValues || [],
|
||||
complete: data.complete ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
// Skip updates if component unmounted or key changed
|
||||
if (
|
||||
@@ -502,8 +523,6 @@ function QuerySearch({
|
||||
}
|
||||
|
||||
// Process the response data
|
||||
const responseData = response.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
@@ -584,6 +603,7 @@ function QuerySearch({
|
||||
debouncedMetricName,
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
valueSuggestionsOverride,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -20,10 +20,6 @@ export const REACT_QUERY_KEY = {
|
||||
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
|
||||
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
|
||||
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
|
||||
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
|
||||
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
|
||||
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
|
||||
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
|
||||
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
|
||||
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
|
||||
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { StatsTimeSeriesItem } from 'types/api/alerts/def';
|
||||
|
||||
import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard';
|
||||
import StatsCard from '../StatsCard/StatsCard';
|
||||
@@ -25,24 +26,59 @@ type StatsCardsRendererProps = {
|
||||
};
|
||||
|
||||
// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title
|
||||
type AdaptedStatsData = {
|
||||
totalCurrentTriggers: number;
|
||||
totalPastTriggers: number;
|
||||
currentAvgResolutionTime: string;
|
||||
pastAvgResolutionTime: string;
|
||||
currentTriggersSeries: StatsTimeSeriesItem[];
|
||||
currentAvgResolutionTimeSeries: StatsTimeSeriesItem[];
|
||||
};
|
||||
|
||||
function StatsCardsRenderer({
|
||||
setTotalCurrentTriggers,
|
||||
}: StatsCardsRendererProps): JSX.Element {
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsStats();
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload?.data?.totalCurrentTriggers !== undefined) {
|
||||
setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers);
|
||||
const adaptedData = useMemo((): AdaptedStatsData | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
}, [data, setTotalCurrentTriggers]);
|
||||
const statsData = data.data;
|
||||
|
||||
const adaptTimeSeries = (
|
||||
series: typeof statsData.currentTriggersSeries,
|
||||
): StatsTimeSeriesItem[] =>
|
||||
series?.values?.map((item) => ({
|
||||
timestamp: item.timestamp ?? 0,
|
||||
value: String(item.value ?? 0),
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
totalCurrentTriggers: statsData.totalCurrentTriggers,
|
||||
totalPastTriggers: statsData.totalPastTriggers,
|
||||
currentAvgResolutionTime: String(statsData.currentAvgResolutionTime),
|
||||
pastAvgResolutionTime: String(statsData.pastAvgResolutionTime),
|
||||
currentTriggersSeries: adaptTimeSeries(statsData.currentTriggersSeries),
|
||||
currentAvgResolutionTimeSeries: adaptTimeSeries(
|
||||
statsData.currentAvgResolutionTimeSeries,
|
||||
),
|
||||
};
|
||||
}, [data?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (adaptedData?.totalCurrentTriggers !== undefined) {
|
||||
setTotalCurrentTriggers(adaptedData.totalCurrentTriggers);
|
||||
}
|
||||
}, [adaptedData, setTotalCurrentTriggers]);
|
||||
|
||||
return (
|
||||
<DataStateRenderer
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
data={data?.payload?.data || null}
|
||||
data={adaptedData}
|
||||
>
|
||||
{(data): JSX.Element => {
|
||||
const {
|
||||
@@ -60,7 +96,7 @@ function StatsCardsRenderer({
|
||||
<TotalTriggeredCard
|
||||
totalCurrentTriggers={totalCurrentTriggers}
|
||||
totalPastTriggers={totalPastTriggers}
|
||||
timeSeries={currentTriggersSeries?.values}
|
||||
timeSeries={currentTriggersSeries}
|
||||
/>
|
||||
) : (
|
||||
<StatsCard
|
||||
@@ -77,7 +113,7 @@ function StatsCardsRenderer({
|
||||
<AverageResolutionCard
|
||||
currentAvgResolutionTime={currentAvgResolutionTime}
|
||||
pastAvgResolutionTime={pastAvgResolutionTime}
|
||||
timeSeries={currentAvgResolutionTimeSeries?.values}
|
||||
timeSeries={currentAvgResolutionTimeSeries}
|
||||
/>
|
||||
) : (
|
||||
<StatsCard
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks';
|
||||
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleStats } from 'types/api/alerts/def';
|
||||
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
|
||||
|
||||
import TopContributorsCard from '../TopContributorsCard/TopContributorsCard';
|
||||
|
||||
@@ -13,15 +15,27 @@ function TopContributorsRenderer({
|
||||
}: TopContributorsRendererProps): JSX.Element {
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTopContributors();
|
||||
const response = data?.payload?.data;
|
||||
|
||||
// TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all
|
||||
const adaptedData = useMemo((): AlertRuleTopContributors[] | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
return data.data.map((contributor) => ({
|
||||
fingerprint: contributor.fingerprint,
|
||||
count: contributor.count,
|
||||
labels: labelsArrayToObject(contributor.labels),
|
||||
relatedLogsLink: contributor.relatedLogsLink ?? '',
|
||||
relatedTracesLink: contributor.relatedTracesLink ?? '',
|
||||
}));
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<DataStateRenderer
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
data={response || null}
|
||||
data={adaptedData}
|
||||
>
|
||||
{(topContributorsData): JSX.Element => (
|
||||
<TopContributorsCard
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const ALERT_STATUS: { [key: string]: number } = {
|
||||
firing: 0,
|
||||
inactive: 1,
|
||||
export const ALERT_STATUS: Record<RuletypesAlertStateDTO, number> & {
|
||||
[key: string]: number;
|
||||
} = {
|
||||
[RuletypesAlertStateDTO.firing]: 0,
|
||||
[RuletypesAlertStateDTO.inactive]: 1,
|
||||
normal: 1,
|
||||
'no-data': 2,
|
||||
disabled: 3,
|
||||
muted: 4,
|
||||
[RuletypesAlertStateDTO.pending]: 2,
|
||||
[RuletypesAlertStateDTO.recovering]: 2,
|
||||
'no-data': 3,
|
||||
[RuletypesAlertStateDTO.nodata]: 3,
|
||||
[RuletypesAlertStateDTO.disabled]: 4,
|
||||
muted: 5,
|
||||
};
|
||||
|
||||
export const STATE_VS_COLOR: {
|
||||
@@ -16,9 +22,10 @@ export const STATE_VS_COLOR: {
|
||||
{
|
||||
0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 },
|
||||
1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 },
|
||||
2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
|
||||
3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
|
||||
4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
|
||||
2: { stroke: Color.BG_AMBER_500, fill: Color.BG_AMBER_500 },
|
||||
3: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
|
||||
4: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
|
||||
5: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
|
||||
|
||||
import Graph from '../Graph/Graph';
|
||||
|
||||
@@ -18,26 +20,16 @@ function GraphWrapper({
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineGraphData();
|
||||
|
||||
// TODO(shaheer): uncomment when the API is ready for
|
||||
// const { startTime } = useAlertHistoryQueryParams();
|
||||
|
||||
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// const checkVerticalGraph = (): void => {
|
||||
// if (startTime) {
|
||||
// const startTimeDate = dayjs(Number(startTime));
|
||||
// const twentyFourHoursAgo = dayjs().subtract(
|
||||
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
|
||||
// DAYJS_MANIPULATE_TYPES.HOUR,
|
||||
// );
|
||||
|
||||
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
|
||||
// }
|
||||
// };
|
||||
|
||||
// checkVerticalGraph();
|
||||
// }, [startTime]);
|
||||
const adaptedData = useMemo((): AlertRuleTimelineGraphResponse[] | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
return data.data.map((item) => ({
|
||||
start: item.start,
|
||||
end: item.end,
|
||||
state: item.state as AlertRuleTimelineGraphResponse['state'],
|
||||
}));
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph">
|
||||
@@ -49,7 +41,7 @@ function GraphWrapper({
|
||||
isLoading={isLoading}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
isRefetching={isRefetching}
|
||||
data={data?.payload?.data || null}
|
||||
data={adaptedData}
|
||||
>
|
||||
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
|
||||
</DataStateRenderer>
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
.timeline-table {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
min-height: 600px;
|
||||
|
||||
&__filter {
|
||||
padding: 12px 16px;
|
||||
background: var(--l1-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__filter-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__filter--loading,
|
||||
&__filter--loading-skeleton {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
background: var(--l1-background);
|
||||
&-placeholder {
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
&-cell {
|
||||
padding: 12px 16px !important;
|
||||
vertical-align: baseline;
|
||||
@@ -23,6 +47,9 @@
|
||||
&-tbody > tr > td {
|
||||
border: none;
|
||||
}
|
||||
&-footer {
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
}
|
||||
|
||||
.label-filter {
|
||||
@@ -86,4 +113,38 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
background: var(--l1-background);
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,126 @@
|
||||
import { HTMLAttributes, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Table } from 'antd';
|
||||
import { HTMLAttributes, useCallback, useMemo } from 'react';
|
||||
import { Button, Skeleton, Table } from 'antd';
|
||||
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { initialFilters } from 'constants/queryBuilder';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
useExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
} from 'components/QueryBuilderV2';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import {
|
||||
useGetAlertRuleDetailsTimelineTable,
|
||||
useTimelineTable,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useAlertHistoryFilterSuggestions } from './useAlertHistoryFilterSuggestions';
|
||||
import { timelineTableColumns } from './useTimelineTable';
|
||||
|
||||
import './Table.styles.scss';
|
||||
|
||||
function TimelineTable(): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(initialFilters);
|
||||
export const ALERT_HISTORY_EXPRESSION_KEY = 'alertHistoryExpression';
|
||||
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineTable({ filters });
|
||||
function TimelineTableContent(): JSX.Element {
|
||||
const expression = useExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const querySearchOnChange = useQuerySearchOnChange();
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
data,
|
||||
error,
|
||||
ruleId,
|
||||
refetch,
|
||||
cancel,
|
||||
} = useGetAlertRuleDetailsTimelineTable({ filterExpression: expression });
|
||||
|
||||
const apiError = useMemo(() => convertToApiError(error), [error]);
|
||||
|
||||
const { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys } =
|
||||
useAlertHistoryFilterSuggestions(ruleId ?? null);
|
||||
|
||||
const { timelineData, totalItems, nextCursor } = useMemo(() => {
|
||||
const response = data?.data;
|
||||
const items: AlertRuleTimelineTableResponse[] | undefined =
|
||||
response?.items?.map((item) => {
|
||||
return {
|
||||
ruleID: item.ruleId,
|
||||
ruleName: item.ruleName,
|
||||
overallState: item.overallState as string,
|
||||
overallStateChanged: item.overallStateChanged,
|
||||
state: item.state as string,
|
||||
stateChanged: item.stateChanged,
|
||||
unixMilli: item.unixMilli,
|
||||
fingerprint: item.fingerprint,
|
||||
value: item.value,
|
||||
labels: labelsArrayToObject(item.labels),
|
||||
relatedLogsLink: item.relatedLogsLink,
|
||||
relatedTracesLink: item.relatedTracesLink,
|
||||
};
|
||||
});
|
||||
|
||||
const { timelineData, totalItems, labels } = useMemo(() => {
|
||||
const response = data?.payload?.data;
|
||||
return {
|
||||
timelineData: response?.items,
|
||||
totalItems: response?.total,
|
||||
labels: response?.labels,
|
||||
timelineData: items,
|
||||
totalItems: response?.total ?? 0,
|
||||
nextCursor: response?.nextCursor,
|
||||
};
|
||||
}, [data?.payload?.data]);
|
||||
}, [data?.data]);
|
||||
|
||||
const { paginationConfig, onChangeHandler } = useTimelineTable({
|
||||
const {
|
||||
paginationConfig,
|
||||
onChangeHandler,
|
||||
handleNextPage,
|
||||
handlePrevPage,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
} = useTimelineTable({
|
||||
totalItems: totalItems ?? 0,
|
||||
nextCursor,
|
||||
});
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (isError || !isValidRuleId || !ruleId) {
|
||||
return <div>{t('something_went_wrong')}</div>;
|
||||
}
|
||||
const handleRunQuery = useCallback(
|
||||
(updatedExpression?: string): void => {
|
||||
const nextExpression = updatedExpression ?? inputExpression;
|
||||
querySearchOnRun(nextExpression);
|
||||
|
||||
if (nextExpression === expression) {
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[querySearchOnRun, refetch, inputExpression, expression],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
() => ({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.LOGS,
|
||||
filter: { expression },
|
||||
expression,
|
||||
}),
|
||||
[expression],
|
||||
);
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Timeline table row: Clicked', {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
labels: record.labels,
|
||||
});
|
||||
@@ -55,23 +129,106 @@ function TimelineTable(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div className="timeline-table__filter-search">
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={handleRunQuery}
|
||||
hardcodedAttributeKeys={hardcodedAttributeKeys}
|
||||
valueSuggestionsOverride={valueSuggestionsOverride}
|
||||
/>
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isLoading || isRefetching}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
handleCancelQuery={cancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="timeline-table__filter timeline-table__filter--loading">
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
rowKey={(row): string => `${row.fingerprint}-${row.value}-${row.unixMilli}`}
|
||||
columns={timelineTableColumns({
|
||||
filters,
|
||||
labels: labels ?? {},
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
})}
|
||||
onRow={handleRowClick}
|
||||
dataSource={timelineData}
|
||||
pagination={paginationConfig}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
onChange={onChangeHandler}
|
||||
loading={isLoading || isRefetching}
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div className="timeline-table__pagination-info">
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
: ((paginationConfig.current ?? 1) - 1) *
|
||||
(paginationConfig.pageSize ?? 10) +
|
||||
1,
|
||||
Math.min(
|
||||
(paginationConfig.current ?? 1) * (paginationConfig.pageSize ?? 10),
|
||||
totalItems,
|
||||
),
|
||||
])}
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!hasPrevPage}
|
||||
onClick={handlePrevPage}
|
||||
data-testid="timeline-prev-page"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!hasNextPage}
|
||||
onClick={handleNextPage}
|
||||
data-testid="timeline-next-page"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineTable(): JSX.Element {
|
||||
return (
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ALERT_HISTORY_EXPRESSION_KEY}
|
||||
initialExpression=""
|
||||
persistOnUnmount
|
||||
>
|
||||
<TimelineTableContent />
|
||||
</QuerySearchV2Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineTable;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
getRuleHistoryFilterValues,
|
||||
useGetRuleHistoryFilterKeys,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { fieldContextToSuggestionContext } from 'container/AlertHistory/Timeline/Table/utils';
|
||||
|
||||
export interface AlertHistoryFilterSuggestions {
|
||||
hardcodedAttributeKeys: QueryKeyDataSuggestionsProps[];
|
||||
valueSuggestionsOverride: (
|
||||
key: string,
|
||||
searchText: string,
|
||||
) => Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
isLoadingKeys: boolean;
|
||||
}
|
||||
|
||||
export function useAlertHistoryFilterSuggestions(
|
||||
ruleId: string | null,
|
||||
): AlertHistoryFilterSuggestions {
|
||||
const { startTime, endTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { data: filterKeysData, isLoading: isLoadingKeys } =
|
||||
useGetRuleHistoryFilterKeys(
|
||||
{ id: ruleId ?? '' },
|
||||
{ startUnixMilli: startTime, endUnixMilli: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: !!ruleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const hardcodedAttributeKeys = useMemo((): QueryKeyDataSuggestionsProps[] => {
|
||||
const keys = filterKeysData?.data?.keys;
|
||||
if (!keys) {
|
||||
// by default, when QuerySearch keys fails, we don't render fallback keys
|
||||
// we just return empty to let user write whatever they want with no
|
||||
// key suggestion
|
||||
return [];
|
||||
}
|
||||
return Object.values(keys).flatMap((items) =>
|
||||
items.map(
|
||||
(item) =>
|
||||
({
|
||||
label: item.name,
|
||||
name: item.name,
|
||||
type: item.fieldDataType || 'string',
|
||||
signal: 'logs' as const,
|
||||
fieldDataType: item.fieldDataType,
|
||||
fieldContext: fieldContextToSuggestionContext(item.fieldContext),
|
||||
}) satisfies QueryKeyDataSuggestionsProps,
|
||||
),
|
||||
);
|
||||
}, [filterKeysData]);
|
||||
|
||||
const valueSuggestionsOverride = useCallback(
|
||||
async (
|
||||
key: string,
|
||||
searchText: string,
|
||||
): Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}> => {
|
||||
if (!ruleId) {
|
||||
return {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
const response = await getRuleHistoryFilterValues(
|
||||
{ id: ruleId },
|
||||
{
|
||||
name: key,
|
||||
searchText,
|
||||
startUnixMilli: startTime,
|
||||
endUnixMilli: endTime,
|
||||
},
|
||||
);
|
||||
const values = response.data?.values;
|
||||
return {
|
||||
stringValues: values?.stringValues ?? [],
|
||||
numberValues: values?.numberValues ?? [],
|
||||
complete: response.data?.complete ?? false,
|
||||
};
|
||||
},
|
||||
[ruleId, startTime, endTime],
|
||||
);
|
||||
|
||||
return { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys };
|
||||
}
|
||||
@@ -1,83 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis, Search } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, TableColumnsType as ColumnsType } from 'antd';
|
||||
import ClientSideQBSearch, {
|
||||
AttributeKey,
|
||||
} from 'components/ClientSideQBSearch/ClientSideQBSearch';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button, TableColumnsType as ColumnsType, Tooltip } from 'antd';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
|
||||
import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import AlertLabels, {
|
||||
AlertLabelsProps,
|
||||
} from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const transformLabelsToQbKeys = (
|
||||
labels: AlertRuleTimelineTableResponse['labels'],
|
||||
): AttributeKey[] => Object.keys(labels).flatMap((key) => [{ key }]);
|
||||
|
||||
function LabelFilter({
|
||||
filters,
|
||||
setFilters,
|
||||
labels,
|
||||
}: {
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
}): JSX.Element | null {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { transformedKeys, attributesMap } = useMemo(
|
||||
() => ({
|
||||
transformedKeys: transformLabelsToQbKeys(labels || {}),
|
||||
attributesMap: transformKeyValuesToAttributeValuesMap(labels),
|
||||
}),
|
||||
[labels],
|
||||
);
|
||||
|
||||
const handleSearch = (tagFilters: TagFilter): void => {
|
||||
const tagFiltersLength = tagFilters.items.length;
|
||||
|
||||
if (
|
||||
(!tagFiltersLength && (!filters || !filters.items.length)) ||
|
||||
tagFiltersLength === filters?.items.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFilters(tagFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientSideQBSearch
|
||||
onChange={handleSearch}
|
||||
filters={filters}
|
||||
className="alert-history-label-search"
|
||||
attributeKeys={transformedKeys}
|
||||
attributeValuesMap={attributesMap}
|
||||
suffixIcon={
|
||||
<Search
|
||||
size={14}
|
||||
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const timelineTableColumns = ({
|
||||
filters,
|
||||
labels,
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
}: {
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
@@ -95,9 +27,7 @@ export const timelineTableColumns = ({
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<LabelFilter setFilters={setFilters} filters={filters} labels={labels} />
|
||||
),
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels">
|
||||
@@ -119,15 +49,27 @@ export const timelineTableColumns = ({
|
||||
title: 'ACTIONS',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (record): JSX.Element => (
|
||||
<ConditionalAlertPopover
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
),
|
||||
render: (_, record): JSX.Element => {
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConditionalAlertPopover
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Options,
|
||||
parseAsInteger,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
|
||||
const defaultNuqsOptions: Options = {
|
||||
history: 'push',
|
||||
};
|
||||
|
||||
export const TIMELINE_TABLE_PARAMS = {
|
||||
PAGE: 'page',
|
||||
ORDER: 'order',
|
||||
} as const;
|
||||
|
||||
const ORDER_VALUES = ['asc', 'desc'] as const;
|
||||
export type OrderDirection = (typeof ORDER_VALUES)[number];
|
||||
|
||||
export const useTimelineTablePage = (): UseQueryStateReturn<number, number> =>
|
||||
useQueryState(
|
||||
TIMELINE_TABLE_PARAMS.PAGE,
|
||||
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useTimelineTableOrder = (): UseQueryStateReturn<
|
||||
OrderDirection,
|
||||
OrderDirection
|
||||
> =>
|
||||
useQueryState(
|
||||
TIMELINE_TABLE_PARAMS.ORDER,
|
||||
parseAsStringLiteral(ORDER_VALUES)
|
||||
.withDefault('asc')
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export function encodeCursor(page: number, limit: number): string | undefined {
|
||||
if (page <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = (page - 1) * limit;
|
||||
// Backend uses base64.RawURLEncoding (URL-safe, no padding)
|
||||
return btoa(JSON.stringify({ offset, limit }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function computeCursorForPage(page: number): string | undefined {
|
||||
return encodeCursor(page, TIMELINE_TABLE_PAGE_SIZE);
|
||||
}
|
||||
26
frontend/src/container/AlertHistory/Timeline/Table/utils.ts
Normal file
26
frontend/src/container/AlertHistory/Timeline/Table/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
|
||||
const fieldContextToSuggestionMap: Record<
|
||||
TelemetrytypesFieldContextDTO,
|
||||
QueryKeyDataSuggestionsProps['fieldContext']
|
||||
> = {
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
[TelemetrytypesFieldContextDTO.span]: 'span',
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
[TelemetrytypesFieldContextDTO['']]: undefined,
|
||||
};
|
||||
|
||||
export function fieldContextToSuggestionContext(
|
||||
fc: TelemetrytypesFieldContextDTO | undefined,
|
||||
): QueryKeyDataSuggestionsProps['fieldContext'] {
|
||||
if (fc === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fieldContextToSuggestionMap[fc];
|
||||
}
|
||||
19
frontend/src/container/AlertHistory/utils/labelAdapters.ts
Normal file
19
frontend/src/container/AlertHistory/utils/labelAdapters.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Querybuildertypesv5LabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { Labels } from 'types/api/alerts/def';
|
||||
|
||||
export function labelsArrayToObject(
|
||||
labels: Querybuildertypesv5LabelDTO[] | null | undefined,
|
||||
): Labels {
|
||||
if (!labels) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return labels.reduce<Labels>((acc, label) => {
|
||||
const key = label.key?.name ?? '';
|
||||
const value = String(label.value ?? '');
|
||||
if (key) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { sortByMeanDesc } from '../sortByMeanDesc';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
values: (number | null)[];
|
||||
}
|
||||
|
||||
const item = (name: string, values: (number | null)[]): Item => ({
|
||||
name,
|
||||
values,
|
||||
});
|
||||
|
||||
const sorted = (items: Item[]): string[] =>
|
||||
sortByMeanDesc(items, {
|
||||
getValues: (entry) => entry.values,
|
||||
getKey: (entry) => entry.name,
|
||||
}).map((entry) => entry.name);
|
||||
|
||||
describe('sortByMeanDesc', () => {
|
||||
it('orders items by descending mean', () => {
|
||||
expect(
|
||||
sorted([item('a', [1, 1]), item('b', [10, 20]), item('c', [5, 5])]),
|
||||
).toStrictEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('sinks items with no finite values to the bottom', () => {
|
||||
expect(
|
||||
sorted([item('a', []), item('b', [NaN, Infinity]), item('c', [1])]),
|
||||
).toStrictEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('ignores non-finite and null values when averaging', () => {
|
||||
// Mean over the finite values only is 10, so NaN must not poison it to last place.
|
||||
expect(
|
||||
sorted([item('a', [2, 2]), item('b', [10, NaN]), item('c', [4, null])]),
|
||||
).toStrictEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('produces the same order whichever order the items arrive in', () => {
|
||||
const a = item('a', [5]);
|
||||
const b = item('b', [5]);
|
||||
const c = item('c', [9]);
|
||||
|
||||
expect(sorted([a, b, c])).toStrictEqual(sorted([c, b, a]));
|
||||
expect(sorted([b, a, c])).toStrictEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('tiebreaks equal means on the key', () => {
|
||||
expect(sorted([item('b', [1]), item('a', [1])])).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('tiebreaks on the key for items that have no finite values either', () => {
|
||||
expect(sorted([item('b', []), item('a', [NaN])])).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const items = [item('a', [1]), item('b', [9])];
|
||||
|
||||
expect(sorted(items)).toStrictEqual(['b', 'a']);
|
||||
expect(items.map((entry) => entry.name)).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns an empty array for no items', () => {
|
||||
expect(sorted([])).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
type MeanInput = number | null | undefined;
|
||||
|
||||
function finiteMean(values: Iterable<MeanInput>): number | null {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const value of values) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
sum += value;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : null;
|
||||
}
|
||||
|
||||
export interface SortByMeanDescOptions<T> {
|
||||
/** Numeric values of an item; non-finite entries are ignored. */
|
||||
getValues: (item: T) => Iterable<MeanInput>;
|
||||
/** Stable identity of an item, used to break ties on equal means. */
|
||||
getKey: (item: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders series by descending mean, on a copy (V1 parity: `utils/getSortedSeriesData`). The wire
|
||||
* order can't stand in for it: the backend returns series in Go map-iteration order.
|
||||
*
|
||||
* Call this before building the uPlot config and aligned data — those two and click attribution
|
||||
* all index the list positionally, and uPlot draws a stacked bar's segments in series-index order.
|
||||
*/
|
||||
export function sortByMeanDesc<T>(
|
||||
items: T[],
|
||||
{ getValues, getKey }: SortByMeanDescOptions<T>,
|
||||
): T[] {
|
||||
return items
|
||||
.map((item) => ({ item, mean: finiteMean(getValues(item)) }))
|
||||
.sort((a, b) => {
|
||||
if (a.mean !== null && b.mean !== null && a.mean !== b.mean) {
|
||||
return b.mean - a.mean;
|
||||
}
|
||||
if ((a.mean === null) !== (b.mean === null)) {
|
||||
return a.mean === null ? 1 : -1;
|
||||
}
|
||||
return getKey(a.item).localeCompare(getKey(b.item));
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { BellOff, CircleCheck, CircleOff, Flame } from '@signozhq/icons';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import './AlertState.styles.scss';
|
||||
|
||||
type AlertStateProps = {
|
||||
state: string;
|
||||
state: RuletypesAlertStateDTO | string;
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
@@ -17,7 +18,7 @@ export default function AlertState({
|
||||
let label;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
switch (state) {
|
||||
case 'nodata':
|
||||
case RuletypesAlertStateDTO.nodata:
|
||||
icon = (
|
||||
<CircleOff
|
||||
size={18}
|
||||
@@ -28,7 +29,7 @@ export default function AlertState({
|
||||
label = <span style={{ color: Color.BG_SIENNA_400 }}>No Data</span>;
|
||||
break;
|
||||
|
||||
case 'disabled':
|
||||
case RuletypesAlertStateDTO.disabled:
|
||||
icon = (
|
||||
<BellOff
|
||||
size={18}
|
||||
@@ -38,15 +39,16 @@ export default function AlertState({
|
||||
);
|
||||
label = <span style={{ color: Color.BG_VANILLA_400 }}>Muted</span>;
|
||||
break;
|
||||
case 'firing':
|
||||
|
||||
case RuletypesAlertStateDTO.firing:
|
||||
icon = (
|
||||
<Flame size={18} fill={Color.BG_CHERRY_500} color={Color.BG_CHERRY_500} />
|
||||
);
|
||||
label = <span style={{ color: Color.BG_CHERRY_500 }}>Firing</span>;
|
||||
break;
|
||||
|
||||
case 'normal':
|
||||
case 'inactive':
|
||||
case RuletypesAlertStateDTO.inactive:
|
||||
case 'normal': // legacy
|
||||
icon = (
|
||||
<CircleCheck
|
||||
size={18}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { TablePaginationConfig, TableProps } from 'antd';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
import { patchRulePartial } from 'api/alerts/patchRulePartial';
|
||||
import ruleStats from 'api/alerts/ruleStats';
|
||||
import timelineGraph from 'api/alerts/timelineGraph';
|
||||
import timelineTable from 'api/alerts/timelineTable';
|
||||
import topContributors from 'api/alerts/topContributors';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
useGetRuleHistoryStats,
|
||||
useGetRuleHistoryTopContributors,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
GetRuleByID200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
import {
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
RuletypesAlertStateDTO,
|
||||
type GetRuleByID200,
|
||||
type GetRuleHistoryOverallStatus200,
|
||||
type GetRuleHistoryStats200,
|
||||
type GetRuleHistoryTimeline200,
|
||||
type GetRuleHistoryTopContributors200,
|
||||
type RenderErrorResponseDTO,
|
||||
type RuletypesPostableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
@@ -31,35 +37,27 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertHistory from 'container/AlertHistory';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import {
|
||||
computeCursorForPage,
|
||||
useTimelineTableOrder,
|
||||
useTimelineTablePage,
|
||||
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
|
||||
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
|
||||
import { urlKey } from 'container/AllError/utils';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import history from 'lib/history';
|
||||
import { History, Table } from '@signozhq/icons';
|
||||
import EditRules from 'pages/EditRules';
|
||||
import { OrderPreferenceItems } from 'pages/Logs/config';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
|
||||
import {
|
||||
AlertDef,
|
||||
AlertRuleStatsPayload,
|
||||
AlertRuleTimelineGraphResponsePayload,
|
||||
AlertRuleTimelineTableResponse,
|
||||
AlertRuleTimelineTableResponsePayload,
|
||||
AlertRuleTopContributorsPayload,
|
||||
} from 'types/api/alerts/def';
|
||||
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import APIError from 'types/api/error';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { nanoToMilli } from 'utils/timeUtils';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
export const useAlertHistoryQueryParams = (): {
|
||||
ruleId: string | null;
|
||||
@@ -201,10 +199,7 @@ type GetAlertRuleDetailsApiProps = {
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & {
|
||||
data:
|
||||
| SuccessResponse<AlertRuleStatsPayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
data: GetRuleHistoryStats200 | undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsStats =
|
||||
@@ -213,18 +208,15 @@ export const useGetAlertRuleDetailsStats =
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime],
|
||||
const { isLoading, isRefetching, isError, data } = useGetRuleHistoryStats(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
{
|
||||
queryFn: () =>
|
||||
ruleStats({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
query: {
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -232,10 +224,7 @@ export const useGetAlertRuleDetailsStats =
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & {
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTopContributorsPayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
data: GetRuleHistoryTopContributors200 | undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTopContributors =
|
||||
@@ -244,90 +233,128 @@ export const useGetAlertRuleDetailsTopContributors =
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime],
|
||||
{
|
||||
queryFn: () =>
|
||||
topContributors({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
const { isLoading, isRefetching, isError, data } =
|
||||
useGetRuleHistoryTopContributors(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & {
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTimelineTableResponsePayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
data: GetRuleHistoryTimeline200 | undefined;
|
||||
error: AxiosError<RenderErrorResponseDTO> | null;
|
||||
refetch: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTimelineTable = ({
|
||||
filters,
|
||||
filterExpression,
|
||||
}: {
|
||||
filters: TagFilter;
|
||||
filterExpression: string;
|
||||
}): GetAlertRuleDetailsTimelineTableProps => {
|
||||
const queryClient = useQueryClient();
|
||||
const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams();
|
||||
const { updatedOrder, offset } = useMemo(
|
||||
() => ({
|
||||
updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC,
|
||||
offset: parseInt(params.get(urlKey.offset) ?? '0', 10),
|
||||
}),
|
||||
[params],
|
||||
const [page, setPage] = useTimelineTablePage();
|
||||
const [order] = useTimelineTableOrder();
|
||||
|
||||
const updatedOrder = useMemo(
|
||||
() =>
|
||||
order === 'asc'
|
||||
? Querybuildertypesv5OrderDirectionDTO.asc
|
||||
: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
[order],
|
||||
);
|
||||
|
||||
const timelineFilter = params.get('timelineFilter');
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
const hasStartAndEnd = startTime !== null && endTime !== null;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE,
|
||||
ruleId,
|
||||
startTime,
|
||||
endTime,
|
||||
timelineFilter,
|
||||
updatedOrder,
|
||||
offset,
|
||||
JSON.stringify(filters.items),
|
||||
],
|
||||
const stateFilter = useMemo(() => {
|
||||
if (!timelineFilter || timelineFilter === TimelineFilter.ALL) {
|
||||
return undefined;
|
||||
}
|
||||
return timelineFilter === TimelineFilter.FIRED
|
||||
? RuletypesAlertStateDTO.firing
|
||||
: RuletypesAlertStateDTO.inactive;
|
||||
}, [timelineFilter]);
|
||||
|
||||
const filtersKey = `${filterExpression}|${stateFilter ?? ''}|${startTime}|${endTime}`;
|
||||
const prevFiltersKeyRef = useRef(filtersKey);
|
||||
const filtersChanged = prevFiltersKeyRef.current !== filtersKey;
|
||||
const cursor = computeCursorForPage(filtersChanged ? 1 : page);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevFiltersKeyRef.current !== filtersKey) {
|
||||
prevFiltersKeyRef.current = filtersKey;
|
||||
if (page > 1) {
|
||||
void setPage(1);
|
||||
}
|
||||
}
|
||||
}, [filtersKey, page, setPage]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
limit: TIMELINE_TABLE_PAGE_SIZE,
|
||||
order: updatedOrder,
|
||||
cursor,
|
||||
filterExpression: filterExpression || undefined,
|
||||
state: stateFilter,
|
||||
}),
|
||||
[startTime, endTime, updatedOrder, cursor, filterExpression, stateFilter],
|
||||
);
|
||||
|
||||
const queryOptions = getGetRuleHistoryTimelineQueryOptions(
|
||||
{ id: ruleId || '' },
|
||||
queryParams,
|
||||
{
|
||||
queryFn: () =>
|
||||
timelineTable({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
limit: TIMELINE_TABLE_PAGE_SIZE,
|
||||
order: updatedOrder,
|
||||
offset,
|
||||
filters,
|
||||
...(timelineFilter && timelineFilter !== TimelineFilter.ALL
|
||||
? {
|
||||
state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal',
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
query: {
|
||||
enabled: isValidRuleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
const { isLoading, isRefetching, isError, data, error, refetch } =
|
||||
useQuery(queryOptions);
|
||||
|
||||
const queryKeyRef = useRef(queryOptions.queryKey);
|
||||
queryKeyRef.current = queryOptions.queryKey;
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
void queryClient.cancelQueries({ queryKey: queryKeyRef.current });
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
data,
|
||||
error: error as AxiosError<RenderErrorResponseDTO> | null,
|
||||
isValidRuleId,
|
||||
ruleId,
|
||||
refetch,
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
|
||||
export const useTimelineTable = ({
|
||||
totalItems,
|
||||
nextCursor,
|
||||
}: {
|
||||
totalItems: number;
|
||||
nextCursor?: string;
|
||||
}): {
|
||||
paginationConfig: TablePaginationConfig;
|
||||
onChangeHandler: (
|
||||
@@ -336,16 +363,13 @@ export const useTimelineTable = ({
|
||||
filters: any,
|
||||
extra: any,
|
||||
) => void;
|
||||
handleNextPage: () => void;
|
||||
handlePrevPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
} => {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const { search } = useLocation();
|
||||
|
||||
const params = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
const offset = params.get('offset') ?? '0';
|
||||
const [page, setPage] = useTimelineTablePage();
|
||||
const [, setOrder] = useTimelineTableOrder();
|
||||
|
||||
const onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'] =
|
||||
useCallback(
|
||||
@@ -357,38 +381,52 @@ export const useTimelineTable = ({
|
||||
| SorterResult<AlertRuleTimelineTableResponse>,
|
||||
) => {
|
||||
if (!Array.isArray(sorter)) {
|
||||
const { pageSize = 0, current = 0 } = pagination;
|
||||
const { order } = sorter;
|
||||
const updatedOrder = order === 'ascend' ? 'asc' : 'desc';
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
safeNavigate(
|
||||
`${pathname}?${createQueryParams({
|
||||
...Object.fromEntries(params),
|
||||
order: updatedOrder,
|
||||
offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE,
|
||||
pageSize,
|
||||
})}`,
|
||||
);
|
||||
void Promise.all([setOrder(updatedOrder), setPage(1)]);
|
||||
}
|
||||
},
|
||||
[pathname, safeNavigate],
|
||||
[setOrder, setPage],
|
||||
);
|
||||
|
||||
const offsetInt = parseInt(offset, 10);
|
||||
const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE);
|
||||
const pageSizeInt = parseInt(pageSize, 10);
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (!nextCursor) {
|
||||
return;
|
||||
}
|
||||
void setPage(page + 1);
|
||||
}, [nextCursor, page, setPage]);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
if (page <= 1) {
|
||||
return;
|
||||
}
|
||||
void setPage(page - 1);
|
||||
}, [page, setPage]);
|
||||
|
||||
const paginationConfig: TablePaginationConfig = {
|
||||
pageSize: pageSizeInt,
|
||||
showTotal: PaginationInfoText,
|
||||
current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1,
|
||||
pageSize: TIMELINE_TABLE_PAGE_SIZE,
|
||||
showTotal: (total, [start, end]) => (
|
||||
<span>
|
||||
<Typography.Text size="small">
|
||||
{start} — {end}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small"> of {total}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
current: page,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
total: totalItems,
|
||||
};
|
||||
|
||||
return { paginationConfig, onChangeHandler };
|
||||
return {
|
||||
paginationConfig,
|
||||
onChangeHandler,
|
||||
handleNextPage,
|
||||
handlePrevPage,
|
||||
hasNextPage: !!nextCursor,
|
||||
hasPrevPage: page > 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const useAlertRuleStatusToggle = ({
|
||||
@@ -581,10 +619,7 @@ export const useAlertRuleDelete = ({
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & {
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTimelineGraphResponsePayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
data: GetRuleHistoryOverallStatus200 | undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTimelineGraphData =
|
||||
@@ -594,20 +629,18 @@ export const useGetAlertRuleDetailsTimelineGraphData =
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
const hasStartAndEnd = startTime !== null && endTime !== null;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime],
|
||||
{
|
||||
queryFn: () =>
|
||||
timelineGraph({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
const { isLoading, isRefetching, isError, data } =
|
||||
useGetRuleHistoryOverallStatus(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
|
||||
}
|
||||
|
||||
/** The list spec a saved variable carries, whatever its plugin. */
|
||||
function listSpec(m: VariableFormModel): {
|
||||
allowMultiple?: boolean;
|
||||
allowAllValue?: boolean;
|
||||
} {
|
||||
return formModelToDto(m).spec as {
|
||||
allowMultiple?: boolean;
|
||||
allowAllValue?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
describe('formModelToDto — the ALL flag needs multi-select', () => {
|
||||
it('keeps ALL for a multi-select dynamic variable', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'host.name',
|
||||
multiSelect: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: true, allowAllValue: true });
|
||||
});
|
||||
|
||||
it('drops ALL for a single-select dynamic variable', () => {
|
||||
// The API rejects allowAllValue without allowMultiple, and this used to be forced
|
||||
// true for every dynamic variable — which blocked saving the whole dashboard.
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'host.name',
|
||||
multiSelect: false,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: false, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('drops ALL for a single-select query variable that still carries the flag', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT 1',
|
||||
multiSelect: false,
|
||||
showAllOption: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: false, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('respects the toggle on a multi-select query variable', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT 1',
|
||||
multiSelect: true,
|
||||
showAllOption: false,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: true, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('round-trips a single-select dynamic variable unchanged', () => {
|
||||
// What the dashboard in the report holds: saving it must not flip the flag.
|
||||
const dto = {
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: 'host.name',
|
||||
display: { name: 'host.name', description: '' },
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
sort: 'alphabetical-asc',
|
||||
plugin: {
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: { name: 'host.name', signal: 'all' },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,9 +133,14 @@ export function formModelToDto(
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
|
||||
// which forced showALLOption true on save); other types respect the toggle.
|
||||
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
|
||||
// forced showALLOption true on save); other types respect the toggle. Either
|
||||
// way it needs multi-select — ALL is a set of values, and the API rejects the
|
||||
// flag without it, which used to make a single-select dynamic variable
|
||||
// unsaveable and blocked every other edit to the dashboard with it.
|
||||
allowAllValue:
|
||||
model.multiSelect &&
|
||||
(model.type === 'DYNAMIC' ? true : model.showAllOption),
|
||||
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { MemoryRouter, Route, useHistory, useParams } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import appStore from 'store';
|
||||
|
||||
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
|
||||
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const { useHistory: useRouterHistory } =
|
||||
jest.requireActual('react-router-dom');
|
||||
return {
|
||||
useSafeNavigate: (): unknown => {
|
||||
const history = useRouterHistory();
|
||||
return {
|
||||
safeNavigate: (to: string): void => history.push(to),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}));
|
||||
|
||||
function makePromPanel(name: string, promql: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/PromQLQuery',
|
||||
spec: { name: 'A', query: promql, legend: '', disabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const PANELS: Record<string, DashboardtypesPanelDTO> = {
|
||||
A: makePromPanel('Panel A', 'up{job="alpha"}'),
|
||||
B: makePromPanel('Panel B', 'up{job="bravo"}'),
|
||||
};
|
||||
|
||||
const noop = (): void => {};
|
||||
|
||||
/** Stands in for the editor route: the same draft + builder sync `PanelEditorContainer` runs. */
|
||||
function EditorRoute(): JSX.Element {
|
||||
const { panelId } = useParams<{ panelId: string }>();
|
||||
const [panel] = useState(PANELS[panelId]);
|
||||
|
||||
usePanelEditorQuerySync({
|
||||
draft: panel,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec: noop,
|
||||
refetch: noop,
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
savedQueries: panel.spec.queries,
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={noop}
|
||||
onCancelQuery={noop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="edit-a"
|
||||
onClick={(): void => openPanelEditor('A', { panel: PANELS.A })}
|
||||
>
|
||||
edit A
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="edit-b"
|
||||
onClick={(): void => openPanelEditor('B', { panel: PANELS.B })}
|
||||
>
|
||||
edit B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="back"
|
||||
onClick={(): void => history.push('/dashboard/dash-1')}
|
||||
>
|
||||
back
|
||||
</button>
|
||||
<Route
|
||||
path="/dashboard/:dashboardId/panel/:panelId"
|
||||
component={EditorRoute}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHarness = (): void => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
|
||||
<CompatRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ReduxProvider store={configureStore([])(appStore.getState())}>
|
||||
<TooltipProvider>
|
||||
<QueryBuilderProvider>
|
||||
<Harness />
|
||||
</QueryBuilderProvider>
|
||||
</TooltipProvider>
|
||||
</ReduxProvider>
|
||||
</QueryClientProvider>
|
||||
</CompatRouter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('Panel editor route, PromQL panels', () => {
|
||||
it('opens on the edited panel query, not the previously edited one', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('edit-a'));
|
||||
expect(screen.getByTestId('promql-query-input')).toHaveValue(
|
||||
'up{job="alpha"}',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('back'));
|
||||
await user.click(screen.getByTestId('edit-b'));
|
||||
expect(screen.getByTestId('promql-query-input')).toHaveValue(
|
||||
'up{job="bravo"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
|
||||
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
import { sortSeriesByMeanDesc } from '../../utils/sortSeriesByMean';
|
||||
|
||||
import { buildBarChartConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -70,7 +71,12 @@ function BarPanelRenderer({
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
|
||||
sortSeriesByMeanDesc(
|
||||
flattenTimeSeries(
|
||||
getTimeSeriesResults(data.response),
|
||||
data.legendMap ?? {},
|
||||
),
|
||||
),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
|
||||
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
import { sortSeriesByMeanDesc } from '../../utils/sortSeriesByMean';
|
||||
|
||||
import { buildTimeSeriesConfig } from './utils/buildConfig';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
@@ -71,7 +72,12 @@ function TimeSeriesPanelRenderer({
|
||||
|
||||
const flatSeries = useMemo(
|
||||
() =>
|
||||
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
|
||||
sortSeriesByMeanDesc(
|
||||
flattenTimeSeries(
|
||||
getTimeSeriesResults(data.response),
|
||||
data.legendMap ?? {},
|
||||
),
|
||||
),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
import { sortSeriesByMeanDesc } from '../sortSeriesByMean';
|
||||
|
||||
function makeSeries(
|
||||
queryName: string,
|
||||
values: number[],
|
||||
overrides: Partial<PanelSeries> = {},
|
||||
): PanelSeries {
|
||||
return {
|
||||
queryName,
|
||||
legend: '',
|
||||
labels: {},
|
||||
kind: 'series',
|
||||
values: values.map((value, index) => ({
|
||||
timestamp: (index + 1) * 1_000,
|
||||
value,
|
||||
})),
|
||||
aggregation: { index: 0, alias: '' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const names = (series: PanelSeries[]): string[] =>
|
||||
series.map((item) => item.queryName);
|
||||
|
||||
describe('sortSeriesByMeanDesc', () => {
|
||||
it('orders series by descending mean', () => {
|
||||
const sorted = sortSeriesByMeanDesc([
|
||||
makeSeries('A', [1, 1]),
|
||||
makeSeries('B', [10, 20]),
|
||||
makeSeries('C', [5, 5]),
|
||||
]);
|
||||
|
||||
expect(names(sorted)).toStrictEqual(['B', 'C', 'A']);
|
||||
});
|
||||
|
||||
it('sinks series with no finite points to the bottom', () => {
|
||||
const sorted = sortSeriesByMeanDesc([
|
||||
makeSeries('A', []),
|
||||
makeSeries('B', [NaN, Infinity]),
|
||||
makeSeries('C', [1]),
|
||||
]);
|
||||
|
||||
expect(names(sorted)).toStrictEqual(['C', 'A', 'B']);
|
||||
});
|
||||
|
||||
it('ignores non-finite points when averaging', () => {
|
||||
const sorted = sortSeriesByMeanDesc([
|
||||
makeSeries('A', [2, 2]),
|
||||
// Mean over finite points only is 10, so NaN must not poison it to last place.
|
||||
makeSeries('B', [10, NaN]),
|
||||
]);
|
||||
|
||||
expect(names(sorted)).toStrictEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('produces the same order regardless of input order', () => {
|
||||
const a = makeSeries('A', [5]);
|
||||
const b = makeSeries('B', [5]);
|
||||
const c = makeSeries('C', [9]);
|
||||
|
||||
expect(names(sortSeriesByMeanDesc([a, b, c]))).toStrictEqual(
|
||||
names(sortSeriesByMeanDesc([c, b, a])),
|
||||
);
|
||||
expect(names(sortSeriesByMeanDesc([b, a, c]))).toStrictEqual(['C', 'A', 'B']);
|
||||
});
|
||||
|
||||
it('tiebreaks equal means on labels when the query name matches', () => {
|
||||
const forward = sortSeriesByMeanDesc([
|
||||
makeSeries('A', [1], { labels: { host: 'b' } }),
|
||||
makeSeries('A', [1], { labels: { host: 'a' } }),
|
||||
]);
|
||||
const reversed = sortSeriesByMeanDesc([
|
||||
makeSeries('A', [1], { labels: { host: 'a' } }),
|
||||
makeSeries('A', [1], { labels: { host: 'b' } }),
|
||||
]);
|
||||
|
||||
expect(forward.map((item) => item.labels.host)).toStrictEqual(['a', 'b']);
|
||||
expect(reversed.map((item) => item.labels.host)).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [makeSeries('A', [1]), makeSeries('B', [9])];
|
||||
|
||||
const sorted = sortSeriesByMeanDesc(input);
|
||||
|
||||
expect(names(input)).toStrictEqual(['A', 'B']);
|
||||
expect(names(sorted)).toStrictEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('returns an empty array for no series', () => {
|
||||
expect(sortSeriesByMeanDesc([])).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getPanelDefinition } from '../registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
|
||||
import { fromPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
/**
|
||||
* The panel's saved query as a builder `Query` — what the editor route and the View
|
||||
* modal put in `compositeQuery` when they open. Matches the seed
|
||||
* `usePanelEditorQuerySync` computes from the panel.
|
||||
*/
|
||||
export function getPanelBuilderQuery(panel: DashboardtypesPanelDTO): Query {
|
||||
const kind = panel.spec.plugin.kind;
|
||||
const [defaultSignal] = getPanelDefinition(kind).supportedSignals;
|
||||
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
|
||||
// metrics default isn't authorable in every kind (e.g. List).
|
||||
if (panel.spec.queries.length === 0 && defaultSignal) {
|
||||
return initialQueriesMap[defaultSignal];
|
||||
}
|
||||
return fromPerses(panel.spec.queries, PANEL_KIND_TO_PANEL_TYPE[kind]);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { sortByMeanDesc } from 'container/DashboardContainer/visualization/charts/utils/sortByMeanDesc';
|
||||
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
|
||||
function seriesKey(series: PanelSeries): string {
|
||||
const labels = Object.keys(series.labels)
|
||||
.sort()
|
||||
.map((name) => `${name}=${series.labels[name]}`)
|
||||
.join(',');
|
||||
return `${series.queryName}|${series.aggregation.index}|${series.kind}|${labels}`;
|
||||
}
|
||||
|
||||
/** `sortByMeanDesc` over flattened V5 series; call it before building the config and chart data. */
|
||||
export function sortSeriesByMeanDesc(series: PanelSeries[]): PanelSeries[] {
|
||||
return sortByMeanDesc(series, {
|
||||
getValues: (item) => item.values.map((point) => point.value),
|
||||
getKey: seriesKey,
|
||||
});
|
||||
}
|
||||
@@ -240,7 +240,10 @@ describe('usePanelActionItems', () => {
|
||||
(i) => 'key' in i && i.key === 'edit-panel',
|
||||
);
|
||||
(edit as { onClick: () => void }).onClick();
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1');
|
||||
// The panel rides along so its saved query lands in the editor URL.
|
||||
expect(mockOpenEditor).toHaveBeenCalledWith('panel-1', {
|
||||
panel: mockPanel,
|
||||
});
|
||||
});
|
||||
|
||||
it('"Move to section" offers a single "Dashboard (root)" target', () => {
|
||||
@@ -394,7 +397,9 @@ describe('usePanelActionItems', () => {
|
||||
(i) => 'key' in i && i.key === 'view-panel',
|
||||
);
|
||||
(view as { onClick: () => void }).onClick();
|
||||
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
|
||||
// The panel goes along so the opener can seed the shared query builder before
|
||||
// the modal mounts (otherwise it renders the previously-viewed panel's query).
|
||||
expect(mockOpenView).toHaveBeenCalledWith('panel-1', baseArgs.panel);
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
|
||||
@@ -130,7 +130,7 @@ export function usePanelActionItems({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => openView(panelId),
|
||||
onClick: (): void => openView(panelId, panel),
|
||||
});
|
||||
}
|
||||
if (canEdit && canEditWidget && panelCapabilities.edit) {
|
||||
@@ -139,7 +139,7 @@ export function usePanelActionItems({
|
||||
label: label('Edit panel'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: isLocked,
|
||||
onClick: (): void => openPanelEditor(panelId),
|
||||
onClick: (): void => openPanelEditor(panelId, { panel }),
|
||||
});
|
||||
}
|
||||
if (canEdit && canEditWidget && panelCapabilities.clone) {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import appStore from 'store';
|
||||
|
||||
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
|
||||
// CodeMirror (the where-clause editor) needs real DOM measurement APIs.
|
||||
beforeAll(() => {
|
||||
const rect = {
|
||||
width: 100,
|
||||
height: 20,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: 20,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: (): unknown => rect,
|
||||
} as DOMRect;
|
||||
const rects = { length: 1, item: (): DOMRect => rect, 0: rect };
|
||||
document.createRange = (): Range =>
|
||||
({
|
||||
getClientRects: (): unknown => rects,
|
||||
getBoundingClientRect: (): DOMRect => rect,
|
||||
setStart: (): void => {},
|
||||
setEnd: (): void => {},
|
||||
startContainer: document.body,
|
||||
endContainer: document.body,
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
collapsed: true,
|
||||
commonAncestorContainer: document.body,
|
||||
}) as unknown as Range;
|
||||
Element.prototype.getBoundingClientRect = (): DOMRect => rect;
|
||||
});
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const { useHistory } = jest.requireActual('react-router-dom');
|
||||
return {
|
||||
useSafeNavigate: (): unknown => {
|
||||
const history = useHistory();
|
||||
return {
|
||||
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
|
||||
if (opts?.replace) {
|
||||
history.replace(to);
|
||||
} else {
|
||||
history.push(to);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { data: { keys: {} } } }),
|
||||
}));
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
|
||||
() => ({
|
||||
usePanelQuery: (): unknown => ({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isPreviousData: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
|
||||
() =>
|
||||
function MockPreviewPane(): ReactElement {
|
||||
return <div data-testid="preview-pane" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../hooks/useDrilldown', () => ({
|
||||
useDrilldown: (): unknown => ({
|
||||
enableDrillDown: false,
|
||||
onPanelClick: jest.fn(),
|
||||
contextMenuProps: {
|
||||
coordinates: null,
|
||||
popoverPosition: null,
|
||||
items: null,
|
||||
onClose: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelInteractions', () => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: { syncMode: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'../ViewPanelModal/ViewPanelModalHeader',
|
||||
() =>
|
||||
function MockViewPanelModalHeader(): ReactElement {
|
||||
return <div data-testid="view-panel-header" />;
|
||||
},
|
||||
);
|
||||
|
||||
function makePanel(
|
||||
name: string,
|
||||
extras: Record<string, unknown>,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
disabled: false,
|
||||
filter: { expression: "service = 'x'" },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
...extras,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const PANELS: Record<string, DashboardtypesPanelDTO> = {
|
||||
plain: makePanel('Plain panel', {}),
|
||||
grouped: makePanel('Grouped panel', {
|
||||
groupBy: [{ name: 'host.name', fieldDataType: 'string' }],
|
||||
having: { expression: 'count() > 5' },
|
||||
}),
|
||||
};
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const { expandedPanelId, openView, closeView } = useViewPanel();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-plain"
|
||||
onClick={(): void => openView('plain', PANELS.plain)}
|
||||
>
|
||||
open plain
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-grouped"
|
||||
onClick={(): void => openView('grouped', PANELS.grouped)}
|
||||
>
|
||||
open grouped
|
||||
</button>
|
||||
<button type="button" data-testid="close" onClick={closeView}>
|
||||
close
|
||||
</button>
|
||||
<ViewPanelModal
|
||||
open={!!expandedPanelId}
|
||||
panel={expandedPanelId ? PANELS[expandedPanelId] : undefined}
|
||||
panelId={expandedPanelId ?? undefined}
|
||||
onClose={closeView}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHarness = (): void => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
|
||||
<CompatRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ReduxProvider store={configureStore([])(appStore.getState())}>
|
||||
<TooltipProvider>
|
||||
<QueryBuilderProvider>
|
||||
<Harness />
|
||||
</QueryBuilderProvider>
|
||||
</TooltipProvider>
|
||||
</ReduxProvider>
|
||||
</QueryClientProvider>
|
||||
</CompatRouter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
// QueryAddOns picks which rows are visible once on mount and never re-seeds, so unlike
|
||||
// the field values these never recover from a context swap that lands after mount.
|
||||
describe('View modal, builder add-on rows', () => {
|
||||
it('shows the opened panel add-ons, not the previous panel ones', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('open-grouped'));
|
||||
expect(screen.getByTestId('group-by-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('having-content')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('close'));
|
||||
await user.click(screen.getByTestId('open-plain'));
|
||||
expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('having-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows add-ons the opened panel has after a panel without them', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('open-plain'));
|
||||
await user.click(screen.getByTestId('close'));
|
||||
await user.click(screen.getByTestId('open-grouped'));
|
||||
|
||||
expect(screen.getByTestId('group-by-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('having-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import appStore from 'store';
|
||||
|
||||
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const { useHistory } = jest.requireActual('react-router-dom');
|
||||
return {
|
||||
useSafeNavigate: (): unknown => {
|
||||
const history = useHistory();
|
||||
return {
|
||||
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
|
||||
if (opts?.replace) {
|
||||
history.replace(to);
|
||||
} else {
|
||||
history.push(to);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
|
||||
() => ({
|
||||
usePanelQuery: (): unknown => ({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isPreviousData: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
|
||||
() =>
|
||||
function MockPreviewPane(): ReactElement {
|
||||
return <div data-testid="preview-pane" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../hooks/useDrilldown', () => ({
|
||||
useDrilldown: (): unknown => ({
|
||||
enableDrillDown: false,
|
||||
onPanelClick: jest.fn(),
|
||||
contextMenuProps: {
|
||||
coordinates: null,
|
||||
popoverPosition: null,
|
||||
items: null,
|
||||
onClose: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelInteractions', () => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: { syncMode: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'../ViewPanelModal/ViewPanelModalHeader',
|
||||
() =>
|
||||
function MockViewPanelModalHeader(): ReactElement {
|
||||
return <div data-testid="view-panel-header" />;
|
||||
},
|
||||
);
|
||||
|
||||
function makePromPanel(name: string, promql: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/PromQLQuery',
|
||||
spec: { name: 'A', query: promql, legend: '', disabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const PANELS: Record<string, DashboardtypesPanelDTO> = {
|
||||
A: makePromPanel('Panel A', 'up{job="alpha"}'),
|
||||
B: makePromPanel('Panel B', 'up{job="bravo"}'),
|
||||
};
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const { expandedPanelId, openView, closeView } = useViewPanel();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-a"
|
||||
onClick={(): void => openView('A', PANELS.A)}
|
||||
>
|
||||
open A
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-b"
|
||||
onClick={(): void => openView('B', PANELS.B)}
|
||||
>
|
||||
open B
|
||||
</button>
|
||||
<button type="button" data-testid="close" onClick={closeView}>
|
||||
close
|
||||
</button>
|
||||
<ViewPanelModal
|
||||
open={!!expandedPanelId}
|
||||
panel={expandedPanelId ? PANELS[expandedPanelId] : undefined}
|
||||
panelId={expandedPanelId ?? undefined}
|
||||
onClose={closeView}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHarness = (): void => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
|
||||
<CompatRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ReduxProvider store={configureStore([])(appStore.getState())}>
|
||||
<TooltipProvider>
|
||||
<QueryBuilderProvider>
|
||||
<Harness />
|
||||
</QueryBuilderProvider>
|
||||
</TooltipProvider>
|
||||
</ReduxProvider>
|
||||
</QueryClientProvider>
|
||||
</CompatRouter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('View modal, PromQL panels', () => {
|
||||
it('shows the opened panel query, not the previously viewed one', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('open-a'));
|
||||
expect(screen.getByTestId('promql-query-input')).toHaveValue(
|
||||
'up{job="alpha"}',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('close'));
|
||||
await user.click(screen.getByTestId('open-b'));
|
||||
expect(screen.getByTestId('promql-query-input')).toHaveValue(
|
||||
'up{job="bravo"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useViewPanelMode } from '../ViewPanelModal/useViewPanelMode';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const { useHistory } = jest.requireActual('react-router-dom');
|
||||
return {
|
||||
useSafeNavigate: (): unknown => {
|
||||
const history = useHistory();
|
||||
return {
|
||||
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
|
||||
if (opts?.replace) {
|
||||
history.replace(to);
|
||||
} else {
|
||||
history.push(to);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
|
||||
() => ({
|
||||
usePanelQuery: (): unknown => ({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
isPreviousData: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
function makePanel(name: string, expression: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
disabled: false,
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const PANELS: Record<string, DashboardtypesPanelDTO> = {
|
||||
A: makePanel('Panel A', "service = 'alpha'"),
|
||||
B: makePanel('Panel B', "service = 'bravo'"),
|
||||
};
|
||||
|
||||
const panelOf = (json: string): string => {
|
||||
if (json.includes('alpha')) {
|
||||
return 'A';
|
||||
}
|
||||
if (json.includes('bravo')) {
|
||||
return 'B';
|
||||
}
|
||||
return 'none';
|
||||
};
|
||||
|
||||
/** Every render of the open modal, so a single stale frame can't hide. */
|
||||
const renders: { current: string; draft: string; filterItems: number }[] = [];
|
||||
|
||||
const stagedIds: (string | undefined)[] = [];
|
||||
|
||||
function ModalBody({ panelId }: { panelId: string }): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { draft } = useViewPanelMode({
|
||||
panel: PANELS[panelId],
|
||||
panelId,
|
||||
time: { startMs: 0, endMs: 1000 },
|
||||
});
|
||||
renders.push({
|
||||
current: panelOf(JSON.stringify(currentQuery)),
|
||||
draft: panelOf(JSON.stringify(draft.spec.queries)),
|
||||
filterItems: currentQuery.builder.queryData[0]?.filters?.items.length ?? 0,
|
||||
});
|
||||
return <div data-testid="modal-body" />;
|
||||
}
|
||||
|
||||
function SearchProbe(): JSX.Element {
|
||||
return <div data-testid="search">{useLocation().search}</div>;
|
||||
}
|
||||
|
||||
function StagedQueryProbe(): null {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
if (stagedIds.at(-1) !== stagedQuery?.id) {
|
||||
stagedIds.push(stagedQuery?.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const drilldownQuery = (base: Query): Query => ({
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: base.builder.queryData.map((q) => ({
|
||||
...q,
|
||||
filter: { expression: "service = 'bravo' AND host = 'h1'" },
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const { expandedPanelId, openView, openViewWithQuery, closeView } =
|
||||
useViewPanel();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-a"
|
||||
onClick={(): void => openView('A', PANELS.A)}
|
||||
>
|
||||
open A
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-b"
|
||||
onClick={(): void => openView('B', PANELS.B)}
|
||||
>
|
||||
open B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="drilldown-b"
|
||||
onClick={(): void =>
|
||||
openViewWithQuery(
|
||||
'B',
|
||||
drilldownQuery(
|
||||
fromPerses(PANELS.B.spec.queries, PANEL_TYPES.TIME_SERIES),
|
||||
),
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
)
|
||||
}
|
||||
>
|
||||
drilldown B
|
||||
</button>
|
||||
<button type="button" data-testid="close" onClick={closeView}>
|
||||
close
|
||||
</button>
|
||||
<StagedQueryProbe />
|
||||
<SearchProbe />
|
||||
{expandedPanelId && <ModalBody panelId={expandedPanelId} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHarness = (): void => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
|
||||
<CompatRouter>
|
||||
<QueryBuilderProvider>
|
||||
<Harness />
|
||||
</QueryBuilderProvider>
|
||||
</CompatRouter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('useViewPanel', () => {
|
||||
beforeEach(() => {
|
||||
renders.length = 0;
|
||||
stagedIds.length = 0;
|
||||
});
|
||||
|
||||
// The builder context is global and outlives the modal; its fields seed themselves
|
||||
// on mount, so a late swap leaves them on the previously-viewed panel.
|
||||
it('seeds the builder with the opened panel before the modal renders', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('open-a'));
|
||||
await user.click(screen.getByTestId('close'));
|
||||
|
||||
renders.length = 0;
|
||||
await user.click(screen.getByTestId('open-b'));
|
||||
|
||||
expect(renders.length).toBeGreaterThan(0);
|
||||
expect(renders.every((r) => r.current === 'B')).toBe(true);
|
||||
});
|
||||
|
||||
it("carries the opened panel's query in the URL", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('open-b'));
|
||||
|
||||
const search = new URLSearchParams(
|
||||
screen.getByTestId('search').textContent ?? '',
|
||||
);
|
||||
expect(search.get(QueryParams.expandedWidgetId)).toBe('B');
|
||||
const carried = search.get(QueryParams.compositeQuery);
|
||||
expect(panelOf(decodeURIComponent(carried as string))).toBe('B');
|
||||
expect(search.get(QueryParams.graphType)).toBeNull();
|
||||
});
|
||||
|
||||
// The edit session commits any staged query on mount, so a staged query left over
|
||||
// from the previous panel would make this panel's preview fetch the old query.
|
||||
it('never lets the previous panel query reach the new panel draft', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('open-a'));
|
||||
await user.click(screen.getByTestId('close'));
|
||||
|
||||
renders.length = 0;
|
||||
await user.click(screen.getByTestId('open-b'));
|
||||
|
||||
expect(renders.every((r) => r.draft === 'B')).toBe(true);
|
||||
});
|
||||
|
||||
// The drilldown URL carries the query's own id, so a staged query with that id makes
|
||||
// the provider skip hydration — losing the normalisation that fills `filters.items`.
|
||||
it('still lets the provider hydrate a drilldown query from the URL', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('open-a'));
|
||||
await user.click(screen.getByTestId('close'));
|
||||
|
||||
renders.length = 0;
|
||||
await user.click(screen.getByTestId('drilldown-b'));
|
||||
|
||||
expect(renders.every((r) => r.current === 'B')).toBe(true);
|
||||
expect(renders.at(-1)?.filterItems).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// `useSyncTimeOnStagedQueryChange` (dashboard toolbar) re-anchors global time when one
|
||||
// non-null staged id replaces another, refetching every panel behind the modal.
|
||||
it.each([['open-b'], ['drilldown-b']])(
|
||||
'never swaps one staged query for another (%s)',
|
||||
async (trigger) => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderHarness();
|
||||
|
||||
await user.click(screen.getByTestId('open-a'));
|
||||
await user.click(screen.getByTestId('close'));
|
||||
await user.click(screen.getByTestId(trigger));
|
||||
|
||||
const swapsWithoutNull = stagedIds.some(
|
||||
(id, i) => i > 0 && id !== undefined && stagedIds[i - 1] !== undefined,
|
||||
);
|
||||
expect(swapsWithoutNull).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { getPanelBuilderQuery } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelBuilderQuery';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
|
||||
@@ -13,8 +16,8 @@ import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
|
||||
openView: (panelId: string) => void;
|
||||
/** Open the View modal on the saved panel, its query carried in `compositeQuery`. */
|
||||
openView: (panelId: string, panel: DashboardtypesPanelDTO) => void;
|
||||
/**
|
||||
* Open the View modal pre-seeded with a drilldown query + kind, persisted in the URL so it
|
||||
* survives refresh (V1 parity); the modal hydrates its draft from these on mount.
|
||||
@@ -30,30 +33,38 @@ export interface UseViewPanelApi {
|
||||
|
||||
/**
|
||||
* Drives the panel View modal off the URL (V1 parity): `expandedWidgetId` holds the open
|
||||
* panel, and a drilldown additionally seeds `compositeQuery` + `graphType`. URL-backed state
|
||||
* is shareable, survives refresh, and the browser back-button closes it.
|
||||
* panel and `compositeQuery` the query it opens on, which a drilldown retargets along with
|
||||
* `graphType`. URL-backed state is shareable, survives refresh, and browser Back closes it.
|
||||
*/
|
||||
export function useViewPanel(): UseViewPanelApi {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { resetQuery } = useQueryBuilder();
|
||||
|
||||
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
|
||||
|
||||
const openView = useCallback(
|
||||
(panelId: string): void => {
|
||||
(panelId: string, panel: DashboardtypesPanelDTO): void => {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
|
||||
// on the saved panel, not stale state the modal would otherwise hydrate from.
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
// Only a drilldown retargets the panel type.
|
||||
next.delete(QueryParams.graphType);
|
||||
clearViewPanelHandoff();
|
||||
const query = getPanelBuilderQuery(panel);
|
||||
next.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
// The provider applies the URL in an effect, a tick after the builder's fields have
|
||||
// mounted and read the query they keep. `resetQuery` — not `initQueryBuilderData`:
|
||||
// swapping one staged id for another re-anchors global time and refetches the grid.
|
||||
resetQuery(query);
|
||||
void logEvent(DashboardDetailEvents.PanelViewed, { panelId });
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
[pathname, safeNavigate, urlQuery, resetQuery],
|
||||
);
|
||||
|
||||
const openViewWithQuery = useCallback(
|
||||
@@ -63,6 +74,10 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
next.set(QueryParams.graphType, panelType);
|
||||
// A grid drilldown opens on the saved panel, never a stale editor handoff.
|
||||
clearViewPanelHandoff();
|
||||
// As in `openView`. Clearing the staged query matters twice over here: the URL
|
||||
// below carries this query's own id, and a staged query with a matching id
|
||||
// makes the provider skip the hydration that normalises legacy filter fields.
|
||||
resetQuery(query);
|
||||
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
|
||||
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
|
||||
next.set(
|
||||
@@ -71,7 +86,7 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
);
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
[pathname, safeNavigate, urlQuery, resetQuery],
|
||||
);
|
||||
|
||||
const closeView = useCallback((): void => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
|
||||
|
||||
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
|
||||
import { useVariableSelection } from './hooks/useVariableSelection';
|
||||
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
@@ -96,12 +97,10 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
// Until the seed commits a selection, stand in the same default it will
|
||||
// commit, through the one resolver — an empty stand-in reads as "nothing
|
||||
// selected" to a control that snapshots it on mount.
|
||||
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
onAutoSelect={(next): void => autoSelect(variable.name, next)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import TextSelector from '../components/selectors/TextSelector';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
function renderSelector(
|
||||
selection: VariableSelection,
|
||||
onChange = jest.fn(),
|
||||
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
|
||||
const { rerender } = render(
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue="flower"
|
||||
onChange={onChange}
|
||||
testId="variable-input-service"
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onChange,
|
||||
rerender: (next): void =>
|
||||
rerender(
|
||||
<TextSelector
|
||||
selection={next}
|
||||
defaultValue="flower"
|
||||
onChange={onChange}
|
||||
testId="variable-input-service"
|
||||
/>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return screen.getByTestId('variable-input-service') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('TextSelector', () => {
|
||||
it('shows the value the seed commits after mount', () => {
|
||||
// The bar mounts before the seed resolves the definition's default, so the first
|
||||
// render sees nothing selected. The box must follow the value once it lands.
|
||||
const { rerender } = renderSelector({ value: '', allSelected: false });
|
||||
|
||||
rerender({ value: 'flower', allSelected: false });
|
||||
|
||||
expect(input().value).toBe('flower');
|
||||
});
|
||||
|
||||
it('follows a selection replaced from outside (share link, reset)', () => {
|
||||
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
|
||||
|
||||
rerender({ value: 'rose', allSelected: false });
|
||||
|
||||
expect(input().value).toBe('rose');
|
||||
});
|
||||
|
||||
it('keeps what the user types until they commit it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
|
||||
|
||||
await user.clear(input());
|
||||
await user.type(input(), 'tulip');
|
||||
|
||||
// Still local — a keystroke must not cascade to dependent panels.
|
||||
expect(input().value).toBe('tulip');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await user.tab();
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: 'tulip',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the default when emptied', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
|
||||
|
||||
await user.clear(input());
|
||||
await user.tab();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: 'flower',
|
||||
allSelected: false,
|
||||
});
|
||||
expect(input().value).toBe('flower');
|
||||
});
|
||||
});
|
||||
@@ -87,4 +87,99 @@ describe('ValueSelector', () => {
|
||||
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('an ALL selection whose options are still loading', () => {
|
||||
it('reads ALL, not the "Select value" placeholder', () => {
|
||||
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
|
||||
// which carries no values at all) — the control must not read as unselected.
|
||||
renderSelector({ value: null, allSelected: true }, [], true);
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-select-selection-placeholder'),
|
||||
).toHaveTextContent('ALL');
|
||||
});
|
||||
|
||||
it('still shows concrete values while options load', () => {
|
||||
renderSelector(
|
||||
{ value: ['checkout-service-prod'], allSelected: false },
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-select-selection-placeholder'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearing', () => {
|
||||
function clearIcon(): Element | null {
|
||||
return document.querySelector('.ant-select-clear');
|
||||
}
|
||||
|
||||
async function openDropdown(): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const control = screen.getByTestId('variable-select-env');
|
||||
await user.click(control.querySelector('input') as HTMLInputElement);
|
||||
}
|
||||
|
||||
it('offers no clear icon while the list is closed', () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
expect(clearIcon()).toBeNull();
|
||||
});
|
||||
|
||||
it('offers it once the list is open', async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(clearIcon()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers no clear icon while every option is selected', async () => {
|
||||
// ALL is every option, so there is nothing to clear — and the shared control
|
||||
// refuses to empty an ALL selection, which would leave the icon inert.
|
||||
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(clearIcon()).toBeNull();
|
||||
});
|
||||
|
||||
it('empties the list and commits nothing until it closes', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={OPTIONS}
|
||||
variableType="query"
|
||||
multiSelect
|
||||
showAllOption
|
||||
selection={{ value: VALUES, allSelected: false }}
|
||||
onChange={onChange}
|
||||
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
await user.click(clearIcon() as Element);
|
||||
|
||||
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
|
||||
0,
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
// Closing fills in whatever the variable should hold.
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: [OPTIONS[0]],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,47 @@ describe('useAutoSelect', () => {
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
|
||||
});
|
||||
|
||||
it('falls back to ALL, not the first option, when every selected value is gone', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
['x', 'y'],
|
||||
{ value: ['a', 'b'], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
|
||||
});
|
||||
|
||||
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
|
||||
const next = run(
|
||||
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
|
||||
it('still honours a configured default over ALL', () => {
|
||||
const next = run(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
defaultValue: 'b',
|
||||
}),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true }),
|
||||
|
||||
@@ -97,6 +97,125 @@ describe('useSeedVariableSelection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the configured default over a stored empty text value', () => {
|
||||
// Persisted from a session where the box was never filled — the default the
|
||||
// definition carries must win, or the variable reads as unset forever.
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: '', allSelected: false } });
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: 'prod',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: [], allSelected: false } });
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
// Custom options need no request, so ALL is materialized here rather than left as
|
||||
// a flag for the post-fetch reconcile to expand.
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a', 'b'],
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b,c',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a', 'b', 'c'],
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops values a custom variable no longer offers, at seed time', () => {
|
||||
useDashboardStore.getState().setVariableValues('d1', {
|
||||
env: { value: ['a', 'gone'], allSelected: false },
|
||||
});
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b',
|
||||
multiSelect: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a stored ALL selection that has not materialized yet', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: null, allSelected: true } });
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
defaultValue: 'b',
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: null,
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rewrite the store when the effect re-runs with the same values', () => {
|
||||
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
|
||||
// re-running the seed. Writing an identical map would re-render every subscriber.
|
||||
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
|
||||
const { rerender } = renderHook(
|
||||
({ dash }) => useSeedVariableSelection(dash),
|
||||
{ initialProps: { dash: dashboard('d1', [variable]) } },
|
||||
);
|
||||
const afterSeed = useDashboardStore.getState().variableValues;
|
||||
|
||||
rerender({ dash: dashboard('d1', [{ ...variable }]) });
|
||||
|
||||
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
|
||||
});
|
||||
|
||||
it('initializes the fetch context with idle states for every variable', () => {
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT' }),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useVariableSelection } from '../hooks/useVariableSelection';
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
|
||||
useQueryState: (): unknown => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({
|
||||
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
|
||||
dtoToFormModel: (dto: unknown): unknown => dto,
|
||||
}));
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
|
||||
const env = model({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
queryValue: 'SELECT env',
|
||||
});
|
||||
const svc = model({
|
||||
name: 'svc',
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT svc WHERE env = $env',
|
||||
});
|
||||
|
||||
const dashboard = {
|
||||
id: 'd1',
|
||||
spec: { variables: [env, svc] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
function svcCycleId(): number {
|
||||
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
|
||||
}
|
||||
|
||||
describe('useVariableSelection — setSelection', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardStore.setState({
|
||||
variableValues: {},
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-cascade when the picked value is the one already held', () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
const afterFirstPick = svcCycleId();
|
||||
|
||||
// Same values, then the same set in a different order: neither is a change, so
|
||||
// the dependent must not be re-fetched.
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['b', 'a'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(svcCycleId()).toBe(afterFirstPick);
|
||||
});
|
||||
|
||||
it('ignores an auto-fill that the store already satisfies', async () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
// What a selector's first-render reconcile produces before the seed commits: a
|
||||
// value the store then resolves to on its own.
|
||||
await act(async () => {
|
||||
result.current.autoSelect('env', { value: ['a'], allSelected: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
});
|
||||
|
||||
expect(svcCycleId()).toBe(before);
|
||||
});
|
||||
|
||||
it('still applies an auto-fill that changes the value', async () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
await act(async () => {
|
||||
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
});
|
||||
|
||||
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
expect(svcCycleId()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('still cascades a genuine change', () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'c'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
|
||||
value: ['a', 'c'],
|
||||
allSelected: false,
|
||||
});
|
||||
expect(svcCycleId()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../selectionTypes';
|
||||
import { textDefault } from '../../utils/resolveVariableSelection';
|
||||
import { computeVariableDependencies } from '../../utils/variableDependencies';
|
||||
import TextSelector from '../selectors/TextSelector';
|
||||
import VariableValueControl from '../selectors/VariableValueControl';
|
||||
@@ -65,7 +66,7 @@ function VariableSelector({
|
||||
variable.type === 'TEXT' ? (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue={variable.textValue}
|
||||
defaultValue={textDefault(variable)}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { InputRef } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
|
||||
import { Input } from 'antd';
|
||||
@@ -31,6 +31,17 @@ function TextSelector({
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
|
||||
// The committed value can land after this input mounts — the seed resolves the
|
||||
// definition's default one tick later, and a share link or a reset can replace it
|
||||
// at any point. Without following it the box would keep showing its first render
|
||||
// (empty, since nothing is seeded yet) until the user focused and blurred it.
|
||||
// Typing never touches the selection, so an edit in progress cannot be interrupted.
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
}, [selection.value, defaultValue]);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => {
|
||||
void logEvent(
|
||||
|
||||
@@ -54,11 +54,26 @@ function ValueSelector({
|
||||
[selection, options],
|
||||
);
|
||||
|
||||
// That "all" path needs the options, so an ALL selection whose options have not
|
||||
// arrived yet has nothing to render and the control would read "Select value"
|
||||
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
|
||||
// selection is known, only its options are pending. Display only, so it can never
|
||||
// be committed as a value.
|
||||
const isAllPendingOptions = selection.allSelected && options.length === 0;
|
||||
|
||||
// Buffer edits while the dropdown is open; the committed selection is shown
|
||||
// when closed. This defers the dependent cascade to a single commit-on-close.
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(committedValues);
|
||||
|
||||
// ALL is every option, so there is nothing to clear — and the shared control refuses
|
||||
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
|
||||
// in the list is the way out of it.
|
||||
const draftIsAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => draft.includes(option));
|
||||
|
||||
const commit = (values: string[]): void => {
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
@@ -94,8 +109,11 @@ function ValueSelector({
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select value"
|
||||
// Clearing belongs to the open list: on the closed control the icon would
|
||||
// appear on hover, in a row of variable pills, for an action whose result is
|
||||
// not visible.
|
||||
allowClear={isOpen && !draftIsAll}
|
||||
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): JSX.Element => (
|
||||
@@ -129,12 +147,10 @@ function ValueSelector({
|
||||
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
|
||||
variableType,
|
||||
});
|
||||
// Empties the list, committing nothing. Closing resolves an empty draft
|
||||
// to whatever the variable should hold — its configured default, else ALL
|
||||
// where it offers one, else the first option.
|
||||
setDraft([]);
|
||||
// A clear on the closed control falls back to the default immediately;
|
||||
// while open it just empties the draft (committed on close).
|
||||
if (!isOpen) {
|
||||
onChange(emptyFallback);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,13 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
|
||||
import { knownVariableOptions } from '../utils/knownVariableOptions';
|
||||
import {
|
||||
areSelectionsEqual,
|
||||
reconcileWithOptions,
|
||||
resolveDefaultSelection,
|
||||
} from '../utils/resolveVariableSelection';
|
||||
import { hasUsableValue } from '../utils/selectionUtils';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
@@ -18,6 +24,44 @@ import {
|
||||
} from '../utils/variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
|
||||
|
||||
function isStoredSelectionSet(
|
||||
stored: VariableSelection,
|
||||
model: VariableFormModel,
|
||||
): boolean {
|
||||
return !!stored.allSelected || hasUsableValue(stored, model.type);
|
||||
}
|
||||
|
||||
/** Whether the seeded map matches, entry for entry, what the store already holds. */
|
||||
function isSameSelection(
|
||||
seeded: VariableSelectionMap,
|
||||
current: VariableSelectionMap,
|
||||
): boolean {
|
||||
const names = Object.keys(seeded);
|
||||
return (
|
||||
names.length === Object.keys(current).length &&
|
||||
names.every(
|
||||
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A seeded value taken as far as it can go: for a variable whose options need no
|
||||
* request, resolve against them now — ALL becomes the concrete array, values the list
|
||||
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
|
||||
* once the options arrive.
|
||||
*/
|
||||
function resolveAgainstKnownOptions(
|
||||
value: VariableSelection,
|
||||
model: VariableFormModel,
|
||||
): VariableSelection {
|
||||
const options = knownVariableOptions(model);
|
||||
if (options.length === 0) {
|
||||
return value;
|
||||
}
|
||||
return reconcileWithOptions(model, value, options) ?? value;
|
||||
}
|
||||
|
||||
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
|
||||
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
|
||||
function fromUrlValue(
|
||||
@@ -70,22 +114,31 @@ export function useSeedVariableSelection(
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
const stored = selection[variable.name];
|
||||
const seed = (value: VariableSelection): void => {
|
||||
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
|
||||
};
|
||||
if (urlValue !== undefined) {
|
||||
const fromUrl = fromUrlValue(urlValue, variable);
|
||||
// When the URL carries only the ALL sentinel but the store already holds
|
||||
// the materialized full-option array, reuse it — avoids the re-fetch +
|
||||
// re-materialize round-trip (and its dependent-refetch cascade) on load.
|
||||
seeded[variable.name] =
|
||||
seed(
|
||||
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
|
||||
? stored
|
||||
: fromUrl;
|
||||
} else if (stored) {
|
||||
seeded[variable.name] = stored;
|
||||
: fromUrl,
|
||||
);
|
||||
} else if (stored && isStoredSelectionSet(stored, variable)) {
|
||||
seed(stored);
|
||||
} else {
|
||||
seeded[variable.name] = resolveDefaultSelection(variable);
|
||||
seed(resolveDefaultSelection(variable));
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
// This runs again whenever the spec's variable array changes identity — a refetch
|
||||
// or any spec edit — and writing an identical map would re-render every selection
|
||||
// subscriber for nothing.
|
||||
if (!isSameSelection(seeded, selection)) {
|
||||
setVariableValues(dashboardId, seeded);
|
||||
}
|
||||
|
||||
// Read-once: a share link's `?variables=` seeds the store, then the param is
|
||||
// dropped so the store is the sole source of truth. Selection changes never
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import {
|
||||
sortValuesByOrder,
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../selectionTypes';
|
||||
import { knownVariableOptions } from '../utils/knownVariableOptions';
|
||||
import {
|
||||
useFetchedVariableOptions,
|
||||
type VariableOptions,
|
||||
@@ -30,14 +27,12 @@ export function useVariableOptions(
|
||||
): VariableOptions {
|
||||
const fetched = useFetchedVariableOptions(variable, variables, selections);
|
||||
|
||||
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
|
||||
// refetch hands back an equal-but-new model, and a new options array would re-fire
|
||||
// the post-fetch reconcile for nothing.
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValuesByOrder(
|
||||
commaValuesParser(variable.customValue),
|
||||
variable.sort,
|
||||
).map(String)
|
||||
: ([] as string[]),
|
||||
() => knownVariableOptions(variable),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[variable.type, variable.customValue, variable.sort],
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
|
||||
import { useSeedVariableSelection } from './useSeedVariableSelection';
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,10 @@ export function useVariableSelection(
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
const current = selectionRef.current[name];
|
||||
if (current && areSelectionsEqual(next, current)) {
|
||||
return;
|
||||
}
|
||||
setVariableValue(dashboardId, name, next);
|
||||
enqueueDescendants(name);
|
||||
},
|
||||
@@ -124,8 +129,19 @@ export function useVariableSelection(
|
||||
if (names.length === 0 || !dashboardId) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
|
||||
enqueueDescendantsBatch(names);
|
||||
// A fill can arrive already satisfied: a selector reconciles against the options
|
||||
// on its first render, before the seed has committed, and the seed then resolves
|
||||
// to the same value. Refresh only what actually moved, so a settled load ends
|
||||
// without a write or a dependent refetch.
|
||||
const current = selectionRef.current;
|
||||
const changed = names.filter(
|
||||
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
|
||||
);
|
||||
if (changed.length === 0) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...current, ...fills });
|
||||
enqueueDescendantsBatch(changed);
|
||||
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
|
||||
|
||||
const autoSelect = useCallback(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/**
|
||||
* The options of a variable whose list needs no request — a CUSTOM variable's comma
|
||||
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
|
||||
* TEXT, which has none.
|
||||
*
|
||||
* Knowing them up front lets the seed resolve such a variable's value completely
|
||||
* (materializing ALL, dropping values the list no longer offers) instead of leaving
|
||||
* that to the post-fetch reconcile, which would cost a second store write and a
|
||||
* refetch of everything downstream.
|
||||
*/
|
||||
export function knownVariableOptions(model: VariableFormModel): string[] {
|
||||
if (model.type !== 'CUSTOM') {
|
||||
return [];
|
||||
}
|
||||
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
|
||||
String,
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
|
||||
}
|
||||
|
||||
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
|
||||
function textDefault(model: VariableFormModel): string {
|
||||
export function textDefault(model: VariableFormModel): string {
|
||||
return firstConfiguredDefault(model) ?? model.textValue;
|
||||
}
|
||||
|
||||
@@ -53,15 +53,31 @@ function isAllDefault(
|
||||
);
|
||||
}
|
||||
|
||||
/** The configured default (or first option) as a fresh selection. */
|
||||
/**
|
||||
* The default selection for a variable with nothing usable selected: its configured
|
||||
* default, else ALL for an ALL-enabled multi-select, else the first option.
|
||||
*/
|
||||
function fillDefault(
|
||||
model: VariableFormModel,
|
||||
options: string[],
|
||||
): VariableSelection {
|
||||
const fallback = firstConfiguredDefault(model);
|
||||
const initial = fallback && options.includes(fallback) ? fallback : options[0];
|
||||
if (fallback && options.includes(fallback)) {
|
||||
return {
|
||||
value: model.multiSelect ? [fallback] : fallback,
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
|
||||
// to an arbitrary first option — the same default the seed applies (keep in sync
|
||||
// with resolveDefaultSelection).
|
||||
if (model.multiSelect && model.showAllOption) {
|
||||
return materializeAll(model, options, null) ?? ALL_SELECTION;
|
||||
}
|
||||
|
||||
return {
|
||||
value: model.multiSelect ? [initial] : initial,
|
||||
value: model.multiSelect ? [options[0]] : options[0],
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { useOpenPanelEditor } from '../useOpenPanelEditor';
|
||||
|
||||
@@ -81,6 +83,37 @@ describe('useOpenPanelEditor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the panel's saved query into the editor URL", () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'Panel A' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/PromQLQuery',
|
||||
spec: { name: 'A', query: 'up{job="alpha"}', disabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9', { panel });
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
const carried = new URLSearchParams(url.split('?')[1]).get('compositeQuery');
|
||||
const parsed = JSON.parse(decodeURIComponent(carried as string));
|
||||
expect(parsed.queryType).toBe(EQueryType.PROM);
|
||||
expect(parsed.promql[0].query).toBe('up{job="alpha"}');
|
||||
});
|
||||
|
||||
it('merges search with the time window (leading ? tolerated)', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { getPanelBuilderQuery } from '../Panels/utils/getPanelBuilderQuery';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTimeSearchParams } from './useTimeSearchParams';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
@@ -13,6 +17,8 @@ interface OpenPanelEditorOptions {
|
||||
handoffState?: PanelEditorHandoffState;
|
||||
/** Extra query merged into the editor URL (leading `?` optional). */
|
||||
search?: string;
|
||||
/** The panel being edited — its query rides in the URL as `compositeQuery`. */
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
/** Opens the V2 panel editor, carrying the active time window in the URL. */
|
||||
@@ -23,6 +29,7 @@ export function useOpenPanelEditor(): (
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { resetQuery } = useQueryBuilder();
|
||||
|
||||
return useCallback(
|
||||
(panelId: string, options?: OpenPanelEditorOptions): void => {
|
||||
@@ -39,12 +46,24 @@ export function useOpenPanelEditor(): (
|
||||
new URLSearchParams(timeSearch).forEach((value, key) => {
|
||||
params.set(key, value);
|
||||
});
|
||||
if (options?.panel) {
|
||||
const query = getPanelBuilderQuery(options.panel);
|
||||
// Single-encoded: `useGetCompositeQueryParam` decodes once on top of the decode
|
||||
// `URLSearchParams` already does.
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
// The provider applies the URL in an effect, a tick after the builder's fields
|
||||
// have mounted and read the query they keep (PromQL inputs, add-on rows).
|
||||
resetQuery(query);
|
||||
}
|
||||
const search = params.toString();
|
||||
safeNavigate(
|
||||
search ? `${path}?${search}` : path,
|
||||
options?.handoffState ? { state: options.handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId, timeSearch],
|
||||
[safeNavigate, dashboardId, timeSearch, resetQuery],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertLabelsProps } from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
|
||||
// default match type for threshold
|
||||
@@ -73,10 +73,6 @@ export interface StatsTimeSeriesItem {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type AlertRuleStatsPayload = {
|
||||
data: AlertRuleStats;
|
||||
};
|
||||
|
||||
export interface AlertRuleTopContributors {
|
||||
fingerprint: number;
|
||||
labels: Labels;
|
||||
@@ -84,9 +80,6 @@ export interface AlertRuleTopContributors {
|
||||
relatedLogsLink: string;
|
||||
relatedTracesLink: string;
|
||||
}
|
||||
export type AlertRuleTopContributorsPayload = {
|
||||
data: AlertRuleTopContributors[];
|
||||
};
|
||||
|
||||
export interface AlertRuleTimelineTableResponse {
|
||||
ruleID: string;
|
||||
@@ -99,24 +92,12 @@ export interface AlertRuleTimelineTableResponse {
|
||||
labels: Labels;
|
||||
fingerprint: number;
|
||||
value: number;
|
||||
relatedTracesLink: string;
|
||||
relatedLogsLink: string;
|
||||
relatedLogsLink?: string;
|
||||
relatedTracesLink?: string;
|
||||
}
|
||||
export type AlertRuleTimelineTableResponsePayload = {
|
||||
data: {
|
||||
items: AlertRuleTimelineTableResponse[];
|
||||
total: number;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
};
|
||||
};
|
||||
|
||||
type AlertState = 'firing' | 'normal' | 'nodata' | 'muted';
|
||||
|
||||
export interface AlertRuleTimelineGraphResponse {
|
||||
start: number;
|
||||
end: number;
|
||||
state: AlertState;
|
||||
state: RuletypesAlertStateDTO;
|
||||
}
|
||||
export type AlertRuleTimelineGraphResponsePayload = {
|
||||
data: AlertRuleTimelineGraphResponse[];
|
||||
};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface RuleStatsProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface GetTimelineGraphRequestProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { TagFilter } from '../queryBuilder/queryBuilderData';
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface GetTimelineTableRequestProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
order: string;
|
||||
filters?: TagFilter;
|
||||
state?: string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface TopContributorsProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
@@ -76,7 +75,6 @@ type provider struct {
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -112,7 +110,6 @@ func NewFactory(
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.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(
|
||||
@@ -151,7 +148,6 @@ func NewFactory(
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -192,7 +188,6 @@ func newProvider(
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -232,7 +227,6 @@ func newProvider(
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -365,10 +359,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addSavedViewRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/saved_views", handler.New(provider.authzMiddleware.ViewAccess(provider.savedViewHandler.ListV2), handler.OpenAPIDef{
|
||||
ID: "ListSavedViews",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "List saved views",
|
||||
Description: "Returns saved views, optionally filtered by source page and name.",
|
||||
Request: nil,
|
||||
RequestQuery: new(savedviewtypes.ListSavedViewsParams),
|
||||
RequestContentType: "",
|
||||
Response: new([]*savedviewtypes.GettableSavedView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.CreateV2), handler.OpenAPIDef{
|
||||
ID: "CreateSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Create saved view",
|
||||
Description: "Persists a saved view for the explore page. Returns the id of the created view.",
|
||||
Request: new(savedviewtypes.PostableSavedView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(valuer.UUID),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.ViewAccess(provider.savedViewHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Get saved view",
|
||||
Description: "Returns a saved view by id.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(savedviewtypes.GettableSavedView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.UpdateV2), handler.OpenAPIDef{
|
||||
ID: "UpdateSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Update saved view",
|
||||
Description: "Replaces a saved view's name and query.",
|
||||
Request: new(savedviewtypes.UpdatableSavedView),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.Delete), handler.OpenAPIDef{
|
||||
ID: "DeleteSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Delete saved view",
|
||||
Description: "Deletes a saved view by id.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -213,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", telemetrytypes.GenAIRequestModel)},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()", Alias: "spanCount"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIRequestModel,
|
||||
Name: telemetrytypes.GenAIRequestModel,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIProviderName,
|
||||
Name: telemetrytypes.GenAIProviderName,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
@@ -254,9 +254,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
switch c.Type {
|
||||
case qbtypes.ColumnTypeGroup:
|
||||
switch c.Name {
|
||||
case llmpricingruletypes.GenAIRequestModel:
|
||||
case telemetrytypes.GenAIRequestModel:
|
||||
modelIdx = i
|
||||
case llmpricingruletypes.GenAIProviderName:
|
||||
case telemetrytypes.GenAIProviderName:
|
||||
providerIdx = i
|
||||
}
|
||||
case qbtypes.ColumnTypeAggregation:
|
||||
|
||||
@@ -485,9 +485,11 @@ func (s *store) buildFilterClause(ctx context.Context, orgID valuer.UUID, filter
|
||||
}
|
||||
|
||||
fieldKeys, _, err := s.telemetryMetadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
if err != nil || len(fieldKeys) == 0 {
|
||||
if err != nil || fieldKeys == nil {
|
||||
fieldKeys = map[string][]*telemetrytypes.TelemetryFieldKey{}
|
||||
for _, sel := range selectors {
|
||||
}
|
||||
for _, sel := range selectors {
|
||||
if len(fieldKeys[sel.Name]) == 0 {
|
||||
fieldKeys[sel.Name] = []*telemetrytypes.TelemetryFieldKey{{
|
||||
Name: sel.Name,
|
||||
Signal: sel.Signal,
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -25,85 +23,6 @@ func NewHandler(module savedview.Module) savedview.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
// legacyExtraData mirrors the frontend's extraData JSON shape so /api/v1
|
||||
// responses can synthesize the same shape back for the legacy frontend.
|
||||
type legacyExtraData struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
SelectColumns []telemetrytypes.TelemetryFieldKey `json:"selectColumns,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MaxLines int `json:"maxLines,omitempty"`
|
||||
FontSize string `json:"fontSize,omitempty"`
|
||||
}
|
||||
|
||||
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
|
||||
var legacy legacyExtraData
|
||||
if v.ExtraData != "" {
|
||||
// Best-effort: malformed/older extraData shapes never fail the request
|
||||
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
|
||||
}
|
||||
|
||||
return savedviewtypes.PostableSavedView{
|
||||
Name: v.Name,
|
||||
SourcePage: savedviewtypes.SourcePage{String: valuer.NewString(v.SourcePage)},
|
||||
SavedViewData: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
|
||||
Queries: v.CompositeQuery.Queries,
|
||||
SelectedFields: legacy.SelectColumns,
|
||||
Display: savedviewtypes.Display{
|
||||
MaxLines: legacy.MaxLines,
|
||||
FontSize: legacy.FontSize,
|
||||
Format: legacy.Format,
|
||||
Color: legacy.Color,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLegacyViewFromGettable(v *savedviewtypes.GettableSavedView) (*v3.SavedView, error) {
|
||||
extraData, err := json.Marshal(legacyExtraData{
|
||||
Color: v.Spec.Display.Color,
|
||||
SelectColumns: v.Spec.SelectedFields,
|
||||
Format: v.Spec.Display.Format,
|
||||
MaxLines: v.Spec.Display.MaxLines,
|
||||
FontSize: v.Spec.Display.FontSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
|
||||
}
|
||||
|
||||
return &v3.SavedView{
|
||||
ID: v.ID,
|
||||
Name: v.Name,
|
||||
CreatedAt: v.CreatedAt,
|
||||
CreatedBy: v.CreatedBy,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
UpdatedBy: v.UpdatedBy,
|
||||
SourcePage: v.SourcePage.StringValue(),
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
|
||||
// Saved views are only ever created from the explorer's builder mode.
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
Queries: v.Spec.Queries,
|
||||
},
|
||||
ExtraData: string(extraData),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newLegacyViewsFromGettable(views []*savedviewtypes.GettableSavedView) ([]*v3.SavedView, error) {
|
||||
out := make([]*v3.SavedView, 0, len(views))
|
||||
for _, view := range views {
|
||||
legacyView, err := newLegacyViewFromGettable(view)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, legacyView)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -125,7 +44,7 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -157,13 +76,7 @@ func (handler *handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
legacyView, err := newLegacyViewFromGettable(view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, legacyView)
|
||||
render.Success(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -193,7 +106,7 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newPostableSavedViewFromLegacyView(&view))
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -240,18 +153,13 @@ func (handler *handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sourcePage := r.URL.Query().Get("sourcePage")
|
||||
name := r.URL.Query().Get("name")
|
||||
category := r.URL.Query().Get("category")
|
||||
|
||||
views, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, savedviewtypes.SourcePage{String: valuer.NewString(sourcePage)}, name)
|
||||
queries, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, sourcePage, name, category)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
legacyViews, err := newLegacyViewsFromGettable(views)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, legacyViews)
|
||||
render.Success(w, http.StatusOK, queries)
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (handler *handler) CreateV2(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var view savedviewtypes.PostableSavedView
|
||||
if err := binding.JSON.BindBody(r.Body, &view, binding.WithDisallowUnknownFields(true)); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := view.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, uuid)
|
||||
}
|
||||
|
||||
func (handler *handler) GetV2(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
viewID := mux.Vars(r)["viewId"]
|
||||
viewUUID, err := valuer.NewUUID(viewID)
|
||||
if err != nil {
|
||||
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.module.GetView(ctx, claims.OrgID, viewUUID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateV2(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
viewID := mux.Vars(r)["viewId"]
|
||||
viewUUID, err := valuer.NewUUID(viewID)
|
||||
if err != nil {
|
||||
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
||||
return
|
||||
}
|
||||
var view savedviewtypes.UpdatableSavedView
|
||||
if err := binding.JSON.BindBody(r.Body, &view, binding.WithDisallowUnknownFields(true)); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := view.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListV2(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
params := new(savedviewtypes.ListSavedViewsParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
queries, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, params.SourcePage, params.Name)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, queries)
|
||||
}
|
||||
@@ -2,72 +2,185 @@ package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store savedviewtypes.Store
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewModule(store savedviewtypes.Store) savedview.Module {
|
||||
return &module{store: store}
|
||||
func NewModule(sqlstore sqlstore.SQLStore) savedview.Module {
|
||||
return &module{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.GettableSavedView, error) {
|
||||
views, err := module.store.List(ctx, orgID, sourcePage, name)
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error) {
|
||||
var views []savedviewtypes.SavedView
|
||||
var err error
|
||||
if len(category) == 0 {
|
||||
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").Scan(ctx)
|
||||
} else {
|
||||
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND category LIKE ? AND name LIKE ?", orgID, sourcePage, "%"+category+"%", "%"+name+"%").Scan(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
|
||||
}
|
||||
|
||||
return savedviewtypes.NewGettableSavedViewsFromSavedViews(views), nil
|
||||
var savedViews []*v3.SavedView
|
||||
for _, view := range views {
|
||||
var compositeQuery v3.CompositeQuery
|
||||
err = json.Unmarshal([]byte(view.Data), &compositeQuery)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in unmarshalling explorer query data: %s", err.Error())
|
||||
}
|
||||
savedViews = append(savedViews, &v3.SavedView{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
CreatedAt: view.CreatedAt,
|
||||
CreatedBy: view.CreatedBy,
|
||||
UpdatedAt: view.UpdatedAt,
|
||||
UpdatedBy: view.UpdatedBy,
|
||||
Tags: strings.Split(view.Tags, ","),
|
||||
SourcePage: view.SourcePage,
|
||||
CompositeQuery: &compositeQuery,
|
||||
ExtraData: view.ExtraData,
|
||||
})
|
||||
}
|
||||
return savedViews, nil
|
||||
}
|
||||
|
||||
func (module *module) CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
func (module *module) CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error) {
|
||||
data, err := json.Marshal(view.CompositeQuery)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling explorer query data")
|
||||
}
|
||||
|
||||
uuid := valuer.GenerateUUID()
|
||||
createdAt := time.Now()
|
||||
updatedAt := time.Now()
|
||||
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
if errv2 != nil {
|
||||
return valuer.UUID{}, errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
|
||||
}
|
||||
|
||||
dbView := savedviewtypes.NewSavedView(orgID, claims.Email, claims.Email, view)
|
||||
createBy := claims.Email
|
||||
updatedBy := claims.Email
|
||||
|
||||
if err := module.store.Create(ctx, dbView); err != nil {
|
||||
return valuer.UUID{}, err
|
||||
}
|
||||
return dbView.ID, nil
|
||||
}
|
||||
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.GettableSavedView, error) {
|
||||
view, err := module.store.Get(ctx, orgID, uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
dbView := savedviewtypes.SavedView{
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: createBy,
|
||||
UpdatedBy: updatedBy,
|
||||
},
|
||||
OrgID: orgID,
|
||||
Identifiable: types.Identifiable{
|
||||
ID: uuid,
|
||||
},
|
||||
Name: view.Name,
|
||||
Category: view.Category,
|
||||
SourcePage: view.SourcePage,
|
||||
Tags: strings.Join(view.Tags, ","),
|
||||
Data: string(data),
|
||||
ExtraData: view.ExtraData,
|
||||
}
|
||||
|
||||
return savedviewtypes.NewGettableSavedViewFromSavedView(view), nil
|
||||
_, err = module.sqlstore.BunDB().NewInsert().Model(&dbView).Exec(ctx)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.WrapInternalf(err, errors.CodeInternal, "error in creating saved view")
|
||||
}
|
||||
return uuid, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
|
||||
var view savedviewtypes.SavedView
|
||||
err := module.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved view")
|
||||
}
|
||||
|
||||
var compositeQuery v3.CompositeQuery
|
||||
err = json.Unmarshal([]byte(view.Data), &compositeQuery)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in unmarshalling explorer query data")
|
||||
}
|
||||
return &v3.SavedView{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
Category: view.Category,
|
||||
CreatedAt: view.CreatedAt,
|
||||
CreatedBy: view.CreatedBy,
|
||||
UpdatedAt: view.UpdatedAt,
|
||||
UpdatedBy: view.UpdatedBy,
|
||||
SourcePage: view.SourcePage,
|
||||
Tags: strings.Split(view.Tags, ","),
|
||||
CompositeQuery: &compositeQuery,
|
||||
ExtraData: view.ExtraData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error {
|
||||
data, err := json.Marshal(view.CompositeQuery)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling explorer query data")
|
||||
}
|
||||
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
if errv2 != nil {
|
||||
return errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
|
||||
}
|
||||
|
||||
dbView := savedviewtypes.NewSavedView(orgID, claims.Email, claims.Email, view)
|
||||
dbView.ID = uuid
|
||||
updatedAt := time.Now()
|
||||
updatedBy := claims.Email
|
||||
|
||||
return module.store.Update(ctx, dbView)
|
||||
_, err = module.sqlstore.BunDB().NewUpdate().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
|
||||
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
|
||||
return module.store.Delete(ctx, orgID, uuid)
|
||||
_, err := module.sqlstore.BunDB().NewDelete().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in deleting explorer query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
savedViews, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.SourcePage{}, "")
|
||||
savedViews := []*savedviewtypes.SavedView{}
|
||||
|
||||
err := module.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&savedViews).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
package implsavedview_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedviewtest"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testPostableSavedView(name string, sourcePage savedviewtypes.SourcePage) savedviewtypes.PostableSavedView {
|
||||
return savedviewtypes.PostableSavedView{
|
||||
Name: name,
|
||||
SourcePage: sourcePage,
|
||||
SavedViewData: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
PanelType: savedviewtypes.PanelTypeGraph,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testSavedView(orgID string, id valuer.UUID, updatedBy string, view savedviewtypes.PostableSavedView) *savedviewtypes.SavedView {
|
||||
savedView := savedviewtypes.NewSavedView(orgID, "creator@signoz.io", updatedBy, view)
|
||||
savedView.ID = id
|
||||
return savedView
|
||||
}
|
||||
|
||||
func contextWithClaims(orgID, email string) context.Context {
|
||||
return authtypes.NewContextWithClaims(context.Background(), authtypes.Claims{
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
})
|
||||
}
|
||||
|
||||
func TestModule_CreateAndGetView(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
view := testPostableSavedView("my view", savedviewtypes.SourcePageLogs)
|
||||
|
||||
store.ExpectCreate()
|
||||
id, err := m.CreateView(ctx, orgID, view)
|
||||
require.NoError(t, err)
|
||||
require.False(t, id.IsZero())
|
||||
|
||||
stored := testSavedView(orgID, id, "creator@signoz.io", view)
|
||||
store.ExpectGet(orgID, id, stored)
|
||||
got, err := m.GetView(ctx, orgID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, got.ID)
|
||||
assert.Equal(t, "my view", got.Name)
|
||||
assert.Equal(t, savedviewtypes.SourcePageLogs, got.SourcePage)
|
||||
assert.Equal(t, "creator@signoz.io", got.CreatedBy)
|
||||
assert.Equal(t, "creator@signoz.io", got.UpdatedBy)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Spec.PanelType)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_GetView_NotFound(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectGet(orgID, id, nil)
|
||||
_, err := m.GetView(contextWithClaims(orgID, "someone@signoz.io"), orgID, id)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_GetView_ScopedToOrg(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
// The mock only has an expectation for orgB's WHERE clause; a lookup
|
||||
// scoped to org A's real id must not accidentally match it.
|
||||
store.ExpectGet(orgB, id, nil)
|
||||
_, err := m.GetView(contextWithClaims(orgB, "b@signoz.io"), orgB, id)
|
||||
require.Error(t, err, "a view created under org A must not be visible to org B")
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_UpdateView(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
updated := testPostableSavedView("renamed", savedviewtypes.SourcePageTraces)
|
||||
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
|
||||
|
||||
store.ExpectUpdate(orgID, id, 1)
|
||||
require.NoError(t, m.UpdateView(contextWithClaims(orgID, "updater@signoz.io"), orgID, id, updated))
|
||||
|
||||
stored := testSavedView(orgID, id, "updater@signoz.io", updated)
|
||||
store.ExpectGet(orgID, id, stored)
|
||||
got, err := m.GetView(contextWithClaims(orgID, "creator@signoz.io"), orgID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", got.Name)
|
||||
assert.Equal(t, savedviewtypes.SourcePageTraces, got.SourcePage)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
|
||||
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_UpdateView_NotFound(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "someone@signoz.io")
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectUpdate(orgID, id, 0)
|
||||
err := m.UpdateView(ctx, orgID, id, testPostableSavedView("does not exist", savedviewtypes.SourcePageLogs))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_UpdateView_ScopedToOrg(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
// Only an update scoped to orgB's WHERE clause is registered; updating
|
||||
// org A's view while authenticated as org B must not match it.
|
||||
store.ExpectUpdate(orgB, id, 0)
|
||||
err := m.UpdateView(contextWithClaims(orgB, "b@signoz.io"), orgB, id, testPostableSavedView("hijacked", savedviewtypes.SourcePageLogs))
|
||||
require.Error(t, err, "org B must not be able to update org A's view")
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_DeleteView(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectDelete(orgID, id, 1)
|
||||
require.NoError(t, m.DeleteView(ctx, orgID, id))
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_DeleteView_NotFound(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "someone@signoz.io")
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectDelete(orgID, id, 0)
|
||||
err := m.DeleteView(ctx, orgID, id)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_DeleteView_ScopedToOrg(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
id := valuer.GenerateUUID()
|
||||
|
||||
store.ExpectDelete(orgB, id, 0)
|
||||
err := m.DeleteView(contextWithClaims(orgB, "b@signoz.io"), orgB, id)
|
||||
require.Error(t, err, "org B must not be able to delete org A's view")
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_GetViewsForFilters(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
|
||||
logsOverview := testSavedView(orgID, valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("logs overview", savedviewtypes.SourcePageLogs))
|
||||
logsErrors := testSavedView(orgID, valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("logs errors", savedviewtypes.SourcePageLogs))
|
||||
tracesOverview := testSavedView(orgID, valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("traces overview", savedviewtypes.SourcePageTraces))
|
||||
|
||||
t.Run("filters by source page", func(t *testing.T) {
|
||||
store.ExpectList(orgID, []*savedviewtypes.SavedView{logsOverview, logsErrors})
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePageLogs, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, views, 2)
|
||||
})
|
||||
|
||||
t.Run("filters by name substring", func(t *testing.T) {
|
||||
store.ExpectList(orgID, []*savedviewtypes.SavedView{logsErrors})
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePageLogs, "errors")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, views, 1)
|
||||
assert.Equal(t, "logs errors", views[0].Name)
|
||||
})
|
||||
|
||||
t.Run("omitted source page returns everything, not nothing", func(t *testing.T) {
|
||||
// Fixes a bug: source_page used to be an unconditional exact-match
|
||||
// clause, so a zero-value sourcePage matched zero rows -- even though
|
||||
// ListSavedViewsParams.Validate() treats a zero SourcePage as valid
|
||||
// ("no filter"). Store.List now only applies the source_page clause
|
||||
// when it's non-zero.
|
||||
store.ExpectList(orgID, []*savedviewtypes.SavedView{logsOverview, logsErrors, tracesOverview})
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePage{}, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, views, 3)
|
||||
})
|
||||
|
||||
t.Run("scoped to org", func(t *testing.T) {
|
||||
otherOrgID := valuer.GenerateUUID().StringValue()
|
||||
store.ExpectList(otherOrgID, nil)
|
||||
views, err := m.GetViewsForFilters(ctx, otherOrgID, savedviewtypes.SourcePageLogs, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, views)
|
||||
})
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
|
||||
func TestModule_Collect(t *testing.T) {
|
||||
store := implsavedviewtest.NewMockSavedViewStore()
|
||||
m := implsavedview.NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
logsA := testSavedView(orgID.StringValue(), valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("logs a", savedviewtypes.SourcePageLogs))
|
||||
logsB := testSavedView(orgID.StringValue(), valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("logs b", savedviewtypes.SourcePageLogs))
|
||||
tracesA := testSavedView(orgID.StringValue(), valuer.GenerateUUID(), "creator@signoz.io", testPostableSavedView("traces a", savedviewtypes.SourcePageTraces))
|
||||
|
||||
store.ExpectList(orgID.StringValue(), []*savedviewtypes.SavedView{logsA, logsB, tracesA})
|
||||
stats, err := m.Collect(context.Background(), orgID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), stats["savedview.count"])
|
||||
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
|
||||
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
|
||||
|
||||
require.NoError(t, store.AssertExpectations())
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) savedviewtypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
_, err := store.sqlstore.BunDB().NewInsert().Model(view).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in creating saved view")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.SavedView, error) {
|
||||
var view savedviewtypes.SavedView
|
||||
err := store.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
|
||||
}
|
||||
|
||||
normalizeSelectedFields(&view)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
res, err := store.sqlstore.BunDB().NewUpdate().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Set("updated_at = ?, updated_by = ?, name = ?, source_page = ?, data = ?",
|
||||
view.UpdatedAt, view.UpdatedBy, view.Name, view.SourcePage, view.Data).
|
||||
Where("id = ?", view.ID.StringValue()).
|
||||
Where("org_id = ?", view.OrgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the updated saved view")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", view.ID.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
|
||||
res, err := store.sqlstore.BunDB().NewDelete().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in deleting saved view")
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the deleted saved view")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.SavedView, error) {
|
||||
var views []*savedviewtypes.SavedView
|
||||
q := store.sqlstore.BunDB().NewSelect().Model(&views).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name LIKE ?", "%"+name+"%")
|
||||
if !sourcePage.IsZero() {
|
||||
q = q.Where("source_page = ?", sourcePage)
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
|
||||
}
|
||||
|
||||
for _, view := range views {
|
||||
normalizeSelectedFields(view)
|
||||
}
|
||||
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// normalizeSelectedFields fixes up a scanned row's nil SelectedFields (an
|
||||
// empty/never-set spec round-trips through the DB as JSON null) to an
|
||||
// explicit empty slice, so the API response never serializes it as null.
|
||||
func normalizeSelectedFields(view *savedviewtypes.SavedView) {
|
||||
if view.Data.Spec.SelectedFields == nil {
|
||||
view.Data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
// Package implsavedviewtest provides a sqlmock-backed savedviewtypes.Store,
|
||||
// mirroring rulestore/rulestoretest and nfroutingstore/nfroutingstoretest:
|
||||
// it delegates to the real implsavedview store so tests assert against
|
||||
// genuinely-generated SQL, rather than a hand-rolled fake.
|
||||
package implsavedviewtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var savedViewColumns = []string{"id", "created_at", "updated_at", "created_by", "updated_by", "org_id", "name", "source_page", "data"}
|
||||
|
||||
type MockSavedViewStore struct {
|
||||
store savedviewtypes.Store
|
||||
mock sqlmock.Sqlmock
|
||||
}
|
||||
|
||||
func NewMockSavedViewStore() *MockSavedViewStore {
|
||||
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
|
||||
store := implsavedview.NewStore(sqlStore)
|
||||
|
||||
return &MockSavedViewStore{
|
||||
store: store,
|
||||
mock: sqlStore.Mock(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Mock() sqlmock.Sqlmock {
|
||||
return m.mock
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
return m.store.Create(ctx, view)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.SavedView, error) {
|
||||
return m.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
return m.store.Update(ctx, view)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
|
||||
return m.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) List(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.SavedView, error) {
|
||||
return m.store.List(ctx, orgID, sourcePage, name)
|
||||
}
|
||||
|
||||
func savedViewRow(view *savedviewtypes.SavedView) []driver.Value {
|
||||
data, _ := json.Marshal(view.Data)
|
||||
return []driver.Value{
|
||||
view.ID.StringValue(),
|
||||
view.CreatedAt,
|
||||
view.UpdatedAt,
|
||||
view.CreatedBy,
|
||||
view.UpdatedBy,
|
||||
view.OrgID,
|
||||
view.Name,
|
||||
view.SourcePage.StringValue(),
|
||||
string(data),
|
||||
}
|
||||
}
|
||||
|
||||
// ExpectCreate sets up the SQL expectation for a Create call.
|
||||
func (m *MockSavedViewStore) ExpectCreate() {
|
||||
m.mock.ExpectExec(`INSERT INTO "saved_view"`).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
}
|
||||
|
||||
// ExpectGet sets up the SQL expectation for a Get call. Pass view = nil to
|
||||
// simulate a not-found row.
|
||||
func (m *MockSavedViewStore) ExpectGet(orgID string, id valuer.UUID, view *savedviewtypes.SavedView) {
|
||||
rows := sqlmock.NewRows(savedViewColumns)
|
||||
if view != nil {
|
||||
rows.AddRow(savedViewRow(view)...)
|
||||
}
|
||||
|
||||
m.mock.ExpectQuery(`SELECT (.+) FROM "saved_view".+WHERE \(org_id = '` + regexp.QuoteMeta(orgID) + `' AND id = '` + regexp.QuoteMeta(id.StringValue()) + `'\)`).
|
||||
WillReturnRows(rows)
|
||||
}
|
||||
|
||||
// ExpectUpdate sets up the SQL expectation for an Update call scoped to
|
||||
// orgID/id. rowsAffected = 0 simulates a not-found target row.
|
||||
func (m *MockSavedViewStore) ExpectUpdate(orgID string, id valuer.UUID, rowsAffected int64) {
|
||||
m.mock.ExpectExec(`UPDATE "saved_view".+WHERE \(id = '` + regexp.QuoteMeta(id.StringValue()) + `'\) AND \(org_id = '` + regexp.QuoteMeta(orgID) + `'\)`).
|
||||
WillReturnResult(sqlmock.NewResult(0, rowsAffected))
|
||||
}
|
||||
|
||||
// ExpectDelete sets up the SQL expectation for a Delete call scoped to
|
||||
// orgID/id. rowsAffected = 0 simulates a not-found target row.
|
||||
func (m *MockSavedViewStore) ExpectDelete(orgID string, id valuer.UUID, rowsAffected int64) {
|
||||
m.mock.ExpectExec(`DELETE FROM "saved_view".+WHERE \(id = '` + regexp.QuoteMeta(id.StringValue()) + `'\) AND \(org_id = '` + regexp.QuoteMeta(orgID) + `'\)`).
|
||||
WillReturnResult(sqlmock.NewResult(0, rowsAffected))
|
||||
}
|
||||
|
||||
// ExpectList sets up the SQL expectation for a List call scoped to orgID.
|
||||
func (m *MockSavedViewStore) ExpectList(orgID string, views []*savedviewtypes.SavedView) {
|
||||
rows := sqlmock.NewRows(savedViewColumns)
|
||||
for _, view := range views {
|
||||
rows.AddRow(savedViewRow(view)...)
|
||||
}
|
||||
|
||||
m.mock.ExpectQuery(`SELECT (.+) FROM "saved_view".+WHERE \(org_id = '` + regexp.QuoteMeta(orgID) + `'\)`).WillReturnRows(rows)
|
||||
}
|
||||
|
||||
func (m *MockSavedViewStore) AssertExpectations() error {
|
||||
return m.mock.ExpectationsWereMet()
|
||||
}
|
||||
@@ -4,19 +4,19 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
GetViewsForFilters(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.GettableSavedView, error)
|
||||
GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error)
|
||||
|
||||
CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error)
|
||||
CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error)
|
||||
|
||||
GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.GettableSavedView, error)
|
||||
GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error)
|
||||
|
||||
UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error
|
||||
UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error
|
||||
|
||||
DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error
|
||||
|
||||
@@ -33,22 +33,9 @@ type Handler interface {
|
||||
// Updates the saved view
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Deletes the saved view. Shared by both API generations -- delete has no
|
||||
// request/response body to reshape.
|
||||
// Deletes the saved view
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Lists the saved views
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
// CreateV2 is the /api/v2/saved_views typed-spec variant of Create.
|
||||
CreateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// GetV2 is the /api/v2/saved_views typed-spec variant of Get.
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// UpdateV2 is the /api/v2/saved_views typed-spec variant of Update.
|
||||
UpdateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// ListV2 is the /api/v2/saved_views typed-spec variant of List.
|
||||
ListV2(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ func (handler *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Reque
|
||||
errs := []error{}
|
||||
|
||||
for idx, item := range queryRangeRequest.CompositeQuery.Queries {
|
||||
if item.Type == qbtypes.QueryTypeBuilder {
|
||||
if item.Type == qbtypes.QueryTypeBuilder || item.Type == qbtypes.QueryTypeBuilderAI {
|
||||
switch spec := item.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
if spec.Filter != nil && spec.Filter.Expression != "" {
|
||||
|
||||
@@ -249,6 +249,7 @@ func (q *querier) buildPreviewProviders(
|
||||
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
|
||||
switch t {
|
||||
case qbtypes.QueryTypeBuilder,
|
||||
qbtypes.QueryTypeBuilderAI,
|
||||
qbtypes.QueryTypePromQL,
|
||||
qbtypes.QueryTypeClickHouseSQL,
|
||||
qbtypes.QueryTypeTraceOperator:
|
||||
|
||||
@@ -52,6 +52,7 @@ type querier struct {
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
@@ -71,6 +72,7 @@ func New(
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
promEngine prometheus.Prometheus,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -92,6 +94,7 @@ func New(
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
aiTraceStmtBuilder: aiTraceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
@@ -242,6 +245,16 @@ func (q *querier) buildQueries(
|
||||
}
|
||||
queries[traceOpQuery.Name] = toq
|
||||
steps[traceOpQuery.Name] = traceOpQuery.StepInterval
|
||||
case qbtypes.QueryTypeBuilderAI:
|
||||
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
if !ok {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid AI builder query spec %T", query.Spec)
|
||||
}
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryTypeBuilder:
|
||||
switch spec := query.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
@@ -308,6 +321,11 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
event.MetricsUsed = true
|
||||
}
|
||||
case qbtypes.QueryTypeBuilderAI:
|
||||
filter := query.GetFilter()
|
||||
event.FilterApplied = event.FilterApplied || (filter != nil && filter.Expression != "")
|
||||
event.GroupByApplied = event.GroupByApplied || len(query.GetGroupBy()) > 0
|
||||
event.TracesUsed = true
|
||||
case qbtypes.QueryTypePromQL:
|
||||
event.MetricsUsed = true
|
||||
case qbtypes.QueryTypeTraceOperator:
|
||||
@@ -870,7 +888,9 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
// Reuse the statement builder the original query was created with, so an AI
|
||||
// query keeps its AI builder without re-deriving it from the spec.
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
@@ -1227,6 +1247,8 @@ func (q *querier) adjustStepInterval(queries []qbtypes.QueryEnvelope, start, end
|
||||
if qe.GetStepInterval().Seconds() == 0 {
|
||||
qe.SetStepInterval(secondsStep(metricRecommended))
|
||||
}
|
||||
case qbtypes.QueryTypeBuilderAI:
|
||||
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
|
||||
case qbtypes.QueryTypeTraceOperator:
|
||||
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -121,6 +122,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
&mockMetricStmtBuilder{},
|
||||
|
||||
@@ -20,6 +20,7 @@ func NewFactory(
|
||||
prometheus prometheus.Prometheus,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -41,6 +42,7 @@ func NewFactory(
|
||||
metadataStore,
|
||||
prometheus,
|
||||
traceStmtBuilder,
|
||||
aiTraceStmtBuilder,
|
||||
logStmtBuilder,
|
||||
auditStmtBuilder,
|
||||
metricStmtBuilder,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/auditstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/meterstatementbuilder"
|
||||
@@ -117,6 +118,8 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
|
||||
ctx := context.Background()
|
||||
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
require.NoError(t, err)
|
||||
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
require.NoError(t, err)
|
||||
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
require.NoError(t, err)
|
||||
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
@@ -128,7 +131,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
|
||||
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
require.NoError(t, err)
|
||||
bucketCache := querier.NewBucketCache(providerSettings, cache, 0, 0)
|
||||
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
|
||||
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
|
||||
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
metricStmtBuilder,
|
||||
@@ -75,6 +76,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
logStmtBuilder,
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -110,6 +112,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
traceStmtBuilder,
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
|
||||
176
pkg/querybuilder/filter_split.go
Normal file
176
pkg/querybuilder/filter_split.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
)
|
||||
|
||||
// SplitFilterForAggregates partitions a single filter expression into a span-level
|
||||
// part (a WHERE over spans) and a trace-level part (a HAVING over per-trace
|
||||
// aggregates), splitting on the top-level AND.
|
||||
//
|
||||
// A key is trace-level when it carries the trace field context (`trace.completion_tokens`)
|
||||
// or, with no context, its bare name is in aggregateNames. Any other explicit context
|
||||
// (`span.`, `resource.`, …) is span-level. Trace-level and span-level keys may be
|
||||
// AND-combined (they run at different query stages) but not OR-combined; an OR that
|
||||
// mixes the two is an error.
|
||||
func SplitFilterForAggregates(query string, aggregateNames map[string]struct{}) (spanExpr string, havingExpr string, err error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
tree, syntaxErrors := parseFilterQuery(query)
|
||||
if len(syntaxErrors) > 0 {
|
||||
combinedErrors := errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"Found %d syntax errors while parsing the filter expression.",
|
||||
len(syntaxErrors),
|
||||
)
|
||||
additionals := make([]string, 0, len(syntaxErrors))
|
||||
for _, syntaxError := range syntaxErrors {
|
||||
if syntaxError.Error() != "" {
|
||||
additionals = append(additionals, syntaxError.Error())
|
||||
}
|
||||
}
|
||||
// TODO: add troubleshooting link to the filter query syntax guide once it's published.
|
||||
return "", "", combinedErrors.WithAdditional(additionals...)
|
||||
}
|
||||
|
||||
s := filterSplitter{query: []rune(query), aggregateNames: aggregateNames}
|
||||
s.visit(tree)
|
||||
|
||||
if s.mixed {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"trace-level and span-level filters cannot be combined within an OR/NOT group; separate them with a top-level AND")
|
||||
}
|
||||
return strings.Join(s.span, " AND "), strings.Join(s.having, " AND "), nil
|
||||
}
|
||||
|
||||
func parseFilterQuery(query string) (antlr.Tree, []*SyntaxErr) {
|
||||
lexerErrorListener := NewErrorListener()
|
||||
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
|
||||
lexer.RemoveErrorListeners()
|
||||
lexer.AddErrorListener(lexerErrorListener)
|
||||
|
||||
parserErrorListener := NewErrorListener()
|
||||
parser := grammar.NewFilterQueryParser(antlr.NewCommonTokenStream(lexer, 0))
|
||||
parser.RemoveErrorListeners()
|
||||
parser.AddErrorListener(parserErrorListener)
|
||||
|
||||
tree := parser.Query()
|
||||
return tree, append(lexerErrorListener.SyntaxErrors, parserErrorListener.SyntaxErrors...)
|
||||
}
|
||||
|
||||
// filterSplitter walks the parse tree once, flattening the top-level AND chain and
|
||||
// routing each atom (a comparison, a NOT expression, or a whole multi-branch OR group)
|
||||
// to the span or having bucket by the class of the keys it references.
|
||||
type filterSplitter struct {
|
||||
query []rune
|
||||
aggregateNames map[string]struct{}
|
||||
span []string
|
||||
having []string
|
||||
mixed bool
|
||||
}
|
||||
|
||||
func (s *filterSplitter) visit(node antlr.Tree) {
|
||||
switch n := node.(type) {
|
||||
case *grammar.QueryContext:
|
||||
if n.Expression() != nil {
|
||||
s.visit(n.Expression())
|
||||
}
|
||||
case *grammar.ExpressionContext:
|
||||
if n.OrExpression() != nil {
|
||||
s.visit(n.OrExpression())
|
||||
}
|
||||
case *grammar.OrExpressionContext:
|
||||
// a single branch is just an AND chain; multiple branches are a real OR, kept
|
||||
// whole so a class-mixing OR can be rejected.
|
||||
if ands := n.AllAndExpression(); len(ands) == 1 {
|
||||
s.visit(ands[0])
|
||||
} else {
|
||||
s.route(n)
|
||||
}
|
||||
case *grammar.AndExpressionContext:
|
||||
for _, u := range n.AllUnaryExpression() {
|
||||
s.visit(u)
|
||||
}
|
||||
case *grammar.UnaryExpressionContext:
|
||||
if n.NOT() != nil {
|
||||
s.route(n)
|
||||
} else if n.Primary() != nil {
|
||||
s.visit(n.Primary())
|
||||
}
|
||||
case *grammar.PrimaryContext:
|
||||
if n.OrExpression() != nil { // parenthesized sub-expression
|
||||
s.visit(n.OrExpression())
|
||||
} else {
|
||||
s.route(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route classifies an atom and appends its original source text to the right bucket.
|
||||
func (s *filterSplitter) route(atom antlr.ParserRuleContext) {
|
||||
isTrace, isSpan := classifyKeys(atom, s.aggregateNames)
|
||||
if isTrace && isSpan {
|
||||
s.mixed = true
|
||||
return
|
||||
}
|
||||
text := atomSourceText(s.query, atom)
|
||||
// A multi-branch OR group's source slice excludes its enclosing parens (they belong
|
||||
// to the parent Primary). Re-wrap it so rejoining a bucket with " AND " cannot invert
|
||||
// OR/AND precedence, e.g. `a AND (b OR c)` must not flatten to `a AND b OR c`.
|
||||
if or, ok := atom.(*grammar.OrExpressionContext); ok && len(or.AllAndExpression()) > 1 {
|
||||
text = "(" + text + ")"
|
||||
}
|
||||
if isTrace {
|
||||
s.having = append(s.having, text)
|
||||
} else {
|
||||
s.span = append(s.span, text)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyKeys reports whether a subtree references trace-level and/or span-level keys.
|
||||
// A key is trace-level when it carries the trace field context or, with no context,
|
||||
// its name is a known aggregate; an unknown name under the trace context stays
|
||||
// trace-level so the aggregate validation rejects it with a targeted error. Any other
|
||||
// explicit context (`span.`, `resource.`, …) is span-level.
|
||||
func classifyKeys(node antlr.Tree, aggregateNames map[string]struct{}) (isTrace, isSpan bool) {
|
||||
kc, ok := node.(*grammar.KeyContext)
|
||||
if ok {
|
||||
key := telemetrytypes.GetFieldKeyFromKeyText(kc.GetText())
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextTrace:
|
||||
isTrace = true
|
||||
case telemetrytypes.FieldContextUnspecified:
|
||||
_, isTrace = aggregateNames[key.Name]
|
||||
isSpan = !isTrace
|
||||
default:
|
||||
isSpan = true
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := 0; i < node.GetChildCount(); i++ {
|
||||
t, s := classifyKeys(node.GetChild(i), aggregateNames)
|
||||
isTrace = isTrace || t
|
||||
isSpan = isSpan || s
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// atomSourceText returns the original source substring for an atom, preserving
|
||||
// whitespace. The token stream drops skipped whitespace, which would glue word
|
||||
// operators (OR/AND/NOT) to their operands, so slice the input by token offsets.
|
||||
// ANTLR offsets are rune indices (InputStream holds []rune), hence the rune slice.
|
||||
func atomSourceText(query []rune, atom antlr.ParserRuleContext) string {
|
||||
start, stop := atom.GetStart(), atom.GetStop()
|
||||
if start == nil || stop == nil || start.GetStart() < 0 || stop.GetStop() >= len(query) || stop.GetStop() < start.GetStart() {
|
||||
return atom.GetText()
|
||||
}
|
||||
return string(query[start.GetStart() : stop.GetStop()+1])
|
||||
}
|
||||
222
pkg/querybuilder/filter_split_test.go
Normal file
222
pkg/querybuilder/filter_split_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSplitFilterForAggregates(t *testing.T) {
|
||||
agg := map[string]struct{}{"completion_tokens": {}, "span_count": {}, "prompt_tokens": {}}
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
query string
|
||||
span string // expected span-level (WHERE) part; "" => empty
|
||||
having string // expected trace-level (HAVING) part; "" => empty
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
// --- empty input ---------------------------------------------------------
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
query: " ",
|
||||
},
|
||||
|
||||
// --- single class --------------------------------------------------------
|
||||
{
|
||||
name: "span only",
|
||||
query: "service.name = 'x'",
|
||||
span: "service.name = 'x'",
|
||||
},
|
||||
{
|
||||
name: "agg only bare",
|
||||
query: "completion_tokens > 1000",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// the user-facing `trace.` prefix marks a trace-level aggregate.
|
||||
name: "agg only trace prefix",
|
||||
query: "trace.completion_tokens > 1000",
|
||||
having: "trace.completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// an unknown name under the trace context still routes trace-level, so the
|
||||
// aggregate validation rejects it with a targeted error instead of the span
|
||||
// path failing on an unknown field.
|
||||
name: "unknown aggregate under trace context stays trace-level",
|
||||
query: "trace.not_an_aggregate > 1000",
|
||||
having: "trace.not_an_aggregate > 1000",
|
||||
},
|
||||
|
||||
{
|
||||
// ANTLR token offsets are rune indices; slicing must not shift after a
|
||||
// multi-byte char (this used to truncate 1000 → 100).
|
||||
name: "unicode value before the split",
|
||||
query: "service.name = 'héllo' AND completion_tokens > 1000",
|
||||
span: "service.name = 'héllo'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- top-level AND splits across the two buckets -------------------------
|
||||
{
|
||||
name: "span AND agg",
|
||||
query: "service.name = 'x' AND completion_tokens > 1000",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// order within a bucket is preserved; the two span atoms join with AND.
|
||||
name: "span AND span AND agg",
|
||||
query: "service.name = 'x' AND kind_string = 'Internal' AND completion_tokens > 1000",
|
||||
span: "service.name = 'x' AND kind_string = 'Internal'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// a parenthesized top-level AND still splits across the two buckets.
|
||||
name: "parenthesized span AND agg",
|
||||
query: "(service.name = 'x' AND completion_tokens > 1000)",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- OR groups are re-wrapped in parens so a later AND-join can't invert
|
||||
// precedence (`a AND (b OR c)` must not flatten to `a AND b OR c`) ------
|
||||
{
|
||||
name: "agg OR agg",
|
||||
query: "completion_tokens > 1000 OR span_count > 3",
|
||||
having: "(completion_tokens > 1000 OR span_count > 3)",
|
||||
},
|
||||
{
|
||||
name: "span OR span",
|
||||
query: "service.name = 'x' OR kind_string = 'Internal'",
|
||||
span: "(service.name = 'x' OR kind_string = 'Internal')",
|
||||
},
|
||||
{
|
||||
name: "span AND (span OR span)",
|
||||
query: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
|
||||
span: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
|
||||
},
|
||||
{
|
||||
name: "agg AND (agg OR agg)",
|
||||
query: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
|
||||
having: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
|
||||
},
|
||||
{
|
||||
// the OR group routes to span, the trailing aggregate to having.
|
||||
name: "span AND (span OR span) AND agg",
|
||||
query: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z') AND completion_tokens > 1000",
|
||||
span: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z')",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- a nested AND group flattens across the buckets (no spurious parens) --
|
||||
{
|
||||
name: "(span AND agg) AND agg",
|
||||
query: "(service.name = 'x' AND completion_tokens > 1000) AND prompt_tokens > 5",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000 AND prompt_tokens > 5",
|
||||
},
|
||||
|
||||
// --- NOT wrapping a single-class group is routed whole to that class ------
|
||||
{
|
||||
name: "not agg",
|
||||
query: "NOT (completion_tokens > 1000)",
|
||||
having: "NOT (completion_tokens > 1000)",
|
||||
},
|
||||
{
|
||||
name: "not span",
|
||||
query: "NOT (service.name = 'x')",
|
||||
span: "NOT (service.name = 'x')",
|
||||
},
|
||||
|
||||
// --- an explicit non-trace context escapes the aggregate-alias shadow -----
|
||||
{
|
||||
// a span attribute named like an aggregate stays reachable via `attribute.`.
|
||||
name: "attribute prefix on aggregate name routes span-level",
|
||||
query: "attribute.completion_tokens > 5",
|
||||
span: "attribute.completion_tokens > 5",
|
||||
},
|
||||
{
|
||||
name: "span prefix on aggregate name routes span-level",
|
||||
query: "span.completion_tokens > 5",
|
||||
span: "span.completion_tokens > 5",
|
||||
},
|
||||
{
|
||||
name: "prefixed attribute AND bare aggregate split across buckets",
|
||||
query: "attribute.completion_tokens > 5 AND completion_tokens > 1000",
|
||||
span: "attribute.completion_tokens > 5",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- class-mixing is rejected in an OR group, a NOT group, or a nested OR -
|
||||
{
|
||||
name: "agg OR span rejected",
|
||||
query: "completion_tokens > 1000 OR service.name = 'x'",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not mixed rejected",
|
||||
query: "NOT (completion_tokens > 1000 AND service.name = 'x')",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "span AND (agg OR span) rejected",
|
||||
query: "service.name = 'x' AND (completion_tokens > 1000 OR kind_string = 'Client')",
|
||||
wantErr: true,
|
||||
},
|
||||
|
||||
// --- syntax errors are rejected, not silently dropped by error recovery ---
|
||||
{
|
||||
// recovery would yield an empty tree → both buckets empty → filter ignored.
|
||||
name: "lone paren rejected",
|
||||
query: ")",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unbalanced parens rejected",
|
||||
query: "((",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bare operator rejected",
|
||||
query: "AND",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// lexer-level error: recovery drops the whole expression.
|
||||
name: "unterminated quote rejected",
|
||||
query: "'unterminated",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// recovery would drop only the malformed atom and keep the rest — a
|
||||
// partially applied filter with no error.
|
||||
name: "garbage atom alongside valid agg rejected",
|
||||
query: ") AND completion_tokens > 5",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing value rejected",
|
||||
query: "completion_tokens >",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
span, having, err := SplitFilterForAggregates(c.query, agg)
|
||||
if c.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.span, span, "span part")
|
||||
require.Equal(t, c.having, having, "having part")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,19 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite rewrites and validates a HAVING expression against a caller-supplied
|
||||
// column map (user-facing name -> SQL identifier/expression). Values are inlined, so
|
||||
// the result is a bare SQL boolean expression with no bound args. Used by callers
|
||||
// that project their own aggregate columns (e.g. the AI trace list) rather than the
|
||||
// query's Aggregations.
|
||||
func (r *HavingExpressionRewriter) Rewrite(expression string, columnMap map[string]string) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
r.columnMap = columnMap
|
||||
return r.rewriteAndValidate(expression)
|
||||
}
|
||||
|
||||
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
|
||||
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
|
||||
@@ -82,6 +82,10 @@ func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.Variable
|
||||
switch queryType {
|
||||
case qbtypes.QueryTypeBuilder.StringValue(), qbtypes.QueryTypeSubQuery.StringValue():
|
||||
return resourcesForBuilderQuery(queryType, query.Get("spec"), variables)
|
||||
case qbtypes.QueryTypeBuilderAI.StringValue():
|
||||
// An AI builder query is always a traces query; the signal is implied by the
|
||||
// type (and may be absent from the payload), so pin the resource directly.
|
||||
return builderQueryResourceRefs(queryType, coretypes.ResourceTelemetryResourceTraces, query.Get("spec"), variables)
|
||||
case qbtypes.QueryTypePromQL.StringValue():
|
||||
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard}}, nil
|
||||
case qbtypes.QueryTypeClickHouseSQL.StringValue():
|
||||
@@ -103,7 +107,10 @@ func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return builderQueryResourceRefs(queryType, resource, spec, variables)
|
||||
}
|
||||
|
||||
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -92,6 +92,20 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query maps to traces resource without a signal",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query without filter is wildcard",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "promql is wildcard only",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
|
||||
|
||||
@@ -81,7 +81,10 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
|
||||
}
|
||||
|
||||
type PreparedWhereClause struct {
|
||||
WhereClause *sqlbuilder.WhereClause
|
||||
WhereClause *sqlbuilder.WhereClause
|
||||
// Expr is the bare predicate in builder-internal format ($n markers bound to
|
||||
// opts.Builder), embeddable outside a WHERE clause (e.g. inside countIf).
|
||||
Expr string
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
}
|
||||
@@ -170,7 +173,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
|
||||
|
||||
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
|
||||
|
||||
return PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
|
||||
return PreparedWhereClause{WhereClause: whereClause, Expr: cond, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
|
||||
}
|
||||
|
||||
// Visit dispatches to the specific visit method based on node type.
|
||||
|
||||
@@ -139,7 +139,7 @@ func NewModules(
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
|
||||
SavedView: implsavedview.NewModule(sqlstore),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
@@ -89,7 +88,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -231,7 +231,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
|
||||
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
|
||||
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
|
||||
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -290,9 +289,9 @@ func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgG
|
||||
)
|
||||
}
|
||||
|
||||
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
|
||||
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -331,7 +330,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlmigrator"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/auditstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/meterstatementbuilder"
|
||||
@@ -99,9 +100,9 @@ type SigNoz struct {
|
||||
|
||||
// newQueryStack assembles the query stack once and returns, in order: the shared
|
||||
// telemetry metadata store (reused elsewhere in signoz.New), the per-signal
|
||||
// statement builders (trace, log, audit, metric, meter, trace-operator), and the
|
||||
// bucket cache. It is the only place that imports the concrete statement-builder
|
||||
// sub-packages.
|
||||
// statement builders (trace, ai-trace, log, audit, metric, meter, trace-operator),
|
||||
// and the bucket cache. It is the only place that imports the concrete
|
||||
// statement-builder sub-packages.
|
||||
func newQueryStack(
|
||||
ctx context.Context,
|
||||
settings factory.ProviderSettings,
|
||||
@@ -112,6 +113,7 @@ func newQueryStack(
|
||||
) (
|
||||
telemetrytypes.MetadataStore,
|
||||
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -125,32 +127,36 @@ func newQueryStack(
|
||||
cfg := config.StatementBuilder
|
||||
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
auditStmtBuilder, err := auditstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
metricStmtBuilder, err := metricsstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
bucketCache := querier.NewBucketCache(settings, cache, config.Querier.CacheTTL, config.Querier.FluxInterval)
|
||||
|
||||
return metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
|
||||
return metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -313,7 +319,7 @@ func New(
|
||||
|
||||
// Assemble the query stack (metadata store, statement builders, bucket cache) once,
|
||||
// and reuse the single metadata store everywhere downstream.
|
||||
telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
|
||||
telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -323,7 +329,7 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.Querier,
|
||||
NewQuerierProviderFactories(telemetrystore, prometheus, telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
NewQuerierProviderFactories(telemetrystore, prometheus, telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
config.Querier.Provider(),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
)
|
||||
|
||||
type restructureSavedViewSpec struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewRestructureSavedViewSpecFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("restructure_saved_view_spec"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &restructureSavedViewSpec{store: store}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// legacySavedViewCompositeQuery is the bare shape saved_view.data held
|
||||
// before this migration -- just the relevant fields of composite query.
|
||||
// Queries is kept as raw JSON since the migration only needs to relocate it, not interpret it.
|
||||
type legacySavedViewCompositeQuery struct {
|
||||
PanelType string `json:"panelType"`
|
||||
Queries json.RawMessage `json:"queries"`
|
||||
}
|
||||
|
||||
// legacySavedViewExtraData mirrors the frontend defined extraData JSON shape.
|
||||
type legacySavedViewExtraData struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
SelectColumns json.RawMessage `json:"selectColumns,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MaxLines int `json:"maxLines,omitempty"`
|
||||
FontSize string `json:"fontSize,omitempty"`
|
||||
}
|
||||
|
||||
type savedViewDisplay struct {
|
||||
MaxLines int `json:"maxLines"`
|
||||
FontSize string `json:"fontSize"`
|
||||
Format string `json:"format"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type savedViewSpec struct {
|
||||
PanelType string `json:"panelType"`
|
||||
Queries json.RawMessage `json:"queries"`
|
||||
SelectedFields json.RawMessage `json:"selectedFields"`
|
||||
Display savedViewDisplay `json:"display"`
|
||||
}
|
||||
|
||||
type savedViewData struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Spec savedViewSpec `json:"spec"`
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var savedViews []struct {
|
||||
ID string `bun:"id"`
|
||||
Data string `bun:"data"`
|
||||
ExtraData string `bun:"extra_data"`
|
||||
}
|
||||
|
||||
err = tx.NewSelect().
|
||||
Table("saved_views").
|
||||
Column("id", "data", "extra_data").
|
||||
Scan(ctx, &savedViews)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, savedView := range savedViews {
|
||||
var compositeQuery legacySavedViewCompositeQuery
|
||||
if err := json.Unmarshal([]byte(savedView.Data), &compositeQuery); err != nil {
|
||||
continue // skip the row on error rather than fail the whole migration
|
||||
}
|
||||
|
||||
var extraData legacySavedViewExtraData
|
||||
if savedView.ExtraData != "" {
|
||||
// best-effort: malformed/older extraData shapes never fail the migration,
|
||||
// they just leave selectedFields/display empty.
|
||||
_ = json.Unmarshal([]byte(savedView.ExtraData), &extraData)
|
||||
}
|
||||
|
||||
dataJSON, err := json.Marshal(savedViewData{
|
||||
SchemaVersion: "v2",
|
||||
Spec: savedViewSpec{
|
||||
PanelType: compositeQuery.PanelType,
|
||||
Queries: compositeQuery.Queries,
|
||||
SelectedFields: extraData.SelectColumns,
|
||||
Display: savedViewDisplay{
|
||||
MaxLines: extraData.MaxLines,
|
||||
FontSize: extraData.FontSize,
|
||||
Format: extraData.Format,
|
||||
Color: extraData.Color,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.NewUpdate().
|
||||
Table("saved_views").
|
||||
Set("data = ?", string(dataJSON)).
|
||||
Where("id = ?", savedView.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, column := range []string{"category", "tags"} {
|
||||
if err := migration.store.Dialect().DropColumn(ctx, tx, "saved_views", column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// matching the singular table-name convention.
|
||||
if _, err := tx.ExecContext(ctx, "ALTER TABLE saved_views RENAME TO saved_view"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Down(context.Context, *bun.DB) error {
|
||||
// this migration is not reversible as we're transforming the structure
|
||||
return nil
|
||||
}
|
||||
84
pkg/statementbuilder/aistatementbuilder/statement_builder.go
Normal file
84
pkg/statementbuilder/aistatementbuilder/statement_builder.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package aistatementbuilder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
scopedtraces "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// NewFactory returns a provider factory for the AI trace statement builder
|
||||
// (builder_ai_query): the gen_ai Scope paired with the domain-neutral scoped-trace
|
||||
// topology, which owns the query construction.
|
||||
//
|
||||
// The gen_ai gate/column keys are surfaced by the metadata store itself
|
||||
// (enrichWithGenAIKeys), so queries work before any gen_ai metadata is ingested.
|
||||
func NewFactory(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fl flagger.Flagger,
|
||||
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
|
||||
return scopedtraces.NewFactory(factory.MustNewName("ai"), Scope(), telemetryStore, metadataStore, fl)
|
||||
}
|
||||
|
||||
// Scope describes gen_ai for the scoped trace builder: an AI trace has >=1 gen_ai
|
||||
// LLM, tool, or agent span, and its list adds AI/LLM per-trace metrics on top of the
|
||||
// common columns. This package holds only gen_ai domain knowledge; the query
|
||||
// topology lives in scopedtracesstatementbuilder.
|
||||
func Scope() scopedtraces.TraceScope {
|
||||
gateKeyNames := []string{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName}
|
||||
gateExprs := make([]string, 0, len(gateKeyNames))
|
||||
gateKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(gateKeyNames))
|
||||
for _, name := range gateKeyNames {
|
||||
gateExprs = append(gateExprs, name+" EXISTS")
|
||||
gateKeys = append(gateKeys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
})
|
||||
}
|
||||
|
||||
defs := telemetrytypes.GenAIFieldDefinitions
|
||||
reqModel := defs[telemetrytypes.GenAIRequestModel]
|
||||
toolName := defs[telemetrytypes.GenAIToolName]
|
||||
inTok := defs[telemetrytypes.GenAIUsageInputTokens]
|
||||
outTok := defs[telemetrytypes.GenAIUsageOutputTokens]
|
||||
cost := defs[telemetrytypes.SignozGenAITotalCost]
|
||||
inMsg := defs[telemetrytypes.GenAIInputMessages]
|
||||
outMsg := defs[telemetrytypes.GenAIOutputMessages]
|
||||
|
||||
str := telemetrytypes.FieldDataTypeString
|
||||
columns := append(scopedtraces.CommonTraceColumns(),
|
||||
// LLM calls only (request model present), not the full gate.
|
||||
scopedtraces.TraceColumn{Alias: "llm_call_count", Orderable: true, Expr: scopedtraces.CountExists(&reqModel)},
|
||||
scopedtraces.TraceColumn{Alias: "tool_call_count", Orderable: true, Expr: scopedtraces.CountExists(&toolName)},
|
||||
scopedtraces.TraceColumn{Alias: "distinct_tool_count", Orderable: true, Expr: scopedtraces.UniqCount(&toolName, str)},
|
||||
// tokens live only on LLM spans, so a plain sum needs no gate scoping.
|
||||
scopedtraces.TraceColumn{Alias: "input_tokens", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &inTok)},
|
||||
scopedtraces.TraceColumn{Alias: "output_tokens", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &outTok)},
|
||||
scopedtraces.TraceColumn{Alias: "total_tokens", Orderable: true, Expr: scopedtraces.SumOfKeys(telemetrytypes.FieldDataTypeFloat64, &inTok, &outTok)},
|
||||
// per-span cost attached by the SigNoz LLM pricing processor.
|
||||
scopedtraces.TraceColumn{Alias: "estimated_total_cost", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &cost)},
|
||||
// slowest single LLM call in the trace.
|
||||
scopedtraces.TraceColumn{Alias: "max_llm_duration_nano", Orderable: true, Expr: scopedtraces.ScopedToKeyColumn(scopedtraces.AggMax, scopedtraces.IntrinsicSpanKey("duration_nano"), &reqModel)},
|
||||
// errors across the whole trace (any span), so display-only.
|
||||
scopedtraces.TraceColumn{Alias: "error_count", Expr: scopedtraces.CondCount(scopedtraces.IntrinsicSpanKey("has_error"), qbtypes.FilterOperatorEqual, true)},
|
||||
// timestamp of the last gen_ai span (LLM/tool/agent), hence gate-scoped.
|
||||
scopedtraces.TraceColumn{Alias: "last_activity_time", Orderable: true, Expr: scopedtraces.ScopedReduce(scopedtraces.AggMax, scopedtraces.IntrinsicSpanKey("timestamp"))},
|
||||
// previews: first call's input (the prompt), last call's output (the answer).
|
||||
scopedtraces.TraceColumn{Alias: "input", SpanLevel: true, Expr: scopedtraces.PickBy(&inMsg, str, scopedtraces.IntrinsicSpanKey("timestamp"), scopedtraces.PickEarliest)},
|
||||
scopedtraces.TraceColumn{Alias: "output", SpanLevel: true, Expr: scopedtraces.PickBy(&outMsg, str, scopedtraces.IntrinsicSpanKey("timestamp"), scopedtraces.PickLatest)},
|
||||
)
|
||||
|
||||
return scopedtraces.TraceScope{
|
||||
FilterExpression: strings.Join(gateExprs, " OR "),
|
||||
FieldKeys: gateKeys,
|
||||
Columns: columns,
|
||||
DefaultOrderAlias: "last_activity_time",
|
||||
}
|
||||
}
|
||||
1106
pkg/statementbuilder/aistatementbuilder/statement_builder_test.go
Normal file
1106
pkg/statementbuilder/aistatementbuilder/statement_builder_test.go
Normal file
File diff suppressed because it is too large
Load Diff
205
pkg/statementbuilder/scopedtracesstatementbuilder/aggregate.go
Normal file
205
pkg/statementbuilder/scopedtracesstatementbuilder/aggregate.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// This file holds the Aggregate constructors a TraceScope's columns are declared
|
||||
// with. All SQL rendering goes through the resolvers: columns/values via the
|
||||
// columnResolver, predicates via the predicateResolver.
|
||||
|
||||
// Aggregate renders one column's SQL through the resolvers and lists the attribute
|
||||
// keys it references so the builder can pre-fetch their metadata. Build one with the
|
||||
// constructors below; the zero value is not usable.
|
||||
type Aggregate struct {
|
||||
keys []*telemetrytypes.TelemetryFieldKey
|
||||
render func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (expr string, err error)
|
||||
}
|
||||
|
||||
// IntrinsicSpanKey references an intrinsic span-index field (timestamp, name, …) by
|
||||
// its canonical name; the field mapper resolves it to the physical column.
|
||||
func IntrinsicSpanKey(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
}
|
||||
}
|
||||
|
||||
// AggFunc is a ClickHouse aggregate function name.
|
||||
type AggFunc string
|
||||
|
||||
const (
|
||||
AggSum AggFunc = "sum"
|
||||
AggMax AggFunc = "max"
|
||||
AggMin AggFunc = "min"
|
||||
)
|
||||
|
||||
// PickDirection selects the earliest (argMin) or latest (argMax) span by ordering.
|
||||
type PickDirection int
|
||||
|
||||
const (
|
||||
PickLatest PickDirection = iota
|
||||
PickEarliest
|
||||
)
|
||||
|
||||
// CountAll renders count().
|
||||
func CountAll() Aggregate {
|
||||
return Aggregate{render: func(context.Context, valuer.UUID, uint64, uint64, *columnResolver, *predicateResolver) (string, error) {
|
||||
return "count()", nil
|
||||
}}
|
||||
}
|
||||
|
||||
// FieldReduce renders <fn>(<field>) over a field-mapper-resolved column.
|
||||
func FieldReduce(fn AggFunc, key *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
f, err := cols.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", fn, f), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// TraceDuration renders the full-trace wall duration: last span end minus first
|
||||
// span start.
|
||||
func TraceDuration(tsKey, durationKey *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
ts, err := cols.FieldFor(ctx, orgID, startNs, endNs, tsKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dur, err := cols.FieldFor(ctx, orgID, startNs, endNs, durationKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tsNano := tracestelemetryschema.UnixNanoExpr(ts)
|
||||
return fmt.Sprintf("(max(%s + %s) - min(%s))", tsNano, dur, tsNano), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// FieldAnyWhere renders anyIf(<field>, <cond>) — the field value from any span
|
||||
// matching the condition.
|
||||
func FieldAnyWhere(valueKey, condKey *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, condValue any) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
v, err := cols.FieldFor(ctx, orgID, startNs, endNs, valueKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cond, err := preds.ConditionFor(ctx, orgID, startNs, endNs, condKey, op, condValue)
|
||||
return fmt.Sprintf("anyIf(%s, %s)", v, cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// AnyValue renders any(<value>) over a metadata-resolved attribute value.
|
||||
func AnyValue(key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{key}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, key, dt)
|
||||
return fmt.Sprintf("any(%s)", v), err
|
||||
}}
|
||||
}
|
||||
|
||||
// CountExists renders countIf(<key> EXISTS) — counts spans carrying key.
|
||||
func CountExists(key *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{key}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, _ *columnResolver, preds *predicateResolver) (string, error) {
|
||||
cond, err := preds.ExistsFor(ctx, orgID, startNs, endNs, key)
|
||||
return fmt.Sprintf("countIf(%s)", cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// CondCount renders countIf(<cond>) over a condition-builder-resolved predicate.
|
||||
func CondCount(key *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, _ *columnResolver, preds *predicateResolver) (string, error) {
|
||||
cond, err := preds.ConditionFor(ctx, orgID, startNs, endNs, key, op, value)
|
||||
return fmt.Sprintf("countIf(%s)", cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// Reduce renders <fn>(<value>) over a resolved numeric attribute value.
|
||||
func Reduce(fn AggFunc, valueKey *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{valueKey}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, valueKey, telemetrytypes.FieldDataTypeFloat64)
|
||||
return fmt.Sprintf("%s(%s)", fn, v), err
|
||||
}}
|
||||
}
|
||||
|
||||
// ScopedReduce renders <fn>If(<field>, <gate mask>) over a field-mapper-resolved column.
|
||||
func ScopedReduce(fn AggFunc, key *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
f, err := cols.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%sIf(%s, %s)", fn, f, preds.maskExpr), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// ScopedToKeyColumn renders <fn>If(<field>, <scopeKey> EXISTS) — a span-index field
|
||||
// aggregated over spans carrying scopeKey (e.g. max LLM latency).
|
||||
func ScopedToKeyColumn(fn AggFunc, columnKey, scopeKey *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{scopeKey}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
col, err := cols.FieldFor(ctx, orgID, startNs, endNs, columnKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cond, err := preds.ExistsFor(ctx, orgID, startNs, endNs, scopeKey)
|
||||
return fmt.Sprintf("%sIf(%s, %s)", fn, col, cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// PickBy renders argMinIf/argMaxIf(<value>, <orderField>, <value> EXISTS) — the value
|
||||
// from the earliest/latest span that carries it.
|
||||
func PickBy(valueKey *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType, orderKey *telemetrytypes.TelemetryFieldKey, dir PickDirection) Aggregate {
|
||||
fn := "argMaxIf"
|
||||
if dir == PickEarliest {
|
||||
fn = "argMinIf"
|
||||
}
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{valueKey}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, valueKey, dt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
order, err := cols.FieldFor(ctx, orgID, startNs, endNs, orderKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cond, err := preds.ExistsFor(ctx, orgID, startNs, endNs, valueKey)
|
||||
return fmt.Sprintf("%s(%s, %s, %s)", fn, v, order, cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// UniqCount renders uniqIf(<value>, <value> EXISTS) — distinct count of an attribute.
|
||||
func UniqCount(valueKey *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{valueKey}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, valueKey, dt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cond, err := preds.ExistsFor(ctx, orgID, startNs, endNs, valueKey)
|
||||
return fmt.Sprintf("uniqIf(%s, %s)", v, cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// SumOfKeys renders coalesce(sum(<v1>), 0) + coalesce(sum(<v2>), 0) + … over several
|
||||
// numeric attributes. Coalesced because a key absent from every span sums to NULL and
|
||||
// NULL + n = NULL — a trace with only output tokens would otherwise total NULL.
|
||||
func SumOfKeys(dt telemetrytypes.FieldDataType, valueKeys ...*telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{keys: valueKeys, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
parts := make([]string, 0, len(valueKeys))
|
||||
for _, k := range valueKeys {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, k, dt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("coalesce(sum(%s), 0)", v))
|
||||
}
|
||||
return strings.Join(parts, " + "), nil
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// columnResolver resolves keys to bare column/value expressions through the shared
|
||||
// field mapper, following its method shapes (FieldFor / …) so column resolution reads
|
||||
// like the other statement builders. keys is the fetched metadata for the keys the
|
||||
// scope's columns reference. It binds no args, so its expressions embed in any
|
||||
// builder; predicates (which do bind args) are the predicateResolver's job.
|
||||
type columnResolver struct {
|
||||
fm qbtypes.FieldMapper
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
}
|
||||
|
||||
func newColumnResolver(fm qbtypes.FieldMapper, keys map[string][]*telemetrytypes.TelemetryFieldKey) *columnResolver {
|
||||
return &columnResolver{fm: fm, keys: keys}
|
||||
}
|
||||
|
||||
// FieldFor returns the column expression for key via the field mapper.
|
||||
func (r *columnResolver) FieldFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
return r.fm.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
}
|
||||
|
||||
// ValueFor returns the value expression for an attribute key.
|
||||
func (r *columnResolver) ValueFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) (string, error) {
|
||||
// TODO(nitya): Fix this as this is not correct way
|
||||
if cands := r.keys[key.Name]; len(cands) > 0 {
|
||||
key = cands[0]
|
||||
}
|
||||
expr, err := r.fm.ColumnExpressionFor(ctx, orgID, startNs, endNs, key, dt, r.keys)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Escape before embedding in the outer builder: a materialized column name carries
|
||||
// `$$` (from the dotted attribute name), which go-sqlbuilder's Build would otherwise
|
||||
// unescape to a single `$` and reference the wrong column.
|
||||
return sqlbuilder.Escape(expr), nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// predicateResolver resolves key + operator + value to boolean predicates through the
|
||||
// shared condition builder, following its method shapes (ConditionFor / ExistsFor).
|
||||
// keys is the fetched metadata for the keys the scope's columns reference; the gate
|
||||
// mask is set by the builder after resolveMask (Scoped* aggregates embed it). Args
|
||||
// bind into sb as $n markers, so returned predicates can be embedded anywhere in sb
|
||||
// (SELECT, WHERE, HAVING) and every occurrence resolves to the same arg.
|
||||
type predicateResolver struct {
|
||||
cb qbtypes.ConditionBuilder
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
sb *sqlbuilder.SelectBuilder
|
||||
maskExpr string
|
||||
}
|
||||
|
||||
func newPredicateResolver(cb qbtypes.ConditionBuilder, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) *predicateResolver {
|
||||
return &predicateResolver{cb: cb, keys: keys, sb: sb}
|
||||
}
|
||||
|
||||
// ConditionFor returns a boolean predicate for key via the condition builder
|
||||
// (materialized column when present, else map access), args bound into sb.
|
||||
func (r *predicateResolver) ConditionFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) (string, error) {
|
||||
// The condition builder owns key resolution: hand it the raw key plus the full
|
||||
// metadata map and it matches/synthesizes the candidates itself.
|
||||
conds, _, err := r.cb.ConditionFor(ctx, orgID, startNs, endNs, key, r.keys, qbtypes.ConditionBuilderOptions{}, op, value, r.sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
// One condition per candidate variant (a key can be ingested under several data
|
||||
// types); OR them all, like the visitor does for EXISTS.
|
||||
if len(conds) == 1 {
|
||||
return conds[0], nil
|
||||
}
|
||||
return r.sb.Or(conds...), nil
|
||||
}
|
||||
|
||||
// ExistsFor returns the EXISTS predicate for key.
|
||||
func (r *predicateResolver) ExistsFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
return r.ConditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorExists, nil)
|
||||
}
|
||||
66
pkg/statementbuilder/scopedtracesstatementbuilder/scope.go
Normal file
66
pkg/statementbuilder/scopedtracesstatementbuilder/scope.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// TraceScope is the configuration the scoped trace builder accepts: which spans are
|
||||
// in scope and which per-trace columns the list computes. It only declares the gate
|
||||
// and the columns; the builder resolves everything through the field mapper, so
|
||||
// attribute access stays materialization-aware. A new span category only needs a
|
||||
// new TraceScope.
|
||||
type TraceScope struct {
|
||||
// FilterExpression is the grammar-level (EXISTS) gate, used on the delegated
|
||||
// span-list path.
|
||||
FilterExpression string
|
||||
// FieldKeys are the gate's keys, used to build the per-span mask
|
||||
// (OR of resolved EXISTS conditions).
|
||||
FieldKeys []*telemetrytypes.TelemetryFieldKey
|
||||
// Columns are the per-trace output columns.
|
||||
Columns []TraceColumn
|
||||
// DefaultOrderAlias is sorted by (desc) when the query gives no order.
|
||||
DefaultOrderAlias string
|
||||
}
|
||||
|
||||
// TraceColumn is one per-trace output column.
|
||||
type TraceColumn struct {
|
||||
// Alias must not reuse a physical span-index column name (e.g. duration_nano):
|
||||
// ClickHouse resolves bare identifiers to same-SELECT aliases first, so any
|
||||
// expression referencing that column would silently bind to the alias.
|
||||
Alias string
|
||||
// Orderable columns can be used in ORDER BY and the aggregate filter. All-span
|
||||
// aggregates (span_count, trace_duration_nano, …) are display-only and set false.
|
||||
Orderable bool
|
||||
// SpanLevel columns surface a real span/resource attribute (service.name,
|
||||
// input/output messages); a filter on them is applied span-level, so they are
|
||||
// excluded from the trace-level aliases.
|
||||
SpanLevel bool
|
||||
Expr Aggregate
|
||||
}
|
||||
|
||||
// CommonTraceColumns are domain-neutral columns any trace list can reuse. All
|
||||
// aggregate over every span, so none is Orderable.
|
||||
func CommonTraceColumns() []TraceColumn {
|
||||
ts := IntrinsicSpanKey("timestamp")
|
||||
duration := IntrinsicSpanKey("duration_nano")
|
||||
name := IntrinsicSpanKey("name")
|
||||
parentSpanID := IntrinsicSpanKey("parent_span_id")
|
||||
serviceName := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
return []TraceColumn{
|
||||
{Alias: "start_time", Expr: FieldReduce(AggMin, ts)},
|
||||
{Alias: "end_time", Expr: FieldReduce(AggMax, ts)},
|
||||
// Not plain "duration_nano": that name is the intrinsic span field, and an
|
||||
// alias would shadow it — both in ClickHouse identifier resolution and in
|
||||
// bare-name filter classification.
|
||||
{Alias: "trace_duration_nano", Expr: TraceDuration(ts, duration)},
|
||||
{Alias: "span_count", Expr: CountAll()},
|
||||
{Alias: "root_span_name", Expr: FieldAnyWhere(name, parentSpanID, qbtypes.FilterOperatorEqual, "")},
|
||||
{Alias: "service.name", SpanLevel: true, Expr: AnyValue(serviceName, telemetrytypes.FieldDataTypeString)},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/resourcefilter"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
qbvariables "github.com/SigNoz/signoz/pkg/variables"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedRequestType = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type for the scoped trace builder")
|
||||
)
|
||||
|
||||
// scopedTraceStatementBuilder builds a trace list scoped to one span category
|
||||
// (e.g. gen_ai spans). The query shape is fixed; the TraceScope decides which spans
|
||||
// are in scope and which per-trace columns to compute, so a new category only needs
|
||||
// a new scope.
|
||||
type scopedTraceStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
scope TraceScope
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*scopedTraceStatementBuilder)(nil)
|
||||
|
||||
// NewFactory returns a provider factory for a scoped trace statement builder. Unlike
|
||||
// the per-signal factories this package is domain-neutral, so the caller supplies the
|
||||
// factory name and the TraceScope (see aistatementbuilder for the gen_ai scope). Its
|
||||
// New delegates the span-list path to a trace statement builder built via the traces
|
||||
// factory — mirroring how the meter factory builds its own metrics builder.
|
||||
func NewFactory(
|
||||
name factory.Name,
|
||||
scope TraceScope,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fl flagger.Flagger,
|
||||
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
|
||||
return factory.NewProviderFactory(
|
||||
name,
|
||||
func(ctx context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
|
||||
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fm := tracestelemetryschema.NewFieldMapper()
|
||||
cb := tracestelemetryschema.NewConditionBuilder(fm)
|
||||
return NewScopedTraceStatementBuilder(settings, metadataStore, fm, cb, scope, traceStmtBuilder, fl), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// NewScopedTraceStatementBuilder wires the generic trace-list builder.
|
||||
// traceStmtBuilder (the delegate for the span-list path) is injected because
|
||||
// NewFactory already builds the canonical instance.
|
||||
func NewScopedTraceStatementBuilder(
|
||||
settings factory.ProviderSettings,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fieldMapper qbtypes.FieldMapper,
|
||||
conditionBuilder qbtypes.ConditionBuilder,
|
||||
scope TraceScope,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
fl flagger.Flagger,
|
||||
) qbtypes.StatementBuilder[qbtypes.TraceAggregation] {
|
||||
scopedSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder")
|
||||
|
||||
// Same resource-fingerprint prune as the standard trace builder — the list scans
|
||||
// the same span index.
|
||||
resourceFilterStmtBuilder := resourcefilter.New[qbtypes.TraceAggregation](
|
||||
settings,
|
||||
tracestelemetryschema.DBName,
|
||||
tracestelemetryschema.TracesResourceV3TableName,
|
||||
telemetrytypes.SignalTraces,
|
||||
telemetrytypes.SourceUnspecified,
|
||||
metadataStore,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
return &scopedTraceStatementBuilder{
|
||||
logger: scopedSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
scope: scope,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) Build(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start uint64,
|
||||
end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceListQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
|
||||
default:
|
||||
return nil, ErrUnsupportedRequestType
|
||||
}
|
||||
}
|
||||
|
||||
// buildDelegated ANDs the base gate into the user filter and delegates to the
|
||||
// standard trace builder (the span-list / raw path).
|
||||
func (b *scopedTraceStatementBuilder) buildDelegated(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
gate := b.scope.FilterExpression
|
||||
expr := gate
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
expr = fmt.Sprintf("(%s) AND (%s)", gate, query.Filter.Expression)
|
||||
}
|
||||
|
||||
// shallow copy; only Filter is replaced, caller's query untouched
|
||||
gated := query
|
||||
gated.Filter = &qbtypes.Filter{Expression: expr}
|
||||
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
// buildTraceListQuery wires the CTE pipeline: one windowed pass picks the top-N
|
||||
// traces, then a bucket-pruned pass enriches only those.
|
||||
// Helpers appear in this file in the order they run. start/end are nanoseconds.
|
||||
//
|
||||
// RESOLVE (keys/columns → SQL via the field mapper)
|
||||
// fetchKeys metadata for every key we reference
|
||||
// resolveMask the "span is in scope" predicate (OR of EXISTS)
|
||||
// resolveColumns per-trace column SQL
|
||||
// resolveListOrders which columns to ORDER BY
|
||||
// splitFilter span-level predicate + trace-level HAVING
|
||||
//
|
||||
// BUILD
|
||||
// matched one windowed, mask-pruned GROUP BY trace_id scan fusing gate + span
|
||||
// │ filter + HAVING + ORDER BY + LIMIT/OFFSET → the top-N trace_ids
|
||||
// ▼
|
||||
// ranked [start,end] bounds of those traces, from the small summary table
|
||||
// ▼
|
||||
// buckets the ts_bucket_start values they touch, to prune the next scan
|
||||
// ▼
|
||||
// enrichment every per-trace column for those traces over their full extent
|
||||
// (not window-clipped), scanning only their buckets
|
||||
//
|
||||
// Only Orderable columns are computable in the mask-pruned matched pass, so only they
|
||||
// can be ordered or filtered on; all-span columns (span_count, …) are output-only.
|
||||
func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
|
||||
endBucket := end / querybuilder.NsToSeconds
|
||||
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Resolve keys once; all attribute access goes through the field mapper. Condition
|
||||
// args bind into the builder an expression is embedded in, so the matched and
|
||||
// enrichment passes each resolve against their own builder.
|
||||
keys, err := b.fetchKeys(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matchedSB := sqlbuilder.NewSelectBuilder()
|
||||
maskExpr, resolved, err := b.resolveFor(ctx, orgID, start, end, keys, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enrichSB := sqlbuilder.NewSelectBuilder()
|
||||
_, enrichResolved, err := b.resolveFor(ctx, orgID, start, end, keys, enrichSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders, err := b.resolveListOrders(query.Order, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderableSet := orderableAliasSet(resolved)
|
||||
|
||||
// If the filter references resource attributes, add a __resource_filter CTE and
|
||||
// narrow the matched scan by resource_fingerprint; the span predicate drops those
|
||||
// keys so they aren't applied twice.
|
||||
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Split the user filter: span-level predicate + trace-level HAVING expression.
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), orderableSet, start, end, variables, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// matched → ranked → buckets → enrichment
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
|
||||
|
||||
// buckets: the ts_bucket_start values the matched traces span, so the enrichment
|
||||
// scan is primary-key pruned. No args.
|
||||
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
|
||||
bucketsFrag := fmt.Sprintf("buckets AS (SELECT DISTINCT b AS ts_bucket FROM ranked "+
|
||||
"ARRAY JOIN range("+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_start), %d) * %d - %d), "+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_end), %d) * %d + %d), "+
|
||||
"%d) AS b)", adj, adj, adj, adj, adj, adj, adj)
|
||||
|
||||
mainSQL, mainArgs := b.buildEnrichmentSelect(enrichSB, enrichResolved, orders)
|
||||
|
||||
cteFragments := []string{matchedFrag, rankedFrag, bucketsFrag}
|
||||
cteArgs := [][]any{matchedArgs, rankedArgs, nil}
|
||||
|
||||
// __resource_filter must precede `matched`, which references it.
|
||||
if resourceFrag != "" {
|
||||
cteFragments = append([]string{resourceFrag}, cteFragments...)
|
||||
cteArgs = append([][]any{resourceArgs}, cteArgs...)
|
||||
}
|
||||
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: fp.warnings,
|
||||
WarningsDocURL: fp.warningsURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maybeAttachResourceFilter builds the __resource_filter CTE (fingerprints matching
|
||||
// the filter's resource conditions) and the predicate narrowing the span scan by
|
||||
// resource_fingerprint; with no resource conditions it returns empty fragments.
|
||||
//
|
||||
// Unlike the standard trace builder there is deliberately no skip-fingerprint
|
||||
// fallback: falling back would leave the resource conditions inside the OR'd
|
||||
// span-filter bucket, which changes trace membership (any span from the resource +
|
||||
// any gen_ai span, instead of a gen_ai span from the resource). Resource conditions
|
||||
// always scope the whole matched scan.
|
||||
func (b *scopedTraceStatementBuilder) maybeAttachResourceFilter(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (cteFrag string, cteArgs []any, fingerprintPred string, err error) {
|
||||
stmt, err := b.resourceFilterStmtBuilder.Build(
|
||||
ctx, orgID, start, end, qbtypes.RequestTypeRaw, query, variables,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if stmt == nil {
|
||||
return "", nil, "", nil
|
||||
}
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args,
|
||||
"resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)", nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RESOLVE — turn keys/columns into field-mapper-aware SQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID valuer.UUID) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
fields := b.resolverFieldKeys()
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
|
||||
for _, k := range fields {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: k.Name,
|
||||
Signal: k.Signal,
|
||||
FieldContext: k.FieldContext,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
})
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolverFieldKeys() []*telemetrytypes.TelemetryFieldKey {
|
||||
seen := make(map[string]struct{})
|
||||
var out []*telemetrytypes.TelemetryFieldKey
|
||||
add := func(k *telemetrytypes.TelemetryFieldKey) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
if _, dup := seen[k.Name]; dup {
|
||||
return
|
||||
}
|
||||
seen[k.Name] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range b.scope.FieldKeys {
|
||||
add(k)
|
||||
}
|
||||
for _, c := range b.scope.Columns {
|
||||
for _, k := range c.Expr.keys {
|
||||
add(k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveFor renders the gate mask and every scope column with condition args bound
|
||||
// into sb, so the returned expressions embed anywhere in that builder.
|
||||
func (b *scopedTraceStatementBuilder) resolveFor(ctx context.Context, orgID valuer.UUID, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) (string, []resolvedColumn, error) {
|
||||
cols := newColumnResolver(b.fm, keys)
|
||||
preds := newPredicateResolver(b.cb, keys, sb)
|
||||
maskExpr, err := b.resolveMask(ctx, orgID, start, end, preds)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
preds.maskExpr = maskExpr
|
||||
resolved, err := b.resolveColumns(ctx, orgID, start, end, cols, preds)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return maskExpr, resolved, nil
|
||||
}
|
||||
|
||||
// resolveMask builds the per-span in-scope mask: OR of resolved EXISTS predicates
|
||||
// over the base condition's field keys.
|
||||
func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, orgID valuer.UUID, start, end uint64, preds *predicateResolver) (string, error) {
|
||||
fieldKeys := b.scope.FieldKeys
|
||||
parts := make([]string, 0, len(fieldKeys))
|
||||
for _, key := range fieldKeys {
|
||||
e, err := preds.ExistsFor(ctx, orgID, start, end, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts = append(parts, e)
|
||||
}
|
||||
return "(" + strings.Join(parts, " OR ") + ")", nil
|
||||
}
|
||||
|
||||
// resolvedColumn is a column resolved to SQL via the field mapper, ready to embed in
|
||||
// the builder it was resolved against.
|
||||
type resolvedColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
orderable bool
|
||||
}
|
||||
|
||||
// resolveColumns turns the declarative columns into SQL through the resolvers, so all
|
||||
// attribute access goes through the field mapper / condition builder.
|
||||
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, cols *columnResolver, preds *predicateResolver) ([]resolvedColumn, error) {
|
||||
out := make([]resolvedColumn, 0, len(b.scope.Columns))
|
||||
for _, c := range b.scope.Columns {
|
||||
expr, err := c.Expr.render(ctx, orgID, start, end, cols, preds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listOrder is a sort key resolved to a column alias + direction; both the matched
|
||||
// CTE and the enrichment ORDER BY it.
|
||||
type listOrder struct {
|
||||
alias string
|
||||
direction string
|
||||
}
|
||||
|
||||
// resolveListOrders maps order keys to the resolved orderable columns; non-orderable
|
||||
// columns are rejected. Defaults to the column provider's default order.
|
||||
func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy, resolved []resolvedColumn) ([]listOrder, error) {
|
||||
byAlias := make(map[string]resolvedColumn, len(resolved))
|
||||
orderable := make([]string, 0, len(resolved))
|
||||
for _, rc := range resolved {
|
||||
byAlias[rc.alias] = rc
|
||||
if rc.orderable {
|
||||
orderable = append(orderable, rc.alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) == 0 {
|
||||
return []listOrder{{alias: b.scope.DefaultOrderAlias, direction: "DESC"}}, nil
|
||||
}
|
||||
|
||||
orders := make([]listOrder, 0, len(order))
|
||||
for _, o := range order {
|
||||
direction := "DESC"
|
||||
if o.Direction == qbtypes.OrderDirectionAsc {
|
||||
direction = "ASC"
|
||||
}
|
||||
rc, ok := byAlias[o.Key.Name]
|
||||
if !ok || !rc.orderable {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported order key %q for the trace list; orderable keys: %s", o.Key.Name, strings.Join(orderable, ", "))
|
||||
}
|
||||
orders = append(orders, listOrder{alias: rc.alias, direction: direction})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// filterParts is the user filter split into a span-level predicate (widens the
|
||||
// matched WHERE prune and becomes a countIf existence check in HAVING) and a
|
||||
// trace-level HAVING expression.
|
||||
type filterParts struct {
|
||||
spanPred string
|
||||
hasSpanFilter bool
|
||||
havingExpr string
|
||||
warnings []string
|
||||
warningsURL string
|
||||
}
|
||||
|
||||
// splitFilter splits query.Filter into a span-level predicate (args bound into sb)
|
||||
// and a trace-level HAVING expression (an explicit query.Having is ANDed onto the
|
||||
// latter), then validates the trace-level part against the matched-pass aggregates.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
|
||||
var fp filterParts
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = traceExpr
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables, sb)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
// pred is empty when the span-level keys were all resource attributes
|
||||
// already handled by the __resource_filter CTE.
|
||||
if strings.TrimSpace(pred) != "" {
|
||||
fp.spanPred, fp.hasSpanFilter = pred, true
|
||||
}
|
||||
fp.warnings, fp.warningsURL = warnings, url
|
||||
}
|
||||
}
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
if fp.havingExpr != "" {
|
||||
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
|
||||
} else {
|
||||
fp.havingExpr = query.Having.Expression
|
||||
}
|
||||
}
|
||||
// The span predicate binds variables via PrepareWhereClause; the HAVING is a plain
|
||||
// text rewrite, so substitute variables here (list/IN quoting, __all__ drops the
|
||||
// condition) before validating.
|
||||
if strings.TrimSpace(fp.havingExpr) != "" && len(variables) > 0 {
|
||||
replaced, err := qbvariables.ReplaceVariablesInExpression(fp.havingExpr, variables)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = replaced
|
||||
}
|
||||
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
|
||||
return fp, err
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean
|
||||
// SQL predicate via the field mapper, args bound into sb.
|
||||
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, orgID valuer.UUID, start, end uint64, expr string, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (string, []string, string, error) {
|
||||
selectors := querybuilder.QueryStringToKeysSelectors(expr)
|
||||
for i := range selectors {
|
||||
selectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
OrgID: orgID,
|
||||
Logger: b.logger,
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
Builder: sb,
|
||||
// resource conditions are always handled by the __resource_filter CTE
|
||||
SkipResourceFilter: true,
|
||||
Variables: variables,
|
||||
StartNs: start,
|
||||
EndNs: end,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if prepared.IsEmpty() {
|
||||
return "", nil, "", nil
|
||||
}
|
||||
return prepared.Expr, prepared.Warnings, prepared.WarningsDocURL, nil
|
||||
}
|
||||
|
||||
// buildMatchedCTE builds `matched`: the single windowed GROUP BY trace_id scan that
|
||||
// fuses gate + span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the
|
||||
// aliases the ORDER BY / HAVING reference. resolved, maskExpr and fp.spanPred carry
|
||||
// $n markers bound to sb, so each can appear several times (SELECT, WHERE, HAVING)
|
||||
// and every occurrence resolves to the same arg.
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
|
||||
// SELECT trace_id + only the aggregates ORDER BY / HAVING reference (as aliases).
|
||||
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := needed[rc.alias]; !ok {
|
||||
continue
|
||||
}
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
|
||||
// WHERE: window + prune to in-scope spans, widened by the span filter so its
|
||||
// spans survive for the countIf existence check below.
|
||||
prune := "(" + maskExpr
|
||||
if fp.hasSpanFilter {
|
||||
prune += " OR " + fp.spanPred
|
||||
}
|
||||
prune += ")"
|
||||
where := []string{
|
||||
sb.GE("timestamp", fmt.Sprintf("%d", start)),
|
||||
sb.L("timestamp", fmt.Sprintf("%d", end)),
|
||||
sb.GE("ts_bucket_start", startBucket),
|
||||
sb.LE("ts_bucket_start", endBucket),
|
||||
prune,
|
||||
}
|
||||
if resourcePred != "" {
|
||||
where = append(where, resourcePred)
|
||||
}
|
||||
sb.Where(where...)
|
||||
sb.GroupBy("trace_id")
|
||||
|
||||
// HAVING: the gate/span existence checks are only needed when the WHERE was
|
||||
// widened by a span filter; otherwise the mask alone already enforces the gate.
|
||||
var having []string
|
||||
if fp.hasSpanFilter {
|
||||
having = append(having, "countIf("+maskExpr+") > 0")
|
||||
having = append(having, "countIf("+fp.spanPred+") > 0")
|
||||
}
|
||||
if strings.TrimSpace(fp.havingExpr) != "" {
|
||||
// Rewrite the trace-level HAVING to the matched-pass column aliases. The
|
||||
// rewriter matches raw key text, so the trace. form is mapped alongside the
|
||||
// bare name.
|
||||
columnMap := make(map[string]string, len(orderableSet)*2)
|
||||
for a := range orderableSet {
|
||||
columnMap[a] = quoteAlias(a)
|
||||
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
|
||||
}
|
||||
hv, err := querybuilder.NewHavingExpressionRewriter().Rewrite(fp.havingExpr, columnMap)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if hv != "" {
|
||||
// hv carries user text with values inlined by the rewriter; escape it so a
|
||||
// literal $ can't be read as an arg marker at Build time. The countIf
|
||||
// entries above hold live $n markers and must stay unescaped.
|
||||
having = append(having, sqlbuilder.Escape(hv))
|
||||
}
|
||||
}
|
||||
if len(having) > 0 {
|
||||
sb.Having(strings.Join(having, " AND "))
|
||||
}
|
||||
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
sb.Limit(limit)
|
||||
if offset > 0 {
|
||||
sb.Offset(offset)
|
||||
}
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args, nil
|
||||
}
|
||||
|
||||
// buildRankedCTE builds `ranked`: [start,end] bounds per matched trace, read from the
|
||||
// small trace-summary table.
|
||||
func (b *scopedTraceStatementBuilder) buildRankedCTE(start, end uint64) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS t_start", "max(end) AS t_end")
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.TraceSummaryTableName))
|
||||
sb.Where(
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM matched)",
|
||||
"end >= fromUnixTimestamp64Nano("+sb.Var(start)+")",
|
||||
"start < fromUnixTimestamp64Nano("+sb.Var(end)+")",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("ranked AS (%s)", sql), args
|
||||
}
|
||||
|
||||
// buildEnrichmentSelect builds the final SELECT: every per-trace column for the
|
||||
// matched traces over their full extent, scanning only their buckets.
|
||||
//
|
||||
// Accepted discrepancy: matched ranks/paginates on window-clipped values (and, with a
|
||||
// resource filter, only over fingerprint-matching spans), while this pass recomputes
|
||||
// and ORDER BYs full-trace values — so a trace with activity outside the window or
|
||||
// resource can sort differently than it ranked. Page membership is unaffected
|
||||
// (LIMIT/OFFSET runs only in matched); rows still sort by the values the user sees.
|
||||
// Ordering by matched's values instead would re-run the matched scan (ClickHouse
|
||||
// re-executes a CTE per reference) without fixing the visible cross-page artifact.
|
||||
func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(sb *sqlbuilder.SelectBuilder, resolved []resolvedColumn, orders []listOrder) (string, []any) {
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
sb.Where(
|
||||
"ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)",
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM ranked)",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// aggregateAliasSet is every trace-level column alias, used to classify filter keys
|
||||
// as trace-level vs span-level. Derived from the scope's columns so a new column
|
||||
// can't be forgotten; SpanLevel columns are filtered span-level, so skip them.
|
||||
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(b.scope.Columns))
|
||||
for _, c := range b.scope.Columns {
|
||||
if !c.SpanLevel {
|
||||
set[c.Alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// orderableAliasSet is the subset of aliases computable in the matched pass — the
|
||||
// only ones usable in ORDER BY and the aggregate filter.
|
||||
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, rc := range resolved {
|
||||
if rc.orderable {
|
||||
set[rc.alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// neededMatchedAliases is the minimal alias set the matched pass must select: those
|
||||
// in ORDER BY plus those in the aggregate HAVING. Everything else is left to the
|
||||
// enrichment scan.
|
||||
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
|
||||
needed := make(map[string]struct{})
|
||||
for _, o := range orders {
|
||||
needed[o.alias] = struct{}{}
|
||||
}
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; ok {
|
||||
needed[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// traceAggregateNames extracts the aggregate names a trace-level HAVING expression
|
||||
// references. QueryStringToKeysSelectors emits an extra attribute-context fallback
|
||||
// selector for context-prefixed keys (`trace.x` → attribute "trace.x"); only the
|
||||
// unspecified- and trace-context selectors name aggregates.
|
||||
func traceAggregateNames(havingExpr string) []string {
|
||||
var names []string
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
if sel.FieldContext == telemetrytypes.FieldContextUnspecified || sel.FieldContext == telemetrytypes.FieldContextTrace {
|
||||
names = append(names, sel.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects a trace-level filter referencing an aggregate not
|
||||
// computable in the matched pass (e.g. span_count, trace_duration_nano).
|
||||
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
|
||||
if strings.TrimSpace(havingExpr) == "" {
|
||||
return nil
|
||||
}
|
||||
allowed := make([]string, 0, len(orderableSet))
|
||||
for a := range orderableSet {
|
||||
allowed = append(allowed, a)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderClause renders the ORDER BY terms plus the trace_id tiebreak.
|
||||
func orderClause(orders []listOrder) []string {
|
||||
out := make([]string, 0, len(orders)+1)
|
||||
for _, o := range orders {
|
||||
out = append(out, fmt.Sprintf("%s %s", quoteAlias(o.alias), o.direction))
|
||||
}
|
||||
return append(out, "trace_id DESC")
|
||||
}
|
||||
|
||||
// quoteAlias backticks an alias containing characters special to the SQL builder.
|
||||
func quoteAlias(alias string) string {
|
||||
if strings.ContainsAny(alias, ".$`") {
|
||||
return "`" + alias + "`"
|
||||
}
|
||||
return alias
|
||||
}
|
||||
@@ -1168,6 +1168,27 @@ func enrichWithIntrinsicMetricKeys(keys map[string][]*telemetrytypes.TelemetryFi
|
||||
return keys
|
||||
}
|
||||
|
||||
// enrichWithGenAIKeys adds keys that can be queried for GenAI signals, even though they have not been ingested yet.
|
||||
func enrichWithGenAIKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
for _, selector := range selectors {
|
||||
if selector.Signal != telemetrytypes.SignalTraces && selector.Signal != telemetrytypes.SignalUnspecified {
|
||||
continue
|
||||
}
|
||||
for name, def := range telemetrytypes.GenAIFieldDefinitions {
|
||||
if len(keys[name]) > 0 {
|
||||
continue // already resolved from ingested data
|
||||
}
|
||||
if !selectorMatchesIntrinsicField(selector, def) {
|
||||
continue
|
||||
}
|
||||
keyCopy := def
|
||||
keys[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func selectorMatchesIntrinsicField(selector *telemetrytypes.FieldKeySelector, definition telemetrytypes.TelemetryFieldKey) bool {
|
||||
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && selector.FieldContext != definition.FieldContext {
|
||||
return false
|
||||
@@ -1253,6 +1274,9 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, orgID valuer.UUID, fie
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, selectors)
|
||||
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, selectors)
|
||||
}
|
||||
|
||||
return mapOfKeys, complete, nil
|
||||
}
|
||||
@@ -1331,6 +1355,9 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, fieldKeySelectors)
|
||||
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, fieldKeySelectors)
|
||||
}
|
||||
|
||||
return mapOfKeys, complete, nil
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
count(),
|
||||
toUnixTimestamp64Nano(min(start)),
|
||||
toUnixTimestamp64Nano(max(end))
|
||||
%s,
|
||||
%s
|
||||
FROM %s.%s
|
||||
WHERE trace_id IN (%s)
|
||||
`, DBName, TraceSummaryTableName, strings.Join(placeholders, ", "))
|
||||
`, UnixNanoExpr("min(start)"), UnixNanoExpr("max(end)"), DBName, TraceSummaryTableName, strings.Join(placeholders, ", "))
|
||||
|
||||
row := f.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...)
|
||||
|
||||
@@ -76,3 +76,9 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
|
||||
|
||||
return startNano, endNano, true, nil
|
||||
}
|
||||
|
||||
// UnixNanoExpr renders the conversion of a timestamp-typed column expression
|
||||
// (DateTime64(9)) to Unix epoch nanoseconds.
|
||||
func UnixNanoExpr(expr string) string {
|
||||
return fmt.Sprintf("toUnixTimestamp64Nano(%s)", expr)
|
||||
}
|
||||
|
||||
@@ -16,18 +16,10 @@ import (
|
||||
const (
|
||||
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
|
||||
|
||||
GenAIRequestModel = "gen_ai.request.model"
|
||||
GenAIProviderName = "gen_ai.provider.name"
|
||||
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
|
||||
SignozGenAICostInput = "_signoz.gen_ai.cost_input"
|
||||
SignozGenAICostOutput = "_signoz.gen_ai.cost_output"
|
||||
SignozGenAICostCacheRead = "_signoz.gen_ai.cost_cache_read"
|
||||
SignozGenAICostCacheWrite = "_signoz.gen_ai.cost_cache_write"
|
||||
SignozGenAITotalCost = "_signoz.gen_ai.total_cost"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -83,11 +84,11 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
|
||||
|
||||
return &LLMPricingRuleProcessorConfig{
|
||||
Attrs: LLMPricingRuleProcessorAttrs{
|
||||
Model: GenAIRequestModel,
|
||||
In: GenAIUsageInputTokens,
|
||||
Out: GenAIUsageOutputTokens,
|
||||
CacheRead: GenAIUsageCacheReadInputTokens,
|
||||
CacheWrite: GenAIUsageCacheCreationInputTokens,
|
||||
Model: telemetrytypes.GenAIRequestModel,
|
||||
In: telemetrytypes.GenAIUsageInputTokens,
|
||||
Out: telemetrytypes.GenAIUsageOutputTokens,
|
||||
CacheRead: telemetrytypes.GenAIUsageCacheReadInputTokens,
|
||||
CacheWrite: telemetrytypes.GenAIUsageCacheCreationInputTokens,
|
||||
},
|
||||
DefaultPricing: LLMPricingRuleProcessorDefaultPricing{
|
||||
Rules: pricingRules,
|
||||
@@ -97,7 +98,7 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
|
||||
Out: SignozGenAICostOutput,
|
||||
CacheRead: SignozGenAICostCacheRead,
|
||||
CacheWrite: SignozGenAICostCacheWrite,
|
||||
Total: SignozGenAITotalCost,
|
||||
Total: telemetrytypes.SignozGenAITotalCost,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user