mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-24 20:30:42 +01:00
Compare commits
67 Commits
issue_6107
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab70762a7 | ||
|
|
8989e0a3a1 | ||
|
|
0d150554c7 | ||
|
|
bee75c8e34 | ||
|
|
ee35fc351f | ||
|
|
720810d424 | ||
|
|
7424885a14 | ||
|
|
2c09fedde1 | ||
|
|
5a1be60745 | ||
|
|
eb54542ca1 | ||
|
|
c92fe4969a | ||
|
|
97a2fefe61 | ||
|
|
c059d3b8f6 | ||
|
|
37d342eb90 | ||
|
|
d60da7b8a7 | ||
|
|
8d5111bc7d | ||
|
|
e2578c15fb | ||
|
|
a446a832ae | ||
|
|
c89c19ce9e | ||
|
|
e543b3ef32 | ||
|
|
3f7531f444 | ||
|
|
abfa9bee1e | ||
|
|
2f497108df | ||
|
|
1fa1e292e9 | ||
|
|
a328988a15 | ||
|
|
7e53bd607d | ||
|
|
4d82eb8436 | ||
|
|
95c11d0550 | ||
|
|
b2810ec117 | ||
|
|
7556dd2868 | ||
|
|
339a10217e | ||
|
|
cd0eb62734 | ||
|
|
a667007991 | ||
|
|
208fc1e8ec | ||
|
|
54eb67392a | ||
|
|
0e0d97c16f | ||
|
|
659f9c7ecf | ||
|
|
ce8442c17a | ||
|
|
7ab21678b5 | ||
|
|
951bfb66cd | ||
|
|
113685fbdb | ||
|
|
b9c306dd26 | ||
|
|
7e71d4507c | ||
|
|
e13cb08097 | ||
|
|
1e7aaa9f14 | ||
|
|
09ab9f4081 | ||
|
|
f945b5f513 | ||
|
|
8b0ab0ff26 | ||
|
|
f2679a6866 | ||
|
|
253a117849 | ||
|
|
c9538be38f | ||
|
|
be51c37317 | ||
|
|
66796d1767 | ||
|
|
8e19b17855 | ||
|
|
8885497e14 | ||
|
|
626e047485 | ||
|
|
cc3d86c3ec | ||
|
|
0100083284 | ||
|
|
648e945471 | ||
|
|
7323d5ae0b | ||
|
|
578b9172ba | ||
|
|
2110006d17 | ||
|
|
01dda19d17 | ||
|
|
b519574b89 | ||
|
|
629ecabce7 | ||
|
|
131e302c55 | ||
|
|
239c92ba67 |
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -200,6 +200,15 @@ go.mod @therealpandey
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/RoutingPolicies/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/AlertBreadcrumb/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/AlertDetailsFilters/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/Alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/routingPolicies/ @SigNoz/pulse-frontend
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -207,6 +216,8 @@ go.mod @therealpandey
|
||||
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
@@ -8928,6 +8928,30 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
RuletypesGettableRuleView:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRuleViewData'
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- data
|
||||
- orgId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
RuletypesGettableTestRule:
|
||||
properties:
|
||||
alertCount:
|
||||
@@ -8935,6 +8959,98 @@ components:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesLabelPair:
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
type: object
|
||||
RuletypesListOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
RuletypesListSort:
|
||||
enum:
|
||||
- updated_at
|
||||
- created_at
|
||||
- name
|
||||
- state
|
||||
- severity
|
||||
type: string
|
||||
RuletypesListableRule:
|
||||
properties:
|
||||
alert:
|
||||
type: string
|
||||
alertType:
|
||||
$ref: '#/components/schemas/RuletypesAlertType'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
ruleType:
|
||||
$ref: '#/components/schemas/RuletypesRuleType'
|
||||
state:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- state
|
||||
- alert
|
||||
- alertType
|
||||
- ruleType
|
||||
type: object
|
||||
RuletypesListableRuleViews:
|
||||
properties:
|
||||
views:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesGettableRuleView'
|
||||
type: array
|
||||
required:
|
||||
- views
|
||||
type: object
|
||||
RuletypesListableRules:
|
||||
properties:
|
||||
labels:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesLabelPair'
|
||||
type: array
|
||||
reservedKeywords:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
rules:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesListableRule'
|
||||
type: array
|
||||
total:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- rules
|
||||
- total
|
||||
- labels
|
||||
- reservedKeywords
|
||||
type: object
|
||||
RuletypesMatchType:
|
||||
enum:
|
||||
- at_least_once
|
||||
@@ -9009,6 +9125,16 @@ components:
|
||||
- ruleType
|
||||
- condition
|
||||
type: object
|
||||
RuletypesPostableRuleView:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRuleViewData'
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- data
|
||||
type: object
|
||||
RuletypesQueryType:
|
||||
enum:
|
||||
- builder
|
||||
@@ -9147,6 +9273,23 @@ components:
|
||||
- promql_rule
|
||||
- anomaly_rule
|
||||
type: string
|
||||
RuletypesRuleViewData:
|
||||
properties:
|
||||
order:
|
||||
$ref: '#/components/schemas/RuletypesListOrder'
|
||||
query:
|
||||
type: string
|
||||
sort:
|
||||
$ref: '#/components/schemas/RuletypesListSort'
|
||||
states:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
version:
|
||||
type: string
|
||||
required:
|
||||
- version
|
||||
type: object
|
||||
RuletypesScheduleType:
|
||||
enum:
|
||||
- hourly
|
||||
@@ -20885,11 +21028,237 @@ paths:
|
||||
summary: Get users by role id
|
||||
tags:
|
||||
- users
|
||||
/api/v2/rules:
|
||||
/api/v2/rule_views:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all alert rules with their current evaluation
|
||||
state
|
||||
description: Returns every saved view in the calling user's org. Saved views
|
||||
are shared org-wide.
|
||||
operationId: ListRuleViews
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesListableRuleViews'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List rule saved views
|
||||
tags:
|
||||
- rules
|
||||
post:
|
||||
deprecated: false
|
||||
description: Persists the calling user's rule listing state (query, states,
|
||||
sort, order) as a named, reusable view shared across the org.
|
||||
operationId: CreateRuleView
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRuleView'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesGettableRuleView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Create rule saved view
|
||||
tags:
|
||||
- rules
|
||||
/api/v2/rule_views/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Removes a saved view. Saved views are shared org-wide. Deleting
|
||||
a non-existent view returns 404.
|
||||
operationId: DeleteRuleView
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Delete rule saved view
|
||||
tags:
|
||||
- rules
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
operationId: UpdateRuleView
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRuleView'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesGettableRuleView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Update rule saved view
|
||||
tags:
|
||||
- rules
|
||||
/api/v2/rules:
|
||||
get:
|
||||
deprecated: true
|
||||
description: 'This endpoint lists all alert rules with their current evaluation
|
||||
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
|
||||
pagination.'
|
||||
operationId: ListRules
|
||||
responses:
|
||||
"200":
|
||||
@@ -26671,6 +27040,93 @@ paths:
|
||||
summary: Get metric dashboards (v2)
|
||||
tags:
|
||||
- metrics
|
||||
/api/v3/rules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a page of alert rules with their current evaluation state,
|
||||
trimmed to the fields the list page renders. Supports a filter DSL (`query`),
|
||||
a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`),
|
||||
order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the
|
||||
filter DSL, a non-reserved key is matched as a rule label directly (`team
|
||||
= infra`); a key that collides with a reserved keyword matches either interpretation
|
||||
(negative operators exclude both), and `labels.<key>` targets only the label.
|
||||
The response also carries the org's label pairs and the reserved filter keys
|
||||
for building filter suggestions.
|
||||
operationId: ListRulesV3
|
||||
parameters:
|
||||
- in: query
|
||||
name: query
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: states
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesListSort'
|
||||
- in: query
|
||||
name: order
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesListOrder'
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesListableRules'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List alert rules (v3)
|
||||
tags:
|
||||
- rules
|
||||
/api/v3/traces/{traceID}/flamegraph:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -23,6 +23,15 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
return append(f.TextToJsonColumn(column), ops...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
|
||||
sql := f.TextToJsonColumn(column)
|
||||
sql = append(sql, "->"...)
|
||||
sql = schema.Append(f.bunf, sql, mapField)
|
||||
sql = append(sql, "->>"...)
|
||||
sql = schema.Append(f.bunf, sql, key)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_typeof("...)
|
||||
|
||||
@@ -55,6 +55,67 @@ func TestJSONExtractString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONExtractMapValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
mapField string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "PlainKey",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "team",
|
||||
expected: `"data"::jsonb->'labels'->>'team'`,
|
||||
},
|
||||
{
|
||||
name: "DottedKey_OneMapEntry",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "k8s.cluster",
|
||||
expected: `"data"::jsonb->'labels'->>'k8s.cluster'`,
|
||||
},
|
||||
{
|
||||
name: "SingleQuoteInKey_Doubled",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "o'brien",
|
||||
expected: `"data"::jsonb->'labels'->>'o''brien'`,
|
||||
},
|
||||
{
|
||||
name: "BackslashInKey_Literal",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: `a\b`,
|
||||
expected: `"data"::jsonb->'labels'->>'a\b'`,
|
||||
},
|
||||
{
|
||||
name: "DoubleQuoteInKey_Literal",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: `a"b`,
|
||||
expected: `"data"::jsonb->'labels'->>'a"b'`,
|
||||
},
|
||||
{
|
||||
name: "QualifiedColumn",
|
||||
column: "rule.data",
|
||||
mapField: "labels",
|
||||
key: "severity",
|
||||
expected: `"rule"."data"::jsonb->'labels'->>'severity'`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -19,7 +19,9 @@ import type {
|
||||
|
||||
import type {
|
||||
CreateRule201,
|
||||
CreateRuleView201,
|
||||
DeleteRuleByIDPathParameters,
|
||||
DeleteRuleViewPathParameters,
|
||||
GetRuleByID200,
|
||||
GetRuleByIDPathParameters,
|
||||
GetRuleHistoryFilterKeys200,
|
||||
@@ -40,13 +42,19 @@ import type {
|
||||
GetRuleHistoryTopContributors200,
|
||||
GetRuleHistoryTopContributorsParams,
|
||||
GetRuleHistoryTopContributorsPathParameters,
|
||||
ListRuleViews200,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
PatchRuleByID200,
|
||||
PatchRuleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
RuletypesPostableRuleViewDTO,
|
||||
TestRule200,
|
||||
UpdateRuleByIDPathParameters,
|
||||
UpdateRuleView200,
|
||||
UpdateRuleViewPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -73,7 +81,353 @@ const withQueryKey = <T extends object, K>(
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state
|
||||
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
export const listRuleViews = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListRuleViews200>({
|
||||
url: `/api/v2/rule_views`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRuleViewsQueryKey = () => {
|
||||
return [`/api/v2/rule_views`] as const;
|
||||
};
|
||||
|
||||
export const getListRuleViewsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRuleViewsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRuleViews>>> = ({
|
||||
signal,
|
||||
}) => listRuleViews(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRuleViewsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRuleViews>>
|
||||
>;
|
||||
export type ListRuleViewsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
|
||||
export function useListRuleViews<
|
||||
TData = Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRuleViewsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
export const invalidateListRuleViews = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRuleViewsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.
|
||||
* @summary Create rule saved view
|
||||
*/
|
||||
export const createRuleView = (
|
||||
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateRuleView201>({
|
||||
url: `/api/v2/rule_views`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createRuleView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createRuleView(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createRuleView>>
|
||||
>;
|
||||
export type CreateRuleViewMutationBody =
|
||||
| BodyType<RuletypesPostableRuleViewDTO>
|
||||
| undefined;
|
||||
export type CreateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create rule saved view
|
||||
*/
|
||||
export const useCreateRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
|
||||
* @summary Delete rule saved view
|
||||
*/
|
||||
export const deleteRuleView = (
|
||||
{ id }: DeleteRuleViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/rule_views/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteRuleView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
{ pathParams: DeleteRuleViewPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteRuleView(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>
|
||||
>;
|
||||
|
||||
export type DeleteRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete rule saved view
|
||||
*/
|
||||
export const useDeleteRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
* @summary Update rule saved view
|
||||
*/
|
||||
export const updateRuleView = (
|
||||
{ id }: UpdateRuleViewPathParameters,
|
||||
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateRuleView200>({
|
||||
url: `/api/v2/rule_views/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateRuleView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateRuleView(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateRuleView>>
|
||||
>;
|
||||
export type UpdateRuleViewMutationBody =
|
||||
| BodyType<RuletypesPostableRuleViewDTO>
|
||||
| undefined;
|
||||
export type UpdateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update rule saved view
|
||||
*/
|
||||
export const useUpdateRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const listRules = (signal?: AbortSignal) => {
|
||||
@@ -115,6 +469,7 @@ export type ListRulesQueryResult = NonNullable<
|
||||
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
|
||||
@@ -134,6 +489,7 @@ export function useListRules<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const invalidateListRules = async (
|
||||
@@ -1388,3 +1744,97 @@ export const useTestRule = <
|
||||
> => {
|
||||
return useMutation(getTestRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
export const listRulesV3 = (
|
||||
params?: ListRulesV3Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: `/api/v3/rules`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRulesV3QueryKey = (params?: ListRulesV3Params) => {
|
||||
return [`/api/v3/rules`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListRulesV3QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListRulesV3Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRulesV3QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRulesV3>>> = ({
|
||||
signal,
|
||||
}) => listRulesV3(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRulesV3QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRulesV3>>
|
||||
>;
|
||||
export type ListRulesV3QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
|
||||
export function useListRulesV3<
|
||||
TData = Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListRulesV3Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRulesV3QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
export const invalidateListRulesV3 = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListRulesV3Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRulesV3QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -10177,6 +10177,60 @@ export enum RuletypesEvaluationKindDTO {
|
||||
rolling = 'rolling',
|
||||
cumulative = 'cumulative',
|
||||
}
|
||||
export enum RuletypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum RuletypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
state = 'state',
|
||||
severity = 'severity',
|
||||
}
|
||||
export interface RuletypesRuleViewDataDTO {
|
||||
order?: RuletypesListOrderDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
query?: string;
|
||||
sort?: RuletypesListSortDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
states?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface RuletypesGettableRuleViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
data: RuletypesRuleViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RuletypesGettableTestRuleDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -10188,6 +10242,95 @@ export interface RuletypesGettableTestRuleDTO {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesLabelPairDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type RuletypesListableRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesListableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alert: string;
|
||||
alertType: RuletypesAlertTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesListableRuleDTOLabels;
|
||||
ruleType: RuletypesRuleTypeDTO;
|
||||
state: RuletypesAlertStateDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesListableRuleViewsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
views: RuletypesGettableRuleViewDTO[];
|
||||
}
|
||||
|
||||
export interface RuletypesListableRulesDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels: RuletypesLabelPairDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reservedKeywords: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
rules: RuletypesListableRuleDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -10284,11 +10427,6 @@ export interface RuletypesRuleConditionDTO {
|
||||
thresholds?: RuletypesRuleThresholdDataDTO;
|
||||
}
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesPostableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -10341,6 +10479,14 @@ export interface RuletypesPostableRuleDTO {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesPostableRuleViewDTO {
|
||||
data: RuletypesRuleViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
|
||||
|
||||
export type RuletypesRuleDTOLabels = { [key: string]: string };
|
||||
@@ -13628,6 +13774,36 @@ export type GetUsersByRoleID200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRuleViews200 = {
|
||||
data: RuletypesListableRuleViewsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateRuleView201 = {
|
||||
data: RuletypesGettableRuleViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteRuleViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRuleViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRuleView200 = {
|
||||
data: RuletypesGettableRuleViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRules200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -14189,6 +14365,45 @@ export type GetMetricDashboardsV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRulesV3Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @description undefined
|
||||
*/
|
||||
states?: string[];
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: RuletypesListSortDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: RuletypesListOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListRulesV3200 = {
|
||||
data: RuletypesListableRulesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
flex: 0 0 0;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
|
||||
// only hand height down; each pane below owns its own scroll.
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
|
||||
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showFilters && (
|
||||
<aside
|
||||
className={styles.filters}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
className={cx(styles.content, contentClassName)}
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<div>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickFiltersLayout;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
|
||||
jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
filters: 'filters',
|
||||
content: 'content',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
|
||||
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
|
||||
QuickFiltersSource.TRACES_EXPLORER,
|
||||
);
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'page-content',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,27 +6,12 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: 342px;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
transition: width 0.05s ease-in-out;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&.qf-logs-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-exceptions {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&.qf-api-monitoring {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-traces-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
38
frontend/src/components/RouteTab/RouteTab.module.scss
Normal file
38
frontend/src/components/RouteTab/RouteTab.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
// Hands the parent's height down to the active pane and lets the pane scroll
|
||||
// its own content, so TopNav and the tab bar stay put. Child combinators only
|
||||
// (nested Tabs must not be caught).
|
||||
.routeTab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active)
|
||||
> :global(.overlay-scrollbar) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import RouteTab from './index';
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
jest.mock('./RouteTab.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: { routeTab: 'routeTab' },
|
||||
}));
|
||||
|
||||
function DummyComponent1(): JSX.Element {
|
||||
return <div>Dummy Component 1</div>;
|
||||
}
|
||||
@@ -74,6 +79,36 @@ describe('RouteTab component', () => {
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
});
|
||||
|
||||
it('applies the layout class alongside a custom className', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab
|
||||
history={history}
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
className="custom-tabs"
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
expect(container.querySelector('.ant-tabs')).toHaveClass(
|
||||
'routeTab',
|
||||
'custom-tabs',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the active tab content inside an overlay scrollbar', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
|
||||
).toHaveTextContent('Dummy Component 1');
|
||||
});
|
||||
|
||||
it('calls onChangeHandler on tab change', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
@@ -5,20 +5,32 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
import styles from './RouteTab.module.scss';
|
||||
|
||||
interface Params {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
|
||||
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
|
||||
* a plain block wrapper the scroller is inert and the page scrolls as usual.
|
||||
* Pane content that needs a bounded box must size itself with `height: 100%`
|
||||
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
|
||||
*/
|
||||
function RouteTab({
|
||||
routes,
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
className,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
@@ -50,11 +62,16 @@ function RouteTab({
|
||||
label: name,
|
||||
key,
|
||||
tabKey: route,
|
||||
children: <Component />,
|
||||
children: (
|
||||
<OverlayScrollbar>
|
||||
<Component />
|
||||
</OverlayScrollbar>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className={cx(styles.routeTab, className)}
|
||||
onChange={onChange}
|
||||
destroyInactiveTabPane
|
||||
activeKey={currentRoute?.key || activeKey}
|
||||
|
||||
@@ -129,6 +129,10 @@ const themeColors = {
|
||||
salmon2: '#FFAB91',
|
||||
salmon3: '#E0876A',
|
||||
},
|
||||
/* Series palette (dark). Hues in the red band are deliberately absent: red is
|
||||
reserved for thresholds and error states, so an arbitrary series must never
|
||||
claim it. generateColor indexes by `hash % Object.keys(...).length`, so
|
||||
adding or removing an entry recolors every existing chart. */
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
@@ -152,13 +156,13 @@ const themeColors = {
|
||||
|
||||
// Oranges (3)
|
||||
festivalOrange: '#F2994A',
|
||||
coralOrange: '#E17055',
|
||||
amber1: '#E1A155',
|
||||
pumpkin: '#FF7F50',
|
||||
|
||||
// Reds (3)
|
||||
radicalRed: '#FF1A66',
|
||||
crimsonRed: '#EB5757',
|
||||
fireRed: '#E10600',
|
||||
// Olives / Greens (3)
|
||||
olive1: '#DFC33A',
|
||||
olive2: '#D5E55D',
|
||||
green7: '#81C220',
|
||||
|
||||
// Pinks (3)
|
||||
hotPink: '#E84393',
|
||||
@@ -191,9 +195,9 @@ const themeColors = {
|
||||
orange1: '#D35400',
|
||||
orange2: '#E67E22',
|
||||
orange3: '#F5B041',
|
||||
red1: '#C0392B',
|
||||
red2: '#E74C3C',
|
||||
red3: '#EC7063',
|
||||
green8: '#5AC02B',
|
||||
green9: '#48E043',
|
||||
green10: '#68E788',
|
||||
pink1: '#D81B60',
|
||||
pink2: '#E91E63',
|
||||
pink3: '#F06292',
|
||||
@@ -212,9 +216,9 @@ const themeColors = {
|
||||
coral1: '#E67E22',
|
||||
coral2: '#F39C12',
|
||||
coral3: '#F5B041',
|
||||
crimson1: '#C0392B',
|
||||
crimson2: '#E74C3C',
|
||||
crimson3: '#EC7063',
|
||||
teal7: '#2BC07B',
|
||||
teal8: '#43E0C5',
|
||||
teal9: '#68D9E7',
|
||||
violet1: '#8E44AD',
|
||||
violet2: '#9B59B6',
|
||||
violet3: '#BB8FCE',
|
||||
@@ -224,18 +228,18 @@ const themeColors = {
|
||||
forest1: '#27AE60',
|
||||
forest2: '#2ECC71',
|
||||
forest3: '#58D68D',
|
||||
blush1: '#FF6F91',
|
||||
cyan4: '#83C2EB',
|
||||
blush2: '#FF85A2',
|
||||
blush3: '#FFA0B3',
|
||||
lavender1: '#9B59B6',
|
||||
lavender2: '#AF7AC5',
|
||||
lavender3: '#C39BD3',
|
||||
tomato1: '#E74C3C',
|
||||
tomato2: '#EC7063',
|
||||
tomato3: '#F1948A',
|
||||
salmon1: '#FF6B6B',
|
||||
salmon2: '#FF8787',
|
||||
salmon3: '#FFA1A1',
|
||||
blue7: '#4375E0',
|
||||
blue8: '#686DE7',
|
||||
indigo1: '#A68EED',
|
||||
indigo2: '#B980EA',
|
||||
purple6: '#EE98D9',
|
||||
olive3: '#F2F0AE',
|
||||
mustard1: '#F1C40F',
|
||||
mustard2: '#F7DC6F',
|
||||
mustard3: '#F9E79F',
|
||||
@@ -254,9 +258,9 @@ const themeColors = {
|
||||
blue4: '#2874A6',
|
||||
blue5: '#2E86C1',
|
||||
blue6: '#3498DB',
|
||||
red4: '#C0392B',
|
||||
red5: '#E74C3C',
|
||||
red6: '#EC7063',
|
||||
purple4: '#A52BC0',
|
||||
purple5: '#E043D0',
|
||||
magenta4: '#E768B5',
|
||||
orange4: '#D35400',
|
||||
orange5: '#E67E22',
|
||||
orange6: '#EB984E',
|
||||
@@ -267,18 +271,19 @@ const themeColors = {
|
||||
gold5: '#F1C40F',
|
||||
gold6: '#F4D03F',
|
||||
},
|
||||
/* Series palette (light). Same red-free constraint as chartcolors above. */
|
||||
lightModeColor: {
|
||||
radicalRed: '#D81B60',
|
||||
magenta1: '#D81B60',
|
||||
|
||||
dodgerBlueDark: '#1E5BD9',
|
||||
steelgrey: '#344B6B',
|
||||
steelpurple: '#5E548E',
|
||||
steelindigo: '#8E4A7C',
|
||||
steelpink: '#B63A6F',
|
||||
steelcoral: '#E14B5A',
|
||||
amber1: '#E1A14B',
|
||||
steelorange: '#E76F2F',
|
||||
steelgold: '#E09B00',
|
||||
steelrust: '#C93A50',
|
||||
olive1: '#C9BD3A',
|
||||
steelgreen: '#2F7D69',
|
||||
|
||||
mediumOrchidDark: '#8E24AA',
|
||||
@@ -286,17 +291,17 @@ const themeColors = {
|
||||
seaGreen: '#1E7F5A',
|
||||
turquoiseBlueDark: '#007EA7',
|
||||
silverDark: '#5F5F5F',
|
||||
outrageousOrangeDark: '#E64A19',
|
||||
roseBudDark: '#D84315',
|
||||
green1: '#ACDB24',
|
||||
green2: '#66CC21',
|
||||
deepSkyBlueDark: '#0277BD',
|
||||
royalBlue: '#2A4FDB',
|
||||
|
||||
avocadoDark: '#6B6B1E',
|
||||
mintGreenDark: '#2E9E55',
|
||||
chestnut: '#8B3A3A',
|
||||
green3: '#3F8B3A',
|
||||
limaDark: '#5C7F00',
|
||||
olive: '#6E7F00',
|
||||
beautyBushDark: '#C93C3C',
|
||||
green4: '#3CC964',
|
||||
|
||||
danube: '#4F6FB3',
|
||||
oliveDrab: '#4F7F1A',
|
||||
@@ -304,13 +309,13 @@ const themeColors = {
|
||||
electricLimeDark: '#6B8F00',
|
||||
robin: '#2F4FCC',
|
||||
|
||||
harleyOrange: '#CC2E12',
|
||||
teal1: '#1FBF83',
|
||||
gladeGreen: '#4F7F46',
|
||||
hemlock: '#5C5C45',
|
||||
vidaLoca: '#3D6B00',
|
||||
rust: '#993300',
|
||||
|
||||
red: '#C62828',
|
||||
teal2: '#28C6C1',
|
||||
blue: '#1A237E',
|
||||
green: '#1B7F3A',
|
||||
purple: '#6A1B9A',
|
||||
@@ -320,7 +325,7 @@ const themeColors = {
|
||||
brown: '#7A3A1E',
|
||||
teal: '#006D6F',
|
||||
limeDark: '#4C8C2B',
|
||||
maroon: '#6D1B1B',
|
||||
cyan1: '#1B546D',
|
||||
navy: '#0D1B5E',
|
||||
gray: '#616161',
|
||||
|
||||
@@ -328,25 +333,25 @@ const themeColors = {
|
||||
indigo: '#303F9F',
|
||||
slateGray: '#556B7C',
|
||||
chocolate: '#9C4A1A',
|
||||
tomato: '#E53935',
|
||||
blue1: '#3B74DF',
|
||||
steelBlue: '#3A6EA5',
|
||||
|
||||
peruDark: '#B35E00',
|
||||
darkOliveGreen: '#445B1F',
|
||||
indianRed: '#B04040',
|
||||
blue2: '#4041B0',
|
||||
mediumSlateBlue: '#5C6BC0',
|
||||
rosyBrownDark: '#A94444',
|
||||
indigo1: '#6644A9',
|
||||
darkSlateGray: '#2E4A4A',
|
||||
|
||||
fuchsia: '#C511C5',
|
||||
salmonDark: '#E64A3C',
|
||||
darkSalmonDark: '#C85A3A',
|
||||
indigo2: '#AD42E0',
|
||||
purple1: '#C83AC5',
|
||||
paleVioletRedDark: '#C2186A',
|
||||
|
||||
mediumPurple: '#7E57C2',
|
||||
darkOrchid: '#7B1FA2',
|
||||
mediumSeaGreenDark: '#2E8B57',
|
||||
lightCoralDark: '#E57373',
|
||||
purple2: '#E573BC',
|
||||
|
||||
gold: '#D4AF37',
|
||||
sandyBrownDark: '#C76A15',
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
.api-monitoring-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.api-monitoring-explorer {
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
.api-quick-filter-left-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
@@ -161,16 +153,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-filtered-domains-message-container {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
@@ -20,20 +19,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
signal={SignalType.API_MONITORING}
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<QuickFiltersLayout
|
||||
className="api-monitoring-explorer"
|
||||
showFilters
|
||||
quickFilterProps={{
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<DomainList />
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,8 +65,6 @@
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
@@ -75,32 +73,8 @@
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -188,26 +186,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
data-testid="llm-observability-explorer"
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
signal: SignalType.AI_OBSERVABILITY,
|
||||
useFieldApis: quickFiltersFieldApis,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
signal={SignalType.AI_OBSERVABILITY}
|
||||
useFieldApis={quickFiltersFieldApis}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
@@ -291,7 +284,7 @@ function Explorer(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
.meter-explorer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
width: 100%;
|
||||
// Clearance for the fixed ExplorerOptions bar.
|
||||
padding-bottom: 80px;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
@@ -83,14 +72,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -121,29 +120,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className={cx('meter-explorer-container', {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
<QuickFiltersLayout
|
||||
className="meter-explorer-container"
|
||||
showFilters={showQuickFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
signal: SignalType.METER_EXPLORER,
|
||||
showFilterCollapse: true,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
source={QuickFiltersSource.METER_EXPLORER}
|
||||
signal={SignalType.METER_EXPLORER}
|
||||
showFilterCollapse
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
<div className="explore-header">
|
||||
@@ -196,7 +187,7 @@ function Explorer(): JSX.Element {
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,9 +60,6 @@
|
||||
.metrics-table-container {
|
||||
padding-bottom: 48px;
|
||||
.ant-table {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('Get Series Data', () => {
|
||||
expect(seriesData).toHaveLength(5);
|
||||
expect(seriesData[1].label).toBe('firstLegend');
|
||||
expect(seriesData[1].show).toBe(true);
|
||||
expect(seriesData[1].fill).toBe('#FF6F91');
|
||||
expect(seriesData[1].fill).toBe('#83C2EB');
|
||||
expect(seriesData[1].width).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -57,3 +57,12 @@
|
||||
color: var(--l3-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The bottom legend's box is only the rows reserved for it. */
|
||||
.container:not(.isRight) .emptyState {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LegendAction, LegendPosition, LegendProps } from '../types';
|
||||
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
|
||||
import LegendRow from './LegendRow';
|
||||
import LegendToolbar from './LegendToolbar';
|
||||
import { filterLegendItems, getShownSeriesState } from './utils';
|
||||
import { getVisibleSeriesState } from './utils';
|
||||
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function Legend({
|
||||
items,
|
||||
position,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
showSearch = false,
|
||||
focusedSeriesIndex,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
@@ -30,27 +31,22 @@ export default function Legend({
|
||||
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
|
||||
const isRightPosition = position === LegendPosition.RIGHT;
|
||||
|
||||
const { visibleCount, soleShownSeriesIndex } = useMemo(
|
||||
() => getShownSeriesState(items),
|
||||
[items],
|
||||
);
|
||||
// The layout decides: it reserves the height.
|
||||
const showToolbar = showSearch && items.length > 0;
|
||||
|
||||
// A bottom legend gets two rows; spending one on chrome costs more chart than
|
||||
// the readout is worth.
|
||||
const showToolbar = isRightPosition && items.length > 0;
|
||||
const showFilter = showToolbar;
|
||||
const effectiveQuery = showToolbar ? filterQuery : '';
|
||||
|
||||
const effectiveQuery = showFilter ? filterQuery : '';
|
||||
|
||||
const visibleLegendItems = useMemo(
|
||||
() => filterLegendItems(items, effectiveQuery),
|
||||
const {
|
||||
listedItems,
|
||||
visibleCount,
|
||||
onlyVisibleSeriesIndex,
|
||||
areAllSeriesVisible,
|
||||
} = useMemo(
|
||||
() => getVisibleSeriesState(items, effectiveQuery),
|
||||
[items, effectiveQuery],
|
||||
);
|
||||
|
||||
const isEmptyState =
|
||||
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
|
||||
|
||||
const isAllShown = visibleCount === items.length;
|
||||
const isEmptyState = !!effectiveQuery.trim() && listedItems.length === 0;
|
||||
|
||||
// A row that unmounts under the pointer never fires its own mouseleave.
|
||||
const handleMouseLeave = useCallback(
|
||||
@@ -63,14 +59,20 @@ export default function Legend({
|
||||
<LegendRow
|
||||
key={item.seriesIndex}
|
||||
item={item}
|
||||
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
|
||||
isAllShown={isAllShown}
|
||||
isOneSeriesVisible={onlyVisibleSeriesIndex === item.seriesIndex}
|
||||
areAllSeriesVisible={areAllSeriesVisible}
|
||||
isFocused={focusedSeriesIndex === item.seriesIndex}
|
||||
showCopy={showCopy}
|
||||
onAction={onAction}
|
||||
/>
|
||||
),
|
||||
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
|
||||
[
|
||||
onlyVisibleSeriesIndex,
|
||||
areAllSeriesVisible,
|
||||
focusedSeriesIndex,
|
||||
showCopy,
|
||||
onAction,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -87,7 +89,7 @@ export default function Legend({
|
||||
<LegendToolbar
|
||||
visibleCount={visibleCount}
|
||||
totalCount={items.length}
|
||||
showFilter={showFilter}
|
||||
position={position}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
/>
|
||||
@@ -101,7 +103,7 @@ export default function Legend({
|
||||
className={styles.scroller}
|
||||
listClassName={styles.gridList}
|
||||
itemClassName={styles.gridItem}
|
||||
data={visibleLegendItems}
|
||||
data={listedItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -13,9 +13,9 @@ import styles from './LegendRow.module.scss';
|
||||
export interface LegendRowProps {
|
||||
item: LegendItem;
|
||||
/** The only series currently shown, so hiding it is refused. */
|
||||
isSoleShown: boolean;
|
||||
isOneSeriesVisible: boolean;
|
||||
/** Nothing is hidden, so the row's action can only narrow the selection. */
|
||||
isAllShown: boolean;
|
||||
areAllSeriesVisible: boolean;
|
||||
isFocused: boolean;
|
||||
showCopy: boolean;
|
||||
onAction: OnLegendAction;
|
||||
@@ -29,15 +29,15 @@ export interface LegendRowProps {
|
||||
*/
|
||||
function LegendRow({
|
||||
item,
|
||||
isSoleShown,
|
||||
isAllShown,
|
||||
isOneSeriesVisible,
|
||||
areAllSeriesVisible,
|
||||
isFocused,
|
||||
showCopy,
|
||||
onAction,
|
||||
}: LegendRowProps): JSX.Element {
|
||||
const { seriesIndex, show } = item;
|
||||
const label = item.label ?? '';
|
||||
const isShowAllAction = show && !isAllShown;
|
||||
const isShowAllAction = show && !areAllSeriesVisible;
|
||||
const scopeActionLabel = isShowAllAction
|
||||
? 'Show all series'
|
||||
: 'Show only current series';
|
||||
@@ -47,15 +47,15 @@ function LegendRow({
|
||||
|
||||
/** Everything showing -> isolate; showing alone -> restore all. */
|
||||
const handleRowClick = useCallback((): void => {
|
||||
if (isSoleShown) {
|
||||
if (isOneSeriesVisible) {
|
||||
onAction({ type: LegendAction.SHOW_ALL });
|
||||
return;
|
||||
}
|
||||
onAction({
|
||||
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
|
||||
type: areAllSeriesVisible ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
|
||||
seriesIndex,
|
||||
});
|
||||
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
|
||||
}, [isOneSeriesVisible, areAllSeriesVisible, onAction, seriesIndex]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
@@ -126,7 +126,7 @@ function LegendRow({
|
||||
backgroundColor: show ? seriesColor : 'transparent',
|
||||
}}
|
||||
onClick={handleMarkerClick}
|
||||
disabled={isSoleShown}
|
||||
disabled={isOneSeriesVisible}
|
||||
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={`legend-marker-${seriesIndex}`}
|
||||
|
||||
@@ -33,3 +33,32 @@
|
||||
.searchIcon {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
/* Height + margin must match LEGEND_TOOLBAR_HEIGHT and LEGEND_TOOLBAR_GAP. */
|
||||
.inlineToolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
height: 24px;
|
||||
margin-bottom: var(--spacing-2);
|
||||
padding-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 0 0 auto;
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.statusInline {
|
||||
// Truncates rather than wrapping onto a row the legend has not reserved.
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchInputInline {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { ChangeEvent, useCallback } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Input } from 'antd';
|
||||
import { Search } from '@signozhq/icons';
|
||||
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
import styles from './LegendToolbar.module.scss';
|
||||
|
||||
export interface LegendToolbarProps {
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
/** Search is intrinsic to the right-positioned legend. */
|
||||
showFilter: boolean;
|
||||
/** Layout only: the column stacks, the bottom row does not. */
|
||||
position: LegendPosition;
|
||||
filterQuery: string;
|
||||
onFilterQueryChange: (query: string) => void;
|
||||
}
|
||||
@@ -17,7 +20,7 @@ export interface LegendToolbarProps {
|
||||
export default function LegendToolbar({
|
||||
visibleCount,
|
||||
totalCount,
|
||||
showFilter,
|
||||
position,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
@@ -27,30 +30,48 @@ export default function LegendToolbar({
|
||||
[onFilterQueryChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilter && (
|
||||
const searchProps = {
|
||||
allowClear: true,
|
||||
prefix: <Search size={12} className={styles.searchIcon} />,
|
||||
placeholder: 'Search...',
|
||||
value: filterQuery,
|
||||
onChange: handleFilterChange,
|
||||
className: styles.searchInput,
|
||||
'data-testid': 'legend-search-input',
|
||||
};
|
||||
|
||||
const status = (
|
||||
<span
|
||||
className={cx(styles.status, {
|
||||
[styles.statusInline]: position !== LegendPosition.RIGHT,
|
||||
})}
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (position === LegendPosition.RIGHT) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.searchContainer}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<Search size={12} className={styles.searchIcon} />}
|
||||
placeholder="Search..."
|
||||
value={filterQuery}
|
||||
onChange={handleFilterChange}
|
||||
className={styles.searchInput}
|
||||
data-testid="legend-search-input"
|
||||
/>
|
||||
<Input {...searchProps} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.toolbar}>
|
||||
<span
|
||||
className={styles.status}
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
<div className={styles.toolbar}>{status}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.inlineToolbar}>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
{...searchProps}
|
||||
className={cx(styles.searchInput, styles.searchInputInline)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function UPlotLegend({
|
||||
position = LegendPosition.BOTTOM,
|
||||
config,
|
||||
averageLegendWidth,
|
||||
showSearch,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
|
||||
const onAction = useLegendActions();
|
||||
@@ -27,6 +28,7 @@ export default function UPlotLegend({
|
||||
items={items}
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onAction}
|
||||
/>
|
||||
|
||||
@@ -88,11 +88,15 @@ describe('UPlotLegend', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
const renderLegend = (
|
||||
position?: LegendPosition,
|
||||
showSearch = true,
|
||||
): RenderResult =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
showSearch={showSearch}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
@@ -100,14 +104,38 @@ describe('UPlotLegend', () => {
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders the search input on a RIGHT legend', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
it.each([LegendPosition.RIGHT, LegendPosition.BOTTOM])(
|
||||
'gives the legend a search box and a readout (%s)',
|
||||
(position) => {
|
||||
renderLegend(position);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legend-status')).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
|
||||
renderLegend();
|
||||
it.each([LegendPosition.RIGHT, LegendPosition.BOTTOM])(
|
||||
'counts down the readout as the search narrows the list (%s)',
|
||||
async (position) => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(position);
|
||||
|
||||
// B is hidden.
|
||||
expect(screen.getByTestId('legend-status')).toHaveTextContent(
|
||||
'Showing 2 of 3 series',
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'a');
|
||||
|
||||
// Only A matches, counted against all three.
|
||||
expect(screen.getByTestId('legend-status')).toHaveTextContent(
|
||||
'Showing 1 of 3 series',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a BOTTOM legend bare while every series is on screen', () => {
|
||||
renderLegend(LegendPosition.BOTTOM, false);
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
|
||||
@@ -116,6 +144,16 @@ describe('UPlotLegend', () => {
|
||||
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters a BOTTOM legend from its search box', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.BOTTOM);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'b');
|
||||
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.queryByText('A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the series colour, filled only when shown', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { filterLegendItems, getShownSeriesState } from '../utils';
|
||||
import { filterLegendItems, getVisibleSeriesState } from '../utils';
|
||||
|
||||
const items = (shown: boolean[]): LegendItem[] =>
|
||||
shown.map((show, index) => ({
|
||||
@@ -10,26 +10,49 @@ const items = (shown: boolean[]): LegendItem[] =>
|
||||
show,
|
||||
}));
|
||||
|
||||
describe('getShownSeriesState', () => {
|
||||
describe('getVisibleSeriesState', () => {
|
||||
it('counts the shown series', () => {
|
||||
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
|
||||
visibleCount: 2,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
const state = getVisibleSeriesState(items([true, false, true]), '');
|
||||
|
||||
expect(state.visibleCount).toBe(2);
|
||||
expect(state.onlyVisibleSeriesIndex).toBeNull();
|
||||
expect(state.areAllSeriesVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('names the series when exactly one is shown', () => {
|
||||
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
soleShownSeriesIndex: 2,
|
||||
});
|
||||
const state = getVisibleSeriesState(items([false, true, false]), '');
|
||||
|
||||
expect(state.visibleCount).toBe(1);
|
||||
expect(state.onlyVisibleSeriesIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('reports nothing shown', () => {
|
||||
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
|
||||
visibleCount: 0,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
const state = getVisibleSeriesState(items([false, false]), '');
|
||||
|
||||
expect(state.visibleCount).toBe(0);
|
||||
expect(state.onlyVisibleSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('reports every series shown', () => {
|
||||
expect(
|
||||
getVisibleSeriesState(items([true, true]), '').areAllSeriesVisible,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('counts only the series the search listed', () => {
|
||||
const state = getVisibleSeriesState(items([true, true, false]), 'series-1');
|
||||
|
||||
expect(state.listedItems.map((item) => item.label)).toStrictEqual([
|
||||
'series-1',
|
||||
]);
|
||||
expect(state.visibleCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reads isolation off every series, not the listed ones', () => {
|
||||
const state = getVisibleSeriesState(items([false, true, false]), 'series-2');
|
||||
|
||||
expect(state.listedItems).toHaveLength(1);
|
||||
expect(state.onlyVisibleSeriesIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,5 +21,9 @@ export const LEGEND_ROW_HEIGHT = 28;
|
||||
export const LEGEND_ROW_GAP = 2;
|
||||
export const LEGEND_MAX_BOTTOM_ROWS = 2;
|
||||
|
||||
/** Must match `.inlineToolbar`'s height and margin-bottom, or it eats a row. */
|
||||
export const LEGEND_TOOLBAR_HEIGHT = 24;
|
||||
export const LEGEND_TOOLBAR_GAP = 4;
|
||||
|
||||
/** Hover delay before a row's full-name tooltip opens. */
|
||||
export const LEGEND_TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
export interface ShownSeriesState {
|
||||
export interface LegendViewState {
|
||||
listedItems: LegendItem[];
|
||||
/** Listed items that are toggled on, against every series in the readout. */
|
||||
visibleCount: number;
|
||||
/** The series index when exactly one series is shown, else null. */
|
||||
soleShownSeriesIndex: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driven by what is actually shown, never a remembered isolation: hiding series
|
||||
* one at a time down to a single one is the same state as "Only".
|
||||
*/
|
||||
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
|
||||
const shown = items.filter((item) => item.show);
|
||||
|
||||
return {
|
||||
visibleCount: shown.length,
|
||||
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
|
||||
};
|
||||
/** The series index when exactly one series is toggled on, else null. */
|
||||
onlyVisibleSeriesIndex: number | null;
|
||||
areAllSeriesVisible: boolean;
|
||||
}
|
||||
|
||||
export function filterLegendItems(
|
||||
@@ -32,3 +22,23 @@ export function filterLegendItems(
|
||||
item.label?.toLowerCase().includes(normalisedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolation is driven by what is actually shown, never a remembered one: hiding
|
||||
* series one at a time down to a single one is the same state as "Only". It is
|
||||
* read off the whole series set, not off what the search left listed.
|
||||
*/
|
||||
export function getVisibleSeriesState(
|
||||
items: LegendItem[],
|
||||
query: string,
|
||||
): LegendViewState {
|
||||
const visible = items.filter((item) => item.show);
|
||||
const listedItems = filterLegendItems(items, query);
|
||||
|
||||
return {
|
||||
listedItems,
|
||||
visibleCount: listedItems.filter((item) => item.show).length,
|
||||
onlyVisibleSeriesIndex: visible.length === 1 ? visible[0].seriesIndex : null,
|
||||
areAllSeriesVisible: visible.length === items.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,9 +73,9 @@ function createTooltipContent(
|
||||
};
|
||||
}
|
||||
|
||||
function createUPlotInstance(cursorIdx: number | null): uPlot {
|
||||
function createUPlotInstance(cursorIdx: number | null, timestamp = 1): uPlot {
|
||||
return {
|
||||
data: [[1], []],
|
||||
data: [[timestamp], []],
|
||||
cursor: { idx: cursorIdx },
|
||||
// The rest of the uPlot fields are not used by Tooltip
|
||||
} as unknown as uPlot;
|
||||
@@ -122,6 +122,19 @@ describe('Tooltip', () => {
|
||||
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the date from the header title for a point on the current day', () => {
|
||||
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
|
||||
const uPlotInstance = createUPlotInstance(0, todayTimestamp);
|
||||
|
||||
renderTooltip({ uPlotInstance });
|
||||
|
||||
const expectedTitle = dayjs(todayTimestamp * 1000)
|
||||
.tz('UTC')
|
||||
.format(DATE_TIME_FORMATS.TIME_SECONDS);
|
||||
|
||||
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render header when showTooltipHeader is false', () => {
|
||||
const uPlotInstance = createUPlotInstance(0);
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
@@ -19,6 +18,7 @@ interface TooltipHeaderProps {
|
||||
isPinned: boolean;
|
||||
activeItem: TooltipContentItem | null;
|
||||
headerRowClassName?: string;
|
||||
/** Overrides the default, which drops the date part for points on the current day. */
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function TooltipHeader({
|
||||
isPinned,
|
||||
activeItem,
|
||||
headerRowClassName,
|
||||
dateFormat = DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
|
||||
dateFormat,
|
||||
}: TooltipHeaderProps): JSX.Element {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
@@ -46,9 +46,11 @@ export default function TooltipHeader({
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(dateFormat);
|
||||
return formatTimestampOmittingTodaysDate(
|
||||
timestamp * 1000,
|
||||
resolvedTimezone,
|
||||
dateFormat,
|
||||
);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
|
||||
@@ -145,6 +145,8 @@ export interface LegendProps {
|
||||
/** Legend placement; always supplied by the container. */
|
||||
position: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Set by the chart layout, which reserves the height for it. */
|
||||
showSearch?: boolean;
|
||||
/** Series index highlighted by the chart cursor. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onAction: OnLegendAction;
|
||||
@@ -158,6 +160,7 @@ export interface UPlotLegendProps {
|
||||
position?: LegendPosition;
|
||||
config: UPlotConfigBuilder;
|
||||
averageLegendWidth?: number;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface TooltipContentItem {
|
||||
|
||||
@@ -265,7 +265,7 @@ function getPathBuilder({
|
||||
drawStyle,
|
||||
lineInterpolation,
|
||||
barAlignment = BarAlignment.Center,
|
||||
barWidthFactor = 0.6,
|
||||
barWidthFactor = 0.85,
|
||||
barMaxWidth = 200,
|
||||
stepInterval,
|
||||
}: {
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('UPlotSeriesBuilder', () => {
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.stroke).toBe('#E64A3C');
|
||||
expect(config.stroke).toBe('#AD42E0');
|
||||
});
|
||||
|
||||
it('passes through pointsFilter when provided', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import ChartLayout from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
import ChartLayout, {
|
||||
LegendLayout,
|
||||
} from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
|
||||
import {
|
||||
LegendPosition,
|
||||
@@ -58,7 +60,7 @@ export default function ChartWrapper({
|
||||
);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
@@ -67,6 +69,7 @@ export default function ChartWrapper({
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -68,17 +68,23 @@ export default function Pie({
|
||||
|
||||
// Reuse the uPlot chart/legend split so the donut + legend get the same area
|
||||
// allocation (right column, or up-to-two bottom rows) as every other panel.
|
||||
const { width, height, legendWidth, legendHeight, averageLegendWidth } =
|
||||
useMemo(
|
||||
() =>
|
||||
calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig: { position },
|
||||
seriesLabels: data.map((slice) => slice.label),
|
||||
}),
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
legendWidth,
|
||||
legendHeight,
|
||||
averageLegendWidth,
|
||||
showLegendSearch,
|
||||
} = useMemo(
|
||||
() =>
|
||||
calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig: { position },
|
||||
seriesLabels: data.map((slice) => slice.label),
|
||||
}),
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
|
||||
// Donut geometry derived from the allocated chart box, sized to leave room
|
||||
// for the external leader labels (see getDonutGeometry).
|
||||
@@ -224,6 +230,7 @@ export default function Pie({
|
||||
items={legendItems}
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showLegendSearch}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('calculateChartDimensions', () => {
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: 0,
|
||||
showLegendSearch: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,10 +107,10 @@ describe('calculateChartDimensions', () => {
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
|
||||
// clipped third row, and none left over.
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
expect(dims.height).toBe(430);
|
||||
// Two 28px rows + 2px gap + 12px padding, plus the 24px search row + 4px.
|
||||
expect(dims.showLegendSearch).toBe(true);
|
||||
expect(dims.legendHeight).toBe(98);
|
||||
expect(dims.height).toBe(402);
|
||||
});
|
||||
|
||||
it('BOTTOM: items one past a row still reserve two rows', () => {
|
||||
@@ -123,6 +124,50 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: no search row while every item is already on screen', () => {
|
||||
// 1000px fits 4 per row, so 8 items fill both reserved rows exactly.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(8),
|
||||
});
|
||||
expect(dims.showLegendSearch).toBe(false);
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: a search row once the grid overflows the reserved rows', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(9),
|
||||
});
|
||||
expect(dims.showLegendSearch).toBe(true);
|
||||
expect(dims.legendHeight).toBe(98);
|
||||
});
|
||||
|
||||
it('BOTTOM: no search row when it would push the legend past a short panel', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 120,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
expect(dims.showLegendSearch).toBe(false);
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
});
|
||||
|
||||
it('RIGHT: always carries its chrome; the column has the height for it', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
expect(dims.showLegendSearch).toBe(true);
|
||||
});
|
||||
|
||||
it('BOTTOM: reserves the rows the grid actually lays out, not the rows a bare width estimate allows', () => {
|
||||
// The item width alone suggests three fit on one row; the grid's per-item
|
||||
// padding and column gap leave room for two.
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
LEGEND_COLUMN_GAP,
|
||||
LEGEND_TOOLBAR_GAP,
|
||||
LEGEND_TOOLBAR_HEIGHT,
|
||||
LEGEND_ITEM_EXTRA_WIDTH,
|
||||
LEGEND_ROW_GAP,
|
||||
LEGEND_ROW_HEIGHT,
|
||||
@@ -15,6 +17,8 @@ export interface ChartDimensions {
|
||||
legendWidth: number;
|
||||
legendHeight: number;
|
||||
averageLegendWidth: number;
|
||||
/** For a BOTTOM legend that row's height is inside `legendHeight`. */
|
||||
showLegendSearch: boolean;
|
||||
}
|
||||
|
||||
const AVG_CHAR_WIDTH = 8;
|
||||
@@ -76,6 +80,8 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
|
||||
* the rectangle never clips a row or reserves space for half of one. Two
|
||||
* rows that would take half a short panel fall back to one row.
|
||||
* - A grid overflowing those rows also gets a search row, whose height is
|
||||
* part of `legendHeight`.
|
||||
* - Chart height is `containerHeight - legendHeight`, never below 0.
|
||||
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
|
||||
*
|
||||
@@ -101,6 +107,7 @@ export function calculateChartDimensions({
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: 0,
|
||||
showLegendSearch: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,6 +147,7 @@ export function calculateChartDimensions({
|
||||
legendHeight: containerHeight,
|
||||
// Single vertical list on the right.
|
||||
averageLegendWidth: rightLegendWidth,
|
||||
showLegendSearch: legendItemCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,18 +166,25 @@ export function calculateChartDimensions({
|
||||
),
|
||||
);
|
||||
|
||||
// The wrapper's bottom padding is inside this height (border-box).
|
||||
const heightForRows = (rowCount: number): number =>
|
||||
// The wrapper's bottom padding and the search row are inside this height.
|
||||
const heightForRows = (rowCount: number, withToolbar: boolean): number =>
|
||||
rowCount * LEGEND_ROW_HEIGHT +
|
||||
(rowCount - 1) * LEGEND_ROW_GAP +
|
||||
LEGEND_PADDING;
|
||||
LEGEND_PADDING +
|
||||
(withToolbar ? LEGEND_TOOLBAR_HEIGHT + LEGEND_TOOLBAR_GAP : 0);
|
||||
|
||||
const shortPanelBudget = containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO;
|
||||
const gridRowCount = Math.ceil(legendItemCount / legendItemsPerRow);
|
||||
|
||||
// Only once rows overflow — below that every series is already on screen —
|
||||
// and only while the row it costs leaves the legend inside the panel's share.
|
||||
const showLegendSearch =
|
||||
gridRowCount > LEGEND_MAX_BOTTOM_ROWS &&
|
||||
heightForRows(1, true) <= shortPanelBudget;
|
||||
|
||||
const neededRowCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
),
|
||||
Math.min(LEGEND_MAX_BOTTOM_ROWS, gridRowCount),
|
||||
);
|
||||
|
||||
// Without this, short grid panels hand most of their area to the legend and
|
||||
@@ -177,11 +192,11 @@ export function calculateChartDimensions({
|
||||
// row's items are clipped rather than removed, so they are scroll-only here.
|
||||
const legendRowCount =
|
||||
neededRowCount > 1 &&
|
||||
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
|
||||
heightForRows(neededRowCount, showLegendSearch) > shortPanelBudget
|
||||
? 1
|
||||
: neededRowCount;
|
||||
|
||||
const bottomLegendHeight = heightForRows(legendRowCount);
|
||||
const bottomLegendHeight = heightForRows(legendRowCount, showLegendSearch);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
@@ -189,5 +204,6 @@ export function calculateChartDimensions({
|
||||
legendWidth: containerWidth,
|
||||
legendHeight: bottomLegendHeight,
|
||||
averageLegendWidth: legendItemWidth,
|
||||
showLegendSearch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
import 'lib/visualization/layout/ChartLayout/ChartLayout.styles.scss';
|
||||
|
||||
export interface LegendLayout {
|
||||
averageLegendWidth: number;
|
||||
showSearch: boolean;
|
||||
}
|
||||
|
||||
export interface ChartLayoutProps {
|
||||
showLegend?: boolean;
|
||||
legendComponent: (legendPerSet: number) => React.ReactNode;
|
||||
legendComponent: (layout: LegendLayout) => React.ReactNode;
|
||||
children: (props: {
|
||||
chartWidth: number;
|
||||
chartHeight: number;
|
||||
@@ -40,6 +45,7 @@ export default function ChartLayout({
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
showLegendSearch: false,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
@@ -81,7 +87,10 @@ export default function ChartLayout({
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
{legendComponent({
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
showSearch: chartDimensions.showLegendSearch,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
.all-errors-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.all-errors-quick-filter-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
.right-toolbar-actions-container {
|
||||
display: flex;
|
||||
@@ -18,14 +11,4 @@
|
||||
.ant-tabs {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.all-errors-quick-filter-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ import { Filter } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import ResourceAttributesFilterV2 from 'container/ResourceAttributeFilterV2/ResourceAttributesFilterV2';
|
||||
@@ -59,63 +57,52 @@ function AllErrors(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
'all-errors-right-section',
|
||||
showFilters ? 'filter-visible' : '',
|
||||
)}
|
||||
>
|
||||
<TypicalOverlayScrollbar>
|
||||
<>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
!showFilters ? (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<QuickFiltersLayout
|
||||
className="all-errors-page"
|
||||
contentClassName="all-errors-right-section"
|
||||
showFilters={showFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-exceptions',
|
||||
source: QuickFiltersSource.EXCEPTIONS,
|
||||
signal: SignalType.EXCEPTIONS,
|
||||
handleFilterVisibilityChange,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
!showFilters ? (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<ResourceAttributesFilterV2 />
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</>
|
||||
</TypicalOverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ResourceAttributesFilterV2 />
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
/>
|
||||
</QuickFiltersLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
.api-monitoring-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
.ant-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
margin-bottom: 0px;
|
||||
@@ -15,22 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,9 +13,12 @@ function ApiMonitoringPage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [Explorer];
|
||||
|
||||
return (
|
||||
<div className="api-monitoring-page">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="api-monitoring-page"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
.infra-monitoring-module-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.ant-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px;
|
||||
@@ -17,22 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,8 +13,11 @@ export default function InfrastructureMonitoringPage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [Hosts, Kubernetes];
|
||||
|
||||
return (
|
||||
<div className="infra-monitoring-module-container">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="infra-monitoring-module-container"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
.logs-module-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
margin-bottom: 0px;
|
||||
@@ -20,25 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,8 +13,11 @@ export default function LogsModulePage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [logsExplorer, logsPipelines, logSaveView];
|
||||
|
||||
return (
|
||||
<div className="logs-module-container">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="logs-module-container"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
.messaging-queues-module-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.ant-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px;
|
||||
@@ -17,22 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -68,8 +68,11 @@ export default function MessagingQueuesMainPage(): JSX.Element {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="messaging-queues-module-container">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="messaging-queues-module-container"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,13 @@ function MeterExplorerPage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [Meter, Explorer, Views];
|
||||
|
||||
return (
|
||||
<div className="meter-explorer-page">
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
defaultActiveKey={ROUTES.METER}
|
||||
/>
|
||||
</div>
|
||||
<RouteTab
|
||||
className="meter-explorer-page"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
defaultActiveKey={ROUTES.METER}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
.metrics-explorer-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.ant-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding-left: 16px;
|
||||
margin-bottom: 0px;
|
||||
@@ -18,20 +9,7 @@
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
|
||||
@@ -42,9 +42,12 @@ function MetricsExplorerPage(): JSX.Element {
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
return (
|
||||
<div className="metrics-explorer-page">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="metrics-explorer-page"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,6 @@
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
@@ -75,32 +73,8 @@
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -261,23 +259,20 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="trace-explorer-page">
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
showFilters={isOpen}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
signal: SignalType.TRACES,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
@@ -369,7 +364,7 @@ function TracesExplorer(): JSX.Element {
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,16 +25,15 @@ function TracesModulePage(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="traces-module-container">
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={
|
||||
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
|
||||
}
|
||||
history={history}
|
||||
onChangeHandler={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
<RouteTab
|
||||
className="traces-module-container"
|
||||
routes={routes}
|
||||
activeKey={
|
||||
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
|
||||
}
|
||||
history={history}
|
||||
onChangeHandler={handleTabChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
36
frontend/src/utils/__tests__/timeUtils.test.ts
Normal file
36
frontend/src/utils/__tests__/timeUtils.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { formatTimestampOmittingTodaysDate } from '../timeUtils';
|
||||
|
||||
describe('formatTimestampOmittingTodaysDate', () => {
|
||||
const timezone = 'Asia/Kolkata';
|
||||
|
||||
it('drops the date for a point on the current day', () => {
|
||||
const now = dayjs().tz(timezone);
|
||||
|
||||
expect(formatTimestampOmittingTodaysDate(now.valueOf(), timezone)).toBe(
|
||||
now.format(DATE_TIME_FORMATS.TIME_SECONDS),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the date for a point on any other day', () => {
|
||||
const yesterday = dayjs().tz(timezone).subtract(1, 'day');
|
||||
|
||||
expect(formatTimestampOmittingTodaysDate(yesterday.valueOf(), timezone)).toBe(
|
||||
yesterday.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS),
|
||||
);
|
||||
});
|
||||
|
||||
it('honours an explicit format over the day check', () => {
|
||||
const now = dayjs().tz(timezone);
|
||||
|
||||
expect(
|
||||
formatTimestampOmittingTodaysDate(
|
||||
now.valueOf(),
|
||||
timezone,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_SECONDS,
|
||||
),
|
||||
).toBe(now.format(DATE_TIME_FORMATS.ISO_DATETIME_SECONDS));
|
||||
});
|
||||
});
|
||||
@@ -342,3 +342,22 @@ export const getMs = (value: string): string =>
|
||||
})
|
||||
.format('SSS'),
|
||||
).toFixed(2);
|
||||
|
||||
/** `overrideFormat`, when given, wins over the same-day check. */
|
||||
export const formatTimestampOmittingTodaysDate = (
|
||||
timestampMs: number,
|
||||
timezone: string,
|
||||
overrideFormat?: string,
|
||||
): string => {
|
||||
const time = dayjs(timestampMs).tz(timezone);
|
||||
|
||||
if (overrideFormat) {
|
||||
return time.format(overrideFormat);
|
||||
}
|
||||
|
||||
return time.format(
|
||||
time.isSame(dayjs().tz(timezone), 'day')
|
||||
? DATE_TIME_FORMATS.TIME_SECONDS
|
||||
: DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,10 +15,26 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
ID: "ListRules",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
|
||||
Response: make([]*ruletypes.Rule, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
|
||||
ID: "ListRulesV3",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules (v3)",
|
||||
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
|
||||
RequestQuery: new(ruletypes.ListRulesParams),
|
||||
Response: new(ruletypes.ListableRules),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
@@ -116,6 +132,64 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRuleViews), handler.OpenAPIDef{
|
||||
ID: "ListRuleViews",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List rule saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Response: new(ruletypes.ListableRuleViews),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.CreateRuleView), handler.OpenAPIDef{
|
||||
ID: "CreateRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create rule saved view",
|
||||
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(ruletypes.PostableRuleView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.GettableRuleView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.UpdateRuleView), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update rule saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(ruletypes.UpdatableRuleView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.GettableRuleView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.DeleteRuleView), handler.OpenAPIDef{
|
||||
ID: "DeleteRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete rule saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"definition": {
|
||||
"schemaVersion": "v6",
|
||||
"name": "signoz---ai-o11y-overview",
|
||||
@@ -437,7 +437,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -489,7 +489,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -722,7 +722,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -782,7 +782,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -850,7 +850,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -868,7 +868,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -886,7 +886,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -904,7 +904,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -961,7 +961,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -979,7 +979,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -997,7 +997,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -1015,7 +1015,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -1076,7 +1076,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": true,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -1175,7 +1175,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": true,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -1192,7 +1192,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": true,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -1248,7 +1248,7 @@
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"type": "builder_ai_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
@@ -1282,7 +1282,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"type": "builder_ai_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
@@ -1353,7 +1353,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -2075,7 +2075,7 @@
|
||||
"stepInterval": 0,
|
||||
"disabled": false,
|
||||
"filter": {
|
||||
"expression": "gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
"expression": "gen_ai.request.model EXISTS AND gen_ai.request.model IN $model AND gen_ai.provider.name IN $provider AND deployment.environment IN $environment AND service.name IN $service_name"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
@@ -2724,7 +2724,7 @@
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"type": "builder_ai_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"definition": {
|
||||
"name": "gen_ai.agent",
|
||||
"condition": {
|
||||
@@ -68,7 +68,7 @@
|
||||
{
|
||||
"key": "final_result",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"operation": "move",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"definition": {
|
||||
"name": "gen_ai.llm",
|
||||
"condition": {
|
||||
@@ -250,19 +250,19 @@
|
||||
{
|
||||
"key": "gen_ai.prompt",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"operation": "move",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.prompt.messages",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"operation": "move",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "input.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"operation": "move",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
@@ -276,25 +276,25 @@
|
||||
{
|
||||
"key": "gen_ai.completion",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"operation": "move",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.response.toolCalls",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"operation": "move",
|
||||
"priority": 25
|
||||
},
|
||||
{
|
||||
"key": "ai.response.text",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"operation": "move",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "output.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"operation": "move",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
|
||||
@@ -105,17 +105,6 @@ func (m *Manager) RecommendAgentConfig(orgId valuer.UUID, currentConfYaml []byte
|
||||
configId string,
|
||||
err error,
|
||||
) {
|
||||
return m.recommendAgentConfig(orgId, currentConfYaml, true)
|
||||
}
|
||||
|
||||
// Implements opamp.AgentConfigProvider
|
||||
func (m *Manager) PreviewAgentConfig(orgId valuer.UUID, currentConfYaml []byte) ([]byte, error) {
|
||||
recommendation, _, err := m.recommendAgentConfig(orgId, currentConfYaml, false)
|
||||
return recommendation, err
|
||||
}
|
||||
|
||||
func (m *Manager) recommendAgentConfig(orgId valuer.UUID, currentConfYaml []byte, recordDeployment bool) ([]byte, string, error) {
|
||||
var configId string
|
||||
recommendation := currentConfYaml
|
||||
settingVersionsUsed := []string{}
|
||||
|
||||
@@ -145,9 +134,6 @@ func (m *Manager) recommendAgentConfig(orgId valuer.UUID, currentConfYaml []byte
|
||||
|
||||
settingVersionsUsed = append(settingVersionsUsed, configId)
|
||||
|
||||
if !recordDeployment {
|
||||
continue
|
||||
}
|
||||
_ = m.updateDeployStatus(
|
||||
context.Background(),
|
||||
orgId,
|
||||
|
||||
@@ -239,7 +239,7 @@ func (ic *LogParsingPipelineController) getNormalizePipeline() pipelinetypes.Get
|
||||
},
|
||||
Config: []pipelinetypes.PipelineOperator{
|
||||
{
|
||||
ID: "normalize_body_default",
|
||||
ID: uuid.NewString(),
|
||||
Type: "normalize",
|
||||
Enabled: true,
|
||||
If: "body != nil",
|
||||
|
||||
@@ -128,12 +128,6 @@ func (ta *MockAgentConfigProvider) HasReportedDeploymentStatus(orgID valuer.UUID
|
||||
return exists
|
||||
}
|
||||
|
||||
// AgentConfigProvider interface
|
||||
func (ta *MockAgentConfigProvider) PreviewAgentConfig(orgId valuer.UUID, baseConfYaml []byte) ([]byte, error) {
|
||||
recommendedYaml, _, err := ta.RecommendAgentConfig(orgId, baseConfYaml)
|
||||
return recommendedYaml, err
|
||||
}
|
||||
|
||||
// AgentConfigProvider interface
|
||||
func (ta *MockAgentConfigProvider) GetDeployStatusByHash(_ context.Context, _ valuer.UUID, _ string) (opamptypes.DeployStatus, error) {
|
||||
return opamptypes.DeployStatusUnknown, nil
|
||||
|
||||
@@ -24,8 +24,6 @@ type Agent struct {
|
||||
remoteConfig *protobufs.AgentRemoteConfig
|
||||
Status *protobufs.AgentToServer
|
||||
|
||||
reconnectConfigChecked bool
|
||||
|
||||
// can this agent be load balancer
|
||||
CanLB bool
|
||||
|
||||
@@ -293,11 +291,6 @@ func (agent *Agent) processStatusUpdate(
|
||||
|
||||
// We need to recalculate the config.
|
||||
configChanged = agent.updateRemoteConfig(configProvider)
|
||||
} else if agent.remoteConfig == nil && !agent.reconnectConfigChecked && agent.Config != "" {
|
||||
// A running agent reconnected after a server restart; settings may have
|
||||
// changed while it was away (e.g. startup reconciliation).
|
||||
agent.reconnectConfigChecked = true
|
||||
configChanged = agent.updateRemoteConfigIfStale(configProvider)
|
||||
}
|
||||
|
||||
// If remote config is changed and different from what the Agent has then
|
||||
@@ -319,20 +312,6 @@ func (agent *Agent) processStatusUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
// updateRemoteConfigIfStale records a deployment only when the recommendation
|
||||
// differs from the agent's effective config.
|
||||
func (agent *Agent) updateRemoteConfigIfStale(configProvider AgentConfigProvider) bool {
|
||||
recommendedConfig, err := configProvider.PreviewAgentConfig(agent.OrgID, []byte(agent.Config))
|
||||
if err != nil {
|
||||
agent.logger.Error("could not preview config recommendation for agent", "agent_id", agent.AgentID, errors.Attr(err))
|
||||
return false
|
||||
}
|
||||
if string(recommendedConfig) == agent.Config {
|
||||
return false
|
||||
}
|
||||
return agent.updateRemoteConfig(configProvider)
|
||||
}
|
||||
|
||||
func (agent *Agent) updateRemoteConfig(configProvider AgentConfigProvider) bool {
|
||||
recommendedConfig, confId, err := configProvider.RecommendAgentConfig(agent.OrgID, []byte(agent.Config))
|
||||
if err != nil {
|
||||
|
||||
@@ -18,10 +18,6 @@ type AgentConfigProvider interface {
|
||||
err error,
|
||||
)
|
||||
|
||||
// PreviewAgentConfig returns the config RecommendAgentConfig would, without
|
||||
// recording a deployment.
|
||||
PreviewAgentConfig(orgId valuer.UUID, currentConfYaml []byte) ([]byte, error)
|
||||
|
||||
// Report deployment status for config recommendations generated by RecommendAgentConfig
|
||||
ReportConfigDeploymentStatus(
|
||||
orgId valuer.UUID,
|
||||
|
||||
20
pkg/query-service/rules/filterquery.go
Normal file
20
pkg/query-service/rules/filterquery.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
// Compile wraps compiler errors in the rules list filter error code.
|
||||
func CompileListFilter(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, ruleFieldResolver{})
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(ruletypes.ErrCodeRuleListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
196
pkg/query-service/rules/filterquery_resolver.go
Normal file
196
pkg/query-service/rules/filterquery_resolver.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleDataColumn = "rule.data"
|
||||
ruleLabelsField = "labels"
|
||||
nameJSONPath = "$.alert"
|
||||
descriptionPath = "$.description"
|
||||
labelsJSONPath = "$.labels"
|
||||
alertTypePath = "$.alertType"
|
||||
ruleTypePath = "$.ruleType"
|
||||
)
|
||||
|
||||
// ruleFieldResolver maps rule list DSL keys; a non-reserved key is a case-sensitive label lookup.
|
||||
type ruleFieldResolver struct{}
|
||||
|
||||
func (r ruleFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, rawKey string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
key := strings.ToLower(rawKey)
|
||||
|
||||
// labels.<key> is the explicit way to target only the label on a reserved-key collision.
|
||||
if strings.HasPrefix(key, ruletypes.DSLLabelsKeyPrefix) {
|
||||
labelKey := rawKey[len(ruletypes.DSLLabelsKeyPrefix):]
|
||||
if labelKey == "" {
|
||||
v.AddError("labels filter is missing a key, use labels.<key>")
|
||||
return ""
|
||||
}
|
||||
if _, allowed := ruletypes.LabelsKeyOps[operation]; !allowed {
|
||||
v.AddError("operator %s is not allowed on a labels.<key> filter", sqlcompiler.OperationName(operation))
|
||||
return ""
|
||||
}
|
||||
return r.labelComparison(v, ctx, operation, labelKey)
|
||||
}
|
||||
|
||||
allowedOperations, isReserved := ruletypes.ReservedOps[ruletypes.DSLKey(key)]
|
||||
_, labelAllowed := ruletypes.LabelsKeyOps[operation]
|
||||
|
||||
if !isReserved {
|
||||
if !labelAllowed {
|
||||
v.AddError("operator %s is not allowed on the label filter %q", sqlcompiler.OperationName(operation), rawKey)
|
||||
return ""
|
||||
}
|
||||
return r.labelComparison(v, ctx, operation, rawKey)
|
||||
}
|
||||
|
||||
_, reservedAllowed := allowedOperations[operation]
|
||||
// reserved severity is itself the severity-label lookup; an identical spelling would duplicate the predicate
|
||||
if ruletypes.DSLKey(key) == ruletypes.DSLKeySeverity && rawKey == string(ruletypes.DSLKeySeverity) {
|
||||
labelAllowed = false
|
||||
}
|
||||
|
||||
switch {
|
||||
case reservedAllowed && labelAllowed:
|
||||
reservedPredicate := r.resolveReservedKey(v, ctx, operation, ruletypes.DSLKey(key))
|
||||
labelPredicate := r.labelComparison(v, ctx, operation, rawKey)
|
||||
if reservedPredicate == "" || labelPredicate == "" {
|
||||
return ""
|
||||
}
|
||||
// the key matches both the reserved field and a same-named label; a negative term must exclude both
|
||||
if operation.IsNegativeOperator() {
|
||||
return v.Sb.And(reservedPredicate, labelPredicate)
|
||||
}
|
||||
return v.Sb.Or(reservedPredicate, labelPredicate)
|
||||
case reservedAllowed:
|
||||
return r.resolveReservedKey(v, ctx, operation, ruletypes.DSLKey(key))
|
||||
case labelAllowed:
|
||||
return r.labelComparison(v, ctx, operation, rawKey)
|
||||
default:
|
||||
v.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (r ruleFieldResolver) resolveReservedKey(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey) string {
|
||||
switch key {
|
||||
case ruletypes.DSLKeyName:
|
||||
columnExpression := string(v.Formatter.JSONExtractString(ruleDataColumn, nameJSONPath))
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, columnExpression, string(key))
|
||||
case ruletypes.DSLKeySeverity:
|
||||
// severity is an alias for labels.severity, sharing its missing-label semantics.
|
||||
return r.labelComparison(v, ctx, operation, "severity")
|
||||
case ruletypes.DSLKeyCreatedBy:
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "rule.created_by", string(key))
|
||||
case ruletypes.DSLKeyUpdatedBy:
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "rule.updated_by", string(key))
|
||||
case ruletypes.DSLKeyCreatedAt:
|
||||
return v.BuildTimestampComparison(ctx, operation, "rule.created_at")
|
||||
case ruletypes.DSLKeyUpdatedAt:
|
||||
return v.BuildTimestampComparison(ctx, operation, "rule.updated_at")
|
||||
case ruletypes.DSLKeyAlertType:
|
||||
return r.enumComparison(v, ctx, operation, key, alertTypePath, alertTypeValues)
|
||||
case ruletypes.DSLKeyRuleType:
|
||||
return r.enumComparison(v, ctx, operation, key, ruleTypePath, ruleTypeValues)
|
||||
}
|
||||
v.AddError("no handler for reserved key %q", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
// A missing label evaluates as the empty string for every value operator; EXISTS/NOT EXISTS test the raw extraction.
|
||||
func (ruleFieldResolver) labelComparison(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, labelKey string) string {
|
||||
columnExpression := string(v.Formatter.JSONExtractMapValue(ruleDataColumn, ruleLabelsField, labelKey))
|
||||
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorExists:
|
||||
return fmt.Sprintf("%s IS NOT NULL", columnExpression)
|
||||
case qbtypesv5.FilterOperatorNotExists:
|
||||
return fmt.Sprintf("%s IS NULL", columnExpression)
|
||||
}
|
||||
|
||||
keyForError := ruletypes.DSLLabelsKeyPrefix + labelKey
|
||||
columnExpression = fmt.Sprintf("COALESCE(%s, '')", columnExpression)
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, columnExpression, keyForError)
|
||||
}
|
||||
|
||||
func (ruleFieldResolver) enumComparison(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey, jsonPath string, allowedValues []string) string {
|
||||
columnExpression := string(v.Formatter.JSONExtractString(ruleDataColumn, jsonPath))
|
||||
|
||||
var values []string
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual:
|
||||
value, ok := v.ExtractSingleStringValue(ctx, string(key))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
values = []string{value}
|
||||
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
|
||||
list, ok := v.ExtractStringValueList(ctx, string(key))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
values = list
|
||||
default:
|
||||
v.AddError("operator %s on %q is not implemented", sqlcompiler.OperationName(operation), key)
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if !slices.Contains(allowedValues, value) {
|
||||
v.AddError("invalid value %q for %q, expected one of: %s", value, key, strings.Join(allowedValues, ", "))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
arguments := make([]any, len(values))
|
||||
for i, s := range values {
|
||||
arguments[i] = s
|
||||
}
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
return v.Sb.Equal(columnExpression, arguments[0])
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
return v.Sb.NotEqual(columnExpression, arguments[0])
|
||||
case qbtypesv5.FilterOperatorNotIn:
|
||||
return v.Sb.NotIn(columnExpression, arguments...)
|
||||
default:
|
||||
return v.Sb.In(columnExpression, arguments...)
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveFreeText searches name, description and the raw labels JSON (which also matches label keys).
|
||||
func (ruleFieldResolver) ResolveFreeText(v *sqlcompiler.Visitor, value string) string {
|
||||
nameColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, nameJSONPath))
|
||||
descriptionColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, descriptionPath))
|
||||
labelsColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, labelsJSONPath))
|
||||
|
||||
return v.Sb.Or(
|
||||
v.BuildFreeTextContains(v.Sb, nameColumn, value),
|
||||
v.BuildFreeTextContains(v.Sb, descriptionColumn, value),
|
||||
v.BuildFreeTextContains(v.Sb, labelsColumn, value),
|
||||
)
|
||||
}
|
||||
|
||||
var alertTypeValues = func() []string {
|
||||
values := make([]string, 0, 4)
|
||||
for _, value := range (ruletypes.AlertType("")).Enum() {
|
||||
values = append(values, string(value.(ruletypes.AlertType)))
|
||||
}
|
||||
return values
|
||||
}()
|
||||
|
||||
var ruleTypeValues = func() []string {
|
||||
values := make([]string, 0, 3)
|
||||
for _, value := range (ruletypes.RuleType{}).Enum() {
|
||||
values = append(values, value.(ruletypes.RuleType).StringValue())
|
||||
}
|
||||
return values
|
||||
}()
|
||||
488
pkg/query-service/rules/filterquery_test.go
Normal file
488
pkg/query-service/rules/filterquery_test.go
Normal file
@@ -0,0 +1,488 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
type compileCase struct {
|
||||
subtestName string
|
||||
dslQueryToCompile string
|
||||
emptyQueryExpected bool
|
||||
expectedSQL string
|
||||
expectedArgs []any
|
||||
expectedErrShouldContain string
|
||||
}
|
||||
|
||||
func runCompileCases(t *testing.T, cases []compileCase) {
|
||||
t.Helper()
|
||||
for _, c := range cases {
|
||||
t.Run(c.subtestName, func(t *testing.T) {
|
||||
out, err := CompileListFilter(c.dslQueryToCompile, formatter(t))
|
||||
|
||||
if c.expectedErrShouldContain != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
if c.emptyQueryExpected {
|
||||
assert.True(t, out.IsEmpty())
|
||||
return
|
||||
}
|
||||
require.NotNil(t, out)
|
||||
|
||||
if c.expectedSQL != "" {
|
||||
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
|
||||
}
|
||||
if c.expectedArgs != nil {
|
||||
require.Len(t, out.Args, len(c.expectedArgs))
|
||||
for i, want := range c.expectedArgs {
|
||||
// Equal instants can differ in *Location, so compare via .Equal() instead of DeepEqual.
|
||||
if wantT, ok := want.(time.Time); ok {
|
||||
gotT, ok := out.Args[i].(time.Time)
|
||||
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
|
||||
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileEmpty(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{subtestName: "EmptyQuery_Nil", dslQueryToCompile: "", emptyQueryExpected: true},
|
||||
{subtestName: "WhitespaceQuery_Nil", dslQueryToCompile: " ", emptyQueryExpected: true},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileName(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "NameEquals_MatchesReservedOrLabel",
|
||||
dslQueryToCompile: "name = 'payment latency'",
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alert') = ? OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') = ?)`,
|
||||
expectedArgs: []any{"payment latency", "payment latency"},
|
||||
},
|
||||
{
|
||||
subtestName: "NameContains_EscapesWildcardsBothSides",
|
||||
dslQueryToCompile: "name CONTAINS '50%'",
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\')`,
|
||||
expectedArgs: []any{`%50\%%`, `%50\%%`},
|
||||
},
|
||||
{
|
||||
subtestName: "NameILike",
|
||||
dslQueryToCompile: "name ILIKE 'Prod%'",
|
||||
expectedSQL: `(lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\' OR lower(COALESCE(json_extract("rule"."data", '$.labels."name"'), '')) LIKE LOWER(?) ESCAPE '\')`,
|
||||
expectedArgs: []any{"Prod%", "Prod%"},
|
||||
},
|
||||
{
|
||||
subtestName: "NameInList",
|
||||
dslQueryToCompile: "name IN ['a', 'b']",
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alert') IN (?, ?) OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') IN (?, ?))`,
|
||||
expectedArgs: []any{"a", "b", "a", "b"},
|
||||
},
|
||||
{
|
||||
subtestName: "NameNotEquals_ExcludesBoth",
|
||||
dslQueryToCompile: "name != 'x'",
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alert') <> ? AND COALESCE(json_extract("rule"."data", '$.labels."name"'), '') <> ?)`,
|
||||
expectedArgs: []any{"x", "x"},
|
||||
},
|
||||
{
|
||||
subtestName: "NameExists_LabelOnly",
|
||||
dslQueryToCompile: "name EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."name"') IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "RangeOperatorOnName_Rejected",
|
||||
dslQueryToCompile: "name > 'x'",
|
||||
expectedErrShouldContain: `operator > is not allowed for key "name"`,
|
||||
},
|
||||
{
|
||||
subtestName: "RegexpOnName_Rejected",
|
||||
dslQueryToCompile: "name REGEXP 'x.*'",
|
||||
expectedErrShouldContain: `operator REGEXP is not allowed for key "name"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileSeverityAndLabels(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "SeverityEquals_TargetsLabelsMap",
|
||||
dslQueryToCompile: "severity = 'critical'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?`,
|
||||
expectedArgs: []any{"critical"},
|
||||
},
|
||||
{
|
||||
subtestName: "SeverityNotEquals_MissingLabelAsEmptyString",
|
||||
dslQueryToCompile: "severity != 'critical'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
|
||||
expectedArgs: []any{"critical"},
|
||||
},
|
||||
{
|
||||
subtestName: "SeverityNotEqualsEmpty_ExcludesRulesWithoutSeverity",
|
||||
dslQueryToCompile: "severity != ''",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
|
||||
expectedArgs: []any{""},
|
||||
},
|
||||
{
|
||||
subtestName: "SeverityExists_ThroughAlias",
|
||||
dslQueryToCompile: "severity EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "SeverityNotExists_ThroughAlias",
|
||||
dslQueryToCompile: "severity NOT EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "LabelEquals",
|
||||
dslQueryToCompile: "labels.team = 'infra'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ?`,
|
||||
expectedArgs: []any{"infra"},
|
||||
},
|
||||
{
|
||||
subtestName: "DottedLabelKey_OneMapEntry",
|
||||
dslQueryToCompile: "labels.k8s.cluster = 'prod-1'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."k8s.cluster"'), '') = ?`,
|
||||
expectedArgs: []any{"prod-1"},
|
||||
},
|
||||
{
|
||||
subtestName: "LabelKey_CaseSensitive",
|
||||
dslQueryToCompile: "labels.Team = 'infra'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."Team"'), '') = ?`,
|
||||
expectedArgs: []any{"infra"},
|
||||
},
|
||||
{
|
||||
subtestName: "LabelExists",
|
||||
dslQueryToCompile: "labels.team EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "LabelNotExists",
|
||||
dslQueryToCompile: "labels.team NOT EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "LabelNotContains_IncludesLabelLessRules",
|
||||
dslQueryToCompile: "labels.team NOT CONTAINS 'infra'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT LIKE ? ESCAPE '\'`,
|
||||
expectedArgs: []any{"%infra%"},
|
||||
},
|
||||
{
|
||||
subtestName: "LabelNotIn_IncludesLabelLessRules",
|
||||
dslQueryToCompile: "labels.team NOT IN ['a', 'b']",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT IN (?, ?)`,
|
||||
expectedArgs: []any{"a", "b"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileEnums(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "AlertTypeEquals_MatchesEnumOrLabel",
|
||||
dslQueryToCompile: "alert_type = 'LOGS_BASED_ALERT'",
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alertType') = ? OR COALESCE(json_extract("rule"."data", '$.labels."alert_type"'), '') = ?)`,
|
||||
expectedArgs: []any{"LOGS_BASED_ALERT", "LOGS_BASED_ALERT"},
|
||||
},
|
||||
{
|
||||
subtestName: "RuleTypeInList",
|
||||
dslQueryToCompile: "rule_type IN ['threshold_rule', 'promql_rule']",
|
||||
expectedSQL: `(json_extract("rule"."data", '$.ruleType') IN (?, ?) OR COALESCE(json_extract("rule"."data", '$.labels."rule_type"'), '') IN (?, ?))`,
|
||||
expectedArgs: []any{"threshold_rule", "promql_rule", "threshold_rule", "promql_rule"},
|
||||
},
|
||||
{
|
||||
subtestName: "InvalidAlertTypeValue_Rejected",
|
||||
dslQueryToCompile: "alert_type = 'bogus'",
|
||||
expectedErrShouldContain: `invalid value "bogus" for "alert_type"`,
|
||||
},
|
||||
{
|
||||
subtestName: "ContainsOnRuleType_LabelOnly",
|
||||
dslQueryToCompile: "rule_type CONTAINS 'thresh'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."rule_type"'), '') LIKE ? ESCAPE '\'`,
|
||||
expectedArgs: []any{"%thresh%"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileAuditColumns(t *testing.T) {
|
||||
createdAt, err := time.Parse(time.RFC3339, "2026-01-02T15:04:05Z")
|
||||
require.NoError(t, err)
|
||||
updatedFrom, err := time.Parse(time.RFC3339, "2026-02-01T00:00:00Z")
|
||||
require.NoError(t, err)
|
||||
updatedTo, err := time.Parse(time.RFC3339, "2026-03-01T00:00:00Z")
|
||||
require.NoError(t, err)
|
||||
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "CreatedByEquals_MatchesColumnOrLabel",
|
||||
dslQueryToCompile: "created_by = 'nikhil@signoz.io'",
|
||||
expectedSQL: `(rule.created_by = ? OR COALESCE(json_extract("rule"."data", '$.labels."created_by"'), '') = ?)`,
|
||||
expectedArgs: []any{"nikhil@signoz.io", "nikhil@signoz.io"},
|
||||
},
|
||||
{
|
||||
subtestName: "CreatedAtRange",
|
||||
dslQueryToCompile: "created_at >= '2026-01-02T15:04:05Z'",
|
||||
expectedSQL: `rule.created_at >= ?`,
|
||||
expectedArgs: []any{createdAt},
|
||||
},
|
||||
{
|
||||
subtestName: "UpdatedAtBetween",
|
||||
dslQueryToCompile: "updated_at BETWEEN '2026-02-01T00:00:00Z' AND '2026-03-01T00:00:00Z'",
|
||||
expectedSQL: `rule.updated_at BETWEEN ? AND ?`,
|
||||
expectedArgs: []any{updatedFrom, updatedTo},
|
||||
},
|
||||
{
|
||||
subtestName: "NonTimestampOnCreatedAt_Rejected",
|
||||
dslQueryToCompile: "created_at >= 'yesterday'",
|
||||
expectedErrShouldContain: "invalid RFC3339 timestamp",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileFreeText(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "BareWord_SearchesNameDescriptionLabels",
|
||||
dslQueryToCompile: "payment",
|
||||
expectedSQL: `(lower(COALESCE(json_extract("rule"."data", '$.alert'), '')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.description'), '')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.labels'), '')) LIKE LOWER(?) ESCAPE '\')`,
|
||||
expectedArgs: []any{"%payment%", "%payment%", "%payment%"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileComposition(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "AndOfLabelAndColumn",
|
||||
dslQueryToCompile: "labels.team = 'infra' AND created_by = 'x'",
|
||||
expectedSQL: `(COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
|
||||
`AND (rule.created_by = ? OR COALESCE(json_extract("rule"."data", '$.labels."created_by"'), '') = ?))`,
|
||||
expectedArgs: []any{"infra", "x", "x"},
|
||||
},
|
||||
{
|
||||
subtestName: "Not_WrapsInnerPredicate",
|
||||
dslQueryToCompile: "NOT (name = 'x')",
|
||||
expectedSQL: `NOT ((json_extract("rule"."data", '$.alert') = ? OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') = ?))`,
|
||||
expectedArgs: []any{"x", "x"},
|
||||
},
|
||||
{
|
||||
subtestName: "OrOfNameAndSeverity",
|
||||
dslQueryToCompile: "name CONTAINS 'pay' OR severity = 'critical'",
|
||||
expectedSQL: `((json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\') ` +
|
||||
`OR COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?)`,
|
||||
expectedArgs: []any{"%pay%", "%pay%", "critical"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileComplexExamples(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "NameContains_LabelEquals_SeverityIn_CreatedByNotEquals",
|
||||
dslQueryToCompile: `name CONTAINS 'latency' AND labels.team = 'payments' ` +
|
||||
`AND severity IN ['critical', 'error'] AND created_by != 'ops@signoz.io'`,
|
||||
expectedSQL: `((json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\') ` +
|
||||
`AND COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
|
||||
`AND COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') IN (?, ?) ` +
|
||||
`AND (rule.created_by <> ? AND COALESCE(json_extract("rule"."data", '$.labels."created_by"'), '') <> ?))`,
|
||||
expectedArgs: []any{"%latency%", "%latency%", "payments", "critical", "error", "ops@signoz.io", "ops@signoz.io"},
|
||||
},
|
||||
{
|
||||
subtestName: "NestedOrAnd_WithParens",
|
||||
dslQueryToCompile: `(labels.env IN ['prod', 'staging'] OR name LIKE '%prod%') ` +
|
||||
`AND (severity = 'critical' OR labels.team EXISTS)`,
|
||||
expectedSQL: `((COALESCE(json_extract("rule"."data", '$.labels."env"'), '') IN (?, ?) ` +
|
||||
`OR (json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\')) ` +
|
||||
`AND (COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ? ` +
|
||||
`OR json_extract("rule"."data", '$.labels."team"') IS NOT NULL))`,
|
||||
expectedArgs: []any{"prod", "staging", "%prod%", "%prod%", "critical"},
|
||||
},
|
||||
{
|
||||
subtestName: "NotOverGroup_AndedWithEnum",
|
||||
dslQueryToCompile: `NOT (labels.team = 'infra' OR name CONTAINS 'cpu') AND alert_type = 'METRIC_BASED_ALERT'`,
|
||||
expectedSQL: `(NOT ((COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
|
||||
`OR (json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\'))) ` +
|
||||
`AND (json_extract("rule"."data", '$.alertType') = ? OR COALESCE(json_extract("rule"."data", '$.labels."alert_type"'), '') = ?))`,
|
||||
expectedArgs: []any{"infra", "%cpu%", "%cpu%", "METRIC_BASED_ALERT", "METRIC_BASED_ALERT"},
|
||||
},
|
||||
{
|
||||
subtestName: "FreeText_ThreeLevelNesting_Timestamp",
|
||||
dslQueryToCompile: `prod AND (name ILIKE '%pay%' ` +
|
||||
`OR (labels.team != 'infra' AND updated_at > '2026-01-02T15:04:05Z'))`,
|
||||
expectedSQL: `((lower(COALESCE(json_extract("rule"."data", '$.alert'), '')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.description'), '')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.labels'), '')) LIKE LOWER(?) ESCAPE '\') ` +
|
||||
`AND ((lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.labels."name"'), '')) LIKE LOWER(?) ESCAPE '\') ` +
|
||||
`OR (COALESCE(json_extract("rule"."data", '$.labels."team"'), '') <> ? AND rule.updated_at > ?)))`,
|
||||
expectedArgs: []any{"%prod%", "%prod%", "%prod%", "%pay%", "%pay%", "infra",
|
||||
time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileBareLabelKeys(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "BareKey_LabelMatch",
|
||||
dslQueryToCompile: "team = 'infra'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ?`,
|
||||
expectedArgs: []any{"infra"},
|
||||
},
|
||||
{
|
||||
subtestName: "BareKey_CaseSensitive",
|
||||
dslQueryToCompile: "Team CONTAINS 'inf'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."Team"'), '') LIKE ? ESCAPE '\'`,
|
||||
expectedArgs: []any{"%inf%"},
|
||||
},
|
||||
{
|
||||
subtestName: "BareKeyExists",
|
||||
dslQueryToCompile: "env EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."env"') IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "State_LabelLookupNotRuleState",
|
||||
dslQueryToCompile: "state = 'firing'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."state"'), '') = ?`,
|
||||
expectedArgs: []any{"firing"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileReservedLabelCollisions(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "UppercaseReservedKey_MatchesReservedOrExactCaseLabel",
|
||||
dslQueryToCompile: "NAME = 'x'",
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alert') = ? OR COALESCE(json_extract("rule"."data", '$.labels."NAME"'), '') = ?)`,
|
||||
expectedArgs: []any{"x", "x"},
|
||||
},
|
||||
{
|
||||
subtestName: "SeverityExactSpelling_SinglePredicate",
|
||||
dslQueryToCompile: "severity = 'critical'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?`,
|
||||
expectedArgs: []any{"critical"},
|
||||
},
|
||||
{
|
||||
subtestName: "SeverityDifferentCase_MatchesBothLabelSpellings",
|
||||
dslQueryToCompile: "Severity = 'critical'",
|
||||
expectedSQL: `(COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ? ` +
|
||||
`OR COALESCE(json_extract("rule"."data", '$.labels."Severity"'), '') = ?)`,
|
||||
expectedArgs: []any{"critical", "critical"},
|
||||
},
|
||||
{
|
||||
subtestName: "RangeOperator_ReservedOnly",
|
||||
dslQueryToCompile: "created_at >= '2026-01-02T15:04:05Z'",
|
||||
expectedSQL: `rule.created_at >= ?`,
|
||||
expectedArgs: []any{time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)},
|
||||
},
|
||||
{
|
||||
subtestName: "LabelsPrefix_LabelOnlyOnCollision",
|
||||
dslQueryToCompile: "labels.name = 'x'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."name"'), '') = ?`,
|
||||
expectedArgs: []any{"x"},
|
||||
},
|
||||
{
|
||||
subtestName: "NotIn_ExcludesBoth",
|
||||
dslQueryToCompile: "created_by NOT IN ['a', 'b']",
|
||||
expectedSQL: `(rule.created_by NOT IN (?, ?) ` +
|
||||
`AND COALESCE(json_extract("rule"."data", '$.labels."created_by"'), '') NOT IN (?, ?))`,
|
||||
expectedArgs: []any{"a", "b", "a", "b"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileErrors(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "RangeOperatorOnBareLabelKey_Rejected",
|
||||
dslQueryToCompile: "team > 'infra'",
|
||||
expectedErrShouldContain: `operator > is not allowed on the label filter "team"`,
|
||||
},
|
||||
{
|
||||
subtestName: "SyntaxError_SurfacesPosition",
|
||||
dslQueryToCompile: "created_by ==== (((",
|
||||
expectedErrShouldContain: "syntax error",
|
||||
},
|
||||
{
|
||||
subtestName: "LikeDanglingEscape_Rejected",
|
||||
dslQueryToCompile: `name LIKE 'prod\\'`,
|
||||
expectedErrShouldContain: "must not end with an unescaped backslash",
|
||||
},
|
||||
{
|
||||
subtestName: "ILikeDanglingEscape_Rejected",
|
||||
dslQueryToCompile: `name ILIKE '%\\'`,
|
||||
expectedErrShouldContain: "must not end with an unescaped backslash",
|
||||
},
|
||||
{
|
||||
subtestName: "LabelLikeDanglingEscape_Rejected",
|
||||
dslQueryToCompile: `labels.team NOT LIKE 'infra\\'`,
|
||||
expectedErrShouldContain: "must not end with an unescaped backslash",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileTrailingLiteralBackslash(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "EscapedTrailingBackslash_Compiles",
|
||||
dslQueryToCompile: `name LIKE '%\\\\'`,
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\')`,
|
||||
expectedArgs: []any{`%\\`, `%\\`},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Guards that every ruletypes.ReservedOps key has a case in resolveReservedKey.
|
||||
func TestCompileReservedKeysAllHandled(t *testing.T) {
|
||||
sampleQueries := map[ruletypes.DSLKey]string{
|
||||
ruletypes.DSLKeyName: "name = 'x'",
|
||||
ruletypes.DSLKeySeverity: "severity = 'critical'",
|
||||
ruletypes.DSLKeyCreatedBy: "created_by = 'x'",
|
||||
ruletypes.DSLKeyUpdatedBy: "updated_by = 'x'",
|
||||
ruletypes.DSLKeyCreatedAt: "created_at >= '2026-01-02T15:04:05Z'",
|
||||
ruletypes.DSLKeyUpdatedAt: "updated_at >= '2026-01-02T15:04:05Z'",
|
||||
ruletypes.DSLKeyAlertType: "alert_type = 'METRIC_BASED_ALERT'",
|
||||
ruletypes.DSLKeyRuleType: "rule_type = 'threshold_rule'",
|
||||
}
|
||||
|
||||
for key := range ruletypes.ReservedOps {
|
||||
query, ok := sampleQueries[key]
|
||||
require.True(t, ok, "no sample query for reserved key %q, add one", key)
|
||||
|
||||
out, err := CompileListFilter(query, formatter(t))
|
||||
require.NoError(t, err, "reserved key %q failed to compile", key)
|
||||
assert.False(t, out.IsEmpty(), "reserved key %q compiled to empty SQL", key)
|
||||
}
|
||||
}
|
||||
|
||||
func formatter(t *testing.T) sqlstore.SQLFormatter {
|
||||
t.Helper()
|
||||
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
|
||||
return p.Formatter()
|
||||
}
|
||||
|
||||
func normalizeSQL(s string) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
s = strings.ReplaceAll(s, "( ", "(")
|
||||
s = strings.ReplaceAll(s, " )", ")")
|
||||
return s
|
||||
}
|
||||
@@ -851,6 +851,8 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
|
||||
// initiate response object
|
||||
resp := make([]*ruletypes.GettableRule, 0)
|
||||
|
||||
stateByRuleID := m.snapshotRuleStates()
|
||||
|
||||
for _, s := range storedRules {
|
||||
|
||||
ruleResponse := ruletypes.GettableRule{}
|
||||
@@ -863,11 +865,11 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
|
||||
ruleResponse.Id = s.ID.StringValue()
|
||||
|
||||
// fetch state of rule from memory
|
||||
if rm, ok := m.rules[ruleResponse.Id]; !ok {
|
||||
if state, ok := stateByRuleID[ruleResponse.Id]; !ok {
|
||||
ruleResponse.State = ruletypes.StateDisabled
|
||||
ruleResponse.Disabled = true
|
||||
} else {
|
||||
ruleResponse.State = rm.State()
|
||||
ruleResponse.State = state
|
||||
}
|
||||
ruleResponse.CreatedAt = s.CreatedAt
|
||||
ruleResponse.CreatedBy = &s.CreatedBy
|
||||
@@ -879,6 +881,71 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
|
||||
return &ruletypes.GettableRules{Rules: resp}, nil
|
||||
}
|
||||
|
||||
// ListRules' total counts what is pageable after corrupt-row drops and the states filter.
|
||||
func (m *Manager) ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error) {
|
||||
// validated here too, not just in the handler: non-API callers reach the manager directly
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
states, err := params.GetAlertStates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stateFilter := make(map[ruletypes.AlertState]struct{}, len(states))
|
||||
for _, state := range states {
|
||||
stateFilter[state] = struct{}{}
|
||||
}
|
||||
|
||||
compiled, err := CompileListFilter(params.Query, m.sqlstore.Formatter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedRules, err := m.ruleStore.GetStoredRulesMatching(ctx, claims.OrgID, compiled.SQL, compiled.Args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stateByRuleID := m.snapshotRuleStates()
|
||||
|
||||
listableRules, errByRuleID := ruletypes.NewListableRulesFromStorableRules(storedRules, stateByRuleID, stateFilter)
|
||||
for ruleID, err := range errByRuleID {
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", ruleID), errors.Attr(err))
|
||||
}
|
||||
|
||||
total := int64(len(listableRules))
|
||||
ruletypes.SortListableRules(listableRules, params.Sort, params.Order)
|
||||
|
||||
start := min(params.Offset, len(listableRules))
|
||||
end := min(start+params.Limit, len(listableRules))
|
||||
currentPageRules := listableRules[start:end]
|
||||
|
||||
rawLabels, err := m.ruleStore.GetStoredRuleLabels(ctx, claims.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labelPairs := ruletypes.NewLabelPairsFromRawJSON(rawLabels, ruletypes.MaxListLabelPairs)
|
||||
|
||||
return ruletypes.NewListableRules(currentPageRules, total, labelPairs), nil
|
||||
}
|
||||
|
||||
func (m *Manager) snapshotRuleStates() map[string]ruletypes.AlertState {
|
||||
m.mtx.RLock()
|
||||
defer m.mtx.RUnlock()
|
||||
|
||||
states := make(map[string]ruletypes.AlertState, len(m.rules))
|
||||
for id, rule := range m.rules {
|
||||
states[id] = rule.State()
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -28,6 +29,17 @@ import (
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
)
|
||||
|
||||
func TestManager_ListRules_ValidatesParams(t *testing.T) {
|
||||
m, err := NewManager(&ManagerOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = m.ListRules(context.Background(), &ruletypes.ListRulesParams{Limit: -1})
|
||||
require.ErrorContains(t, err, "invalid limit")
|
||||
|
||||
_, err = m.ListRules(context.Background(), &ruletypes.ListRulesParams{ListFilter: ruletypes.ListFilter{States: []string{"bogus"}}})
|
||||
require.ErrorContains(t, err, `invalid state "bogus"`)
|
||||
}
|
||||
|
||||
func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
|
||||
target := 10.0
|
||||
recovery := 5.0
|
||||
|
||||
@@ -4,6 +4,7 @@ import "net/http"
|
||||
|
||||
type Handler interface {
|
||||
ListRules(http.ResponseWriter, *http.Request)
|
||||
ListRulesV3(http.ResponseWriter, *http.Request)
|
||||
GetRuleByID(http.ResponseWriter, *http.Request)
|
||||
CreateRule(http.ResponseWriter, *http.Request)
|
||||
UpdateRuleByID(http.ResponseWriter, *http.Request)
|
||||
@@ -11,6 +12,11 @@ type Handler interface {
|
||||
PatchRuleByID(http.ResponseWriter, *http.Request)
|
||||
TestRule(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListRuleViews(http.ResponseWriter, *http.Request)
|
||||
CreateRuleView(http.ResponseWriter, *http.Request)
|
||||
UpdateRuleView(http.ResponseWriter, *http.Request)
|
||||
DeleteRuleView(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListDowntimeSchedules(http.ResponseWriter, *http.Request)
|
||||
GetDowntimeScheduleByID(http.ResponseWriter, *http.Request)
|
||||
CreateDowntimeSchedule(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -17,6 +17,9 @@ type Ruler interface {
|
||||
// ListRuleStates returns all rules with their current evaluation state.
|
||||
ListRuleStates(ctx context.Context) (*ruletypes.GettableRules, error)
|
||||
|
||||
// ListRules returns a filtered, sorted page of rules with state, plus label pairs and reserved filter keys.
|
||||
ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error)
|
||||
|
||||
// GetRule returns a single rule by ID.
|
||||
GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
|
||||
|
||||
@@ -46,4 +49,9 @@ type Ruler interface {
|
||||
// TODO: expose downtime CRUD as methods on Ruler directly instead of leaking the
|
||||
// store interface. The handler should not call store methods directly.
|
||||
MaintenanceStore() alertmanagertypes.MaintenanceStore
|
||||
|
||||
CreateRuleView(ctx context.Context, orgID valuer.UUID, postable ruletypes.PostableRuleView) (*ruletypes.GettableRuleView, error)
|
||||
ListRuleViews(ctx context.Context, orgID valuer.UUID) (*ruletypes.ListableRuleViews, error)
|
||||
UpdateRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable ruletypes.UpdatableRuleView) (*ruletypes.GettableRuleView, error)
|
||||
DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
}
|
||||
|
||||
@@ -64,6 +64,16 @@ func (m *MockSQLRuleStore) GetStoredRules(ctx context.Context, orgID string) ([]
|
||||
return m.ruleStore.GetStoredRules(ctx, orgID)
|
||||
}
|
||||
|
||||
// GetStoredRulesMatching implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
|
||||
func (m *MockSQLRuleStore) GetStoredRulesMatching(ctx context.Context, orgID string, filterSQL string, filterArgs []any) ([]*ruletypes.StorableRule, error) {
|
||||
return m.ruleStore.GetStoredRulesMatching(ctx, orgID, filterSQL, filterArgs)
|
||||
}
|
||||
|
||||
// GetStoredRuleLabels implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
|
||||
func (m *MockSQLRuleStore) GetStoredRuleLabels(ctx context.Context, orgID string) ([]string, error) {
|
||||
return m.ruleStore.GetStoredRuleLabels(ctx, orgID)
|
||||
}
|
||||
|
||||
// GetStoredRulesByMetricName implements ruletypes.RuleStore - delegates to underlying ruleStore.
|
||||
func (m *MockSQLRuleStore) GetStoredRulesByMetricName(ctx context.Context, orgID string, metricName string) ([]ruletypes.RuleAlert, error) {
|
||||
return m.ruleStore.GetStoredRulesByMetricName(ctx, orgID, metricName)
|
||||
|
||||
@@ -3,6 +3,7 @@ package sqlrulestore
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
@@ -89,6 +90,41 @@ func (r *rule) DeleteRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rule) GetStoredRulesMatching(ctx context.Context, orgID string, filterSQL string, filterArgs []any) ([]*ruletypes.StorableRule, error) {
|
||||
rules := make([]*ruletypes.StorableRule, 0)
|
||||
q := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID)
|
||||
if filterSQL != "" {
|
||||
q = q.Where(filterSQL, filterArgs...)
|
||||
}
|
||||
if err := q.Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (r *rule) GetStoredRuleLabels(ctx context.Context, orgID string) ([]string, error) {
|
||||
labelsExpression := string(r.sqlstore.Formatter().JSONExtractString("rule.data", "$.labels"))
|
||||
|
||||
labels := make([]string, 0)
|
||||
err := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model((*ruletypes.StorableRule)(nil)).
|
||||
ColumnExpr(fmt.Sprintf("COALESCE(%s, '')", labelsExpression)).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx, &labels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func (r *rule) GetStoredRules(ctx context.Context, orgID string) ([]*ruletypes.StorableRule, error) {
|
||||
rules := make([]*ruletypes.StorableRule, 0)
|
||||
err := r.sqlstore.
|
||||
|
||||
93
pkg/ruler/rulestore/sqlrulestore/rule_view.go
Normal file
93
pkg/ruler/rulestore/sqlrulestore/rule_view.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlrulestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (r *rule) CreateRuleView(ctx context.Context, view *ruletypes.StorableRuleView) error {
|
||||
_, err := r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(view).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return r.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "rule view with id %s already exists", view.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rule) GetRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*ruletypes.StorableRuleView, error) {
|
||||
view := new(ruletypes.StorableRuleView)
|
||||
err := r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(view).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, r.sqlstore.WrapNotFoundErrf(err, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", id)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (r *rule) ListRuleViews(ctx context.Context, orgID valuer.UUID) ([]*ruletypes.StorableRuleView, error) {
|
||||
views := make([]*ruletypes.StorableRuleView, 0)
|
||||
err := r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&views).
|
||||
Where("org_id = ?", orgID).
|
||||
OrderExpr("updated_at DESC").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list rule views")
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (r *rule) UpdateRuleView(ctx context.Context, view *ruletypes.StorableRuleView) error {
|
||||
res, err := r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(view).
|
||||
WherePK().
|
||||
Where("org_id = ?", view.OrgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't update rule view")
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read rule view update result")
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", view.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rule) DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
res, err := r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(ruletypes.StorableRuleView)).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete rule view")
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read rule view delete result")
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -43,6 +43,29 @@ func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
|
||||
render.Success(rw, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
params := new(ruletypes.ListRulesParams)
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), params); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
listableRules, err := handler.ruler.ListRules(ctx, params)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, listableRules)
|
||||
}
|
||||
|
||||
func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -322,3 +345,122 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListRuleViews(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.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
|
||||
}
|
||||
|
||||
views, err := handler.ruler.ListRuleViews(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, views)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateRuleView(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.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
|
||||
}
|
||||
|
||||
var postable ruletypes.PostableRuleView
|
||||
if err := binding.JSON.BindBody(req.Body, &postable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.ruler.CreateRuleView(ctx, orgID, postable)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, view)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateRuleView(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.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, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
var updatable ruletypes.UpdatableRuleView
|
||||
if err := binding.JSON.BindBody(req.Body, &updatable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.ruler.UpdateRuleView(ctx, orgID, id, updatable)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteRuleView(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.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, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.ruler.DeleteRuleView(ctx, orgID, id); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package signozruler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
@@ -26,6 +28,7 @@ import (
|
||||
type provider struct {
|
||||
manager *rules.Manager
|
||||
ruleStore ruletypes.RuleStore
|
||||
logger *slog.Logger
|
||||
stopC chan struct{}
|
||||
healthyC chan struct{}
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func NewFactory(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &provider{manager: manager, ruleStore: ruleStore, stopC: make(chan struct{}), healthyC: make(chan struct{})}, nil
|
||||
return &provider{manager: manager, ruleStore: ruleStore, logger: providerSettings.Logger, stopC: make(chan struct{}), healthyC: make(chan struct{})}, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,6 +119,10 @@ func (provider *provider) ListRuleStates(ctx context.Context) (*ruletypes.Gettab
|
||||
return provider.manager.ListRuleStates(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error) {
|
||||
return provider.manager.ListRules(ctx, params)
|
||||
}
|
||||
|
||||
func (provider *provider) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
|
||||
return provider.manager.GetRule(ctx, id)
|
||||
}
|
||||
@@ -143,3 +150,50 @@ func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUI
|
||||
func (provider *provider) MaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
return provider.manager.MaintenanceStore()
|
||||
}
|
||||
|
||||
func (provider *provider) CreateRuleView(ctx context.Context, orgID valuer.UUID, postable ruletypes.PostableRuleView) (*ruletypes.GettableRuleView, error) {
|
||||
if err := postable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storable, err := postable.ToStorableRuleView(orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := provider.ruleStore.CreateRuleView(ctx, storable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storable.ToGettableRuleView()
|
||||
}
|
||||
|
||||
func (provider *provider) ListRuleViews(ctx context.Context, orgID valuer.UUID) (*ruletypes.ListableRuleViews, error) {
|
||||
storables, err := provider.ruleStore.ListRuleViews(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views, errByViewID := ruletypes.NewGettableRuleViewsFromStorableRuleViews(storables)
|
||||
for viewID, err := range errByViewID {
|
||||
provider.logger.ErrorContext(ctx, "failed to decode rule view from db", slog.String("rule_view_id", viewID), errors.Attr(err))
|
||||
}
|
||||
return &ruletypes.ListableRuleViews{Views: views}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) UpdateRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable ruletypes.UpdatableRuleView) (*ruletypes.GettableRuleView, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storable, err := provider.ruleStore.GetRuleView(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := storable.Update(updatable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := provider.ruleStore.UpdateRuleView(ctx, storable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storable.ToGettableRuleView()
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return provider.ruleStore.DeleteRuleView(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddCloudIntegrationTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddNotificationChannelTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddAIObservabilityQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
78
pkg/sqlmigration/131_add_rule_view.go
Normal file
78
pkg/sqlmigration/131_add_rule_view.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addRuleView struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddRuleViewFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_rule_view"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addRuleView{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addRuleView) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addRuleView) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "rule_view",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "data", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.NewCreateIndex().
|
||||
Table("rule_view").
|
||||
Column("org_id").
|
||||
Index("idx_rule_view_org_id").
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addRuleView) Down(_ context.Context, _ *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package sqlitesqlstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
@@ -25,6 +26,12 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
|
||||
// Quote the key as one path segment; a double quote in it is inexpressible in sqlite JSON paths.
|
||||
escapedKey := strings.NewReplacer(`\`, `\\`).Replace(key)
|
||||
return f.JSONExtractString(column, fmt.Sprintf(`$.%s."%s"`, mapField, escapedKey))
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_type("...)
|
||||
|
||||
@@ -55,6 +55,60 @@ func TestJSONExtractString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONExtractMapValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
mapField string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "PlainKey",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "team",
|
||||
expected: `json_extract("data", '$.labels."team"')`,
|
||||
},
|
||||
{
|
||||
name: "DottedKey_OneMapEntry",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "k8s.cluster",
|
||||
expected: `json_extract("data", '$.labels."k8s.cluster"')`,
|
||||
},
|
||||
{
|
||||
name: "BackslashInKey_Escaped",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: `a\b`,
|
||||
expected: `json_extract("data", '$.labels."a\\b"')`,
|
||||
},
|
||||
{
|
||||
name: "SingleQuoteInKey_Doubled",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "o'brien",
|
||||
expected: `json_extract("data", '$.labels."o''brien"')`,
|
||||
},
|
||||
{
|
||||
name: "QualifiedColumn",
|
||||
column: "rule.data",
|
||||
mapField: "labels",
|
||||
key: "severity",
|
||||
expected: `json_extract("rule"."data", '$.labels."severity"')`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -114,6 +114,9 @@ type SQLFormatter interface {
|
||||
// JSONKeys return extracted key from json as well as alias to be used for select and where clause
|
||||
JSONKeys(column, path, alias string) ([]byte, []byte)
|
||||
|
||||
// JSONExtractMapValue extracts one key's value from a JSON object field; dots in the key are not path nesting.
|
||||
JSONExtractMapValue(column, mapField, key string) []byte
|
||||
|
||||
// TextToJsonColumn converts a text column to JSON type
|
||||
TextToJsonColumn(column string) []byte
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sqlstoretest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
@@ -25,6 +26,11 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
|
||||
escapedKey := strings.NewReplacer(`\`, `\\`).Replace(key)
|
||||
return f.JSONExtractString(column, fmt.Sprintf(`$.%s."%s"`, mapField, escapedKey))
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_type("...)
|
||||
|
||||
62
pkg/sqlstore/sqlstoretest/formatter_test.go
Normal file
62
pkg/sqlstore/sqlstoretest/formatter_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package sqlstoretest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
)
|
||||
|
||||
func TestJSONExtractMapValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
mapField string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "PlainKey",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "team",
|
||||
expected: `json_extract("data", '$.labels."team"')`,
|
||||
},
|
||||
{
|
||||
name: "DottedKey_OneMapEntry",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "k8s.cluster",
|
||||
expected: `json_extract("data", '$.labels."k8s.cluster"')`,
|
||||
},
|
||||
{
|
||||
name: "BackslashInKey_Escaped",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: `a\b`,
|
||||
expected: `json_extract("data", '$.labels."a\\b"')`,
|
||||
},
|
||||
{
|
||||
name: "SingleQuoteInKey_Doubled",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "o'brien",
|
||||
expected: `json_extract("data", '$.labels."o''brien"')`,
|
||||
},
|
||||
{
|
||||
name: "QualifiedColumn",
|
||||
column: "rule.data",
|
||||
mapField: "labels",
|
||||
key: "severity",
|
||||
expected: `json_extract("rule"."data", '$.labels."severity"')`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,18 @@ var alertStateSeverity = map[AlertState]int{
|
||||
func (a AlertState) Severity() int {
|
||||
return alertStateSeverity[a]
|
||||
}
|
||||
|
||||
// Display priority for list sorting, worst first from a user's view; deliberately
|
||||
// NOT Severity(), which ranks disabled/nodata above firing for overall-state computation.
|
||||
var alertStateDisplayRank = map[AlertState]int{
|
||||
StateFiring: 5,
|
||||
StateNoData: 4,
|
||||
StatePending: 3,
|
||||
StateRecovering: 2,
|
||||
StateInactive: 1,
|
||||
StateDisabled: 0,
|
||||
}
|
||||
|
||||
func (a AlertState) DisplayRank() int {
|
||||
return alertStateDisplayRank[a]
|
||||
}
|
||||
|
||||
21
pkg/types/ruletypes/alert_state_test.go
Normal file
21
pkg/types/ruletypes/alert_state_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Both rankings must stay exhaustive: a new AlertState needs an entry in each.
|
||||
func TestAlertStateRankingsAreExhaustive(t *testing.T) {
|
||||
states := AlertState{}.Enum()
|
||||
|
||||
assert.Len(t, alertStateSeverity, len(states))
|
||||
assert.Len(t, alertStateDisplayRank, len(states))
|
||||
|
||||
for _, s := range states {
|
||||
state := s.(AlertState)
|
||||
assert.Contains(t, alertStateSeverity, state, "missing severity for state %q", state)
|
||||
assert.Contains(t, alertStateDisplayRank, state, "missing display rank for state %q", state)
|
||||
}
|
||||
}
|
||||
147
pkg/types/ruletypes/list.go
Normal file
147
pkg/types/ruletypes/list.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultListLimit = 20
|
||||
MaxListLimit = 200
|
||||
MaxListQueryLen = 1024
|
||||
)
|
||||
|
||||
var ErrCodeRuleListInvalid = errors.MustNewCode("rule_list_invalid")
|
||||
|
||||
type ListSort struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ListSortUpdatedAt = ListSort{valuer.NewString("updated_at")}
|
||||
ListSortCreatedAt = ListSort{valuer.NewString("created_at")}
|
||||
ListSortName = ListSort{valuer.NewString("name")}
|
||||
ListSortState = ListSort{valuer.NewString("state")}
|
||||
ListSortSeverity = ListSort{valuer.NewString("severity")}
|
||||
)
|
||||
|
||||
func (ListSort) Enum() []any {
|
||||
return []any{ListSortUpdatedAt, ListSortCreatedAt, ListSortName, ListSortState, ListSortSeverity}
|
||||
}
|
||||
|
||||
func (s ListSort) IsValid() bool {
|
||||
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
|
||||
}
|
||||
|
||||
type ListOrder struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ListOrderAsc = ListOrder{valuer.NewString("asc")}
|
||||
ListOrderDesc = ListOrder{valuer.NewString("desc")}
|
||||
)
|
||||
|
||||
func (ListOrder) Enum() []any {
|
||||
return []any{ListOrderAsc, ListOrderDesc}
|
||||
}
|
||||
|
||||
func (o ListOrder) IsValid() bool {
|
||||
return slices.ContainsFunc(o.Enum(), func(v any) bool { return v == o })
|
||||
}
|
||||
|
||||
// ListFilter is the rule listing state shared by the v3 list params and saved views.
|
||||
type ListFilter struct {
|
||||
Query string `query:"query" json:"query"`
|
||||
// gin cannot bind a slice of valuer enums; GetAlertStates converts these.
|
||||
States []string `query:"states" json:"states" nullable:"false"`
|
||||
Sort ListSort `query:"sort" json:"sort"`
|
||||
Order ListOrder `query:"order" json:"order"`
|
||||
}
|
||||
|
||||
// Validate normalizes in place; zero sort/order get the defaults, nil states an empty slice.
|
||||
func (f *ListFilter) Validate() error {
|
||||
if f.States == nil {
|
||||
f.States = []string{}
|
||||
}
|
||||
|
||||
if n := utf8.RuneCountInString(f.Query); n > MaxListQueryLen {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
|
||||
"query cannot be longer than %d characters, got %d", MaxListQueryLen, n)
|
||||
}
|
||||
|
||||
if _, err := f.GetAlertStates(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.Sort.IsZero() {
|
||||
f.Sort = ListSortUpdatedAt
|
||||
} else if !f.Sort.IsValid() {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
|
||||
"invalid sort %q, expected one of: `updated_at`, `created_at`, `name`, `state`, `severity`", f.Sort)
|
||||
}
|
||||
|
||||
if f.Order.IsZero() {
|
||||
f.Order = ListOrderDesc
|
||||
} else if !f.Order.IsValid() {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
|
||||
"invalid order %q, expected `asc` or `desc`", f.Order)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAlertStates parses States; empty means no state filtering.
|
||||
func (f *ListFilter) GetAlertStates() ([]AlertState, error) {
|
||||
if len(f.States) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
states := make([]AlertState, 0, len(f.States))
|
||||
for _, raw := range f.States {
|
||||
state, err := parseAlertState(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states = append(states, state)
|
||||
}
|
||||
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func parseAlertState(raw string) (AlertState, error) {
|
||||
state := AlertState{valuer.NewString(raw)}
|
||||
if !slices.Contains(state.Enum(), any(state)) {
|
||||
return AlertState{}, errors.NewInvalidInputf(ErrCodeRuleListInvalid,
|
||||
"invalid state %q, expected one of: `firing`, `pending`, `recovering`, `inactive`, `nodata`, `disabled`", raw)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
type ListRulesParams struct {
|
||||
ListFilter
|
||||
Limit int `query:"limit"`
|
||||
Offset int `query:"offset"`
|
||||
}
|
||||
|
||||
// Validate normalizes in place; an over-max limit is clamped, not rejected.
|
||||
func (p *ListRulesParams) Validate() error {
|
||||
if err := p.ListFilter.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.Limit == 0 {
|
||||
p.Limit = DefaultListLimit
|
||||
} else if p.Limit < 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
|
||||
"invalid limit %d, must be a positive integer", p.Limit)
|
||||
} else if p.Limit > MaxListLimit {
|
||||
p.Limit = MaxListLimit
|
||||
}
|
||||
|
||||
if p.Offset < 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
|
||||
"invalid offset %d, must be a non-negative integer", p.Offset)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
100
pkg/types/ruletypes/list_filter.go
Normal file
100
pkg/types/ruletypes/list_filter.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
var ErrCodeRuleListFilterInvalid = errors.MustNewCode("rule_list_filter_invalid")
|
||||
|
||||
// DSLKey is a reserved (column-level) key in the rule list filter DSL.
|
||||
type DSLKey string
|
||||
|
||||
const (
|
||||
DSLKeyName DSLKey = "name"
|
||||
DSLKeySeverity DSLKey = "severity"
|
||||
DSLKeyCreatedBy DSLKey = "created_by"
|
||||
DSLKeyUpdatedBy DSLKey = "updated_by"
|
||||
DSLKeyCreatedAt DSLKey = "created_at"
|
||||
DSLKeyUpdatedAt DSLKey = "updated_at"
|
||||
DSLKeyAlertType DSLKey = "alert_type"
|
||||
DSLKeyRuleType DSLKey = "rule_type"
|
||||
|
||||
// Label keys under this prefix are matched exactly (case-sensitive).
|
||||
DSLLabelsKeyPrefix = "labels."
|
||||
|
||||
// Advertised in reservedKeywords; not itself a filterable key.
|
||||
DSLKeyLabelsPlaceholder DSLKey = "labels.<key>"
|
||||
)
|
||||
|
||||
func ReservedFilterKeys() []DSLKey {
|
||||
keys := make([]DSLKey, 0, len(ReservedOps)+1)
|
||||
for key := range ReservedOps {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
keys = append(keys, DSLKeyLabelsPlaceholder)
|
||||
slices.SortFunc(keys, func(a, b DSLKey) int {
|
||||
return strings.Compare(string(a), string(b))
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
// ReservedOps lists the operators each reserved DSL key accepts; `labels.<key>` terms use LabelsKeyOps.
|
||||
var ReservedOps = map[DSLKey]map[qbtypesv5.FilterOperator]struct{}{
|
||||
DSLKeyName: stringSearchOps(),
|
||||
// severity aliases labels.severity, so it takes the labels operator set.
|
||||
DSLKeySeverity: LabelsKeyOps,
|
||||
DSLKeyCreatedBy: stringSearchOps(),
|
||||
DSLKeyUpdatedBy: stringSearchOps(),
|
||||
DSLKeyCreatedAt: numericRangeOps(),
|
||||
DSLKeyUpdatedAt: numericRangeOps(),
|
||||
DSLKeyAlertType: enumOps(),
|
||||
DSLKeyRuleType: enumOps(),
|
||||
}
|
||||
|
||||
// LabelsKeyOps operators target the label's value; EXISTS/NOT EXISTS test its presence.
|
||||
var LabelsKeyOps = opsSet(
|
||||
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
|
||||
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
|
||||
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
|
||||
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
|
||||
qbtypesv5.FilterOperatorExists, qbtypesv5.FilterOperatorNotExists,
|
||||
)
|
||||
|
||||
func stringSearchOps() map[qbtypesv5.FilterOperator]struct{} {
|
||||
return opsSet(
|
||||
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
|
||||
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
|
||||
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
|
||||
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
|
||||
)
|
||||
}
|
||||
|
||||
func numericRangeOps() map[qbtypesv5.FilterOperator]struct{} {
|
||||
return opsSet(
|
||||
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
|
||||
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq,
|
||||
qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween,
|
||||
)
|
||||
}
|
||||
|
||||
func enumOps() map[qbtypesv5.FilterOperator]struct{} {
|
||||
return opsSet(
|
||||
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
|
||||
)
|
||||
}
|
||||
|
||||
func opsSet(ops ...qbtypesv5.FilterOperator) map[qbtypesv5.FilterOperator]struct{} {
|
||||
m := make(map[qbtypesv5.FilterOperator]struct{}, len(ops))
|
||||
for _, op := range ops {
|
||||
m[op] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
34
pkg/types/ruletypes/list_filter_test.go
Normal file
34
pkg/types/ruletypes/list_filter_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReservedFilterKeys(t *testing.T) {
|
||||
assert.Equal(t, []DSLKey{
|
||||
DSLKeyAlertType,
|
||||
DSLKeyCreatedAt,
|
||||
DSLKeyCreatedBy,
|
||||
DSLKeyLabelsPlaceholder,
|
||||
DSLKeyName,
|
||||
DSLKeyRuleType,
|
||||
DSLKeySeverity,
|
||||
DSLKeyUpdatedAt,
|
||||
DSLKeyUpdatedBy,
|
||||
}, ReservedFilterKeys())
|
||||
}
|
||||
|
||||
func TestFilterOpsExcludeRegexp(t *testing.T) {
|
||||
for key, ops := range ReservedOps {
|
||||
assert.NotEmpty(t, ops, "key %q has no operators", key)
|
||||
assert.NotContains(t, ops, qbtypesv5.FilterOperatorRegexp, "key %q allows REGEXP", key)
|
||||
assert.NotContains(t, ops, qbtypesv5.FilterOperatorNotRegexp, "key %q allows NOT REGEXP", key)
|
||||
}
|
||||
assert.NotContains(t, LabelsKeyOps, qbtypesv5.FilterOperatorRegexp)
|
||||
assert.NotContains(t, LabelsKeyOps, qbtypesv5.FilterOperatorNotRegexp)
|
||||
assert.Contains(t, LabelsKeyOps, qbtypesv5.FilterOperatorExists)
|
||||
assert.Contains(t, LabelsKeyOps, qbtypesv5.FilterOperatorNotExists)
|
||||
}
|
||||
126
pkg/types/ruletypes/list_test.go
Normal file
126
pkg/types/ruletypes/list_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListRulesParamsValidate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
params ListRulesParams
|
||||
wantErr string
|
||||
wantSort ListSort
|
||||
wantOrder ListOrder
|
||||
wantLimit int
|
||||
}{
|
||||
{
|
||||
name: "EmptyParams_Defaults",
|
||||
params: ListRulesParams{},
|
||||
wantSort: ListSortUpdatedAt,
|
||||
wantOrder: ListOrderDesc,
|
||||
wantLimit: DefaultListLimit,
|
||||
},
|
||||
{
|
||||
name: "ExplicitValues_Kept",
|
||||
params: ListRulesParams{ListFilter: ListFilter{Sort: ListSortSeverity, Order: ListOrderAsc}, Limit: 50, Offset: 100},
|
||||
wantSort: ListSortSeverity,
|
||||
wantOrder: ListOrderAsc,
|
||||
wantLimit: 50,
|
||||
},
|
||||
{
|
||||
name: "OverMaxLimit_Clamped",
|
||||
params: ListRulesParams{Limit: MaxListLimit + 1},
|
||||
wantSort: ListSortUpdatedAt,
|
||||
wantOrder: ListOrderDesc,
|
||||
wantLimit: MaxListLimit,
|
||||
},
|
||||
{
|
||||
name: "InvalidState_Rejected",
|
||||
params: ListRulesParams{ListFilter: ListFilter{States: []string{"bogus"}}},
|
||||
wantErr: `invalid state "bogus"`,
|
||||
},
|
||||
{
|
||||
name: "InvalidSort_Rejected",
|
||||
params: ListRulesParams{ListFilter: ListFilter{Sort: ListSort{valuer.NewString("bogus")}}},
|
||||
wantErr: "invalid sort",
|
||||
},
|
||||
{
|
||||
name: "InvalidOrder_Rejected",
|
||||
params: ListRulesParams{ListFilter: ListFilter{Order: ListOrder{valuer.NewString("bogus")}}},
|
||||
wantErr: "invalid order",
|
||||
},
|
||||
{
|
||||
name: "NegativeLimit_Rejected",
|
||||
params: ListRulesParams{Limit: -1},
|
||||
wantErr: "invalid limit",
|
||||
},
|
||||
{
|
||||
name: "NegativeOffset_Rejected",
|
||||
params: ListRulesParams{Offset: -1},
|
||||
wantErr: "invalid offset",
|
||||
},
|
||||
{
|
||||
name: "OverLongQuery_Rejected",
|
||||
params: ListRulesParams{ListFilter: ListFilter{Query: strings.Repeat("a", MaxListQueryLen+1)}},
|
||||
wantErr: "query cannot be longer",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.params.Validate()
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantSort, tc.params.Sort)
|
||||
assert.Equal(t, tc.wantOrder, tc.params.Order)
|
||||
assert.Equal(t, tc.wantLimit, tc.params.Limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRulesParamsAlertStates(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
states []string
|
||||
wantErr string
|
||||
wantStates []AlertState
|
||||
}{
|
||||
{
|
||||
name: "ValidStates_ParsedToTypedValues",
|
||||
states: []string{"firing", "pending"},
|
||||
wantStates: []AlertState{StateFiring, StatePending},
|
||||
},
|
||||
{
|
||||
name: "AbsentStates_NoFiltering",
|
||||
states: nil,
|
||||
},
|
||||
{
|
||||
name: "InvalidState_Rejected",
|
||||
states: []string{"bogus"},
|
||||
wantErr: `invalid state "bogus"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
params := ListRulesParams{ListFilter: ListFilter{States: tc.states}}
|
||||
states, err := params.GetAlertStates()
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantStates, states)
|
||||
})
|
||||
}
|
||||
}
|
||||
190
pkg/types/ruletypes/listable_rule.go
Normal file
190
pkg/types/ruletypes/listable_rule.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
)
|
||||
|
||||
const MaxListLabelPairs = 1000
|
||||
|
||||
// ListableRule is the slim per-row shape of the list endpoint; the full rule stays behind get-by-id.
|
||||
type ListableRule struct {
|
||||
Id string `json:"id" required:"true"`
|
||||
State AlertState `json:"state" required:"true"`
|
||||
AlertName string `json:"alert" required:"true"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AlertType AlertType `json:"alertType" required:"true"`
|
||||
RuleType RuleType `json:"ruleType" required:"true"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
}
|
||||
|
||||
// storedRuleData is the subset of the persisted rule data blob the list page needs.
|
||||
type storedRuleData struct {
|
||||
AlertName string `json:"alert"`
|
||||
Description string `json:"description"`
|
||||
AlertType AlertType `json:"alertType"`
|
||||
RuleType RuleType `json:"ruleType"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
|
||||
// ToListableRule leaves State zero; the caller overlays evaluation state.
|
||||
func (rule *StorableRule) ToListableRule() (*ListableRule, error) {
|
||||
data := storedRuleData{}
|
||||
if err := json.Unmarshal([]byte(rule.Data), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListableRule{
|
||||
Id: rule.ID.StringValue(),
|
||||
AlertName: data.AlertName,
|
||||
Description: data.Description,
|
||||
AlertType: data.AlertType,
|
||||
RuleType: data.RuleType,
|
||||
Disabled: data.Disabled,
|
||||
Labels: data.Labels,
|
||||
TimeAuditable: rule.TimeAuditable,
|
||||
UserAuditable: rule.UserAuditable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewListableRulesFromStorableRules converts rows, overlays evaluation state (absent means
|
||||
// disabled) and applies the state filter; corrupt rows come back keyed by rule id for the
|
||||
// caller to log.
|
||||
func NewListableRulesFromStorableRules(storedRules []*StorableRule, stateByRuleID map[string]AlertState, stateFilter map[AlertState]struct{}) ([]*ListableRule, map[string]error) {
|
||||
listableRules := make([]*ListableRule, 0, len(storedRules))
|
||||
errByRuleID := make(map[string]error)
|
||||
|
||||
for _, rule := range storedRules {
|
||||
listable, err := rule.ToListableRule()
|
||||
if err != nil {
|
||||
errByRuleID[rule.ID.StringValue()] = err
|
||||
continue
|
||||
}
|
||||
|
||||
if state, ok := stateByRuleID[listable.Id]; ok {
|
||||
listable.State = state
|
||||
} else {
|
||||
listable.State = StateDisabled
|
||||
listable.Disabled = true
|
||||
}
|
||||
if len(stateFilter) > 0 {
|
||||
if _, ok := stateFilter[listable.State]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
listableRules = append(listableRules, listable)
|
||||
}
|
||||
|
||||
return listableRules, errByRuleID
|
||||
}
|
||||
|
||||
// LabelPair is one distinct label key/value observed on the org's rules.
|
||||
type LabelPair struct {
|
||||
Key string `json:"key" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
}
|
||||
|
||||
type ListableRules struct {
|
||||
Rules []*ListableRule `json:"rules" required:"true" nullable:"false"`
|
||||
Total int64 `json:"total" required:"true"`
|
||||
Labels []LabelPair `json:"labels" required:"true" nullable:"false"`
|
||||
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func NewListableRules(rules []*ListableRule, total int64, labels []LabelPair) *ListableRules {
|
||||
return &ListableRules{
|
||||
Rules: rules,
|
||||
Total: total,
|
||||
Labels: labels,
|
||||
ReservedKeywords: ReservedFilterKeys(),
|
||||
}
|
||||
}
|
||||
|
||||
var severityDisplayRank = map[string]int{
|
||||
"critical": 4,
|
||||
"error": 3,
|
||||
"warning": 2,
|
||||
"info": 1,
|
||||
}
|
||||
|
||||
// Ties break on name then id ascending (order applies to the primary key only) so pages stay stable.
|
||||
func SortListableRules(rules []*ListableRule, sortBy ListSort, order ListOrder) {
|
||||
direction := 1
|
||||
if order == ListOrderDesc {
|
||||
direction = -1
|
||||
}
|
||||
slices.SortStableFunc(rules, func(a, b *ListableRule) int {
|
||||
if c := direction * compareListableRules(a, b, sortBy); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := strings.Compare(strings.ToLower(a.AlertName), strings.ToLower(b.AlertName)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Id, b.Id)
|
||||
})
|
||||
}
|
||||
|
||||
func compareListableRules(a, b *ListableRule, sortBy ListSort) int {
|
||||
switch sortBy {
|
||||
case ListSortName:
|
||||
return strings.Compare(strings.ToLower(a.AlertName), strings.ToLower(b.AlertName))
|
||||
case ListSortCreatedAt:
|
||||
return a.CreatedAt.Compare(b.CreatedAt)
|
||||
case ListSortState:
|
||||
return cmp.Compare(a.State.DisplayRank(), b.State.DisplayRank())
|
||||
case ListSortSeverity:
|
||||
severityA := a.Labels["severity"]
|
||||
severityB := b.Labels["severity"]
|
||||
rankA := severityDisplayRank[strings.ToLower(severityA)]
|
||||
rankB := severityDisplayRank[strings.ToLower(severityB)]
|
||||
if rankA != rankB {
|
||||
return cmp.Compare(rankA, rankB)
|
||||
}
|
||||
if rankA == 0 {
|
||||
return strings.Compare(strings.ToLower(severityA), strings.ToLower(severityB))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return a.UpdatedAt.Compare(b.UpdatedAt)
|
||||
}
|
||||
|
||||
// NewLabelPairsFromRawJSON skips blank or malformed entries and caps the result at limit.
|
||||
func NewLabelPairsFromRawJSON(raws []string, limit int) []LabelPair {
|
||||
set := make(map[LabelPair]struct{})
|
||||
for _, raw := range raws {
|
||||
if raw == "" || raw == "null" {
|
||||
continue
|
||||
}
|
||||
labels := make(map[string]string)
|
||||
if err := json.Unmarshal([]byte(raw), &labels); err != nil {
|
||||
continue
|
||||
}
|
||||
for key, value := range labels {
|
||||
set[LabelPair{Key: key, Value: value}] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
pairs := make([]LabelPair, 0, len(set))
|
||||
for pair := range set {
|
||||
pairs = append(pairs, pair)
|
||||
}
|
||||
slices.SortFunc(pairs, func(a, b LabelPair) int {
|
||||
if c := strings.Compare(a.Key, b.Key); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.Value, b.Value)
|
||||
})
|
||||
if len(pairs) > limit {
|
||||
pairs = pairs[:limit]
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
261
pkg/types/ruletypes/listable_rule_test.go
Normal file
261
pkg/types/ruletypes/listable_rule_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func listableRule(name string, state AlertState, severity string, updatedAt time.Time) *ListableRule {
|
||||
rule := &ListableRule{
|
||||
AlertName: name,
|
||||
State: state,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
}
|
||||
if severity != "" {
|
||||
rule.Labels = map[string]string{"severity": severity}
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func names(rules []*ListableRule) []string {
|
||||
out := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
out = append(out, rule.AlertName)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestToListableRule(t *testing.T) {
|
||||
created := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
updated := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
storable := &StorableRule{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: created, UpdatedAt: updated},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: "creator@signoz.io", UpdatedBy: "updater@signoz.io"},
|
||||
Data: `{"alert":"High CPU","description":"cpu is hot","alertType":"METRIC_BASED_ALERT","ruleType":"threshold_rule","disabled":true,"labels":{"severity":"critical"}}`,
|
||||
}
|
||||
|
||||
listable, err := storable.ToListableRule()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, storable.ID.StringValue(), listable.Id)
|
||||
assert.Equal(t, "High CPU", listable.AlertName)
|
||||
assert.Equal(t, "cpu is hot", listable.Description)
|
||||
assert.Equal(t, AlertTypeMetric, listable.AlertType)
|
||||
assert.Equal(t, RuleTypeThreshold, listable.RuleType)
|
||||
assert.True(t, listable.Disabled)
|
||||
assert.Equal(t, map[string]string{"severity": "critical"}, listable.Labels)
|
||||
assert.Equal(t, created, listable.CreatedAt)
|
||||
assert.Equal(t, "creator@signoz.io", listable.CreatedBy)
|
||||
assert.Equal(t, updated, listable.UpdatedAt)
|
||||
assert.Equal(t, "updater@signoz.io", listable.UpdatedBy)
|
||||
assert.True(t, listable.State.IsZero())
|
||||
|
||||
_, err = (&StorableRule{Data: "not json"}).ToListableRule()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewListableRulesFromStorableRules(t *testing.T) {
|
||||
enabledID := valuer.GenerateUUID()
|
||||
pausedID := valuer.GenerateUUID()
|
||||
corruptID := valuer.GenerateUUID()
|
||||
|
||||
storedRules := []*StorableRule{
|
||||
{
|
||||
Identifiable: types.Identifiable{ID: enabledID},
|
||||
Data: `{"alert":"cpu high","alertType":"METRIC_BASED_ALERT","ruleType":"threshold_rule"}`,
|
||||
},
|
||||
{
|
||||
Identifiable: types.Identifiable{ID: pausedID},
|
||||
Data: `{"alert":"mem high","alertType":"METRIC_BASED_ALERT","ruleType":"threshold_rule","disabled":true}`,
|
||||
},
|
||||
{
|
||||
Identifiable: types.Identifiable{ID: corruptID},
|
||||
Data: "not json",
|
||||
},
|
||||
}
|
||||
stateByRuleID := map[string]AlertState{enabledID.StringValue(): StateFiring}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
stateFilter map[AlertState]struct{}
|
||||
wantNames []string
|
||||
wantStates map[string]AlertState
|
||||
}{
|
||||
{
|
||||
name: "NoFilter_KeepsAllParseableRows",
|
||||
wantNames: []string{"cpu high", "mem high"},
|
||||
wantStates: map[string]AlertState{"cpu high": StateFiring, "mem high": StateDisabled},
|
||||
},
|
||||
{
|
||||
name: "FiringFilter_KeepsOverlaidState",
|
||||
stateFilter: map[AlertState]struct{}{StateFiring: {}},
|
||||
wantNames: []string{"cpu high"},
|
||||
wantStates: map[string]AlertState{"cpu high": StateFiring},
|
||||
},
|
||||
{
|
||||
name: "DisabledFilter_KeepsAbsentFromSnapshot",
|
||||
stateFilter: map[AlertState]struct{}{StateDisabled: {}},
|
||||
wantNames: []string{"mem high"},
|
||||
wantStates: map[string]AlertState{"mem high": StateDisabled},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
listableRules, errByRuleID := NewListableRulesFromStorableRules(storedRules, stateByRuleID, testCase.stateFilter)
|
||||
|
||||
require.Len(t, errByRuleID, 1)
|
||||
assert.Error(t, errByRuleID[corruptID.StringValue()])
|
||||
|
||||
assert.Equal(t, testCase.wantNames, names(listableRules))
|
||||
for _, rule := range listableRules {
|
||||
assert.Equal(t, testCase.wantStates[rule.AlertName], rule.State)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disabledRow, _ := NewListableRulesFromStorableRules(storedRules[1:2], stateByRuleID, nil)
|
||||
require.Len(t, disabledRow, 1)
|
||||
assert.True(t, disabledRow[0].Disabled)
|
||||
}
|
||||
|
||||
func TestSortListableRules(t *testing.T) {
|
||||
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
rules []*ListableRule
|
||||
sortBy ListSort
|
||||
order ListOrder
|
||||
wantNames []string
|
||||
}{
|
||||
{
|
||||
name: "StateDesc_DisplayPriority_FiringFirst",
|
||||
rules: []*ListableRule{
|
||||
listableRule("disabled", StateDisabled, "", base),
|
||||
listableRule("nodata", StateNoData, "", base),
|
||||
listableRule("firing", StateFiring, "", base),
|
||||
listableRule("inactive", StateInactive, "", base),
|
||||
listableRule("pending", StatePending, "", base),
|
||||
listableRule("recovering", StateRecovering, "", base),
|
||||
},
|
||||
sortBy: ListSortState,
|
||||
order: ListOrderDesc,
|
||||
wantNames: []string{"firing", "nodata", "pending", "recovering", "inactive", "disabled"},
|
||||
},
|
||||
{
|
||||
name: "SeverityDesc_KnownRanksThenCustomLexical",
|
||||
rules: []*ListableRule{
|
||||
listableRule("warn", StateInactive, "warning", base),
|
||||
listableRule("custom-b", StateInactive, "bbb", base),
|
||||
listableRule("crit", StateInactive, "critical", base),
|
||||
listableRule("custom-a", StateInactive, "aaa", base),
|
||||
listableRule("none", StateInactive, "", base),
|
||||
},
|
||||
sortBy: ListSortSeverity,
|
||||
order: ListOrderDesc,
|
||||
// desc flips the lexical compare between custom values too
|
||||
wantNames: []string{"crit", "warn", "custom-b", "custom-a", "none"},
|
||||
},
|
||||
{
|
||||
name: "NameAsc_CaseInsensitive",
|
||||
rules: []*ListableRule{
|
||||
listableRule("banana", StateInactive, "", base),
|
||||
listableRule("Apple", StateInactive, "", base),
|
||||
listableRule("cherry", StateInactive, "", base),
|
||||
},
|
||||
sortBy: ListSortName,
|
||||
order: ListOrderAsc,
|
||||
wantNames: []string{"Apple", "banana", "cherry"},
|
||||
},
|
||||
{
|
||||
name: "UpdatedAtDesc_NewestFirst",
|
||||
rules: []*ListableRule{
|
||||
listableRule("old", StateInactive, "", base),
|
||||
listableRule("new", StateInactive, "", base.Add(time.Hour)),
|
||||
},
|
||||
sortBy: ListSortUpdatedAt,
|
||||
order: ListOrderDesc,
|
||||
wantNames: []string{"new", "old"},
|
||||
},
|
||||
{
|
||||
name: "StateDescTies_BreakOnNameAsc",
|
||||
rules: []*ListableRule{
|
||||
listableRule("banana", StateFiring, "", base),
|
||||
listableRule("zebra", StateDisabled, "", base),
|
||||
listableRule("Apple", StateFiring, "", base),
|
||||
listableRule("cherry", StateFiring, "", base),
|
||||
},
|
||||
sortBy: ListSortState,
|
||||
order: ListOrderDesc,
|
||||
wantNames: []string{"Apple", "banana", "cherry", "zebra"},
|
||||
},
|
||||
{
|
||||
name: "StateAsc_FlipsBuckets_TiebreakNameAsc",
|
||||
rules: []*ListableRule{
|
||||
listableRule("banana", StateFiring, "", base),
|
||||
listableRule("zebra", StateDisabled, "", base),
|
||||
listableRule("Apple", StateFiring, "", base),
|
||||
listableRule("cherry", StateFiring, "", base),
|
||||
},
|
||||
sortBy: ListSortState,
|
||||
order: ListOrderAsc,
|
||||
wantNames: []string{"zebra", "Apple", "banana", "cherry"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
SortListableRules(tc.rules, tc.sortBy, tc.order)
|
||||
assert.Equal(t, tc.wantNames, names(tc.rules))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortListableRulesIdTiebreak(t *testing.T) {
|
||||
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, order := range []ListOrder{ListOrderAsc, ListOrderDesc} {
|
||||
t.Run(order.StringValue(), func(t *testing.T) {
|
||||
older := listableRule("dup", StateFiring, "", base)
|
||||
older.Id = "01aaa"
|
||||
newer := listableRule("dup", StateFiring, "", base)
|
||||
newer.Id = "01bbb"
|
||||
|
||||
rules := []*ListableRule{newer, older}
|
||||
SortListableRules(rules, ListSortState, order)
|
||||
assert.Equal(t, []string{"01aaa", "01bbb"}, []string{rules[0].Id, rules[1].Id})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLabelPairsFromRawJSON(t *testing.T) {
|
||||
pairs := NewLabelPairsFromRawJSON([]string{
|
||||
`{"team":"infra","severity":"critical"}`,
|
||||
`{"team":"infra"}`,
|
||||
`{"team":"payments"}`,
|
||||
"",
|
||||
"null",
|
||||
"not-json",
|
||||
}, MaxListLabelPairs)
|
||||
|
||||
assert.Equal(t, []LabelPair{
|
||||
{Key: "severity", Value: "critical"},
|
||||
{Key: "team", Value: "infra"},
|
||||
{Key: "team", Value: "payments"},
|
||||
}, pairs)
|
||||
}
|
||||
|
||||
func TestNewLabelPairsFromRawJSONCap(t *testing.T) {
|
||||
pairs := NewLabelPairsFromRawJSON([]string{`{"a":"1","b":"2","c":"3"}`}, 2)
|
||||
assert.Len(t, pairs, 2)
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
)
|
||||
|
||||
type StorableRule struct {
|
||||
bun.BaseModel `bun:"table:rule"`
|
||||
// The alias must stay rule: the list filter compiler emits rule.<col> refs.
|
||||
bun.BaseModel `bun:"table:rule,alias:rule"`
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
@@ -58,6 +59,16 @@ type RuleStore interface {
|
||||
EditRule(context.Context, *StorableRule, func(context.Context) error) error
|
||||
DeleteRule(context.Context, valuer.UUID, valuer.UUID, func(context.Context) error) error
|
||||
GetStoredRules(context.Context, string) ([]*StorableRule, error)
|
||||
// GetStoredRulesMatching returns the org's rules matching a compiled filter clause; an empty clause matches all.
|
||||
GetStoredRulesMatching(context.Context, string, string, []any) ([]*StorableRule, error)
|
||||
// GetStoredRuleLabels returns each rule's labels as raw JSON text, empty string when absent.
|
||||
GetStoredRuleLabels(context.Context, string) ([]string, error)
|
||||
GetStoredRule(context.Context, valuer.UUID, valuer.UUID) (*StorableRule, error)
|
||||
GetStoredRulesByMetricName(context.Context, string, string) ([]RuleAlert, error)
|
||||
|
||||
CreateRuleView(context.Context, *StorableRuleView) error
|
||||
GetRuleView(context.Context, valuer.UUID, valuer.UUID) (*StorableRuleView, error)
|
||||
ListRuleViews(context.Context, valuer.UUID) ([]*StorableRuleView, error)
|
||||
UpdateRuleView(context.Context, *StorableRuleView) error
|
||||
DeleteRuleView(context.Context, valuer.UUID, valuer.UUID) error
|
||||
}
|
||||
|
||||
163
pkg/types/ruletypes/rule_view.go
Normal file
163
pkg/types/ruletypes/rule_view.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const (
|
||||
RuleViewSchemaVersion = "v1"
|
||||
MaxRuleViewNameLen = 64
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeRuleViewInvalidInput = errors.MustNewCode("rule_view_invalid_input")
|
||||
ErrCodeRuleViewNotFound = errors.MustNewCode("rule_view_not_found")
|
||||
)
|
||||
|
||||
type StorableRuleView struct {
|
||||
bun.BaseModel `bun:"table:rule_view,alias:rule_view"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Data string `bun:"data,type:text,notnull"`
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
}
|
||||
|
||||
func (s *StorableRuleView) ToGettableRuleView() (*GettableRuleView, error) {
|
||||
data := RuleViewData{}
|
||||
if err := json.Unmarshal([]byte(s.Data), &data); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't decode rule view %s", s.ID)
|
||||
}
|
||||
|
||||
return &GettableRuleView{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Data: data,
|
||||
OrgID: s.OrgID,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StorableRuleView) Update(updatable UpdatableRuleView) error {
|
||||
data, err := json.Marshal(updatable.Data)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't encode rule view %s", s.ID)
|
||||
}
|
||||
|
||||
s.Name = updatable.Name
|
||||
s.Data = string(data)
|
||||
s.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
type GettableRuleView struct {
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Data RuleViewData `json:"data" required:"true"`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
CreatedAt time.Time `json:"createdAt" required:"true"`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true"`
|
||||
}
|
||||
|
||||
// RuleViewData holds the rule listing state (ListRulesParams minus pagination) a view replays.
|
||||
type RuleViewData struct {
|
||||
Version string `json:"version" required:"true"`
|
||||
ListFilter
|
||||
}
|
||||
|
||||
func (d *RuleViewData) Validate() error {
|
||||
if d.Version != RuleViewSchemaVersion {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
|
||||
"version must be %q, got %q", RuleViewSchemaVersion, d.Version)
|
||||
}
|
||||
return d.ListFilter.Validate()
|
||||
}
|
||||
|
||||
type PostableRuleView struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Data RuleViewData `json:"data" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableRuleView) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
type alias PostableRuleView
|
||||
var tmp alias
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeRuleViewInvalidInput, "invalid saved view request body").WithAdditional(err.Error())
|
||||
}
|
||||
*p = PostableRuleView(tmp)
|
||||
return p.Validate()
|
||||
}
|
||||
|
||||
func (p *PostableRuleView) Validate() error {
|
||||
if err := validateRuleViewName(p.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Data.Validate()
|
||||
}
|
||||
|
||||
func (p PostableRuleView) ToStorableRuleView(orgID valuer.UUID) (*StorableRuleView, error) {
|
||||
data, err := json.Marshal(p.Data)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't encode rule view %q", p.Name)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return &StorableRuleView{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
Name: p.Name,
|
||||
Data: string(data),
|
||||
OrgID: orgID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type UpdatableRuleView = PostableRuleView
|
||||
|
||||
// NewGettableRuleViewsFromStorableRuleViews converts rows; corrupt rows come back keyed by view id for the caller to log.
|
||||
func NewGettableRuleViewsFromStorableRuleViews(storables []*StorableRuleView) ([]*GettableRuleView, map[string]error) {
|
||||
views := make([]*GettableRuleView, 0, len(storables))
|
||||
errByViewID := make(map[string]error)
|
||||
|
||||
for _, storable := range storables {
|
||||
view, err := storable.ToGettableRuleView()
|
||||
if err != nil {
|
||||
errByViewID[storable.ID.StringValue()] = err
|
||||
continue
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
|
||||
return views, errByViewID
|
||||
}
|
||||
|
||||
type ListableRuleViews struct {
|
||||
Views []*GettableRuleView `json:"views" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func validateRuleViewName(name string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput, "name is required")
|
||||
}
|
||||
if name != strings.TrimSpace(name) {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput, "name must not have leading or trailing whitespace")
|
||||
}
|
||||
if n := utf8.RuneCountInString(name); n > MaxRuleViewNameLen {
|
||||
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
|
||||
"name must be at most %d characters, got %d", MaxRuleViewNameLen, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
219
pkg/types/ruletypes/rule_view_test.go
Normal file
219
pkg/types/ruletypes/rule_view_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRuleViewDataValidate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
data RuleViewData
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "AllFieldsSet_Valid",
|
||||
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Query: "name CONTAINS 'prod'", States: []string{"firing", "pending"}, Sort: ListSortName, Order: ListOrderAsc}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "ZeroStatesSortOrder_Valid",
|
||||
data: RuleViewData{Version: RuleViewSchemaVersion},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "QueryOverCap_Rejected",
|
||||
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Query: strings.Repeat("x", MaxListQueryLen+1)}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "WrongVersion_Rejected",
|
||||
data: RuleViewData{Version: "v2"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "EmptyVersion_Rejected",
|
||||
data: RuleViewData{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownState_Rejected",
|
||||
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{States: []string{"exploding"}}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownSort_Rejected",
|
||||
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Sort: ListSort{valuer.NewString("bogus")}}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownOrder_Rejected",
|
||||
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Order: ListOrder{valuer.NewString("sideways")}}},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := testCase.data.Validate()
|
||||
if testCase.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleViewDataValidateDefaults(t *testing.T) {
|
||||
data := RuleViewData{Version: RuleViewSchemaVersion}
|
||||
require.NoError(t, data.Validate())
|
||||
assert.Equal(t, ListSortUpdatedAt, data.Sort)
|
||||
assert.Equal(t, ListOrderDesc, data.Order)
|
||||
assert.Equal(t, []string{}, data.States)
|
||||
}
|
||||
|
||||
func TestPostableRuleViewUnmarshalJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
expectError bool
|
||||
expectedErrMsg string
|
||||
expectedName string
|
||||
}{
|
||||
{
|
||||
name: "ValidBody_NameKeptAsIs",
|
||||
body: `{"name":"my view","data":{"version":"v1","query":"severity = 'critical'","states":["firing"],"sort":"name","order":"asc"}}`,
|
||||
expectError: false,
|
||||
expectedName: "my view",
|
||||
},
|
||||
{
|
||||
name: "NameSurroundingWhitespace_Rejected",
|
||||
body: `{"name":" my view ","data":{"version":"v1"}}`,
|
||||
expectError: true,
|
||||
expectedErrMsg: "name must not have leading or trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "UnknownField_Rejected",
|
||||
body: `{"name":"my view","data":{"version":"v1"},"extra":true}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "BlankName_Rejected",
|
||||
body: `{"name":" ","data":{"version":"v1"}}`,
|
||||
expectError: true,
|
||||
expectedErrMsg: "name is required",
|
||||
},
|
||||
{
|
||||
name: "NameOverMaxLength_Rejected",
|
||||
body: `{"name":"` + strings.Repeat("x", MaxRuleViewNameLen+1) + `","data":{"version":"v1"}}`,
|
||||
expectError: true,
|
||||
expectedErrMsg: "name must be at most",
|
||||
},
|
||||
{
|
||||
name: "InvalidDataVersion_Rejected",
|
||||
body: `{"name":"my view","data":{"version":"v9"}}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "InvalidState_Rejected",
|
||||
body: `{"name":"my view","data":{"version":"v1","states":["exploding"]}}`,
|
||||
expectError: true,
|
||||
expectedErrMsg: "invalid state",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var p PostableRuleView
|
||||
err := json.Unmarshal([]byte(testCase.body), &p)
|
||||
if testCase.expectError {
|
||||
assert.Error(t, err)
|
||||
if testCase.expectedErrMsg != "" {
|
||||
assert.ErrorContains(t, err, testCase.expectedErrMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expectedName, p.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStorableRuleView(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
postable := PostableRuleView{
|
||||
Name: "my view",
|
||||
Data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{States: []string{"firing"}, Sort: ListSortName, Order: ListOrderAsc}},
|
||||
}
|
||||
|
||||
storable, err := postable.ToStorableRuleView(orgID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, orgID, storable.OrgID)
|
||||
assert.Equal(t, "my view", storable.Name)
|
||||
assert.False(t, storable.ID.IsZero())
|
||||
assert.False(t, storable.CreatedAt.IsZero())
|
||||
assert.Equal(t, storable.CreatedAt, storable.UpdatedAt)
|
||||
|
||||
gettable, err := storable.ToGettableRuleView()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, storable.ID, gettable.ID)
|
||||
assert.Equal(t, "my view", gettable.Name)
|
||||
assert.Equal(t, postable.Data, gettable.Data)
|
||||
assert.Equal(t, orgID, gettable.OrgID)
|
||||
assert.Equal(t, storable.CreatedAt, gettable.CreatedAt)
|
||||
}
|
||||
|
||||
func TestToGettableRuleView_CorruptData_Errors(t *testing.T) {
|
||||
_, err := (&StorableRuleView{Data: "not json"}).ToGettableRuleView()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewGettableRuleViewsFromStorableRuleViews(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
valid, err := PostableRuleView{
|
||||
Name: "valid view",
|
||||
Data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Sort: ListSortName, Order: ListOrderAsc}},
|
||||
}.ToStorableRuleView(orgID)
|
||||
require.NoError(t, err)
|
||||
corrupt := &StorableRuleView{Identifiable: types.Identifiable{ID: valuer.GenerateUUID()}, Data: "not json"}
|
||||
|
||||
views, errByViewID := NewGettableRuleViewsFromStorableRuleViews([]*StorableRuleView{valid, corrupt})
|
||||
|
||||
require.Len(t, views, 1)
|
||||
assert.Equal(t, "valid view", views[0].Name)
|
||||
require.Len(t, errByViewID, 1)
|
||||
assert.Error(t, errByViewID[corrupt.ID.StringValue()])
|
||||
}
|
||||
|
||||
func TestStorableRuleViewUpdate(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
storable, err := PostableRuleView{
|
||||
Name: "original",
|
||||
Data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Sort: ListSortName, Order: ListOrderAsc}},
|
||||
}.ToStorableRuleView(orgID)
|
||||
require.NoError(t, err)
|
||||
createdAt := storable.CreatedAt
|
||||
|
||||
err = storable.Update(UpdatableRuleView{
|
||||
Name: "renamed",
|
||||
Data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{States: []string{"disabled"}, Sort: ListSortCreatedAt, Order: ListOrderDesc}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
gettable, err := storable.ToGettableRuleView()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", gettable.Name)
|
||||
assert.Equal(t, []string{"disabled"}, gettable.Data.States)
|
||||
assert.Equal(t, ListSortCreatedAt, gettable.Data.Sort)
|
||||
assert.Equal(t, ListOrderDesc, gettable.Data.Order)
|
||||
assert.Equal(t, createdAt, storable.CreatedAt)
|
||||
assert.True(t, storable.UpdatedAt.After(createdAt))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user