mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-03 07:30:34 +01:00
Compare commits
283 Commits
issue_5201
...
nv/v2-dash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7c820f87 | ||
|
|
af72a4118b | ||
|
|
dc6a1d0a4e | ||
|
|
2e60ab0f81 | ||
|
|
29ed44a8ce | ||
|
|
43ff0dc90f | ||
|
|
13d9641f9d | ||
|
|
9f559d33d7 | ||
|
|
f56f7381a5 | ||
|
|
3656d117cc | ||
|
|
8e39f44a9e | ||
|
|
e92a0e953c | ||
|
|
7add0b924c | ||
|
|
d2070d4844 | ||
|
|
e9b0c32f19 | ||
|
|
ae18dbc722 | ||
|
|
87ff61e887 | ||
|
|
8afb247046 | ||
|
|
3ee706c68e | ||
|
|
58767e67c0 | ||
|
|
ccecc13bb9 | ||
|
|
1c8b2c1d21 | ||
|
|
8fbec89aab | ||
|
|
40da02e5b4 | ||
|
|
6704e15a01 | ||
|
|
fbf30cc192 | ||
|
|
ecd9670bf6 | ||
|
|
d8da99fcbb | ||
|
|
075e8d3029 | ||
|
|
f7fc3eade8 | ||
|
|
9415c06166 | ||
|
|
fa8139b279 | ||
|
|
3271e35eeb | ||
|
|
a48beef8ec | ||
|
|
ad4e3dcf45 | ||
|
|
2e8295f0b3 | ||
|
|
3e6394ba50 | ||
|
|
95da13ecb9 | ||
|
|
8b38e3969f | ||
|
|
f3d097a61a | ||
|
|
81249342ff | ||
|
|
5ce097ed2a | ||
|
|
3c985af24c | ||
|
|
c3f50b5db8 | ||
|
|
000dcc10b2 | ||
|
|
d7ffbe3f9f | ||
|
|
72cbc0450b | ||
|
|
7d7fd425e2 | ||
|
|
55cfe57a9e | ||
|
|
0a4c75d899 | ||
|
|
6702930688 | ||
|
|
4a24ee44e8 | ||
|
|
0045ecadab | ||
|
|
69aad53eb4 | ||
|
|
7bacb03483 | ||
|
|
b610056954 | ||
|
|
db77b398e7 | ||
|
|
574867bafb | ||
|
|
d87edca9d1 | ||
|
|
a7debaa6ed | ||
|
|
85ac805fae | ||
|
|
8a0441293a | ||
|
|
25ae787ecb | ||
|
|
f0ed0a8967 | ||
|
|
b47343bc09 | ||
|
|
bb39c52229 | ||
|
|
5fe69473c9 | ||
|
|
9be77ace42 | ||
|
|
c804d8f9b6 | ||
|
|
996bd949f2 | ||
|
|
84225023a5 | ||
|
|
a5b9dd279c | ||
|
|
172418a337 | ||
|
|
d1d5a9fa32 | ||
|
|
6f81e9f364 | ||
|
|
1933bec786 | ||
|
|
6d3d9bfb49 | ||
|
|
8b89f4af85 | ||
|
|
66e4132504 | ||
|
|
29e14ce9c6 | ||
|
|
d7dc789a58 | ||
|
|
d2d129eea9 | ||
|
|
9e1704615f | ||
|
|
db06557c12 | ||
|
|
1475e2b53a | ||
|
|
e58119a416 | ||
|
|
3dc5b53cc3 | ||
|
|
60fef93100 | ||
|
|
f5935ccaf4 | ||
|
|
d354044fbe | ||
|
|
fe6cbc3c0c | ||
|
|
45fa0c739c | ||
|
|
e1527dd148 | ||
|
|
5063be6467 | ||
|
|
b453655dea | ||
|
|
08a59145cc | ||
|
|
744856680e | ||
|
|
681e205ac1 | ||
|
|
4cb27e330e | ||
|
|
b47bc6bcc4 | ||
|
|
5321e9ee87 | ||
|
|
dd615869f6 | ||
|
|
fc4f326953 | ||
|
|
2af4cbf0f9 | ||
|
|
e82f568a27 | ||
|
|
c2aac3a278 | ||
|
|
ddfec3e5f7 | ||
|
|
af623f66e8 | ||
|
|
1cdecceece | ||
|
|
fb4f0a9c63 | ||
|
|
268e747f5c | ||
|
|
3fc72329c9 | ||
|
|
10ecb7524c | ||
|
|
8fc21ca6b9 | ||
|
|
422149369d | ||
|
|
2063697350 | ||
|
|
685faa9211 | ||
|
|
de6fcb9fbb | ||
|
|
828619a9e6 | ||
|
|
aff2e1be6b | ||
|
|
9728d17a0a | ||
|
|
ffaf334dfd | ||
|
|
d87e7241c0 | ||
|
|
ae184315a9 | ||
|
|
29f782a3a0 | ||
|
|
71d8dafce1 | ||
|
|
412320d7d9 | ||
|
|
9b5d78b5a0 | ||
|
|
444464ae15 | ||
|
|
d5841f8daa | ||
|
|
c344cd256f | ||
|
|
48f4838b93 | ||
|
|
ce7735d348 | ||
|
|
2f6b7b6260 | ||
|
|
5e61be1606 | ||
|
|
d8f7e62565 | ||
|
|
9380569223 | ||
|
|
1605b1c1ec | ||
|
|
42660ca8a6 | ||
|
|
1f7032953c | ||
|
|
173037d3be | ||
|
|
e9aab5a618 | ||
|
|
c0113324ca | ||
|
|
6d59fa4700 | ||
|
|
4713fd4839 | ||
|
|
4ad872b722 | ||
|
|
642fb66831 | ||
|
|
d12c846212 | ||
|
|
4e5bd7cf6f | ||
|
|
3982cce603 | ||
|
|
1a43c85cb8 | ||
|
|
bd11e985e1 | ||
|
|
abd7e41f97 | ||
|
|
3ebde75ebd | ||
|
|
3e849ee2d3 | ||
|
|
f7d9a57637 | ||
|
|
7dfa474dc1 | ||
|
|
5c223e9b04 | ||
|
|
fceb770337 | ||
|
|
44496d9d8d | ||
|
|
3b0fa192d8 | ||
|
|
8c44c42e13 | ||
|
|
398943fe41 | ||
|
|
a17debc61b | ||
|
|
0fae729715 | ||
|
|
6079e9869c | ||
|
|
3113b82904 | ||
|
|
71c60c3f2a | ||
|
|
3cc2a689c8 | ||
|
|
b74f5854fc | ||
|
|
3b824d50a3 | ||
|
|
d0a693b034 | ||
|
|
cd7899795d | ||
|
|
ad2d1467ec | ||
|
|
90377f8116 | ||
|
|
cabfd7271b | ||
|
|
750d63cf6b | ||
|
|
44cf8ed8e7 | ||
|
|
4d1129c85f | ||
|
|
e4c4acb5df | ||
|
|
c9235cd3d2 | ||
|
|
ec837c7006 | ||
|
|
59b8fa0e05 | ||
|
|
133a3a0057 | ||
|
|
b4e524dae0 | ||
|
|
4de0092664 | ||
|
|
337d23c91f | ||
|
|
a1f73655ca | ||
|
|
0d6081d0d0 | ||
|
|
2c0c7240a4 | ||
|
|
28cb0a8be7 | ||
|
|
54832cad34 | ||
|
|
a45178d709 | ||
|
|
c4224ecf72 | ||
|
|
8bf650192e | ||
|
|
f8fb7e5f8d | ||
|
|
ff578f7d92 | ||
|
|
cd630b1152 | ||
|
|
bd0842ac17 | ||
|
|
e37e427079 | ||
|
|
1e99ab4659 | ||
|
|
3353cda021 | ||
|
|
f5a71037bf | ||
|
|
97b85c386a | ||
|
|
00bdf50c1c | ||
|
|
5dec4ec580 | ||
|
|
325767c240 | ||
|
|
5fed2a4585 | ||
|
|
664337ae0f | ||
|
|
a0ea276681 | ||
|
|
2dc8699f08 | ||
|
|
ed81ed8ab5 | ||
|
|
48c9da19df | ||
|
|
eb9663d518 | ||
|
|
a56a862338 | ||
|
|
021f33f65e | ||
|
|
f311fcabf7 | ||
|
|
a37c07f881 | ||
|
|
4d9386f418 | ||
|
|
737473521d | ||
|
|
1863db8ba8 | ||
|
|
661af09a13 | ||
|
|
6024fa2b91 | ||
|
|
8996a96387 | ||
|
|
d6db5c2aab | ||
|
|
709590ea1b | ||
|
|
1add46b4c5 | ||
|
|
8401261e20 | ||
|
|
0ff34a7274 | ||
|
|
44e3bd9608 | ||
|
|
c3944d779e | ||
|
|
f5ec783a53 | ||
|
|
35b729c425 | ||
|
|
4f43c3d803 | ||
|
|
5dbde6c64d | ||
|
|
fb6fdd54ec | ||
|
|
64b8ba62da | ||
|
|
7c66df408b | ||
|
|
54049de391 | ||
|
|
a82f4237c8 | ||
|
|
89606b6238 | ||
|
|
db5ce958eb | ||
|
|
c8d3a9a54b | ||
|
|
637870b1fc | ||
|
|
d46a7e24c9 | ||
|
|
2a451e1c31 | ||
|
|
60b6d1d890 | ||
|
|
36f755b232 | ||
|
|
c1b3e3683a | ||
|
|
4c68544b1a | ||
|
|
90d9ab95f9 | ||
|
|
065e712e0c | ||
|
|
50db309ecd | ||
|
|
261bc552b0 | ||
|
|
bab720e98b | ||
|
|
71fef6636b | ||
|
|
fc3cdecbbb | ||
|
|
860fcfa641 | ||
|
|
a090e3a4aa | ||
|
|
6cf73e2ade | ||
|
|
bbcb6a45d6 | ||
|
|
d13934febc | ||
|
|
d5a7b7523d | ||
|
|
5b8984f131 | ||
|
|
6ddc5f1f12 | ||
|
|
055968bfad | ||
|
|
1bf0f38ed9 | ||
|
|
842125e20a | ||
|
|
6dab35caf8 | ||
|
|
047e9e2001 | ||
|
|
45eaa7db58 | ||
|
|
8a3d894eba | ||
|
|
5239060b53 | ||
|
|
42c6f507ac | ||
|
|
1b695a0b80 | ||
|
|
438cfab155 | ||
|
|
69f7617e01 | ||
|
|
4420a7e1fc | ||
|
|
b4bc68c5c5 | ||
|
|
eb9eb317cc | ||
|
|
0b1eb16a42 | ||
|
|
05a4d12183 | ||
|
|
bbaf64c4f0 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -52,6 +52,7 @@ jobs:
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
|
||||
@@ -2645,6 +2645,42 @@ components:
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
type: object
|
||||
DashboardtypesJSONPatchDocument:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesJSONPatchOperation'
|
||||
nullable: true
|
||||
type: array
|
||||
DashboardtypesJSONPatchOperation:
|
||||
properties:
|
||||
from:
|
||||
description: Source JSON Pointer for move/copy ops; ignored for other ops.
|
||||
type: string
|
||||
op:
|
||||
enum:
|
||||
- add
|
||||
- remove
|
||||
- replace
|
||||
- move
|
||||
- copy
|
||||
- test
|
||||
type: string
|
||||
path:
|
||||
description: JSON Pointer (RFC 6901) into the dashboard's postable shape
|
||||
— e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0,
|
||||
/tags/-.
|
||||
type: string
|
||||
value:
|
||||
description: 'Value to add/replace/test against. The expected type depends
|
||||
on the path. Common shapes (see referenced schemas for the exact field
|
||||
set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N
|
||||
(or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable;
|
||||
/spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a
|
||||
TagtypesPostableTag; /spec/display/name and other leaf string fields take
|
||||
a string. Required for add/replace/test; ignored for remove/move/copy.'
|
||||
required:
|
||||
- op
|
||||
- path
|
||||
type: object
|
||||
DashboardtypesLayout:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
|
||||
@@ -12902,6 +12938,212 @@ paths:
|
||||
summary: Get dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
patch:
|
||||
deprecated: false
|
||||
description: This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard.
|
||||
The patch is applied against the postable view of the dashboard (metadata,
|
||||
data, tags), so individual panels, queries, variables, layouts, or tags can
|
||||
be updated without re-sending the rest of the dashboard. Locked dashboards
|
||||
are rejected.
|
||||
operationId: PatchDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardtypesJSONPatchDocument'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Patch dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates a v2-shape dashboard's metadata, data, and
|
||||
tag set. Locked dashboards are rejected.
|
||||
operationId: UpdateDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DashboardtypesPostableDashboardV2'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/lock:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint unlocks a v2-shape dashboard. Only the dashboard's
|
||||
creator or an org admin may lock or unlock.
|
||||
operationId: UnlockDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"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: Unlock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint locks a v2-shape dashboard. Only the dashboard's
|
||||
creator or an org admin may lock or unlock.
|
||||
operationId: LockDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"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: Lock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/factor_password/forgot:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -221,6 +221,18 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updateable)
|
||||
}
|
||||
|
||||
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
|
||||
}
|
||||
|
||||
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
CreateDashboardV2201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
DashboardtypesJSONPatchDocumentDTO,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
@@ -33,7 +34,13 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
LockDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UnlockDashboardV2PathParameters,
|
||||
UpdateDashboardV2200,
|
||||
UpdateDashboardV2PathParameters,
|
||||
UpdatePublicDashboardPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
@@ -816,3 +823,360 @@ export const invalidateGetDashboardV2 = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const patchDashboardV2 = (
|
||||
{ id }: PatchDashboardV2PathParameters,
|
||||
dashboardtypesJSONPatchDocumentDTONull?: BodyType<DashboardtypesJSONPatchDocumentDTO | null> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PatchDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesJSONPatchDocumentDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchDashboardV2'];
|
||||
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 patchDashboardV2>>,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>
|
||||
>;
|
||||
export type PatchDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesJSONPatchDocumentDTO | null>
|
||||
| undefined;
|
||||
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const usePatchDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const updateDashboardV2 = (
|
||||
{ id }: UpdateDashboardV2PathParameters,
|
||||
dashboardtypesPostableDashboardV2DTO?: BodyType<DashboardtypesPostableDashboardV2DTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardV2DTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateDashboardV2'];
|
||||
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 updateDashboardV2>>,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>
|
||||
>;
|
||||
export type UpdateDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesPostableDashboardV2DTO>
|
||||
| undefined;
|
||||
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const useUpdateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const unlockDashboardV2 = (
|
||||
{ id }: UnlockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnlockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['unlockDashboardV2'];
|
||||
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 unlockDashboardV2>>,
|
||||
{ pathParams: UnlockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return unlockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const useUnlockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnlockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const lockDashboardV2 = (
|
||||
{ id }: LockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getLockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['lockDashboardV2'];
|
||||
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 lockDashboardV2>>,
|
||||
{ pathParams: LockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return lockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type LockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const useLockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -4653,6 +4653,43 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesJSONPatchOperationDTOOp {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
replace = 'replace',
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
test = 'test',
|
||||
}
|
||||
export interface DashboardtypesJSONPatchOperationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
|
||||
*/
|
||||
from?: string;
|
||||
/**
|
||||
* @enum add,remove,replace,move,copy,test
|
||||
* @type string
|
||||
*/
|
||||
op: DashboardtypesJSONPatchOperationDTOOp;
|
||||
/**
|
||||
* @type string
|
||||
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
|
||||
*/
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesJSONPatchDocumentDTO =
|
||||
| DashboardtypesJSONPatchOperationDTO[]
|
||||
| null;
|
||||
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
@@ -9476,6 +9513,34 @@ export type GetDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type PatchDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type LockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -359,8 +359,7 @@ function CustomTimePickerPopoverContent({
|
||||
<Clock
|
||||
color={Color.BG_ROBIN_400}
|
||||
className="timezone-container__clock-icon"
|
||||
height={12}
|
||||
width={12}
|
||||
size={14}
|
||||
/>
|
||||
|
||||
<span className="timezone__name">{timezone.name}</span>
|
||||
|
||||
@@ -144,10 +144,7 @@ function AddedFields({
|
||||
field={field}
|
||||
onRemove={handleRemove}
|
||||
allowDrag={allowDrag}
|
||||
isRequired={
|
||||
requiredFields.includes(field.name) ||
|
||||
requiredFields.includes(field.key as string)
|
||||
}
|
||||
isRequired={requiredFields.includes(field.key as string)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('hides the Remove button for fields whose name is in requiredFields', () => {
|
||||
it('hides the Remove button for fields whose composite key is in requiredFields', () => {
|
||||
const fields = [makeField('a'), makeField('b'), makeField('c')];
|
||||
|
||||
render(
|
||||
@@ -33,7 +33,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['a', 'c']}
|
||||
requiredFields={['log.a', 'log.c']}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['a']}
|
||||
requiredFields={['log.a']}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -58,9 +58,9 @@ describe('AddedFields — requiredFields', () => {
|
||||
expect(screen.getByText('b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks all variants of a required name regardless of fieldContext', () => {
|
||||
// Two `body` fields with different contexts — both should lock when
|
||||
// `body` is in requiredFields.
|
||||
it('locks ONLY the canonical variant — a same-name field from another context stays removable', () => {
|
||||
// Two `body` fields with different contexts. requiredFields holds the
|
||||
// canonical composite key only, so the attribute variant is deletable.
|
||||
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
|
||||
|
||||
render(
|
||||
@@ -68,41 +68,6 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Both 'body' variants locked → zero Remove buttons.
|
||||
expect(screen.queryAllByRole('button', { name: /remove/i })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats requiredFields as exact-name match (substring does not lock)', () => {
|
||||
const fields = [makeField('body'), makeField('body_extra')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 'body' locked, 'body_extra' removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('also accepts composite IDs in requiredFields (locks a specific variant)', () => {
|
||||
// Two `body` fields with different contexts.
|
||||
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
// Composite ID — locks ONLY the log variant, attribute variant stays
|
||||
// removable.
|
||||
requiredFields={['log.body']}
|
||||
/>,
|
||||
);
|
||||
@@ -110,4 +75,37 @@ describe('AddedFields — requiredFields', () => {
|
||||
// One Remove button: the attribute variant. log variant is locked.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not lock anything when a bare name is passed (composite key required)', () => {
|
||||
// Bare `body` no longer matches — matching is composite-key only now.
|
||||
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Neither variant locked → both removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('treats requiredFields as exact composite-key match (substring does not lock)', () => {
|
||||
const fields = [makeField('body'), makeField('body_extra')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 'log.body' locked, 'log.body_extra' removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,9 @@ jest.mock('providers/Timezone', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const field = (name: string): IField => ({
|
||||
const field = (name: string, type = ''): IField => ({
|
||||
name,
|
||||
type: '',
|
||||
type,
|
||||
dataType: 'string',
|
||||
});
|
||||
|
||||
@@ -38,18 +38,16 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
useLogsTableColumns({
|
||||
fields: [
|
||||
field('service.name'),
|
||||
field('body'),
|
||||
field('body', 'log'),
|
||||
field('request.id'),
|
||||
field('timestamp'),
|
||||
field('timestamp', 'log'),
|
||||
],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
// body/timestamp are NOT pinned to fixed positions — they appear where the
|
||||
// caller placed them in `fields`. body/timestamp use composite IDs
|
||||
// ('log.body', 'log.timestamp') since their fieldContext is fixed; user
|
||||
// fields here have empty `type` so their composite collapses to bare name.
|
||||
// body/timestamp appear where the caller placed them, keyed by their
|
||||
// composite IDs ('log.*'); contextless user fields collapse to bare name.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'service.name',
|
||||
@@ -59,6 +57,43 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a same-name field from another context as a DISTINCT column (no collision)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('body', 'log'), field('body', 'attribute')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// Attribute variant is its own column, not a duplicate 'log.body'.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.body',
|
||||
'attribute.body',
|
||||
]);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the same distinct-column treatment to timestamp variants', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('timestamp', 'log'), field('timestamp', 'attribute')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.timestamp',
|
||||
'attribute.timestamp',
|
||||
]);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the synthetic "id" field name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
@@ -77,7 +112,11 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
it('uses the special body/timestamp coldefs (canBeHidden=false), not the generic user field def', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('body'), field('timestamp'), field('user_field')],
|
||||
fields: [
|
||||
field('body', 'log'),
|
||||
field('timestamp', 'log'),
|
||||
field('user_field'),
|
||||
],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -96,15 +96,18 @@ export function useLogsTableColumns({
|
||||
),
|
||||
});
|
||||
|
||||
// Match body/timestamp by composite key, not bare name — else a variant
|
||||
// like `attribute.body` collapses onto `log.body`, duplicating the column.
|
||||
const fieldCols = fields
|
||||
.map((f): TableColumnDef<ILog> | null => {
|
||||
if (f.name === 'id') {
|
||||
return null;
|
||||
}
|
||||
if (f.name === 'timestamp') {
|
||||
const compositeKey = buildCompositeKey(f.name, f.type);
|
||||
if (compositeKey === timestampCol.id) {
|
||||
return timestampCol;
|
||||
}
|
||||
if (f.name === 'body') {
|
||||
if (compositeKey === bodyCol.id) {
|
||||
return bodyCol;
|
||||
}
|
||||
return makeUserFieldCol(f);
|
||||
|
||||
@@ -109,6 +109,16 @@ $custom-border-color: #2c3044;
|
||||
color: color-mix(in srgb, var(--l2-foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.ant-select-clear {
|
||||
background-color: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Customize tags in multiselect (dark mode by default)
|
||||
.ant-select-selection-item {
|
||||
background-color: var(--l1-border);
|
||||
@@ -392,7 +402,9 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for multi-select
|
||||
.custom-multiselect-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 350px;
|
||||
// Tall enough to hold the react-virtuoso list (<=300px) + header/footer
|
||||
// so only the list scrolls (avoids a second scrollbar on this container).
|
||||
max-height: 410px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -483,8 +495,12 @@ $custom-border-color: #2c3044;
|
||||
.option-checkbox {
|
||||
width: 100%;
|
||||
|
||||
> span:not(.ant-checkbox) {
|
||||
width: 100%;
|
||||
// @signozhq/ui Checkbox renders children inside a <label> that is
|
||||
// content-sized by default. Make it fill the row (min-width: 0 lets it
|
||||
// shrink) so the option text below can truncate instead of overflowing.
|
||||
> label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.all-option-text {
|
||||
@@ -501,7 +517,12 @@ $custom-border-color: #2c3044;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
@@ -514,26 +535,30 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
display: none;
|
||||
}
|
||||
// Size the buttons to the row's resting content height (20px) and fully
|
||||
// override antd's default 32px Button box, so revealing them on hover
|
||||
// never changes the row height.
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
.only-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background-color: unset;
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.option-content:hover {
|
||||
.only-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
@@ -548,9 +573,6 @@ $custom-border-color: #2c3044;
|
||||
.option-checkbox:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
gap: 4px;
|
||||
|
||||
&--success {
|
||||
background: color-mix(in srgb, var(--success-background) 10%, transparent);
|
||||
color: var(--success-foreground);
|
||||
background: color-mix(in srgb, var(--text-forest-500) 10%, transparent);
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
|
||||
&--error {
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
font-size: 10px;
|
||||
color: var(--l2-foreground);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,18 +47,10 @@
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
.overview-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.variables-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.overview-btn,
|
||||
.variables-btn,
|
||||
.public-dashboard-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
color: var(--primary-background);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
|
||||
.sidenav-beta-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-type-btn + .variable-type-btn {
|
||||
@@ -177,6 +186,7 @@
|
||||
|
||||
.multiple-values-section {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
@@ -193,6 +203,7 @@
|
||||
|
||||
.all-option-section {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
|
||||
@@ -518,7 +518,6 @@ function VariableItem({
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -614,7 +613,6 @@ function VariableItem({
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
}
|
||||
.ant-btn-default {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.ant-tabs-tab-active {
|
||||
|
||||
@@ -67,13 +67,23 @@ describe('ensureLogsRequiredColumns', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate if a required column appears twice in the input', () => {
|
||||
// Tolerant of malformed input — invariant only adds *missing* required
|
||||
// columns; it does not deduplicate existing entries (that's a separate
|
||||
// concern, not its job).
|
||||
it('collapses composite-key duplicates in the input', () => {
|
||||
// Two identical `body` entries → deduped to one, then timestamp prepended.
|
||||
const input = [BODY, BODY, ATTR_A];
|
||||
const result = ensureLogsRequiredColumns(input);
|
||||
expect(result.filter((c) => c.name === 'timestamp')).toHaveLength(1);
|
||||
expect(result[0]).toStrictEqual(TIMESTAMP);
|
||||
expect(result).toStrictEqual([TIMESTAMP, BODY, ATTR_A]);
|
||||
expect(result.filter((c) => c.name === 'body')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps same-name fields with different contexts as distinct columns', () => {
|
||||
// Different composite keys → both legitimate, neither deduped.
|
||||
const ATTR_BODY: TelemetryFieldKey = {
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
const input = [TIMESTAMP, BODY, ATTR_BODY];
|
||||
expect(ensureLogsRequiredColumns(input)).toStrictEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { FontSize, OptionsQuery } from './types';
|
||||
import { buildCompositeKey } from './utils';
|
||||
|
||||
export const URL_OPTIONS = 'options';
|
||||
|
||||
@@ -35,22 +36,48 @@ export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LOGS_REQUIRED_COLUMNS = ['timestamp', 'body'] as const;
|
||||
// Names that must always be present in logs selectColumns (writer invariant).
|
||||
const LOGS_REQUIRED_COLUMN_NAMES = defaultLogsSelectedColumns.map(
|
||||
(c) => c.name,
|
||||
);
|
||||
|
||||
/**
|
||||
* Always-on invariant: every logs selectColumns array must contain `body` and
|
||||
* `timestamp`. Applied at both loader and writer boundaries so the picker, the
|
||||
* table, and persisted state can never diverge into a "missing required
|
||||
* column" state.
|
||||
*/
|
||||
// Composite keys (not bare names) so the picker locks ONLY the canonical
|
||||
// `log.body`/`log.timestamp` — a same-name variant like `attribute.body` stays
|
||||
// removable.
|
||||
export const LOGS_REQUIRED_COLUMNS = defaultLogsSelectedColumns.map((c) =>
|
||||
buildCompositeKey(c.name, c.fieldContext),
|
||||
);
|
||||
|
||||
// Drop composite-key duplicates (never legitimate — they only come from
|
||||
// corrupted state). Returns the same array reference when nothing to dedupe.
|
||||
export function dedupeColumnsByCompositeKey(
|
||||
columns: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] {
|
||||
const seen = new Set<string>();
|
||||
let hasDuplicate = false;
|
||||
const deduped = columns.filter((c) => {
|
||||
const key = buildCompositeKey(c.name, c.fieldContext);
|
||||
if (seen.has(key)) {
|
||||
hasDuplicate = true;
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return hasDuplicate ? deduped : columns;
|
||||
}
|
||||
|
||||
// Logs selectColumns invariant: no composite-key duplicates, and body +
|
||||
// timestamp always present. Applied at loader + writer boundaries.
|
||||
export function ensureLogsRequiredColumns(
|
||||
columns: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] {
|
||||
const missing = LOGS_REQUIRED_COLUMNS.filter(
|
||||
(name) => !columns.some((c) => c.name === name),
|
||||
const deduped = dedupeColumnsByCompositeKey(columns);
|
||||
const missing = LOGS_REQUIRED_COLUMN_NAMES.filter(
|
||||
(name) => !deduped.some((c) => c.name === name),
|
||||
);
|
||||
if (missing.length === 0) {
|
||||
return columns;
|
||||
return deduped;
|
||||
}
|
||||
const defaultsByName = new Map(
|
||||
defaultLogsSelectedColumns.map((c) => [c.name, c]),
|
||||
@@ -58,7 +85,7 @@ export function ensureLogsRequiredColumns(
|
||||
const prepended = missing
|
||||
.map((name) => defaultsByName.get(name))
|
||||
.filter((c): c is TelemetryFieldKey => c !== undefined);
|
||||
return [...prepended, ...columns];
|
||||
return [...prepended, ...deduped];
|
||||
}
|
||||
|
||||
export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
|
||||
|
||||
@@ -103,7 +103,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(domainToAdminEmail && { domainToAdminEmail }),
|
||||
domainToAdminEmail: domainToAdminEmail ?? {},
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
@@ -129,7 +129,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(groupMappings && { groupMappings }),
|
||||
groupMappings: groupMappings ?? {},
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
convertDomainMappingsToList,
|
||||
convertDomainMappingsToRecord,
|
||||
convertGroupMappingsToList,
|
||||
convertGroupMappingsToRecord,
|
||||
prepareInitialValues,
|
||||
} from './CreateEdit.utils';
|
||||
|
||||
describe('convertGroupMappingsToRecord', () => {
|
||||
it('returns undefined for an empty list', () => {
|
||||
expect(convertGroupMappingsToRecord([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when input is undefined', () => {
|
||||
expect(convertGroupMappingsToRecord(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('converts entries to a Record', () => {
|
||||
expect(
|
||||
convertGroupMappingsToRecord([
|
||||
{ groupName: 'admins', role: 'ADMIN' },
|
||||
{ groupName: 'viewers', role: 'VIEWER' },
|
||||
]),
|
||||
).toStrictEqual({ admins: 'ADMIN', viewers: 'VIEWER' });
|
||||
});
|
||||
|
||||
it('skips entries with missing groupName or role', () => {
|
||||
expect(
|
||||
convertGroupMappingsToRecord([
|
||||
{ groupName: 'admins', role: 'ADMIN' },
|
||||
{ groupName: '', role: 'VIEWER' },
|
||||
{ role: 'EDITOR' },
|
||||
]),
|
||||
).toStrictEqual({ admins: 'ADMIN' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertDomainMappingsToRecord', () => {
|
||||
it('returns undefined for an empty list', () => {
|
||||
expect(convertDomainMappingsToRecord([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when input is undefined', () => {
|
||||
expect(convertDomainMappingsToRecord(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('converts entries to a Record', () => {
|
||||
expect(
|
||||
convertDomainMappingsToRecord([
|
||||
{ domain: 'example.com', adminEmail: 'admin@example.com' },
|
||||
{ domain: 'corp.io', adminEmail: 'it@corp.io' },
|
||||
]),
|
||||
).toStrictEqual({
|
||||
'example.com': 'admin@example.com',
|
||||
'corp.io': 'it@corp.io',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip fidelity', () => {
|
||||
it('Record → list → Record preserves group mappings', () => {
|
||||
const original = { admins: 'ADMIN', devs: 'EDITOR', viewers: 'VIEWER' };
|
||||
expect(
|
||||
convertGroupMappingsToRecord(convertGroupMappingsToList(original)),
|
||||
).toStrictEqual(original);
|
||||
});
|
||||
|
||||
it('Record → list → Record preserves domain mappings', () => {
|
||||
const original = {
|
||||
'example.com': 'admin@example.com',
|
||||
'corp.io': 'it@corp.io',
|
||||
};
|
||||
expect(
|
||||
convertDomainMappingsToRecord(convertDomainMappingsToList(original)),
|
||||
).toStrictEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareInitialValues', () => {
|
||||
it('returns empty defaults when no record is provided', () => {
|
||||
expect(prepareInitialValues(undefined)).toStrictEqual({
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates groupMappings Record into groupMappingsList for the form', () => {
|
||||
const result = prepareInitialValues({
|
||||
id: 'domain-1',
|
||||
name: 'example.com',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
roleMapping: {
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.roleMapping?.groupMappingsList).toStrictEqual([
|
||||
{ groupName: 'admins', role: 'ADMIN' },
|
||||
{ groupName: 'viewers', role: 'VIEWER' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates domainToAdminEmail Record into domainToAdminEmailList for the form', () => {
|
||||
const result = prepareInitialValues({
|
||||
id: 'domain-1',
|
||||
name: 'example.com',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
domainToAdminEmail: { 'example.com': 'admin@example.com' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.googleAuthConfig?.domainToAdminEmailList).toStrictEqual([
|
||||
{ domain: 'example.com', adminEmail: 'admin@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets groupMappingsList to empty array when roleMapping has no groupMappings', () => {
|
||||
const result = prepareInitialValues({
|
||||
id: 'domain-1',
|
||||
name: 'example.com',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.roleMapping?.groupMappingsList).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
import {
|
||||
AUTH_DOMAINS_UPDATE_ENDPOINT,
|
||||
mockDomainWithRoleMapping,
|
||||
mockGoogleAuthWithWorkspaceGroups,
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
|
||||
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
|
||||
// which mocks @signozhq/ui/switch for the same reason.
|
||||
jest.mock('@signozhq/ui/button', () => ({
|
||||
...jest.requireActual('@signozhq/ui/button'),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
prefix,
|
||||
suffix,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{prefix}
|
||||
{children}
|
||||
{suffix}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('CreateEdit — save payload correctness', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('sends groupMappings: {} when all group mappings are deleted', async () => {
|
||||
let capturedPayload: unknown = null;
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedPayload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithRoleMapping}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open the Role Mapping collapse (Ant Design Collapse responds to click events)
|
||||
fireEvent.click(screen.getByText(/role mapping \(advanced\)/i));
|
||||
|
||||
// Wait for the 3 group mapping rows to appear
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(3),
|
||||
);
|
||||
|
||||
// Delete each row; re-query after each removal
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i })[0],
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i })[0],
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: /remove mapping/i })[0],
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(0),
|
||||
);
|
||||
|
||||
// Submit — MSW intercepts the PUT request
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(capturedPayload).not.toBeNull());
|
||||
|
||||
expect(capturedPayload).toMatchObject({
|
||||
config: expect.objectContaining({
|
||||
roleMapping: expect.objectContaining({ groupMappings: {} }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('sends domainToAdminEmail: {} when all domain mappings are deleted', async () => {
|
||||
let capturedPayload: unknown = null;
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedPayload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthWithWorkspaceGroups}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open the Google Workspace Groups collapse
|
||||
fireEvent.click(screen.getByText(/google workspace groups/i));
|
||||
|
||||
// Wait for the single domain mapping row
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /remove mapping/i }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Delete the row
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove mapping/i }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryAllByRole('button', { name: /remove mapping/i }),
|
||||
).toHaveLength(0),
|
||||
);
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(capturedPayload).not.toBeNull());
|
||||
|
||||
expect(capturedPayload).toMatchObject({
|
||||
config: expect.objectContaining({
|
||||
googleAuthConfig: expect.objectContaining({
|
||||
domainToAdminEmail: {},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,8 +72,20 @@
|
||||
.alert-rule-scope {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ant-radio-wrapper {
|
||||
color: var(--l1-foreground);
|
||||
// `.createForm label` styles field labels (font-weight 500, 14px,
|
||||
// 6px bottom padding). Those bleed into the @signozhq/ui RadioGroup
|
||||
// option labels, making them bold and vertically misaligned with the
|
||||
// radio control. Reset them back to plain option-text styling.
|
||||
label {
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
// Loosen the design-system default (grid gap 0.5rem) between options.
|
||||
.silence-alerts-radio-group {
|
||||
margin-top: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +156,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.schedule-created-at {
|
||||
|
||||
@@ -54,8 +54,7 @@ import {
|
||||
} from './PlannedDowntimeutils';
|
||||
|
||||
import './PlannedDowntime.styles.scss';
|
||||
import { RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { RadioGroupItem, RadioGroup } from '@signozhq/ui/radio-group';
|
||||
|
||||
dayjs.locale('en');
|
||||
dayjs.extend(utc);
|
||||
@@ -471,7 +470,7 @@ export function PlannedDowntimeForm(
|
||||
initialValue="specific"
|
||||
className="alert-rule-scope"
|
||||
>
|
||||
<RadioGroup>
|
||||
<RadioGroup className="silence-alerts-radio-group">
|
||||
<RadioGroupItem value="all">All alert rules</RadioGroupItem>
|
||||
<RadioGroupItem value="specific">Specific alert rules</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
|
||||
@@ -35,10 +35,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.routing-policies-table {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
) !important;
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0;
|
||||
@@ -34,9 +34,9 @@
|
||||
}
|
||||
|
||||
.refresh-interval-text {
|
||||
padding: 12px 14px 8px 14px;
|
||||
padding: 12px 14px 8px 14px !important;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 8px;
|
||||
padding-left: 16px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
&::before {
|
||||
|
||||
1
go.mod
1
go.mod
@@ -89,6 +89,7 @@ require (
|
||||
gonum.org/v1/gonum v0.17.0
|
||||
google.golang.org/api v0.272.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/apimachinery v0.35.3
|
||||
|
||||
@@ -48,6 +48,80 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
|
||||
ID: "UpdateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Update dashboard (v2)",
|
||||
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.UpdateableDashboardV2),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
|
||||
ID: "PatchDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Patch dashboard (v2)",
|
||||
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.",
|
||||
Request: new(dashboardtypes.JSONPatchDocument),
|
||||
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
|
||||
// but our OpenAPI generator only reflects schemas for content types it
|
||||
// understands (application/json, form-urlencoded, multipart) — anything
|
||||
// else degrades to `type: string`. Declaring application/json here keeps
|
||||
// the array-of-ops schema visible to spec consumers; the runtime decoder
|
||||
// parses JSON regardless of the request's actual Content-Type header.
|
||||
RequestContentType: "application/json",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
|
||||
ID: "LockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Lock dashboard (v2)",
|
||||
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
|
||||
ID: "UnlockDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Unlock dashboard (v2)",
|
||||
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
|
||||
ID: "CreatePublicDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
<svg id="b70acf0a-34b4-4bdf-9024-7496043ff915" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><radialGradient id="e2cf8746-c9a8-4eee-86c2-4951983c6032" cx="13428.81" cy="3518.86" r="56.67" gradientTransform="translate(-2005.33 -518.83) scale(0.15)" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#5ea0ef"/><stop offset="1" stop-color="#0078d4"/></radialGradient><linearGradient id="bdd213dd-d313-473c-8ff4-0133fd3a9033" x1="4.4" y1="11.48" x2="4.37" y2="7.53" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="afcc63c5-3649-4476-a742-bcb53a569f3c" x1="10.13" y1="15.45" x2="10.13" y2="11.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="bd873f0b-9954-4aa5-a3df-9f4c64e8729d" x1="14.18" y1="11.15" x2="14.18" y2="7.38" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient></defs><title>Icon-web-41</title><path id="ee75dd06-1aca-4f76-9d11-d05a284020ad" d="M14.21,15.72A8.5,8.5,0,0,1,3.79,2.28l.09-.06a8.5,8.5,0,0,1,10.33,13.5" fill="url(#e2cf8746-c9a8-4eee-86c2-4951983c6032)"/><path d="M6.69,7.23A13,13,0,0,1,15.6,3.65a8.47,8.47,0,0,0-1.49-1.44,14.34,14.34,0,0,0-4.69,1.1A12.54,12.54,0,0,0,5.34,6.13,2.76,2.76,0,0,1,6.69,7.23Z" fill="#fff" opacity="0.6"/><path d="M2.48,10.65a17.86,17.86,0,0,0-.83,2.62,7.82,7.82,0,0,0,.62.92c.18.23.35.44.55.65A17.94,17.94,0,0,1,3.9,11.37,2.76,2.76,0,0,1,2.48,10.65Z" fill="#fff" opacity="0.6"/><path d="M3.46,6.11a12,12,0,0,1-.69-2.94,8.15,8.15,0,0,0-1.1,1.45A12.69,12.69,0,0,0,2.24,7,2.69,2.69,0,0,1,3.46,6.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><path d="M8.36,13.67A1.77,1.77,0,0,1,8.9,12.4a11.88,11.88,0,0,1-2.53-1.86,2.74,2.74,0,0,1-1.49.83,13.1,13.1,0,0,0,1.45,1.28A12.12,12.12,0,0,0,8.38,13.9,1.79,1.79,0,0,1,8.36,13.67Z" fill="#f2f2f2" opacity="0.55"/><path d="M14.66,13.88a12,12,0,0,1-2.76-.32.41.41,0,0,1,0,.11,1.75,1.75,0,0,1-.51,1.24,13.69,13.69,0,0,0,3.42.24A8.21,8.21,0,0,0,16,13.81,11.5,11.5,0,0,1,14.66,13.88Z" fill="#f2f2f2" opacity="0.55"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/><path d="M12.32,8.93a1.83,1.83,0,0,1,.61-1A25.5,25.5,0,0,1,8.47,3.79a16.91,16.91,0,0,1-2-2.92,7.64,7.64,0,0,0-1.09.42A18.14,18.14,0,0,0,7.53,4.47,26.44,26.44,0,0,0,12.32,8.93Z" fill="#f2f2f2" opacity="0.7"/><circle cx="14.18" cy="9.27" r="1.89" fill="url(#bd873f0b-9954-4aa5-a3df-9f4c64e8729d)"/><path d="M17.35,10.54,17,10.37l0,0-.3-.16-.06,0L16.38,10l-.07,0L16,9.8a1.76,1.76,0,0,1-.64.92c.12.08.25.15.38.22l.08.05.35.19,0,0,.86.45h0a8.63,8.63,0,0,0,.29-1.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,185 +0,0 @@
|
||||
{
|
||||
"id": "appservice",
|
||||
"title": "App Services",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "azure_activewebsocketconnections_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_averagewebsocketconnectionduration_average",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_backendhealthpercentage_average",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_backendrequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_backendrequestlatency_average",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_billableresponsesize_total",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_bytehitratio_average",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_originhealthpercentage_average",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_originlatency_average",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_originrequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_originshieldoriginrequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_originshieldratelimitrequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_originshieldrequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_originshieldrequestsize_total",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_percentage4xx_average",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_percentage5xx_average",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_requestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_requestsize_total",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_responsesize_total",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_totallatency_average",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_webapplicationfirewallrequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_webapplicationfirewallcaptcharequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_webapplicationfirewalljsrequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_websocketconnections_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": [
|
||||
{
|
||||
"name": "Resource ID",
|
||||
"path": "resources.azure.resource.id",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"azure": {
|
||||
"resourceProvider": "Microsoft.Web",
|
||||
"resourceType": "sites",
|
||||
"metrics": {},
|
||||
"logs": {
|
||||
"categoryGroups": ["allLogs"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "App Services Overview",
|
||||
"description": "Overview of App Services metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
### Monitor Azure App Services with SigNoz
|
||||
|
||||
Collect key App Services metrics and view them with an out of the box dashboard.
|
||||
|
||||
Note: This integration DO NOT collect metrics for any database that was setup with your App Service (if any).
|
||||
@@ -60,6 +60,12 @@ type Module interface {
|
||||
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
|
||||
|
||||
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
@@ -89,4 +95,12 @@ type Handler interface {
|
||||
CreateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
LockV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
UnlockV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
PatchV2(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func (store *store) ListPublic(ctx context.Context, orgID valuer.UUID) ([]*dashb
|
||||
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashboard *dashboardtypes.StorableDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(storableDashboard).
|
||||
WherePK().
|
||||
|
||||
@@ -2,6 +2,7 @@ package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"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/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -72,3 +74,147 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, true)
|
||||
}
|
||||
|
||||
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, false)
|
||||
}
|
||||
|
||||
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
selectors := []coretypes.Selector{
|
||||
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
|
||||
}
|
||||
err = handler.authz.CheckWithTupleCreation(
|
||||
ctx,
|
||||
claims,
|
||||
valuer.MustNewUUID(claims.OrgID),
|
||||
authtypes.Relation{Verb: coretypes.VerbAssignee},
|
||||
coretypes.NewResourceRole(),
|
||||
selectors,
|
||||
selectors,
|
||||
)
|
||||
if err == nil {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateV2(rw 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(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := dashboardtypes.UpdateableDashboardV2{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) PatchV2(rw 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(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := dashboardtypes.PatchableDashboardV2{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
@@ -55,3 +55,97 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updateable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
|
||||
if err := existing.CanUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(updateable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.Update(ctx, orgID, storable)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
|
||||
if err := existing.CanUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateable, err := patch.Apply(existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(*updateable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.Update(ctx, orgID, storable)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
storable, err := existing.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.Update(ctx, orgID, storable)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type SkipResourceFingerprint struct {
|
||||
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
|
||||
// If count of fingerprint is above threshold, skip the fingerprint subquery and filter on main table instead.
|
||||
Threshold uint64 `yaml:"threshold" mapstructure:"threshold"`
|
||||
}
|
||||
|
||||
// Config represents the configuration for the querier.
|
||||
type Config struct {
|
||||
// CacheTTL is the TTL for cached query results
|
||||
@@ -15,6 +21,8 @@ type Config struct {
|
||||
FluxInterval time.Duration `yaml:"flux_interval" mapstructure:"flux_interval"`
|
||||
// MaxConcurrentQueries is the maximum number of concurrent queries for missing ranges
|
||||
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
|
||||
// SkipResourceFingerprint configures when the resource fingerprint subquery is skipped in favor of main-table filtering.
|
||||
SkipResourceFingerprint SkipResourceFingerprint `yaml:"skip_resource_fingerprint" mapstructure:"skip_resource_fingerprint"`
|
||||
}
|
||||
|
||||
// NewConfigFactory creates a new config factory for querier.
|
||||
@@ -28,6 +36,10 @@ func newConfig() factory.Config {
|
||||
CacheTTL: 168 * time.Hour,
|
||||
FluxInterval: 5 * time.Minute,
|
||||
MaxConcurrentQueries: 4,
|
||||
SkipResourceFingerprint: SkipResourceFingerprint{
|
||||
Enabled: false,
|
||||
Threshold: 100000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +54,9 @@ func (c Config) Validate() error {
|
||||
if c.MaxConcurrentQueries <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "max_concurrent_queries must be positive, got %v", c.MaxConcurrentQueries)
|
||||
}
|
||||
if c.SkipResourceFingerprint.Enabled && c.SkipResourceFingerprint.Threshold == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "skip_resource_fingerprint.threshold must be > 0 when enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ func newProvider(
|
||||
traceAggExprRewriter,
|
||||
telemetryStore,
|
||||
flagger,
|
||||
cfg.SkipResourceFingerprint.Enabled,
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
)
|
||||
|
||||
// Create trace operator statement builder
|
||||
@@ -121,6 +123,9 @@ func newProvider(
|
||||
telemetrylogs.DefaultFullTextColumn,
|
||||
telemetrylogs.GetBodyJSONKey,
|
||||
flagger,
|
||||
telemetryStore,
|
||||
cfg.SkipResourceFingerprint.Enabled,
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
)
|
||||
|
||||
// Create audit statement builder
|
||||
|
||||
@@ -89,6 +89,9 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
telemetrylogs.DefaultFullTextColumn,
|
||||
telemetrylogs.GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
return querier.New(
|
||||
@@ -134,6 +137,8 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
traceAggExprRewriter,
|
||||
telemetryStore,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
return querier.New(
|
||||
|
||||
@@ -1205,6 +1205,9 @@ func buildJSONTestStatementBuilder(t *testing.T, addIndexes bool) (*logQueryStat
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
return statementBuilder, mockMetadataStore
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryresourcefilter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -19,13 +20,14 @@ import (
|
||||
)
|
||||
|
||||
type logQueryStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
fl flagger.Flagger
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
resourceFilterResolver *telemetryresourcefilter.ResourceFingerprintResolver[qbtypes.LogAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
fl flagger.Flagger
|
||||
skipResourceFingerprintEnabled bool
|
||||
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
|
||||
@@ -42,10 +44,13 @@ func NewLogQueryStatementBuilder(
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey,
|
||||
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
|
||||
fl flagger.Flagger,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
skipResourceFingerprintEnable bool,
|
||||
skipResourceFingerprintThreshold uint64,
|
||||
) *logQueryStatementBuilder {
|
||||
logsSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrylogs")
|
||||
|
||||
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.LogAggregation](
|
||||
resourceFilterResolver := telemetryresourcefilter.NewResolver[qbtypes.LogAggregation](
|
||||
settings,
|
||||
DBName,
|
||||
LogsResourceV2TableName,
|
||||
@@ -55,18 +60,21 @@ func NewLogQueryStatementBuilder(
|
||||
fullTextColumn,
|
||||
jsonKeyToKey,
|
||||
fl,
|
||||
telemetryStore,
|
||||
skipResourceFingerprintThreshold,
|
||||
)
|
||||
|
||||
return &logQueryStatementBuilder{
|
||||
logger: logsSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
|
||||
aggExprRewriter: aggExprRewriter,
|
||||
fl: fl,
|
||||
fullTextColumn: fullTextColumn,
|
||||
jsonKeyToKey: jsonKeyToKey,
|
||||
logger: logsSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
resourceFilterResolver: resourceFilterResolver,
|
||||
aggExprRewriter: aggExprRewriter,
|
||||
fl: fl,
|
||||
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
|
||||
fullTextColumn: fullTextColumn,
|
||||
jsonKeyToKey: jsonKeyToKey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,9 +279,11 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if frag != "" {
|
||||
}
|
||||
if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -315,7 +325,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -373,9 +383,11 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if frag != "" {
|
||||
}
|
||||
if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -419,7 +431,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
// Add FROM clause
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -531,9 +543,11 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if frag != "" && !skipResourceCTE {
|
||||
}
|
||||
if frag != "" && !skipResourceCTE {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -576,7 +590,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -640,6 +654,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
skipResourceFilter bool,
|
||||
) (querybuilder.PreparedWhereClause, error) {
|
||||
|
||||
var preparedWhereClause querybuilder.PreparedWhereClause
|
||||
@@ -656,7 +671,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
BodyJSONEnabled: bodyJSONEnabled,
|
||||
SkipResourceFilter: true,
|
||||
SkipResourceFilter: skipResourceFilter,
|
||||
FullTextColumn: b.fullTextColumn,
|
||||
JsonKeyToKey: b.jsonKeyToKey,
|
||||
Variables: variables,
|
||||
@@ -707,33 +722,30 @@ func (b *logQueryStatementBuilder) maybeAttachResourceFilter(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (cteSQL string, cteArgs []any, err error) {
|
||||
) (cteSQL string, cteArgs []any, skipResourceFilter bool, err error) {
|
||||
|
||||
stmt, err := b.buildResourceFilterCTE(ctx, query, start, end, variables)
|
||||
if b.skipResourceFingerprintEnabled {
|
||||
decision, err := b.resourceFilterResolver.Resolve(ctx, query, start, end, variables)
|
||||
if err != nil {
|
||||
return "", nil, true, err
|
||||
}
|
||||
switch decision {
|
||||
case qbtypes.ResourceFilterResolveKindNoOp:
|
||||
return "", nil, true, nil
|
||||
case qbtypes.ResourceFilterResolveKindFallback:
|
||||
return "", nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
stmt, err := b.resourceFilterResolver.StatementBuilder().Build(
|
||||
ctx, start, end, qbtypes.RequestTypeRaw, query, variables,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, true, err
|
||||
}
|
||||
if stmt == nil {
|
||||
return "", nil, nil
|
||||
return "", nil, true, nil
|
||||
}
|
||||
|
||||
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
|
||||
}
|
||||
|
||||
func (b *logQueryStatementBuilder) buildResourceFilterCTE(
|
||||
ctx context.Context,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
return b.resourceFilterStmtBuilder.Build(
|
||||
ctx,
|
||||
start,
|
||||
end,
|
||||
qbtypes.RequestTypeRaw,
|
||||
query,
|
||||
variables,
|
||||
)
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
|
||||
}
|
||||
|
||||
@@ -2,19 +2,36 @@ package telemetrylogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type regexQueryMatcher struct{}
|
||||
|
||||
func (m *regexQueryMatcher) Match(expectedSQL, actualSQL string) error {
|
||||
re, err := regexp.Compile(expectedSQL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !re.MatchString(actualSQL) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected query to match %s, got %s", expectedSQL, actualSQL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStatementBuilderTimeSeries(t *testing.T) {
|
||||
// Create a test release time
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
@@ -212,6 +229,9 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -353,6 +373,9 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -499,6 +522,9 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -575,6 +601,9 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -670,6 +699,9 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -894,6 +926,9 @@ func TestAdjustKey(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -1039,6 +1074,9 @@ func TestStmtBuilderBodyField(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
@@ -1138,6 +1176,9 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
@@ -1157,3 +1198,110 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkipResourceFingerprintLogs exercises the three resolver outcomes for
|
||||
// logs: use-CTE (count < threshold), fallback (count >= threshold), and the
|
||||
// legacy path (feature disabled).
|
||||
func TestSkipResourceFingerprintLogs(t *testing.T) {
|
||||
const (
|
||||
startMs = uint64(1747947419000)
|
||||
endMs = uint64(1747983448000)
|
||||
threshold = uint64(10)
|
||||
)
|
||||
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "service.name = 'redis-manual'",
|
||||
},
|
||||
Limit: 5,
|
||||
}
|
||||
|
||||
t.Run("disabled uses the legacy CTE", func(t *testing.T) {
|
||||
sb := newSkipResourceFingerprintLogsBuilder(t, nil, false, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
|
||||
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
})
|
||||
|
||||
t.Run("CTE attached when count below threshold", func(t *testing.T) {
|
||||
mockStore := telemetrystoretest.New(telemetrystore.Config{}, ®exQueryMatcher{})
|
||||
mock := mockStore.Mock()
|
||||
|
||||
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_logs\.distributed_logs_v2_resource`).
|
||||
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
|
||||
{Name: "count", Type: "UInt64"},
|
||||
}, []any{uint64(2)}))
|
||||
|
||||
sb := newSkipResourceFingerprintLogsBuilder(t, mockStore, true, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
|
||||
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
|
||||
t.Run("fallback when count at or above threshold", func(t *testing.T) {
|
||||
mockStore := telemetrystoretest.New(telemetrystore.Config{}, ®exQueryMatcher{})
|
||||
mock := mockStore.Mock()
|
||||
|
||||
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_logs\.distributed_logs_v2_resource`).
|
||||
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
|
||||
{Name: "count", Type: "UInt64"},
|
||||
}, []any{threshold}))
|
||||
|
||||
sb := newSkipResourceFingerprintLogsBuilder(t, mockStore, true, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotContains(t, stmt.Query, "__resource_filter AS")
|
||||
require.NotContains(t, stmt.Query, "resource_fingerprint")
|
||||
require.Contains(t, stmt.Query, "service.name")
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
|
||||
func newSkipResourceFingerprintLogsBuilder(
|
||||
t *testing.T,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
skipEnable bool,
|
||||
threshold uint64,
|
||||
) *logQueryStatementBuilder {
|
||||
t.Helper()
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
fm := NewFieldMapper(fl)
|
||||
cb := NewConditionBuilder(fm, fl)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
DefaultFullTextColumn,
|
||||
fm,
|
||||
cb,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
)
|
||||
|
||||
return NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
telemetryStore,
|
||||
skipEnable,
|
||||
threshold,
|
||||
)
|
||||
}
|
||||
|
||||
77
pkg/telemetryresourcefilter/fingerprint_resolver.go
Normal file
77
pkg/telemetryresourcefilter/fingerprint_resolver.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package telemetryresourcefilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type ResourceFingerprintResolver[T any] struct {
|
||||
stmtBuilder *resourceFilterStatementBuilder[T]
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
threshold uint64
|
||||
}
|
||||
|
||||
func NewResolver[T any](
|
||||
settings factory.ProviderSettings,
|
||||
dbName string,
|
||||
tableName string,
|
||||
signal telemetrytypes.Signal,
|
||||
source telemetrytypes.Source,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey,
|
||||
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
|
||||
fl flagger.Flagger,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
threshold uint64,
|
||||
) *ResourceFingerprintResolver[T] {
|
||||
return &ResourceFingerprintResolver[T]{
|
||||
stmtBuilder: New[T](
|
||||
settings,
|
||||
dbName,
|
||||
tableName,
|
||||
signal,
|
||||
source,
|
||||
metadataStore,
|
||||
fullTextColumn,
|
||||
jsonKeyToKey,
|
||||
fl,
|
||||
),
|
||||
telemetryStore: telemetryStore,
|
||||
threshold: threshold,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResourceFingerprintResolver[T]) StatementBuilder() qbtypes.StatementBuilder[T] {
|
||||
return r.stmtBuilder
|
||||
}
|
||||
|
||||
func (r *ResourceFingerprintResolver[T]) Resolve(
|
||||
ctx context.Context,
|
||||
query qbtypes.QueryBuilderQuery[T],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (qbtypes.ResourceFilterResolveKind, error) {
|
||||
countStmt, err := r.stmtBuilder.BuildCount(ctx, start, end, query, variables)
|
||||
if err != nil {
|
||||
return qbtypes.ResourceFilterResolveKindNoOp, err
|
||||
}
|
||||
if countStmt == nil {
|
||||
return qbtypes.ResourceFilterResolveKindNoOp, nil
|
||||
}
|
||||
|
||||
var count uint64
|
||||
row := r.telemetryStore.ClickhouseDB().QueryRow(ctx, countStmt.Query, countStmt.Args...)
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return qbtypes.ResourceFilterResolveKindNoOp, err
|
||||
}
|
||||
|
||||
if count >= r.threshold {
|
||||
return qbtypes.ResourceFilterResolveKindFallback, nil
|
||||
}
|
||||
return qbtypes.ResourceFilterResolveKindUseCTE, nil
|
||||
}
|
||||
@@ -127,6 +127,25 @@ func (b *resourceFilterStatementBuilder[T]) Build(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildCount returns a statement that counts the distinct fingerprints matching
|
||||
// the resource filter. Returns (nil, nil) when the filter is a no-op.
|
||||
func (b *resourceFilterStatementBuilder[T]) BuildCount(
|
||||
ctx context.Context,
|
||||
start uint64,
|
||||
end uint64,
|
||||
query qbtypes.QueryBuilderQuery[T],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
inner, err := b.Build(ctx, start, end, qbtypes.RequestTypeRaw, query, variables)
|
||||
if err != nil || inner == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &qbtypes.Statement{
|
||||
Query: fmt.Sprintf("SELECT count() FROM (%s)", inner.Query),
|
||||
Args: inner.Args,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addConditions adds both filter and time conditions to the query.
|
||||
// Returns true (isNoOp) when the filter expression evaluated to no resource conditions,
|
||||
// meaning the CTE would select all fingerprints and should be skipped entirely.
|
||||
|
||||
@@ -24,13 +24,13 @@ var (
|
||||
)
|
||||
|
||||
type traceQueryStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
resourceFilterResolver *telemetryresourcefilter.ResourceFingerprintResolver[qbtypes.TraceAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
skipResourceFingerprintEnabled bool
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil)
|
||||
@@ -43,10 +43,12 @@ func NewTraceQueryStatementBuilder(
|
||||
aggExprRewriter qbtypes.AggExprRewriter,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
flagger flagger.Flagger,
|
||||
skipResourceFingerprintEnable bool,
|
||||
skipResourceFingerprintThreshold uint64,
|
||||
) *traceQueryStatementBuilder {
|
||||
tracesSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrytraces")
|
||||
|
||||
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.TraceAggregation](
|
||||
resourceFilterResolver := telemetryresourcefilter.NewResolver[qbtypes.TraceAggregation](
|
||||
settings,
|
||||
DBName,
|
||||
TracesResourceV3TableName,
|
||||
@@ -56,16 +58,18 @@ func NewTraceQueryStatementBuilder(
|
||||
nil,
|
||||
nil,
|
||||
flagger,
|
||||
telemetryStore,
|
||||
skipResourceFingerprintThreshold,
|
||||
)
|
||||
|
||||
return &traceQueryStatementBuilder{
|
||||
logger: tracesSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
|
||||
aggExprRewriter: aggExprRewriter,
|
||||
telemetryStore: telemetryStore,
|
||||
logger: tracesSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
resourceFilterResolver: resourceFilterResolver,
|
||||
aggExprRewriter: aggExprRewriter,
|
||||
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,9 +312,11 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
cteArgs [][]any
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if frag != "" {
|
||||
}
|
||||
if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -328,7 +334,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -389,15 +395,17 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
cteArgs [][]any
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, distSB, query, start, end, variables); err != nil {
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, distSB, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if frag != "" {
|
||||
}
|
||||
if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, query, keys, variables, skipResourceFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -498,9 +506,11 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
cteArgs [][]any
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if frag != "" {
|
||||
}
|
||||
if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -541,7 +551,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
}
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -650,9 +660,11 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
cteArgs [][]any
|
||||
)
|
||||
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if frag != "" && !skipResourceCTE {
|
||||
}
|
||||
if frag != "" && !skipResourceCTE {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -694,7 +706,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -757,6 +769,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
skipResourceFilter bool,
|
||||
) (querybuilder.PreparedWhereClause, error) {
|
||||
|
||||
var preparedWhereClause querybuilder.PreparedWhereClause
|
||||
@@ -770,7 +783,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
SkipResourceFilter: true,
|
||||
SkipResourceFilter: skipResourceFilter,
|
||||
Variables: variables,
|
||||
StartNs: start,
|
||||
EndNs: end,
|
||||
@@ -811,34 +824,30 @@ func (b *traceQueryStatementBuilder) maybeAttachResourceFilter(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (cteSQL string, cteArgs []any, err error) {
|
||||
) (cteSQL string, cteArgs []any, skipResourceFilter bool, err error) {
|
||||
|
||||
stmt, err := b.buildResourceFilterCTE(ctx, query, start, end, variables)
|
||||
if b.skipResourceFingerprintEnabled {
|
||||
decision, err := b.resourceFilterResolver.Resolve(ctx, query, start, end, variables)
|
||||
if err != nil {
|
||||
return "", nil, true, err
|
||||
}
|
||||
switch decision {
|
||||
case qbtypes.ResourceFilterResolveKindNoOp:
|
||||
return "", nil, true, nil
|
||||
case qbtypes.ResourceFilterResolveKindFallback:
|
||||
return "", nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
stmt, err := b.resourceFilterResolver.StatementBuilder().Build(
|
||||
ctx, start, end, qbtypes.RequestTypeRaw, query, variables,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, true, err
|
||||
}
|
||||
if stmt == nil {
|
||||
return "", nil, nil
|
||||
return "", nil, true, nil
|
||||
}
|
||||
|
||||
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
|
||||
}
|
||||
|
||||
func (b *traceQueryStatementBuilder) buildResourceFilterCTE(
|
||||
ctx context.Context,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
return b.resourceFilterStmtBuilder.Build(
|
||||
ctx,
|
||||
start,
|
||||
end,
|
||||
qbtypes.RequestTypeRaw,
|
||||
query,
|
||||
variables,
|
||||
)
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
|
||||
}
|
||||
|
||||
@@ -2,19 +2,36 @@ package telemetrytraces
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type regexQueryMatcher struct{}
|
||||
|
||||
func (m *regexQueryMatcher) Match(expectedSQL, actualSQL string) error {
|
||||
re, err := regexp.Compile(expectedSQL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !re.MatchString(actualSQL) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected query to match %s, got %s", expectedSQL, actualSQL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStatementBuilder(t *testing.T) {
|
||||
// releaseTime is chosen so it lands inside the standard [1747947419000, 1747983448000]ms
|
||||
// test window, keeping the multiIf SQL form for resource fields.
|
||||
@@ -370,6 +387,8 @@ func TestStatementBuilder(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
vars := map[string]qbtypes.VariableItem{
|
||||
@@ -666,6 +685,8 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -794,6 +815,8 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
@@ -864,6 +887,8 @@ func TestStatementBuilderGroupByResourceEvolution(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
@@ -1029,6 +1054,8 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -1561,3 +1588,115 @@ func TestAdjustKeys(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkipResourceFingerprint exercises the three resolver outcomes when
|
||||
// skip_resource_fingerprint is enabled: use-CTE (count < threshold),
|
||||
// fallback (count >= threshold), and the legacy path (feature disabled).
|
||||
func TestSkipResourceFingerprint(t *testing.T) {
|
||||
const (
|
||||
startMs = uint64(1747947419000)
|
||||
endMs = uint64(1747983448000)
|
||||
threshold = uint64(10)
|
||||
)
|
||||
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "service.name = 'redis-manual'",
|
||||
},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "name",
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
Limit: 5,
|
||||
}
|
||||
|
||||
t.Run("disabled uses the legacy CTE", func(t *testing.T) {
|
||||
sb := newSkipResourceFingerprintBuilder(t, nil, false, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
|
||||
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
})
|
||||
|
||||
t.Run("CTE attached when count below threshold", func(t *testing.T) {
|
||||
mockStore := telemetrystoretest.New(telemetrystore.Config{}, ®exQueryMatcher{})
|
||||
mock := mockStore.Mock()
|
||||
|
||||
// Only the count query runs against the telemetry store; the CTE
|
||||
// itself is embedded as SQL in the main query (no extra round trip).
|
||||
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_traces\.distributed_traces_v3_resource`).
|
||||
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
|
||||
{Name: "count", Type: "UInt64"},
|
||||
}, []any{uint64(2)}))
|
||||
|
||||
sb := newSkipResourceFingerprintBuilder(t, mockStore, true, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
|
||||
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
|
||||
t.Run("fallback when count at or above threshold", func(t *testing.T) {
|
||||
mockStore := telemetrystoretest.New(telemetrystore.Config{}, ®exQueryMatcher{})
|
||||
mock := mockStore.Mock()
|
||||
|
||||
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_traces\.distributed_traces_v3_resource`).
|
||||
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
|
||||
{Name: "count", Type: "UInt64"},
|
||||
}, []any{threshold}))
|
||||
|
||||
sb := newSkipResourceFingerprintBuilder(t, mockStore, true, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotContains(t, stmt.Query, "__resource_filter AS")
|
||||
require.NotContains(t, stmt.Query, "resource_fingerprint")
|
||||
// resource conditions are pushed onto the main table via the
|
||||
// resource.`service.name` / resources_string lookup
|
||||
require.Contains(t, stmt.Query, "service.name")
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
|
||||
func newSkipResourceFingerprintBuilder(
|
||||
t *testing.T,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
skipEnable bool,
|
||||
threshold uint64,
|
||||
) *traceQueryStatementBuilder {
|
||||
t.Helper()
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(
|
||||
instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl,
|
||||
)
|
||||
|
||||
return NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
telemetryStore,
|
||||
fl,
|
||||
skipEnable,
|
||||
threshold,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementB
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, fm, cb, aggExprRewriter, nil, fl,
|
||||
mockMetadataStore, fm, cb, aggExprRewriter, nil, fl, false, 100000,
|
||||
)
|
||||
return NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -46,8 +46,10 @@ func TestTraceTimeRangeOptimization(t *testing.T) {
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil, // telemetryStore is nil - optimization won't happen but code path is tested
|
||||
nil, // telemetryStore is nil - adaptive path is disabled
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -27,7 +27,6 @@ var (
|
||||
// Azure services.
|
||||
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
|
||||
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
|
||||
AzureServiceAppService = ServiceID{valuer.NewString("appservice")}
|
||||
)
|
||||
|
||||
func (ServiceID) Enum() []any {
|
||||
@@ -47,7 +46,6 @@ func (ServiceID) Enum() []any {
|
||||
AWSServiceSQS,
|
||||
AzureServiceStorageAccountsBlob,
|
||||
AzureServiceCDNProfile,
|
||||
AzureServiceAppService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +69,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
|
||||
CloudProviderTypeAzure: {
|
||||
AzureServiceStorageAccountsBlob,
|
||||
AzureServiceCDNProfile,
|
||||
AzureServiceAppService,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/perses/pkg/model/api/v1/common"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
jsonpatch "gopkg.in/evanphx/json-patch.v4"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
@@ -62,6 +64,54 @@ type DashboardV2 struct {
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (d *DashboardV2) CanUpdate() error {
|
||||
if d.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
|
||||
}
|
||||
if d.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) Update(updateable UpdateableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
|
||||
if err := d.CanUpdate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if updateable.Name != d.Name {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updateable.Name)
|
||||
}
|
||||
d.DashboardV2MetadataBase = updateable.DashboardV2MetadataBase
|
||||
d.Tags = resolvedTags
|
||||
d.Spec = updateable.Spec
|
||||
d.UpdatedBy = updatedBy
|
||||
d.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
|
||||
if d.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
|
||||
}
|
||||
if d.Source == SourceSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be locked or unlocked")
|
||||
}
|
||||
if d.CreatedBy != updatedBy && !isAdmin {
|
||||
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
|
||||
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Locked = lock
|
||||
d.UpdatedBy = updatedBy
|
||||
d.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
type DashboardV2MetadataBase struct {
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Image string `json:"image,omitempty"`
|
||||
@@ -263,6 +313,87 @@ func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardDat
|
||||
|
||||
type StorableDashboardV2Metadata = DashboardV2MetadataBase
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Updateable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type UpdateableDashboardV2 = PostableDashboardV2
|
||||
|
||||
func (d DashboardV2) toUpdateableDashboardV2() UpdateableDashboardV2 {
|
||||
return PostableDashboardV2{
|
||||
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
|
||||
Name: d.Name,
|
||||
Tags: tagtypes.NewPostableTagsFromTags(d.Tags),
|
||||
Spec: d.Spec,
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Patchable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// PatchableDashboardV2 is an RFC 6902 JSON Patch document applied against a
|
||||
// PostableDashboardV2-shaped view of an existing dashboard. Patch ops can
|
||||
// target any field — including individual entries inside `data.panels`,
|
||||
// `data.panels.<id>.spec.queries`, or `tags` — without re-sending the rest of
|
||||
// the dashboard.
|
||||
type PatchableDashboardV2 struct {
|
||||
patch jsonpatch.Patch
|
||||
}
|
||||
|
||||
// JSONPatchDocument is the OpenAPI-facing schema for an RFC 6902 patch body.
|
||||
// PatchableDashboardV2 has only an internal `jsonpatch.Patch` field, so the
|
||||
// reflector would emit an empty schema; the handler def points at this type
|
||||
// instead so consumers see the array-of-ops shape.
|
||||
type JSONPatchDocument []JSONPatchOperation
|
||||
|
||||
// JSONPatchOperation is one RFC 6902 op. Not every field is valid on every
|
||||
// op kind (e.g. `value` is required for add/replace/test, ignored for remove;
|
||||
// `from` is required for move/copy) — the JSON Patch RFC governs that.
|
||||
type JSONPatchOperation struct {
|
||||
Op string `json:"op" required:"true"`
|
||||
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-."`
|
||||
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
|
||||
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
|
||||
}
|
||||
|
||||
// PrepareJSONSchema constrains the `op` field to the six RFC 6902 verbs.
|
||||
func (JSONPatchOperation) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
op, ok := s.Properties["op"]
|
||||
if !ok || op.TypeObject == nil {
|
||||
return errors.NewInternalf(errors.CodeInternal, "JSONPatchOperation schema missing `op` property")
|
||||
}
|
||||
op.TypeObject.WithEnum("add", "remove", "replace", "move", "copy", "test")
|
||||
s.Properties["op"] = op
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
patch, err := jsonpatch.DecodePatch(data)
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
|
||||
}
|
||||
p.patch = patch
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdateableDashboardV2, error) {
|
||||
existingAsUpdateable := existing.toUpdateableDashboardV2()
|
||||
raw, err := json.Marshal(existingAsUpdateable)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
|
||||
}
|
||||
patched, err := p.patch.Apply(raw)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
|
||||
}
|
||||
out := &UpdateableDashboardV2{}
|
||||
if err := json.Unmarshal(patched, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Convertors
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
566
pkg/types/dashboardtypes/perses_dashboard_patch_test.go
Normal file
566
pkg/types/dashboardtypes/perses_dashboard_patch_test.go
Normal file
@@ -0,0 +1,566 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// basePostableJSON is the postable shape of a small but realistic v2
|
||||
// dashboard used as the base document for patch tests. Each panel carries
|
||||
// one builder query in the same shape production dashboards use
|
||||
// (aggregations, filter, groupBy populated), and the dashboard has one
|
||||
// variable — the variable is not patched in any test here, that's
|
||||
// covered in a separate variable-focused suite.
|
||||
const basePostableJSON = `{
|
||||
"schemaVersion": "v6",
|
||||
"name": "service-overview",
|
||||
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
|
||||
"spec": {
|
||||
"display": {"name": "Service overview"},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "service",
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{
|
||||
"metricName": "signoz_calls_total",
|
||||
"temporality": "cumulative",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum"
|
||||
}],
|
||||
"filter": {"expression": "service.name IN $service"},
|
||||
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}]
|
||||
}}}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"p2": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "X",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{
|
||||
"metricName": "signoz_latency_count",
|
||||
"temporality": "cumulative",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum"
|
||||
}]
|
||||
}}}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"display": {"title": "Row 1"},
|
||||
"items": [
|
||||
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
|
||||
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"duration": "1h"
|
||||
}
|
||||
}`
|
||||
|
||||
func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
// Apply doesn't mutate the input *DashboardV2 — it marshals it to
|
||||
// JSON, applies the patch, and unmarshals the result into a fresh
|
||||
// struct. Sharing one base across subtests is safe.
|
||||
var p PostableDashboardV2
|
||||
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
|
||||
testOrgID := valuer.GenerateUUID()
|
||||
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
|
||||
base.Tags = []*tagtypes.Tag{
|
||||
{Key: "team", Value: "alpha"},
|
||||
{Key: "env", Value: "prod"},
|
||||
}
|
||||
|
||||
decode := func(t *testing.T, body string) PatchableDashboardV2 {
|
||||
t.Helper()
|
||||
var patch PatchableDashboardV2
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &patch))
|
||||
return patch
|
||||
}
|
||||
|
||||
// jsonOf marshals the patched dashboard back to JSON so subtests can
|
||||
// assert on field values without reaching into the typed plugin specs.
|
||||
jsonOf := func(t *testing.T, out *UpdateableDashboardV2) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Successful patches
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("no-op preserves all fields", func(t *testing.T) {
|
||||
out, err := decode(t, `[]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, base.DashboardV2MetadataBase, out.DashboardV2MetadataBase)
|
||||
assert.Equal(t, tagtypes.NewPostableTagsFromTags(base.Tags), out.Tags)
|
||||
assert.Equal(t, base.Spec.Display.Name, out.Spec.Display.Name)
|
||||
require.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels))
|
||||
for k, panel := range base.Spec.Panels {
|
||||
require.Contains(t, out.Spec.Panels, k)
|
||||
assert.Equal(t, panel.Spec.Plugin.Kind, out.Spec.Panels[k].Spec.Plugin.Kind)
|
||||
}
|
||||
assert.Len(t, out.Tags, len(base.Tags))
|
||||
assert.Len(t, out.Spec.Variables, len(base.Spec.Variables))
|
||||
assert.Len(t, out.Spec.Layouts, len(base.Spec.Layouts))
|
||||
})
|
||||
|
||||
t.Run("add metadata image", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/image", "value": "https://example.com/img.png"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com/img.png", out.Image)
|
||||
assert.Equal(t, SchemaVersion, out.SchemaVersion, "schemaVersion preserved")
|
||||
})
|
||||
|
||||
t.Run("replace display name", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": "Renamed"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Renamed", out.Spec.Display.Name)
|
||||
})
|
||||
|
||||
// Per RFC 6902 § 4.1, `add` on an existing object member replaces the
|
||||
// existing value rather than erroring — same effect as `replace`.
|
||||
t.Run("add overwrites existing display name", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/spec/display/name", "value": "Overwritten"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Overwritten", out.Spec.Display.Name)
|
||||
})
|
||||
|
||||
t.Run("add data refreshInterval", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/spec/refreshInterval", "value": "30s"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "30s", string(out.Spec.RefreshInterval))
|
||||
})
|
||||
|
||||
t.Run("add panel leaves others untouched", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "add",
|
||||
"path": "/spec/panels/p3",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.Spec.Panels, 3)
|
||||
assert.Contains(t, out.Spec.Panels, "p3")
|
||||
// Plugin specs round-trip through MarshalJSON which resolves defaults
|
||||
// (e.g. timePreference → "global_time"), so compare the serialized
|
||||
// shape rather than the in-memory structs to skip that normalization.
|
||||
for _, id := range []string{"p1", "p2"} {
|
||||
wantJSON, err := json.Marshal(base.Spec.Panels[id])
|
||||
require.NoError(t, err)
|
||||
gotJSON, err := json.Marshal(out.Spec.Panels[id])
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(wantJSON), string(gotJSON), "panel %s untouched", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replace single panel", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p2",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{
|
||||
"metricName": "signoz_calls_total",
|
||||
"temporality": "cumulative",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum"
|
||||
}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, PanelPluginKind("signoz/BarChartPanel"), out.Spec.Panels["p2"].Spec.Plugin.Kind)
|
||||
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Spec.Panels["p1"].Spec.Plugin.Kind, "p1 untouched")
|
||||
})
|
||||
|
||||
// Removing a panel realistically also drops its layout item — exercise
|
||||
// the multi-op shape the UI sends.
|
||||
t.Run("remove panel and its layout item", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "remove", "path": "/spec/panels/p2"},
|
||||
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.Spec.Panels, 1)
|
||||
assert.Contains(t, out.Spec.Panels, "p1")
|
||||
assert.NotContains(t, out.Spec.Panels, "p2")
|
||||
raw := jsonOf(t, out)
|
||||
assert.NotContains(t, raw, `"$ref":"#/spec/panels/p2"`)
|
||||
assert.Contains(t, raw, `"$ref":"#/spec/panels/p1"`)
|
||||
})
|
||||
|
||||
// The headline use case: edit a single field of a single query inside
|
||||
// one panel without re-sending any other part of the dashboard.
|
||||
t.Run("rename single query inside panel", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p1/spec/queries/0/spec/plugin/spec/name",
|
||||
"value": "renamed"
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
|
||||
assert.Contains(t, jsonOf(t, out), `"name":"renamed"`)
|
||||
})
|
||||
|
||||
// Replace a query at a specific index — swaps query "A" out for "B"
|
||||
// without re-sending the rest of the panel.
|
||||
t.Run("replace query at index", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p1/spec/queries/0",
|
||||
"value": {
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "B",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{
|
||||
"metricName": "signoz_db_calls_total",
|
||||
"temporality": "cumulative",
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum"
|
||||
}]
|
||||
}}}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"name":"B"`)
|
||||
assert.NotContains(t, raw, `"name":"A"`)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Layout edits
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
// The first item used to live at x=0, now lives at x=6.
|
||||
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
|
||||
})
|
||||
|
||||
t.Run("resize panel by editing layout width", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"width":12`)
|
||||
})
|
||||
|
||||
t.Run("rename layout row title", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/display/title", "value": "Latency"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, jsonOf(t, out), `"title":"Latency"`)
|
||||
})
|
||||
|
||||
t.Run("append layout item", func(t *testing.T) {
|
||||
out, err := decode(t, `[{
|
||||
"op": "add",
|
||||
"path": "/spec/layouts/0/spec/items/-",
|
||||
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
|
||||
}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
// Item count went 2 → 3.
|
||||
raw := jsonOf(t, out)
|
||||
assert.Equal(t, 3, strings.Count(raw, `"$ref":"#/spec/panels/`))
|
||||
})
|
||||
|
||||
// Composing add-panel + add-layout-item is the realistic shape of the
|
||||
// "add a new chart to my dashboard" UI flow — exercise it end-to-end.
|
||||
t.Run("add panel and corresponding layout item", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/spec/panels/p3",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "TimeSeriesQuery",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/spec/layouts/0/spec/items/-",
|
||||
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}
|
||||
}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.Spec.Panels, 3)
|
||||
raw := jsonOf(t, out)
|
||||
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
|
||||
})
|
||||
|
||||
t.Run("append tag", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 3)
|
||||
assert.Equal(t, "env", out.Tags[2].Key)
|
||||
assert.Equal(t, "staging", out.Tags[2].Value)
|
||||
})
|
||||
|
||||
t.Run("append tag when none exist", func(t *testing.T) {
|
||||
noTagsBase := &DashboardV2{
|
||||
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
|
||||
Name: base.Name,
|
||||
Tags: nil,
|
||||
Spec: base.Spec,
|
||||
}
|
||||
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "team", "value": "new"}}]`).Apply(noTagsBase)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 1)
|
||||
assert.Equal(t, "team", out.Tags[0].Key)
|
||||
assert.Equal(t, "new", out.Tags[0].Value)
|
||||
})
|
||||
|
||||
t.Run("replace tag value", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/value", "value": "beta"}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Tags, 2)
|
||||
assert.Equal(t, "team", out.Tags[0].Key)
|
||||
assert.Equal(t, "beta", out.Tags[0].Value)
|
||||
assert.Equal(t, "env", out.Tags[1].Key, "tag at index 1 untouched")
|
||||
assert.Equal(t, "prod", out.Tags[1].Value, "tag at index 1 untouched")
|
||||
for _, tag := range out.Tags {
|
||||
assert.NotEqual(t, "alpha", tag.Value, "old tag value must be gone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple ops applied in order", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
|
||||
{"op": "remove", "path": "/spec/panels/p2"},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Multi-step", out.Spec.Display.Name)
|
||||
assert.Len(t, out.Spec.Panels, 1)
|
||||
assert.Len(t, out.Tags, 3)
|
||||
})
|
||||
|
||||
// `test` is an RFC 6902 precondition op: aborts the patch if the value
|
||||
// at the path doesn't equal the supplied value. Used for optimistic
|
||||
// concurrency. Here it matches, so the subsequent ops apply.
|
||||
t.Run("test op passes", func(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "test", "path": "/spec/display/name", "value": "Service overview"},
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Confirmed"}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Confirmed", out.Spec.Display.Name)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Failure cases
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
t.Run("decode rejects non-array body", func(t *testing.T) {
|
||||
var patch PatchableDashboardV2
|
||||
err := json.Unmarshal([]byte(`{"op": "replace"}`), &patch)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("decode rejects malformed JSON", func(t *testing.T) {
|
||||
var patch PatchableDashboardV2
|
||||
// Outer json.Unmarshal rejects non-JSON before PatchableDashboardV2's
|
||||
// UnmarshalJSON runs, so the error is a stdlib SyntaxError rather
|
||||
// than the InvalidInput-classified wrap.
|
||||
err := json.Unmarshal([]byte(`not json`), &patch)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
// `test` precondition fails — the whole patch is rejected, including
|
||||
// the subsequent replace.
|
||||
t.Run("test op failure rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[
|
||||
{"op": "test", "path": "/spec/display/name", "value": "Wrong"},
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Should not apply"}
|
||||
]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("remove at missing path rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{"op": "remove", "path": "/spec/panels/does-not-exist"}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("remove schemaVersion rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{"op": "remove", "path": "/schemaVersion"}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("wrong schemaVersion rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{"op": "replace", "path": "/schemaVersion", "value": "v5"}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), SchemaVersion)
|
||||
})
|
||||
|
||||
t.Run("empty display name defaults to dashboard name", func(t *testing.T) {
|
||||
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": ""}]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, base.Name, out.Spec.Display.Name, "empty display.name should default from name")
|
||||
})
|
||||
|
||||
t.Run("unknown top-level field rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{"op": "add", "path": "/bogus", "value": 42}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "bogus")
|
||||
})
|
||||
|
||||
t.Run("invalid panel kind rejected", func(t *testing.T) {
|
||||
_, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p1",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "NotAPanel")
|
||||
})
|
||||
|
||||
t.Run("query kind incompatible with panel rejected", func(t *testing.T) {
|
||||
// PromQLQuery is not allowed on ListPanel — verify the cross-check
|
||||
// in Validate still runs after a patch.
|
||||
_, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p2",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
|
||||
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("removing the only query rejected", func(t *testing.T) {
|
||||
// Validate requires exactly one query per panel — leaving zero is rejected.
|
||||
_, err := decode(t, `[{"op": "remove", "path": "/spec/panels/p2/spec/queries/0"}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "panel must have one query")
|
||||
})
|
||||
|
||||
t.Run("two direct queries rejected", func(t *testing.T) {
|
||||
// Validate requires exactly one query per panel. To display multiple
|
||||
// data sources in one panel, wrap them in a CompositeQuery (see the
|
||||
// "replace query with composite" subtest below).
|
||||
_, err := decode(t, `[{
|
||||
"op": "replace",
|
||||
"path": "/spec/panels/p1",
|
||||
"value": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "metrics",
|
||||
"aggregations": [{"metricName": "signoz_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
|
||||
}}}},
|
||||
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "B", "signal": "metrics",
|
||||
"aggregations": [{"metricName": "signoz_db_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
|
||||
}}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "panel must have one query")
|
||||
})
|
||||
|
||||
t.Run("too many tags rejected", func(t *testing.T) {
|
||||
// Base already has 2 tags; add 9 more to exceed MaxTagsPerDashboard (10).
|
||||
_, err := decode(t, `[
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "1"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "2"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "3"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "4"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "5"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "6"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "7"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "8"}},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "9"}}
|
||||
]`).Apply(base)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "at most")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
type ResourceFilterResolveKind int
|
||||
|
||||
const (
|
||||
ResourceFilterResolveKindNoOp ResourceFilterResolveKind = iota
|
||||
ResourceFilterResolveKindUseCTE
|
||||
ResourceFilterResolveKindFallback
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Transparency check for the skip_resource_fingerprint optimization (traces and logs).
|
||||
|
||||
At or above the configured fingerprint threshold the optimization pushes resource
|
||||
conditions onto the main spans/logs table instead of the fingerprint CTE. That
|
||||
rewrite must change ClickHouse performance, never the rows: each test runs the same
|
||||
query against the primary instance (optimization on, threshold=2) and
|
||||
`signoz_fingerprint` (optimization off) and asserts the responses are identical.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
TelemetryFieldKey,
|
||||
assert_identical_query_response,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import Traces
|
||||
|
||||
|
||||
def test_skip_resource_fingerprint_traces_fallback_matches_fingerprint(
|
||||
signoz: types.SigNoz,
|
||||
signoz_fingerprint: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A >= 2-fingerprint filter drives the fallback path; rows must match the fingerprint baseline."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
# 3 distinct services share one env (3 fingerprints > threshold 2 -> fallback);
|
||||
# the 4th has a different env and must be excluded.
|
||||
env = {"deployment.environment": "skip-fallback"}
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=now - timedelta(seconds=10), resources={"service.name": "skip-fb-svc-a", **env}),
|
||||
Traces(timestamp=now - timedelta(seconds=9), resources={"service.name": "skip-fb-svc-b", **env}),
|
||||
Traces(timestamp=now - timedelta(seconds=8), resources={"service.name": "skip-fb-svc-c", **env}),
|
||||
Traces(timestamp=now - timedelta(seconds=7), resources={"service.name": "skip-fb-other", "deployment.environment": "skip-other"}),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
limit=50,
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
|
||||
filter_expression="deployment.environment = 'skip-fallback'",
|
||||
select_fields=[TelemetryFieldKey("service.name", "string", "resource")],
|
||||
).to_dict()
|
||||
|
||||
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
|
||||
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
|
||||
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
|
||||
|
||||
assert len(get_rows(optimized)) == 3
|
||||
assert_identical_query_response(optimized, fingerprint)
|
||||
|
||||
|
||||
def test_skip_resource_fingerprint_logs_fallback_matches_fingerprint(
|
||||
signoz: types.SigNoz,
|
||||
signoz_fingerprint: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""A >= 2-fingerprint filter drives the fallback path; rows must match the fingerprint baseline."""
|
||||
now = datetime.now(tz=UTC)
|
||||
|
||||
# 3 distinct services share one env (3 fingerprints > threshold 2 -> fallback);
|
||||
# the 4th has a different env and must be excluded.
|
||||
env = {"deployment.environment": "skip-logs-fallback"}
|
||||
insert_logs(
|
||||
[
|
||||
Logs(timestamp=now - timedelta(seconds=10), resources={"service.name": "skip-logs-fb-svc-a", **env}, body="a"),
|
||||
Logs(timestamp=now - timedelta(seconds=9), resources={"service.name": "skip-logs-fb-svc-b", **env}, body="b"),
|
||||
Logs(timestamp=now - timedelta(seconds=8), resources={"service.name": "skip-logs-fb-svc-c", **env}, body="c"),
|
||||
Logs(timestamp=now - timedelta(seconds=7), resources={"service.name": "skip-logs-fb-other", "deployment.environment": "skip-logs-other"}, body="noise"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = BuilderQuery(
|
||||
signal="logs",
|
||||
limit=50,
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
|
||||
filter_expression="deployment.environment = 'skip-logs-fallback'",
|
||||
select_fields=[TelemetryFieldKey("service.name", "string", "resource"), TelemetryFieldKey("body")],
|
||||
).to_dict()
|
||||
|
||||
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
|
||||
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
|
||||
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
|
||||
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
|
||||
|
||||
assert len(get_rows(optimized)) == 3
|
||||
assert_identical_query_response(optimized, fingerprint)
|
||||
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_skip_resource_fingerprint(
|
||||
network: Network,
|
||||
migrator: types.Operation, # pylint: disable=unused-argument
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
"""
|
||||
Package-scoped SigNoz instance with the skip_resource_fingerprint
|
||||
optimization enabled and a low threshold so the fallback resolver path is
|
||||
exercised (filters matching >= 2 fingerprints skip the fingerprint CTE).
|
||||
"""
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-skip-resource-fingerprint",
|
||||
env_overrides={
|
||||
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_ENABLED": True,
|
||||
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_THRESHOLD": 2,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz_fingerprint", scope="package")
|
||||
def signoz_fingerprint(
|
||||
network: Network,
|
||||
migrator: types.Operation, # pylint: disable=unused-argument
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
"""
|
||||
A second SigNoz instance with the optimization disabled, so every query
|
||||
resolves resource filters through the fingerprint CTE (the path the primary
|
||||
also takes below the threshold). It shares the same ClickHouse (telemetry)
|
||||
and sqlstore (users) as the primary instance, which lets a single admin
|
||||
token authenticate against both and lets the tests diff the optimized
|
||||
result against this fingerprint baseline for the same query and data.
|
||||
"""
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-skip-resource-fingerprint-baseline",
|
||||
env_overrides={
|
||||
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_ENABLED": False,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user