mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-02 09:30:42 +01:00
Compare commits
2 Commits
fix/filter
...
issue_5975
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c0559b71b | ||
|
|
d90f025875 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -58,7 +58,6 @@ jobs:
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- promapiconformance
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
@@ -46,7 +46,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -104,8 +103,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
|
||||
},
|
||||
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return noopgateway.NewProviderFactory()
|
||||
|
||||
@@ -63,7 +63,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -137,8 +136,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
}
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
|
||||
},
|
||||
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return httpgateway.NewProviderFactory(licensing)
|
||||
|
||||
@@ -109,57 +109,6 @@ components:
|
||||
webhook_url:
|
||||
$ref: '#/components/schemas/ConfigSecretURL'
|
||||
type: object
|
||||
AlertmanagertypesJSMOpsReceiverConfig:
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
http_config:
|
||||
$ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
send_resolved:
|
||||
type: boolean
|
||||
tags:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesJiraReceiverConfig:
|
||||
properties:
|
||||
custom_fields:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
http_config:
|
||||
$ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
issue_type:
|
||||
type: string
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
project:
|
||||
type: string
|
||||
reopen_duration:
|
||||
$ref: '#/components/schemas/ModelDuration'
|
||||
reopen_transition:
|
||||
type: string
|
||||
resolve_transition:
|
||||
type: string
|
||||
send_resolved:
|
||||
type: boolean
|
||||
site:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
wont_fix_resolution:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesMaintenanceKind:
|
||||
enum:
|
||||
- fixed
|
||||
@@ -213,10 +162,6 @@ components:
|
||||
oneOf:
|
||||
- required:
|
||||
- googlechat_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- jsmops_configs
|
||||
- required:
|
||||
- discord_configs
|
||||
- required:
|
||||
@@ -247,6 +192,8 @@ components:
|
||||
- msteams_configs
|
||||
- required:
|
||||
- msteamsv2_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- rocketchat_configs
|
||||
- required:
|
||||
@@ -270,11 +217,7 @@ components:
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
|
||||
type: array
|
||||
jsmops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
@@ -401,11 +344,7 @@ components:
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
|
||||
type: array
|
||||
jsmops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
@@ -2620,7 +2559,6 @@ components:
|
||||
- factor-api-key
|
||||
- license
|
||||
- subscription
|
||||
- deployment-host
|
||||
- logs
|
||||
- traces
|
||||
- metrics
|
||||
@@ -3005,46 +2943,6 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettableSystemDashboard:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
locked:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/DashboardtypesSource'
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/components/schemas/TagtypesGettableTag'
|
||||
nullable: true
|
||||
type: array
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- orgId
|
||||
- locked
|
||||
- source
|
||||
- schemaVersion
|
||||
- name
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -6561,148 +6459,6 @@ components:
|
||||
type: object
|
||||
PreferencetypesValue:
|
||||
type: object
|
||||
PrometheusErrorResponseSchema:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
errorType:
|
||||
enum:
|
||||
- bad_data
|
||||
- execution
|
||||
- canceled
|
||||
- timeout
|
||||
- internal
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- error
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- errorType
|
||||
- error
|
||||
type: object
|
||||
PrometheusMatrixDataSchema:
|
||||
properties:
|
||||
result:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusMatrixSeriesSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
resultType:
|
||||
enum:
|
||||
- matrix
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusMatrixSeriesSchema:
|
||||
properties:
|
||||
metric:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
values:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metric
|
||||
- values
|
||||
type: object
|
||||
PrometheusQueryDataSchema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/PrometheusMatrixDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusVectorDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusScalarDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusStringDataSchema'
|
||||
type: object
|
||||
PrometheusSamplePairSchema:
|
||||
description: 'A [timestamp, value] pair: float unix seconds, then the string-encoded
|
||||
sample value ("NaN", "+Inf", "-Inf" included).'
|
||||
items:
|
||||
oneOf:
|
||||
- type: number
|
||||
- type: string
|
||||
maxItems: 2
|
||||
minItems: 2
|
||||
nullable: true
|
||||
type: array
|
||||
PrometheusScalarDataSchema:
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
resultType:
|
||||
enum:
|
||||
- scalar
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusStringDataSchema:
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
resultType:
|
||||
enum:
|
||||
- string
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusSuccessResponseSchema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PrometheusQueryDataSchema'
|
||||
infos:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
status:
|
||||
enum:
|
||||
- success
|
||||
type: string
|
||||
warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
PrometheusVectorDataSchema:
|
||||
properties:
|
||||
result:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusVectorSampleSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
resultType:
|
||||
enum:
|
||||
- vector
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusVectorSampleSchema:
|
||||
properties:
|
||||
metric:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
value:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
required:
|
||||
- metric
|
||||
- value
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
indexes:
|
||||
@@ -9466,6 +9222,10 @@ paths:
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: existingQuery
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -15602,73 +15362,6 @@ paths:
|
||||
summary: Migrate dashboard to v2
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/system/{name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
|
||||
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
|
||||
are read-only and upgraded through releases. The dashboard's own `name` field
|
||||
carries a reserved prefix that the path segment must not include.
|
||||
operationId: GetSystemDashboard
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableSystemDashboard'
|
||||
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:
|
||||
- dashboard:read
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
summary: Get system dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/factor_password/forgot:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -24253,9 +23946,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- deployment-host:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- deployment-host:list
|
||||
- VIEWER
|
||||
summary: Get host info from Zeus.
|
||||
tags:
|
||||
- zeus
|
||||
@@ -24309,9 +24002,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- deployment-host:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- deployment-host:update
|
||||
- ADMIN
|
||||
summary: Put host in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
@@ -25121,374 +24814,6 @@ paths:
|
||||
summary: Replace variables
|
||||
tags:
|
||||
- querier
|
||||
/prometheus/api/v1/query:
|
||||
get:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQuery
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
in: query
|
||||
name: time
|
||||
schema:
|
||||
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
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
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus instant query
|
||||
tags:
|
||||
- prometheus
|
||||
post:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryPost
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
in: query
|
||||
name: time
|
||||
schema:
|
||||
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
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
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus instant query
|
||||
tags:
|
||||
- prometheus
|
||||
/prometheus/api/v1/query_range:
|
||||
get:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryRange
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Range start: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: start
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range start: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Range end: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: end
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range end: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Resolution step: duration string or float seconds.'
|
||||
in: query
|
||||
name: step
|
||||
required: true
|
||||
schema:
|
||||
description: 'Resolution step: duration string or float seconds.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
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
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus range query
|
||||
tags:
|
||||
- prometheus
|
||||
post:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryRangePost
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Range start: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: start
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range start: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Range end: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: end
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range end: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Resolution step: duration string or float seconds.'
|
||||
in: query
|
||||
name: step
|
||||
required: true
|
||||
schema:
|
||||
description: 'Resolution step: duration string or float seconds.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
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
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus range query
|
||||
tags:
|
||||
- prometheus
|
||||
servers:
|
||||
- description: The fully qualified URL to the SigNoz APIServer.
|
||||
url: https://{host}:{port}{base_path}
|
||||
|
||||
@@ -299,11 +299,8 @@ substituted. One subtlety makes it exact: we write stale markers at absent
|
||||
grid points. Without them, the engine's lookback would resurrect a point
|
||||
from up to `lookback` earlier. The marker encodes "absent here" the way the
|
||||
engine itself encodes it. Units evaluate concurrently. Each unit is one
|
||||
grid statement: the group-key join resolves the matchers, and the samples
|
||||
primary key takes the metric name straight from the selector. Only a
|
||||
selector without a static `__name__` runs the series lookup first, to learn
|
||||
the concrete metric names. A step of 0 is an instant query: a single
|
||||
evaluation at `end`.
|
||||
series lookup plus one grid statement. A step of 0 is an instant query: a
|
||||
single evaluation at `end`.
|
||||
|
||||
A note on the window sliver: when the window is narrower than the step, the
|
||||
grid windows cover only `window/step` of the timeline. A sample in a gap
|
||||
@@ -318,9 +315,8 @@ selectors and `last_over_time` transpile at window < step too.
|
||||
|
||||
## Series lookup
|
||||
|
||||
The engine path resolves matchers once per selector (`selectSeries`); the
|
||||
transpiled path builds the same conditions into its group-key join. Both
|
||||
read the same tables. The series tables hold one row per (fingerprint, bucket)
|
||||
Both paths resolve matchers the same way, once per selector
|
||||
(`selectSeries`). The series tables hold one row per (fingerprint, bucket)
|
||||
at 1h/6h/1d/1w granularities. The shared schema package
|
||||
(`pkg/telemetryschema/metricstelemetryschema`) picks the table whose bucket
|
||||
fits the window. It rounds the window start down to the bucket boundary, so
|
||||
|
||||
@@ -32,9 +32,9 @@ type module struct {
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
|
||||
|
||||
return &module{
|
||||
pkgDashboardModule: pkgDashboardModule,
|
||||
@@ -361,14 +361,6 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
|
||||
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
|
||||
@@ -26,55 +26,6 @@
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
|
||||
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
"field_jira_summary": "Summary (issue title)",
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
"help_jira_priority": "Must match a priority in the project's scheme, e.g. High.",
|
||||
"field_jira_labels": "Labels",
|
||||
"placeholder_jira_labels": "Type a label and press Enter",
|
||||
"help_jira_labels": "signoz and a deduplication label are added automatically.",
|
||||
"field_jira_resolve_transition": "Resolve transition",
|
||||
"field_jira_reopen_transition": "Reopen transition",
|
||||
"help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.",
|
||||
"help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).",
|
||||
"placeholder_jira_resolve_transition": "Auto-detected, e.g. Done",
|
||||
"placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do",
|
||||
"field_jira_reopen_duration": "Reopen window",
|
||||
"placeholder_jira_reopen_duration": "e.g. 72h",
|
||||
"help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.",
|
||||
"tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.",
|
||||
"jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)",
|
||||
"jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.",
|
||||
"jsmops_tip_link": "Learn how",
|
||||
"field_jsmops_api_key": "API key",
|
||||
"help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.",
|
||||
"field_jsmops_message": "Message (alert title)",
|
||||
"help_jsmops_message": "Template for the alert title. Truncated to 130 characters.",
|
||||
"field_jsmops_description": "Description",
|
||||
"help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.",
|
||||
"jsmops_advanced_section": "Advanced Options",
|
||||
"field_jsmops_priority": "Priority",
|
||||
"help_jsmops_priority": "Template resolving to one of P1–P5. Leave as-is to map from alert severity.",
|
||||
"field_jsmops_tags": "Tags",
|
||||
"placeholder_jsmops_tags": "Type a tag and press Enter",
|
||||
"help_jsmops_tags": "Tags added to every alert.",
|
||||
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
|
||||
@@ -26,55 +26,6 @@
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
|
||||
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
"field_jira_summary": "Summary (issue title)",
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
"help_jira_priority": "Must match a priority in the project's scheme, e.g. High.",
|
||||
"field_jira_labels": "Labels",
|
||||
"placeholder_jira_labels": "Type a label and press Enter",
|
||||
"help_jira_labels": "signoz and a deduplication label are added automatically.",
|
||||
"field_jira_resolve_transition": "Resolve transition",
|
||||
"field_jira_reopen_transition": "Reopen transition",
|
||||
"help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.",
|
||||
"help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).",
|
||||
"placeholder_jira_resolve_transition": "Auto-detected, e.g. Done",
|
||||
"placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do",
|
||||
"field_jira_reopen_duration": "Reopen window",
|
||||
"placeholder_jira_reopen_duration": "e.g. 72h",
|
||||
"help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.",
|
||||
"tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.",
|
||||
"jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)",
|
||||
"jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.",
|
||||
"jsmops_tip_link": "Learn how",
|
||||
"field_jsmops_api_key": "API key",
|
||||
"help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.",
|
||||
"field_jsmops_message": "Message (alert title)",
|
||||
"help_jsmops_message": "Template for the alert title. Truncated to 130 characters.",
|
||||
"field_jsmops_description": "Description",
|
||||
"help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.",
|
||||
"jsmops_advanced_section": "Advanced Options",
|
||||
"field_jsmops_priority": "Priority",
|
||||
"help_jsmops_priority": "Template resolving to one of P1–P5. Leave as-is to map from alert severity.",
|
||||
"field_jsmops_tags": "Tags",
|
||||
"placeholder_jsmops_tags": "Type a tag and press Enter",
|
||||
"help_jsmops_tags": "Tags added to every alert.",
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
"field_slack_description": "Description",
|
||||
|
||||
@@ -46,8 +46,6 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
GetSystemDashboard200,
|
||||
GetSystemDashboardPathParameters,
|
||||
ListDashboardViews200,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
@@ -1887,108 +1885,6 @@ export const useMigrateDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getMigrateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const getSystemDashboard = (
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSystemDashboard200>({
|
||||
url: `/api/v2/dashboards/system/${name}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryKey = ({
|
||||
name,
|
||||
}: GetSystemDashboardPathParameters) => {
|
||||
return [`/api/v2/dashboards/system/${name}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
> = ({ signal }) => getSystemDashboard({ name }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!name,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSystemDashboardQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
>;
|
||||
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
|
||||
export function useGetSystemDashboard<
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const invalidateGetSystemDashboard = async (
|
||||
queryClient: QueryClient,
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
PrometheusErrorResponseSchemaDTO,
|
||||
PrometheusQueryParams,
|
||||
PrometheusQueryPostParams,
|
||||
PrometheusQueryRangeParams,
|
||||
PrometheusQueryRangePostParams,
|
||||
PrometheusSuccessResponseSchemaDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const prometheusQuery = (
|
||||
params: PrometheusQueryParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryQueryKey = (params?: PrometheusQueryParams) => {
|
||||
return [`/prometheus/api/v1/query`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getPrometheusQueryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getPrometheusQueryQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof prometheusQuery>>> = ({
|
||||
signal,
|
||||
}) => prometheusQuery(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type PrometheusQueryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>
|
||||
>;
|
||||
export type PrometheusQueryQueryError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
|
||||
export function usePrometheusQuery<
|
||||
TData = Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getPrometheusQueryQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const invalidatePrometheusQuery = async (
|
||||
queryClient: QueryClient,
|
||||
params: PrometheusQueryParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getPrometheusQueryQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const prometheusQueryPost = (
|
||||
params: PrometheusQueryPostParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query`,
|
||||
method: 'POST',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryPostMutationOptions = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['prometheusQueryPost'];
|
||||
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 prometheusQueryPost>>,
|
||||
{ params: PrometheusQueryPostParams }
|
||||
> = (props) => {
|
||||
const { params } = props ?? {};
|
||||
|
||||
return prometheusQueryPost(params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PrometheusQueryPostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>
|
||||
>;
|
||||
|
||||
export type PrometheusQueryPostMutationError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const usePrometheusQueryPost = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPrometheusQueryPostMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const prometheusQueryRange = (
|
||||
params: PrometheusQueryRangeParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query_range`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangeQueryKey = (
|
||||
params?: PrometheusQueryRangeParams,
|
||||
) => {
|
||||
return [
|
||||
`/prometheus/api/v1/query_range`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangeQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getPrometheusQueryRangeQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>
|
||||
> = ({ signal }) => prometheusQueryRange(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangeQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>
|
||||
>;
|
||||
export type PrometheusQueryRangeQueryError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
|
||||
export function usePrometheusQueryRange<
|
||||
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getPrometheusQueryRangeQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const invalidatePrometheusQueryRange = async (
|
||||
queryClient: QueryClient,
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getPrometheusQueryRangeQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const prometheusQueryRangePost = (
|
||||
params: PrometheusQueryRangePostParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query_range`,
|
||||
method: 'POST',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangePostMutationOptions = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['prometheusQueryRangePost'];
|
||||
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 prometheusQueryRangePost>>,
|
||||
{ params: PrometheusQueryRangePostParams }
|
||||
> = (props) => {
|
||||
const { params } = props ?? {};
|
||||
|
||||
return prometheusQueryRangePost(params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangePostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>
|
||||
>;
|
||||
|
||||
export type PrometheusQueryRangePostMutationError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const usePrometheusQueryRangePost = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPrometheusQueryRangePostMutationOptions(options));
|
||||
};
|
||||
@@ -385,93 +385,6 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
|
||||
webhook_url?: ConfigSecretURLDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesJSMOpsReceiverConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_key?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ModelDurationDTO = number;
|
||||
|
||||
export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
site?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
@@ -718,6 +631,69 @@ export interface ConfigIncidentioConfigDTO {
|
||||
url_file?: string;
|
||||
}
|
||||
|
||||
export interface ConfigJiraFieldConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
enable_update?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export type ModelDurationDTO = number;
|
||||
|
||||
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
|
||||
|
||||
export interface ConfigJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_type?: string;
|
||||
api_url?: ConfigURLType2DTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: ConfigJiraConfigDTOCustomFields;
|
||||
description?: ConfigJiraFieldConfigDTO;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
summary?: ConfigJiraFieldConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface ConfigMattermostFieldDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
@@ -1676,11 +1652,7 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -1807,11 +1779,7 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -2207,7 +2175,6 @@ export enum CoretypesKindDTO {
|
||||
'factor-api-key' = 'factor-api-key',
|
||||
license = 'license',
|
||||
subscription = 'subscription',
|
||||
'deployment-host' = 'deployment-host',
|
||||
logs = 'logs',
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
@@ -3300,67 +3267,6 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
|
||||
|
||||
export interface ConfigJiraFieldConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
enable_update?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface ConfigJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_type?: string;
|
||||
api_url?: ConfigURLType2DTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: ConfigJiraConfigDTOCustomFields;
|
||||
description?: ConfigJiraFieldConfigDTO;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
summary?: ConfigJiraFieldConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface DashboardGridItemDTO {
|
||||
content?: CommonJSONRefDTO;
|
||||
/**
|
||||
@@ -5053,53 +4959,6 @@ export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettableSystemDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
locked: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
source: DashboardtypesSourceDTO;
|
||||
spec: DashboardtypesDashboardSpecDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
tags: TagtypesGettableTagDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -8114,164 +7973,6 @@ export interface PreferencetypesUpdatablePreferenceDTO {
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export enum PrometheusErrorResponseSchemaDTOErrorType {
|
||||
bad_data = 'bad_data',
|
||||
execution = 'execution',
|
||||
canceled = 'canceled',
|
||||
timeout = 'timeout',
|
||||
internal = 'internal',
|
||||
}
|
||||
export enum PrometheusErrorResponseSchemaDTOStatus {
|
||||
error = 'error',
|
||||
}
|
||||
export interface PrometheusErrorResponseSchemaDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
error: string;
|
||||
/**
|
||||
* @enum bad_data,execution,canceled,timeout,internal
|
||||
* @type string
|
||||
*/
|
||||
errorType: PrometheusErrorResponseSchemaDTOErrorType;
|
||||
/**
|
||||
* @enum error
|
||||
* @type string
|
||||
*/
|
||||
status: PrometheusErrorResponseSchemaDTOStatus;
|
||||
}
|
||||
|
||||
export enum PrometheusMatrixDataSchemaDTOResultType {
|
||||
matrix = 'matrix',
|
||||
}
|
||||
export type PrometheusSamplePairSchemaDTOItem = number | string;
|
||||
|
||||
/**
|
||||
* A [timestamp, value] pair: float unix seconds, then the string-encoded sample value ("NaN", "+Inf", "-Inf" included).
|
||||
* @minItems 2
|
||||
* @maxItems 2
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusSamplePairSchemaDTO =
|
||||
| PrometheusSamplePairSchemaDTOItem[]
|
||||
| null;
|
||||
|
||||
export type PrometheusMatrixSeriesSchemaDTOMetricAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusMatrixSeriesSchemaDTOMetric =
|
||||
PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
|
||||
|
||||
export interface PrometheusMatrixSeriesSchemaDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
metric: PrometheusMatrixSeriesSchemaDTOMetric;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
values: (PrometheusSamplePairSchemaDTO | null)[] | null;
|
||||
}
|
||||
|
||||
export interface PrometheusMatrixDataSchemaDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
result: PrometheusMatrixSeriesSchemaDTO[] | null;
|
||||
/**
|
||||
* @enum matrix
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusMatrixDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export type PrometheusVectorSampleSchemaDTOMetricAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusVectorSampleSchemaDTOMetric =
|
||||
PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
|
||||
|
||||
export interface PrometheusVectorSampleSchemaDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
metric: PrometheusVectorSampleSchemaDTOMetric;
|
||||
value: PrometheusSamplePairSchemaDTO | null;
|
||||
}
|
||||
|
||||
export enum PrometheusVectorDataSchemaDTOResultType {
|
||||
vector = 'vector',
|
||||
}
|
||||
export interface PrometheusVectorDataSchemaDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
result: PrometheusVectorSampleSchemaDTO[] | null;
|
||||
/**
|
||||
* @enum vector
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusVectorDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export enum PrometheusScalarDataSchemaDTOResultType {
|
||||
scalar = 'scalar',
|
||||
}
|
||||
export interface PrometheusScalarDataSchemaDTO {
|
||||
result: PrometheusSamplePairSchemaDTO | null;
|
||||
/**
|
||||
* @enum scalar
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusScalarDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export enum PrometheusStringDataSchemaDTOResultType {
|
||||
string = 'string',
|
||||
}
|
||||
export interface PrometheusStringDataSchemaDTO {
|
||||
result: PrometheusSamplePairSchemaDTO | null;
|
||||
/**
|
||||
* @enum string
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusStringDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export type PrometheusQueryDataSchemaDTO =
|
||||
| PrometheusMatrixDataSchemaDTO
|
||||
| PrometheusVectorDataSchemaDTO
|
||||
| PrometheusScalarDataSchemaDTO
|
||||
| PrometheusStringDataSchemaDTO;
|
||||
|
||||
export enum PrometheusSuccessResponseSchemaDTOStatus {
|
||||
success = 'success',
|
||||
}
|
||||
export interface PrometheusSuccessResponseSchemaDTO {
|
||||
data: PrometheusQueryDataSchemaDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
infos?: string[];
|
||||
/**
|
||||
* @enum success
|
||||
* @type string
|
||||
*/
|
||||
status: PrometheusSuccessResponseSchemaDTOStatus;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface PromotetypesWrappedIndexDTO {
|
||||
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
|
||||
/**
|
||||
@@ -10547,6 +10248,11 @@ export type GetAIObservabilityFieldsValuesParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
existingQuery?: string;
|
||||
};
|
||||
|
||||
export type GetAIObservabilityFieldsValues200 = {
|
||||
@@ -11611,17 +11317,6 @@ export type MigrateDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSystemDashboardPathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type GetSystemDashboard200 = {
|
||||
data: DashboardtypesGettableSystemDashboardDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -12780,115 +12475,3 @@ export type ReplaceVariables200 = {
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
|
||||
*/
|
||||
time?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryPostParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
|
||||
*/
|
||||
time?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangeParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range start: RFC3339 or float unix seconds.
|
||||
*/
|
||||
start: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range end: RFC3339 or float unix seconds.
|
||||
*/
|
||||
end: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Resolution step: duration string or float seconds.
|
||||
*/
|
||||
step: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangePostParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range start: RFC3339 or float unix seconds.
|
||||
*/
|
||||
start: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range end: RFC3339 or float unix seconds.
|
||||
*/
|
||||
end: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Resolution step: duration string or float seconds.
|
||||
*/
|
||||
step: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
@@ -7,8 +7,9 @@ import axios from 'axios';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
@@ -68,7 +69,9 @@ function ExplorerCard({
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
export const ExploreHeaderToolTip = {
|
||||
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
|
||||
text: 'More details on how to use query builder',
|
||||
@@ -7,3 +9,5 @@ export const SaveButtonText = {
|
||||
SAVE_AS_NEW_VIEW: 'Save as new view',
|
||||
SAVE_VIEW: 'Save view',
|
||||
};
|
||||
|
||||
export type QuerySearchParamNames = QueryParams.viewName | QueryParams.viewKey;
|
||||
|
||||
@@ -241,29 +241,28 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
))
|
||||
)}
|
||||
|
||||
{!showOnlyWhereClause &&
|
||||
currentQuery.builder.queryFormulas?.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowFooter && (
|
||||
<QueryFooter
|
||||
@@ -291,7 +290,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentQuery.builder.queryFormulas?.map((formula) => (
|
||||
{currentQuery.builder.queryFormulas.map((formula) => (
|
||||
<div key={formula.queryName} className="formula-name">
|
||||
{formula.queryName}
|
||||
</div>
|
||||
|
||||
@@ -212,32 +212,6 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
expect(handleRunQueryMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not crash when builder.queryFormulas/queryTraceOperator are missing (partial/legacy query)', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
...baseQBContext,
|
||||
currentQuery: {
|
||||
...currentQueryBase,
|
||||
builder: {
|
||||
queryData: currentQueryBase.builder.queryData,
|
||||
queryFormulas: undefined as unknown as [],
|
||||
queryTraceOperator: undefined as unknown as [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />),
|
||||
).not.toThrow();
|
||||
|
||||
// query list still renders from queryData, formulas block is skipped
|
||||
expect(document.querySelector('.query-names-section')).toBeInTheDocument();
|
||||
expect(
|
||||
document.querySelector('.qb-formulas-container'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fx button is disabled when functions already exist', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
const supersetQueryBase = baseQBContext.supersetQuery as Query;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
@@ -110,7 +110,10 @@ describe('ServiceAccountDrawer — permissions', () => {
|
||||
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
|
||||
server.use(setupAuthzDeny(APIKeyListPermission));
|
||||
|
||||
renderDrawer({ account: 'sa-1', tab: 'keys' });
|
||||
renderDrawer();
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();
|
||||
|
||||
@@ -29,7 +29,6 @@ export enum InfraMonitoringEvents {
|
||||
MetricsView = 'metrics',
|
||||
Total = 'total',
|
||||
Cluster = 'cluster',
|
||||
Container = 'container',
|
||||
DaemonSet = 'daemonSet',
|
||||
Deployment = 'deployment',
|
||||
Job = 'job',
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import {
|
||||
GoogleChatInitialConfig,
|
||||
JiraInitialConfig,
|
||||
JsmOpsInitialConfig,
|
||||
} from 'container/CreateAlertChannels/defaults';
|
||||
import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults';
|
||||
import {
|
||||
googleChatDescriptionDefaultValue,
|
||||
googleChatTitleDefaultValue,
|
||||
@@ -530,213 +526,6 @@ describe('Create Alert Channel', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Jira', () => {
|
||||
const validSite = 'https://acme.atlassian.net';
|
||||
|
||||
const fillRequired = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
site: string,
|
||||
): Promise<void> => {
|
||||
await user.type(screen.getByTestId('channel-name-textbox'), 'jira-channel');
|
||||
await user.type(screen.getByTestId('jira-site-textbox'), site);
|
||||
await user.type(screen.getByTestId('jira-email-textbox'), 'me@acme.com');
|
||||
await user.type(screen.getByTestId('jira-api-token-textbox'), 'tok123');
|
||||
await user.type(screen.getByTestId('jira-project-textbox'), 'KAN');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
render(<CreateAlertChannels preType={ChannelType.Jira} />);
|
||||
});
|
||||
|
||||
it('Should check if the selected item in the type dropdown has text "Jira"', () => {
|
||||
expect(screen.getByText('Jira')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should check if the Site URL field is displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_jira_site',
|
||||
testId: 'jira-site-textbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should prefill the issue type with Task', () => {
|
||||
expect(screen.getByTestId('jira-issue-type-textbox')).toHaveValue('Task');
|
||||
});
|
||||
|
||||
it('Should show the service-account recommendation tip linking to the docs', () => {
|
||||
expect(screen.getByTestId('jira-service-account-tip')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'jira_service_account_tip_link' }),
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended',
|
||||
);
|
||||
});
|
||||
|
||||
it('Should display an error when the site is not an atlassian.net URL', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await fillRequired(user, 'https://example.com');
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'jira_site_invalid',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should send a jira_configs payload with basic auth', async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
|
||||
requestBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: 'channel created' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await fillRequired(user, validSite);
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(successNotification).toHaveBeenCalledWith({
|
||||
message: 'Success',
|
||||
description: 'channel_creation_done',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requestBody).toStrictEqual({
|
||||
name: 'jira-channel',
|
||||
jira_configs: [
|
||||
{
|
||||
site: validSite,
|
||||
project: 'KAN',
|
||||
issue_type: 'Task',
|
||||
summary: JiraInitialConfig.summary,
|
||||
description: JiraInitialConfig.description,
|
||||
send_resolved: true,
|
||||
http_config: {
|
||||
basic_auth: { username: 'me@acme.com', password: 'tok123' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('Should block save when the reopen window is below the 1m minimum', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await fillRequired(user, validSite);
|
||||
|
||||
await user.click(screen.getByText('jira_advanced_section'));
|
||||
await user.type(screen.getByTestId('jira-reopen-duration-textbox'), '30s');
|
||||
|
||||
// the rule surfaces an inline message, not just a red border
|
||||
await expect(
|
||||
screen.findByText('jira_reopen_duration_invalid'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'jira_reopen_duration_invalid',
|
||||
}),
|
||||
);
|
||||
}, 15000);
|
||||
});
|
||||
describe('JSM Ops', () => {
|
||||
beforeEach(() => {
|
||||
render(<CreateAlertChannels preType={ChannelType.JsmOps} />);
|
||||
});
|
||||
|
||||
it('Should show "Jira Service Management Ops" as the selected type', () => {
|
||||
expect(screen.getByText('Jira Service Management Ops')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should display the API key field properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_jsmops_api_key',
|
||||
testId: 'jsmops-api-key-textbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should show the tip linking to the JSM Ops docs', () => {
|
||||
expect(screen.getByTestId('jsmops-tip')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'jsmops_tip_link' }),
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/',
|
||||
);
|
||||
});
|
||||
|
||||
it('Should block save when the API key is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
await user.type(
|
||||
screen.getByTestId('channel-name-textbox'),
|
||||
'jsmops-channel',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'api_key_required',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should send a jsmops_configs payload with prefilled defaults', async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
|
||||
requestBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: 'channel created' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(
|
||||
screen.getByTestId('channel-name-textbox'),
|
||||
'jsmops-channel',
|
||||
);
|
||||
await user.type(screen.getByTestId('jsmops-api-key-textbox'), 'key-abc');
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(successNotification).toHaveBeenCalledWith({
|
||||
message: 'Success',
|
||||
description: 'channel_creation_done',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requestBody).toStrictEqual({
|
||||
name: 'jsmops-channel',
|
||||
jsmops_configs: [
|
||||
{
|
||||
api_key: 'key-abc',
|
||||
send_resolved: true,
|
||||
message: JsmOpsInitialConfig.message,
|
||||
description: JsmOpsInitialConfig.description,
|
||||
priority: JsmOpsInitialConfig.priority,
|
||||
tags: JsmOpsInitialConfig.tags?.join(','),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Changing the channel type', () => {
|
||||
async function selectType(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
|
||||
@@ -58,38 +58,6 @@ describe('EditAlertChannels save', () => {
|
||||
expect(edit.calls[0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('blocks jira save when the reopen window is below the 1m minimum', async () => {
|
||||
const edit = mockEditChannel();
|
||||
const jiraInitialValue = {
|
||||
type: 'jira',
|
||||
name: 'jira-channel',
|
||||
site: 'https://acme.atlassian.net',
|
||||
username: 'user@acme.io',
|
||||
password: 'token',
|
||||
project: 'OPS',
|
||||
issue_type: 'Task',
|
||||
send_resolved: true,
|
||||
reopen_duration: '30s',
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<EditAlertChannels channelId="3" initialValue={jiraInitialValue} />,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
expect(edit.calls).toHaveLength(0);
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={{ ...jiraInitialValue, reopen_duration: '72h' }}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
await waitFor(() => expect(edit.calls).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('persists send_resolved toggle in the edit request', async () => {
|
||||
const edit = mockEditChannel();
|
||||
render(
|
||||
|
||||
@@ -105,8 +105,6 @@ export enum ChannelType {
|
||||
Opsgenie = 'opsgenie',
|
||||
MsTeams = 'msteams',
|
||||
GoogleChat = 'googlechat',
|
||||
Jira = 'jira',
|
||||
JsmOps = 'jsmops',
|
||||
}
|
||||
|
||||
// LabelFilterStatement will be used for preparing filter conditions / matchers
|
||||
@@ -136,39 +134,3 @@ export interface GoogleChatChannel extends Channel {
|
||||
title?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
|
||||
// (Atlassian account email + API token) carried in username / password.
|
||||
export interface JiraChannel extends Channel {
|
||||
// Jira Cloud base URL, e.g. https://acme.atlassian.net
|
||||
site: string;
|
||||
project: string;
|
||||
issue_type: string;
|
||||
// issue title template
|
||||
summary?: string;
|
||||
// issue body template, rendered to rich text server-side
|
||||
description?: string;
|
||||
// basic auth: username is the Atlassian account email, password is the API token
|
||||
username: string;
|
||||
password: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
resolve_transition?: string;
|
||||
reopen_transition?: string;
|
||||
// duration string, e.g. 72h or 3d
|
||||
reopen_duration?: string;
|
||||
}
|
||||
|
||||
// JsmOpsChannel configures the Jira Service Management Ops alert channel
|
||||
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
|
||||
export interface JsmOpsChannel extends Channel {
|
||||
api_key: string;
|
||||
// alert title template
|
||||
message?: string;
|
||||
// alert body template (markdown, rendered to HTML server-side)
|
||||
description?: string;
|
||||
// priority template, resolves to P1-P5
|
||||
priority?: string;
|
||||
// tags, joined to a comma-separated string for the backend
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
@@ -49,23 +47,6 @@ export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
|
||||
{{ end }}`,
|
||||
};
|
||||
|
||||
// mirrors DefaultJiraSummaryTemplate / DefaultJiraDescriptionTemplate in
|
||||
// pkg/types/alertmanagertypes/jira.go, which the backend applies when the
|
||||
// summary / description are left empty. The description is markdown here and is
|
||||
// wrapped in the ADF status panel + deep-links server-side.
|
||||
export const JiraInitialConfig: Partial<JiraChannel> = {
|
||||
issue_type: 'Task',
|
||||
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
{{ if .Annotations.summary }}
|
||||
**Summary:** {{ .Annotations.summary }}
|
||||
{{ end }}{{ if .Annotations.description }}
|
||||
**Description:** {{ .Annotations.description }}
|
||||
{{ end }}
|
||||
{{ end }}`,
|
||||
};
|
||||
|
||||
export const PagerInitialConfig: Partial<PagerChannel> = {
|
||||
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
|
||||
@@ -117,33 +98,6 @@ export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
|
||||
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
};
|
||||
|
||||
// mirrors DefaultJSMOpsMessageTemplate / DefaultJSMOpsDescriptionTemplate in
|
||||
// pkg/types/alertmanagertypes/jsmops.go, applied by the backend when message /
|
||||
// description are left empty. send_resolved is seeded on so JSM alerts close on
|
||||
// resolve (the backend cannot default it, see jsmops.go). priority mirrors the
|
||||
// Opsgenie template mapping severity to P1-P5.
|
||||
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
|
||||
send_resolved: true,
|
||||
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
|
||||
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
|
||||
|
||||
{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }}
|
||||
|
||||
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
|
||||
|
||||
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
|
||||
|
||||
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
|
||||
|
||||
{{ end }}{{ end }}`,
|
||||
priority:
|
||||
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
tags: ['signoz-alert'],
|
||||
};
|
||||
|
||||
export const EmailInitialConfig: Partial<EmailChannel> = {
|
||||
send_resolved: true,
|
||||
html: `<!--
|
||||
@@ -551,16 +505,12 @@ export const ChannelInitialConfig: Record<
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
GoogleChatChannel
|
||||
>
|
||||
> = {
|
||||
[ChannelType.Slack]: SlackInitialConfig,
|
||||
[ChannelType.MsTeams]: SlackInitialConfig,
|
||||
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
|
||||
[ChannelType.Jira]: JiraInitialConfig,
|
||||
[ChannelType.JsmOps]: JsmOpsInitialConfig,
|
||||
[ChannelType.Pagerduty]: PagerInitialConfig,
|
||||
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
|
||||
[ChannelType.Email]: EmailInitialConfig,
|
||||
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
@@ -45,11 +43,7 @@ import { ChannelInitialConfig } from './defaults';
|
||||
import {
|
||||
isChannelType,
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
prepareGoogleChatRequest,
|
||||
prepareJiraRequest,
|
||||
prepareJsmOpsRequest,
|
||||
} from './utils';
|
||||
|
||||
import './CreateAlertChannels.styles.scss';
|
||||
@@ -75,9 +69,7 @@ function CreateAlertChannels({
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
GoogleChatChannel
|
||||
>
|
||||
>(() => ({
|
||||
send_resolved: true,
|
||||
@@ -442,114 +434,6 @@ function CreateAlertChannels({
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateJiraConfig = useCallback((): boolean => {
|
||||
if (
|
||||
!selectedConfig.site ||
|
||||
!selectedConfig.username ||
|
||||
!selectedConfig.password ||
|
||||
!selectedConfig.project ||
|
||||
!selectedConfig.issue_type
|
||||
) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_required_fields'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidJiraSiteURL(selectedConfig.site)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_site_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedConfig.reopen_duration &&
|
||||
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
|
||||
) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_reopen_duration_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig, notifications, t]);
|
||||
|
||||
const onJiraHandler = useCallback(async () => {
|
||||
if (!validateJiraConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJiraConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateJsmOpsConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.api_key) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('api_key_required'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [selectedConfig.api_key, notifications, t]);
|
||||
|
||||
const onJsmOpsHandler = useCallback(async () => {
|
||||
if (!validateJsmOpsConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJsmOpsConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
if (!selectedConfig.name) {
|
||||
@@ -568,8 +452,6 @@ function CreateAlertChannels({
|
||||
[ChannelType.MsTeams]: onMsTeamsHandler,
|
||||
[ChannelType.Email]: onEmailHandler,
|
||||
[ChannelType.GoogleChat]: onGoogleChatHandler,
|
||||
[ChannelType.Jira]: onJiraHandler,
|
||||
[ChannelType.JsmOps]: onJsmOpsHandler,
|
||||
};
|
||||
|
||||
if (isChannelType(value)) {
|
||||
@@ -602,8 +484,6 @@ function CreateAlertChannels({
|
||||
onMsTeamsHandler,
|
||||
onEmailHandler,
|
||||
onGoogleChatHandler,
|
||||
onJiraHandler,
|
||||
onJsmOpsHandler,
|
||||
notifications,
|
||||
t,
|
||||
],
|
||||
@@ -648,20 +528,6 @@ function CreateAlertChannels({
|
||||
}
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.Jira:
|
||||
if (!validateJiraConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.JsmOps:
|
||||
if (!validateJsmOpsConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
break;
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
@@ -710,8 +576,6 @@ function CreateAlertChannels({
|
||||
prepareMsTeamsRequest,
|
||||
prepareEmailRequest,
|
||||
validateGoogleChatConfig,
|
||||
validateJiraConfig,
|
||||
validateJsmOpsConfig,
|
||||
testChannel,
|
||||
notifications,
|
||||
],
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import {
|
||||
AlertmanagertypesJiraReceiverConfigDTO,
|
||||
AlertmanagertypesJSMOpsReceiverConfigDTO,
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
ConfigSecretURLDTO,
|
||||
ModelDurationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
ChannelType,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
} from './config';
|
||||
import { ChannelType, GoogleChatChannel } from './config';
|
||||
|
||||
export const isChannelType = (type: string): type is ChannelType =>
|
||||
Object.values(ChannelType).includes(type as ChannelType);
|
||||
@@ -45,126 +37,3 @@ export const prepareGoogleChatRequest = (
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
|
||||
|
||||
// the backend enforces the same rule, this is only for a nicer error experience
|
||||
export const isValidJiraSiteURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
return (
|
||||
protocol === 'https:' &&
|
||||
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// mirrors go's prometheus model.Duration units
|
||||
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
|
||||
ms: 1,
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
w: 604_800_000,
|
||||
y: 31_536_000_000,
|
||||
};
|
||||
const JIRA_DURATION_RE = /^(\d+(ms|s|m|h|d|w|y))+$/;
|
||||
const JIRA_DURATION_TOKEN_RE = /(\d+)(ms|s|m|h|d|w|y)/g;
|
||||
const JIRA_MIN_REOPEN_MS = 60_000;
|
||||
|
||||
// backend requires the same format and a >= 1m minimum, this is only for a
|
||||
// nicer error experience. Empty and "0" defer to the backend default.
|
||||
export const isValidJiraReopenDuration = (value: string): boolean => {
|
||||
if (!value || value === '0') {
|
||||
return true;
|
||||
}
|
||||
if (!JIRA_DURATION_RE.test(value)) {
|
||||
return false;
|
||||
}
|
||||
let totalMs = 0;
|
||||
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
|
||||
totalMs += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
|
||||
}
|
||||
return totalMs >= JIRA_MIN_REOPEN_MS;
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape. Optional fields are
|
||||
// omitted when empty so the backend applies its defaults.
|
||||
export const prepareJiraRequest = (
|
||||
config: Partial<JiraChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => {
|
||||
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
|
||||
site: config.site || '',
|
||||
project: config.project || '',
|
||||
issue_type: config.issue_type || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
http_config: {
|
||||
basic_auth: {
|
||||
username: config.username || '',
|
||||
password: config.password || '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (config.summary) {
|
||||
jira.summary = config.summary;
|
||||
}
|
||||
if (config.description) {
|
||||
jira.description = config.description;
|
||||
}
|
||||
if (config.priority) {
|
||||
jira.priority = config.priority;
|
||||
}
|
||||
if (config.labels?.length) {
|
||||
jira.labels = config.labels;
|
||||
}
|
||||
if (config.resolve_transition) {
|
||||
jira.resolve_transition = config.resolve_transition;
|
||||
}
|
||||
if (config.reopen_transition) {
|
||||
jira.reopen_transition = config.reopen_transition;
|
||||
}
|
||||
if (config.reopen_duration) {
|
||||
// the generated type models go's model.Duration as a number, the api takes a
|
||||
// duration string like "72h"
|
||||
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jira_configs: [jira],
|
||||
};
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape. Optional fields are
|
||||
// omitted when empty so the backend applies its defaults.
|
||||
export const prepareJsmOpsRequest = (
|
||||
config: Partial<JsmOpsChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => {
|
||||
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
|
||||
api_key: config.api_key || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
};
|
||||
|
||||
if (config.message) {
|
||||
jsmops.message = config.message;
|
||||
}
|
||||
if (config.description) {
|
||||
jsmops.description = config.description;
|
||||
}
|
||||
if (config.priority) {
|
||||
jsmops.priority = config.priority;
|
||||
}
|
||||
if (config.tags?.length) {
|
||||
// the backend takes a comma-separated string and splits it back
|
||||
jsmops.tags = config.tags.join(',');
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jsmops_configs: [jsmops],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
@@ -36,11 +34,7 @@ import {
|
||||
} from 'container/CreateAlertChannels/config';
|
||||
import {
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
prepareGoogleChatRequest,
|
||||
prepareJiraRequest,
|
||||
prepareJsmOpsRequest,
|
||||
} from 'container/CreateAlertChannels/utils';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -64,9 +58,7 @@ function EditAlertChannels({
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
GoogleChatChannel
|
||||
>
|
||||
>({
|
||||
...initialValue,
|
||||
@@ -460,124 +452,6 @@ function EditAlertChannels({
|
||||
t,
|
||||
]);
|
||||
|
||||
const validateJiraConfig = useCallback((): string => {
|
||||
if (
|
||||
!selectedConfig.site ||
|
||||
!selectedConfig.username ||
|
||||
!selectedConfig.password ||
|
||||
!selectedConfig.project ||
|
||||
!selectedConfig.issue_type
|
||||
) {
|
||||
return t('jira_required_fields');
|
||||
}
|
||||
|
||||
if (!isValidJiraSiteURL(selectedConfig.site)) {
|
||||
return t('jira_site_invalid');
|
||||
}
|
||||
|
||||
if (
|
||||
selectedConfig.reopen_duration &&
|
||||
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
|
||||
) {
|
||||
return t('jira_reopen_duration_invalid');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onJiraEditHandler = useCallback(async () => {
|
||||
const validationError = validateJiraConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareJiraRequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJiraConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const validateJsmOpsConfig = useCallback((): string => {
|
||||
if (!selectedConfig.api_key) {
|
||||
return t('api_key_required');
|
||||
}
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onJsmOpsEditHandler = useCallback(async () => {
|
||||
const validationError = validateJsmOpsConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareJsmOpsRequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJsmOpsConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
let result;
|
||||
@@ -595,10 +469,6 @@ function EditAlertChannels({
|
||||
result = await onEmailEditHandler();
|
||||
} else if (value === ChannelType.GoogleChat) {
|
||||
result = await onGoogleChatEditHandler();
|
||||
} else if (value === ChannelType.Jira) {
|
||||
result = await onJiraEditHandler();
|
||||
} else if (value === ChannelType.JsmOps) {
|
||||
result = await onJsmOpsEditHandler();
|
||||
}
|
||||
logEvent('Alert Channel: Save channel', {
|
||||
type: value,
|
||||
@@ -618,13 +488,10 @@ function EditAlertChannels({
|
||||
onOpsgenieEditHandler,
|
||||
onEmailEditHandler,
|
||||
onGoogleChatEditHandler,
|
||||
onJiraEditHandler,
|
||||
onJsmOpsEditHandler,
|
||||
],
|
||||
);
|
||||
|
||||
const performChannelTest = useCallback(
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
async (channelType: ChannelType) => {
|
||||
setTestingState(true);
|
||||
try {
|
||||
@@ -675,32 +542,6 @@ function EditAlertChannels({
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
case ChannelType.Jira: {
|
||||
const validationError = validateJiraConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
case ChannelType.JsmOps: {
|
||||
const validationError = validateJsmOpsConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
@@ -738,8 +579,6 @@ function EditAlertChannels({
|
||||
t,
|
||||
notifyError,
|
||||
validateGoogleChatConfig,
|
||||
validateJiraConfig,
|
||||
validateJsmOpsConfig,
|
||||
testChannel,
|
||||
prepareWebhookRequest,
|
||||
preparePagerRequest,
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { useSaveView } from 'hooks/saveViews/useSaveView';
|
||||
@@ -287,7 +287,8 @@ function ExplorerOptions({
|
||||
|
||||
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
|
||||
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
|
||||
|
||||
const extraData = viewsData?.data?.data?.find(
|
||||
(view) => view.id === viewKey,
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { JiraChannel } from '../../CreateAlertChannels/config';
|
||||
import {
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
} from '../../CreateAlertChannels/utils';
|
||||
|
||||
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const update = (patch: Partial<JiraChannel>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const advanced = (
|
||||
<>
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label={t('field_jira_priority')}
|
||||
help={t('help_jira_priority')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_priority')}
|
||||
onChange={(event): void => update({ priority: event.target.value })}
|
||||
data-testid="jira-priority-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="labels"
|
||||
label={t('field_jira_labels')}
|
||||
help={t('help_jira_labels')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
open={false}
|
||||
placeholder={t('placeholder_jira_labels')}
|
||||
onChange={(value): void => update({ labels: value as string[] })}
|
||||
data-testid="jira-labels-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="resolve_transition"
|
||||
label={t('field_jira_resolve_transition')}
|
||||
help={t('help_jira_resolve_transition')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_resolve_transition')}
|
||||
onChange={(event): void =>
|
||||
update({ resolve_transition: event.target.value })
|
||||
}
|
||||
data-testid="jira-resolve-transition-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reopen_transition"
|
||||
label={t('field_jira_reopen_transition')}
|
||||
help={t('help_jira_reopen_transition')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_reopen_transition')}
|
||||
onChange={(event): void =>
|
||||
update({ reopen_transition: event.target.value })
|
||||
}
|
||||
data-testid="jira-reopen-transition-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reopen_duration"
|
||||
label={t('field_jira_reopen_duration')}
|
||||
extra={t('help_jira_reopen_duration')}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string): Promise<void> =>
|
||||
isValidJiraReopenDuration(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(t('jira_reopen_duration_invalid'))),
|
||||
},
|
||||
]}
|
||||
tooltip={{
|
||||
title: (
|
||||
<MarkdownRenderer
|
||||
markdownContent={t('tooltip_jira_reopen_duration')}
|
||||
variables={{}}
|
||||
/>
|
||||
),
|
||||
overlayInnerStyle: { maxWidth: 400 },
|
||||
placement: 'right',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_reopen_duration')}
|
||||
onChange={(event): void => update({ reopen_duration: event.target.value })}
|
||||
data-testid="jira-reopen-duration-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="sm"
|
||||
testId="jira-service-account-tip"
|
||||
style={{ display: 'block', marginBottom: 16 }}
|
||||
>
|
||||
{t('jira_service_account_tip')}{' '}
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('jira_service_account_tip_link')}
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
|
||||
<Form.Item
|
||||
name="site"
|
||||
label={t('field_jira_site')}
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string): Promise<void> =>
|
||||
!value || isValidJiraSiteURL(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(t('jira_site_invalid'))),
|
||||
},
|
||||
]}
|
||||
tooltip={{
|
||||
title: (
|
||||
<MarkdownRenderer
|
||||
markdownContent={t('tooltip_jira_site')}
|
||||
variables={{}}
|
||||
/>
|
||||
),
|
||||
overlayInnerStyle: { maxWidth: 400 },
|
||||
placement: 'right',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://your-domain.atlassian.net"
|
||||
onChange={(event): void => update({ site: event.target.value })}
|
||||
data-testid="jira-site-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={t('field_jira_email')}
|
||||
help={t('help_jira_email')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => update({ username: event.target.value })}
|
||||
data-testid="jira-email-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('field_jira_api_token')}
|
||||
help={t('help_jira_api_token')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => update({ password: event.target.value })}
|
||||
data-testid="jira-api-token-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="project" label={t('field_jira_project')} required>
|
||||
<Input
|
||||
placeholder="e.g. OPS"
|
||||
onChange={(event): void => update({ project: event.target.value })}
|
||||
data-testid="jira-project-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="issue_type"
|
||||
label={t('field_jira_issue_type')}
|
||||
help={t('help_jira_issue_type')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => update({ issue_type: event.target.value })}
|
||||
data-testid="jira-issue-type-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="summary"
|
||||
label={t('field_jira_summary')}
|
||||
help={t('help_jira_summary')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
onChange={(event): void => update({ summary: event.target.value })}
|
||||
data-testid="jira-summary-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('field_jira_description')}
|
||||
help={t('help_jira_description')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
onChange={(event): void => update({ description: event.target.value })}
|
||||
data-testid="jira-description-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('jira_advanced_section'),
|
||||
children: advanced,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface JiraProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
|
||||
}
|
||||
|
||||
export default JiraSettings;
|
||||
@@ -1,117 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const update = (patch: Partial<JsmOpsChannel>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const advanced = (
|
||||
<>
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label={t('field_jsmops_priority')}
|
||||
help={t('help_jsmops_priority')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
onChange={(event): void => update({ priority: event.target.value })}
|
||||
data-testid="jsmops-priority-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label={t('field_jsmops_tags')}
|
||||
help={t('help_jsmops_tags')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
open={false}
|
||||
placeholder={t('placeholder_jsmops_tags')}
|
||||
onChange={(value): void => update({ tags: value as string[] })}
|
||||
data-testid="jsmops-tags-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="sm"
|
||||
testId="jsmops-tip"
|
||||
style={{ display: 'block', marginBottom: 16 }}
|
||||
>
|
||||
{t('jsmops_tip')}{' '}
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('jsmops_tip_link')}
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
|
||||
<Form.Item
|
||||
name="api_key"
|
||||
label={t('field_jsmops_api_key')}
|
||||
help={t('help_jsmops_api_key')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => update({ api_key: event.target.value })}
|
||||
data-testid="jsmops-api-key-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="message"
|
||||
label={t('field_jsmops_message')}
|
||||
help={t('help_jsmops_message')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
onChange={(event): void => update({ message: event.target.value })}
|
||||
data-testid="jsmops-message-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('field_jsmops_description')}
|
||||
help={t('help_jsmops_description')}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
onChange={(event): void => update({ description: event.target.value })}
|
||||
data-testid="jsmops-description-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('jsmops_advanced_section'),
|
||||
children: advanced,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface JsmOpsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
|
||||
}
|
||||
|
||||
export default JsmOpsSettings;
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
@@ -21,8 +19,6 @@ import history from 'lib/history';
|
||||
|
||||
import EmailSettings from './Settings/Email';
|
||||
import GoogleChatSettings from './Settings/GoogleChat';
|
||||
import JiraSettings from './Settings/Jira';
|
||||
import JsmOpsSettings from './Settings/JsmOps';
|
||||
import MsTeamsSettings from './Settings/MsTeams';
|
||||
import OpsgenieSettings from './Settings/Opsgenie';
|
||||
import PagerSettings from './Settings/Pager';
|
||||
@@ -57,10 +53,6 @@ function FormAlertChannels({
|
||||
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.GoogleChat:
|
||||
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Jira:
|
||||
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.JsmOps:
|
||||
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Opsgenie:
|
||||
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Email:
|
||||
@@ -149,14 +141,6 @@ function FormAlertChannels({
|
||||
>
|
||||
Google Chat
|
||||
</Select.Option>
|
||||
|
||||
<Select.Option value="jira" key="jira" data-testid="select-option">
|
||||
Jira
|
||||
</Select.Option>
|
||||
|
||||
<Select.Option value="jsmops" key="jsmops" data-testid="select-option">
|
||||
Jira Service Management Ops
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
@@ -205,9 +189,7 @@ interface FormAlertChannelsProps {
|
||||
PagerChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
GoogleChatChannel
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -53,24 +53,17 @@ export const getUpdatedStepInterval = (evalWindow?: string): number => {
|
||||
};
|
||||
|
||||
export const getSelectedQueryOptions = (
|
||||
queries:
|
||||
| Array<
|
||||
| IBuilderQuery
|
||||
| IBuilderTraceOperator
|
||||
| IBuilderFormula
|
||||
| IClickHouseQuery
|
||||
| IPromQLQuery
|
||||
>
|
||||
| undefined
|
||||
| null,
|
||||
): SelectProps['options'] => {
|
||||
if (!queries) {
|
||||
return [];
|
||||
}
|
||||
return queries
|
||||
queries: Array<
|
||||
| IBuilderQuery
|
||||
| IBuilderTraceOperator
|
||||
| IBuilderFormula
|
||||
| IClickHouseQuery
|
||||
| IPromQLQuery
|
||||
>,
|
||||
): SelectProps['options'] =>
|
||||
queries
|
||||
.filter((query) => !query.disabled)
|
||||
.map((query) => ({
|
||||
label: 'queryName' in query ? query.queryName : query.name,
|
||||
value: 'queryName' in query ? query.queryName : query.name,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -4,9 +4,8 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import styles from './ColumnHeader.module.scss';
|
||||
import cx from 'classnames';
|
||||
import { MouseEventHandler } from 'react';
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
|
||||
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface ColumnHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -44,7 +43,7 @@ function ColumnHeader({
|
||||
<div onClick={stopPropagationHandler}>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={stopPropagationHandler}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
|
||||
@@ -2,9 +2,8 @@ import { Group, Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './EntityGroupHeader.module.scss';
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
|
||||
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface EntityGroupHeaderProps {
|
||||
title: string;
|
||||
@@ -29,7 +28,7 @@ function EntityGroupHeader({
|
||||
<>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Copy, X } from '@signozhq/icons';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
@@ -18,7 +20,6 @@ import {
|
||||
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
|
||||
import { useInfraMonitoringSelectedItemParams } from '../hooks';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
|
||||
@@ -94,14 +95,12 @@ export default function K8sBaseDetails<T>({
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
selectedItemParams.containerName,
|
||||
),
|
||||
[
|
||||
queryKeyPrefix,
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
selectedItemParams.containerName,
|
||||
selectedTime,
|
||||
getAutoRefreshQueryKey,
|
||||
],
|
||||
@@ -171,9 +170,14 @@ export default function K8sBaseDetails<T>({
|
||||
[handleClose],
|
||||
);
|
||||
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}, []);
|
||||
if (selectedItem) {
|
||||
copyToClipboard(selectedItem);
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}
|
||||
}, [copyToClipboard, selectedItem]);
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
@@ -207,13 +211,17 @@ export default function K8sBaseDetails<T>({
|
||||
(isEntityLoading && 'Loading...') ||
|
||||
'-'}
|
||||
</Typography.Text>
|
||||
<CopyButton
|
||||
value={selectedItem ?? ''}
|
||||
ariaLabel="Copy ID"
|
||||
className={styles.copyIdButton}
|
||||
testId="copy-id-button"
|
||||
onCopy={handleCopyId}
|
||||
/>
|
||||
<TooltipSimple title="Copy ID">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleCopyId}
|
||||
data-testid="copy-id-button"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</>
|
||||
) as unknown as string;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
} from '../hooks';
|
||||
|
||||
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
|
||||
import { EntityMetadataItem } from './components/EntityMetadataItem/EntityMetadataItem';
|
||||
import { K8sBaseDetailsContentProps } from './types';
|
||||
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
|
||||
|
||||
@@ -239,18 +239,41 @@ export default function K8sBaseDetailsContent<T>({
|
||||
<>
|
||||
<div className={styles.entityDetailsEntity}>
|
||||
<div className={styles.entityDetailsGrid}>
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
|
||||
return (
|
||||
<EntityMetadataItem
|
||||
<div className={styles.labelsRow}>
|
||||
{metadataConfig.map((config) => (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
label={config.label}
|
||||
value={String(value)}
|
||||
renderedValue={config.render?.(value, entity)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.entityDetailsMetadataLabel}
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.valuesRow}>
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
|
||||
if (config.render) {
|
||||
return config.render(value, entity);
|
||||
}
|
||||
|
||||
const displayValue = String(value);
|
||||
return (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.entityDetailsMetadataValue}
|
||||
>
|
||||
{displayValue}
|
||||
</Typography.Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
|
||||
@@ -296,7 +296,6 @@ export function K8sBaseList<
|
||||
params.selectedItem,
|
||||
params.clusterName,
|
||||
params.namespaceName,
|
||||
params.containerName,
|
||||
);
|
||||
queryClient.setQueryData(detailQueryKey, { data: record });
|
||||
}
|
||||
@@ -349,12 +348,6 @@ export function K8sBaseList<
|
||||
params.namespaceName,
|
||||
);
|
||||
}
|
||||
if (params.containerName) {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME,
|
||||
params.containerName,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
|
||||
|
||||
@@ -19,12 +19,6 @@
|
||||
& [data-hide-expanded='true'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// The icon slot is rendered even when the icon inside it is hidden, and the
|
||||
// header's flex gap then indents the title past the values below it.
|
||||
& [data-slot='icon']:has([data-hide-expanded='true']) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.expandedTableFooter {
|
||||
|
||||
@@ -214,7 +214,6 @@ export function K8sExpandedRow<
|
||||
params.selectedItem,
|
||||
params.clusterName,
|
||||
params.namespaceName,
|
||||
params.containerName,
|
||||
);
|
||||
queryClient.setQueryData(detailQueryKey, { data: row });
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
.metadataItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.valueRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Single-line ellipsis rather than Typography's `truncate`, which line-clamps:
|
||||
// clamping still wraps the text, so a value breaking at a hyphen ends its line
|
||||
// early and leaves a gap between the ellipsis and the copy button.
|
||||
//
|
||||
// This has to be a block: overflow and text-overflow do not apply to inline
|
||||
// boxes, and Typography.Text renders an inline span.
|
||||
.value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.valueText {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
}
|
||||
|
||||
// Sized to the icon rather than the default 2rem tap target, so it sits flush
|
||||
// against the value instead of floating in its own block of padding.
|
||||
.copyButton {
|
||||
--button-padding: 2px;
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
export interface EntityMetadataItemProps {
|
||||
label: string;
|
||||
value: string;
|
||||
/** An entity-supplied renderer, which opts out of clamping, tooltip and copy. */
|
||||
renderedValue?: ReactNode;
|
||||
}
|
||||
|
||||
export function EntityMetadataItem({
|
||||
label,
|
||||
value,
|
||||
renderedValue,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
const handleCopy = useCallback((): void => {
|
||||
toast.success(`${label} copied to clipboard`, { position: 'bottom-left' });
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<div className={styles.metadataItem}>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.label}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
|
||||
{renderedValue ?? (
|
||||
<div className={styles.valueRow}>
|
||||
<TooltipSimple title={value} arrow side="bottom" align="start">
|
||||
<span className={styles.value}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.valueText}
|
||||
>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
{!!value && (
|
||||
<CopyButton
|
||||
value={value}
|
||||
size={10}
|
||||
ariaLabel={`Copy ${label}`}
|
||||
className={styles.copyButton}
|
||||
testId={`copy-metadata-${label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { EntityMetadataItem } from '../EntityMetadataItem';
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
|
||||
}));
|
||||
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('EntityMetadataItem', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the label and its value', () => {
|
||||
render(<EntityMetadataItem label="Cluster Name" value="prod-cluster" />);
|
||||
|
||||
expect(screen.getByText('Cluster Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('prod-cluster')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies the full value and confirms which field was copied', async () => {
|
||||
render(
|
||||
<EntityMetadataItem
|
||||
label="Image:Tag"
|
||||
value="ghcr.io/open-telemetry/demo:1.12.0-loadgenerator"
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('copy-metadata-image:tag'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'ghcr.io/open-telemetry/demo:1.12.0-loadgenerator',
|
||||
);
|
||||
});
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'Image:Tag copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers no copy control when the value is empty', () => {
|
||||
render(<EntityMetadataItem label="Node" value="" />);
|
||||
|
||||
expect(screen.queryByTestId('copy-metadata-node')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the full value on hover', async () => {
|
||||
render(
|
||||
<EntityMetadataItem
|
||||
label="Node"
|
||||
value="gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t"
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.hover(
|
||||
screen.getByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t'),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getAllByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t').length,
|
||||
).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('never presents the value as clickable', () => {
|
||||
render(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
|
||||
|
||||
const valueEl = screen.getByText('a-very-long-node-name');
|
||||
expect(valueEl).not.toHaveAttribute('data-interactive');
|
||||
expect(valueEl).not.toHaveAttribute('data-truncate');
|
||||
});
|
||||
|
||||
it('triggers the tooltip from the wrapper, never from the text itself', () => {
|
||||
render(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
|
||||
|
||||
// Radix merges its handlers onto the trigger, and Typography styles
|
||||
// itself interactive off any merged onClick — so the trigger has to stay
|
||||
// off the text.
|
||||
const textEl = screen.getByText('a-very-long-node-name');
|
||||
expect(textEl).not.toHaveAttribute('data-slot', 'tooltip-trigger');
|
||||
expect(textEl.parentElement).toHaveAttribute('data-slot', 'tooltip-trigger');
|
||||
});
|
||||
|
||||
it('leaves an entity-supplied renderer alone', () => {
|
||||
render(
|
||||
<EntityMetadataItem
|
||||
label="Status"
|
||||
value="running"
|
||||
renderedValue={<span data-testid="custom">custom node</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('copy-metadata-status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import { jobEntityConfig } from '../Jobs/entity.config';
|
||||
import { daemonSetEntityConfig } from '../DaemonSets/entity.config';
|
||||
import { statefulSetEntityConfig } from '../StatefulSets/entity.config';
|
||||
import { volumeEntityConfig } from '../Volumes/entity.config';
|
||||
import { containerEntityConfig } from '../Containers/entity.config';
|
||||
|
||||
type AnyEntityConfig = K8sEntityConfig<
|
||||
K8sEntityData,
|
||||
@@ -35,7 +34,6 @@ export const entityRegistry: Record<string, AnyEntityConfig> = {
|
||||
[K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig),
|
||||
[K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig),
|
||||
[K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig),
|
||||
[K8sCategories.CONTAINERS]: registerConfig(containerEntityConfig),
|
||||
};
|
||||
|
||||
export function getEntityConfig(category: string): AnyEntityConfig | undefined {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
k8sContainerGetSelectedItemExpression,
|
||||
k8sContainerInitialEventsExpression,
|
||||
k8sContainerInitialLogTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Containers/constants';
|
||||
import { getContainerMetricsQueryPayload } from 'container/InfraMonitoringK8sV2/Containers/metrics';
|
||||
import {
|
||||
getK8sContainerItemKey,
|
||||
getK8sContainerRowKey,
|
||||
} from 'container/InfraMonitoringK8sV2/Containers/table.config';
|
||||
import { getContainerImageWithTag } from 'container/InfraMonitoringK8sV2/Containers/utils';
|
||||
|
||||
function makeContainer(
|
||||
overrides: Partial<InframonitoringtypesContainerRecordDTO> = {},
|
||||
): InframonitoringtypesContainerRecordDTO {
|
||||
return {
|
||||
containerName: 'nginx',
|
||||
podUID: 'pod-uid-1',
|
||||
meta: {
|
||||
'k8s.container.name': 'nginx',
|
||||
'k8s.pod.uid': 'pod-uid-1',
|
||||
'k8s.pod.name': 'web-0',
|
||||
'k8s.namespace.name': 'production',
|
||||
'k8s.cluster.name': 'prod-cluster',
|
||||
'container.image.name': 'nginx',
|
||||
'container.image.tag': '1.27',
|
||||
},
|
||||
...overrides,
|
||||
} as InframonitoringtypesContainerRecordDTO;
|
||||
}
|
||||
|
||||
describe('container identity', () => {
|
||||
it('keys a row by the (pod UID, container name) pair', () => {
|
||||
expect(getK8sContainerRowKey(makeContainer())).toBe('pod-uid-1/nginx');
|
||||
});
|
||||
|
||||
it('carries the container name alongside the pod UID into the drawer params', () => {
|
||||
expect(getK8sContainerItemKey(makeContainer())).toStrictEqual({
|
||||
selectedItem: 'pod-uid-1',
|
||||
containerName: 'nginx',
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to meta when the record fields are empty', () => {
|
||||
const container = makeContainer({ containerName: '', podUID: '' });
|
||||
|
||||
expect(getK8sContainerItemKey(container)).toStrictEqual({
|
||||
selectedItem: 'pod-uid-1',
|
||||
containerName: 'nginx',
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes the details fetch to both halves of the identity', () => {
|
||||
expect(
|
||||
k8sContainerGetSelectedItemExpression({
|
||||
selectedItem: 'pod-uid-1',
|
||||
containerName: 'nginx',
|
||||
}),
|
||||
).toBe("k8s.pod.uid = 'pod-uid-1' AND k8s.container.name = 'nginx'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerImageWithTag', () => {
|
||||
it('renders name and tag together', () => {
|
||||
expect(getContainerImageWithTag(makeContainer())).toBe('nginx:1.27');
|
||||
});
|
||||
|
||||
it('drops the tag when the image is not pinned', () => {
|
||||
const container = makeContainer({
|
||||
meta: { 'container.image.name': 'nginx' },
|
||||
});
|
||||
|
||||
expect(getContainerImageWithTag(container)).toBe('nginx');
|
||||
});
|
||||
|
||||
it('renders nothing when the image name is missing', () => {
|
||||
expect(getContainerImageWithTag(makeContainer({ meta: {} }))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('container drawer expressions', () => {
|
||||
it('scopes logs and traces to the container within its pod', () => {
|
||||
expect(k8sContainerInitialLogTracesExpression(makeContainer())).toBe(
|
||||
"k8s.pod.uid = 'pod-uid-1' AND k8s.cluster.name = 'prod-cluster' AND k8s.namespace.name = 'production' AND k8s.container.name = 'nginx'",
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes events to the pod, since k8s emits events per pod', () => {
|
||||
expect(k8sContainerInitialEventsExpression(makeContainer())).toBe(
|
||||
"k8s.object.kind = 'Pod' AND k8s.object.name = 'web-0' AND k8s.cluster.name = 'prod-cluster' AND attribute.k8s.namespace.name = 'production'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerMetricsQueryPayload', () => {
|
||||
const payloads = getContainerMetricsQueryPayload(makeContainer(), 1000, 2000);
|
||||
|
||||
it('returns one payload per documented chart', () => {
|
||||
expect(payloads).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('scopes every query to the selected container', () => {
|
||||
payloads.forEach((payload) => {
|
||||
payload.query.builder.queryData.forEach((query) => {
|
||||
expect(query.filters?.items).toStrictEqual([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'k8s.pod.uid' }),
|
||||
op: '=',
|
||||
value: 'pod-uid-1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'k8s.container.name' }),
|
||||
op: '=',
|
||||
value: 'nginx',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('derives cache memory from the working set and RSS queries', () => {
|
||||
const memoryByState = payloads[4];
|
||||
|
||||
expect(
|
||||
memoryByState.query.builder.queryData.map((query) => [
|
||||
query.queryName,
|
||||
query.aggregateAttribute?.key,
|
||||
]),
|
||||
).toStrictEqual([
|
||||
['A', 'container.memory.rss'],
|
||||
['B', 'container.memory.working_set'],
|
||||
]);
|
||||
expect(memoryByState.query.builder.queryFormulas).toStrictEqual([
|
||||
expect.objectContaining({ expression: 'B - A', legend: 'Cache Memory' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
import {
|
||||
buildEventsExpression,
|
||||
buildLogsTracesExpression,
|
||||
} from '../Base/utils';
|
||||
import { K8sDetailsMetadataConfig, K8sDetailsWidgetInfo } from '../Base/types';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
CONTAINERS_DOC_PATH,
|
||||
getContainerImageWithTag,
|
||||
getContainerName,
|
||||
getContainerPodUID,
|
||||
} from './utils';
|
||||
|
||||
/** A container row is identified by the (pod UID, container name) pair. */
|
||||
export const k8sContainerGetSelectedItemExpression = (
|
||||
params: SelectedItemParams,
|
||||
): string =>
|
||||
[
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID} = ${formatValueForExpression(
|
||||
params.selectedItem ?? '',
|
||||
)}`,
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME} = ${formatValueForExpression(
|
||||
params.containerName ?? '',
|
||||
)}`,
|
||||
].join(' AND ');
|
||||
|
||||
export const k8sContainerGetEntityName = getContainerName;
|
||||
|
||||
export const k8sContainerDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesContainerRecordDTO>[] =
|
||||
[
|
||||
{
|
||||
label: 'Pod',
|
||||
getValue: (c): string =>
|
||||
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
|
||||
},
|
||||
{
|
||||
label: 'NAMESPACE',
|
||||
getValue: (c): string =>
|
||||
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
|
||||
},
|
||||
{
|
||||
label: 'Node',
|
||||
getValue: (c): string =>
|
||||
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
|
||||
},
|
||||
{
|
||||
label: 'Cluster Name',
|
||||
getValue: (c): string =>
|
||||
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
|
||||
},
|
||||
{
|
||||
label: 'Image:Tag',
|
||||
getValue: getContainerImageWithTag,
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sContainerInitialLogTracesExpression = (
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string => {
|
||||
const base = buildLogsTracesExpression({
|
||||
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
mainAttributeValue: getContainerPodUID(container),
|
||||
clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
|
||||
namespaceName:
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
|
||||
});
|
||||
|
||||
const containerName = getContainerName(container);
|
||||
if (!containerName) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const containerClause = `${
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME
|
||||
} = ${formatValueForExpression(containerName)}`;
|
||||
|
||||
return base ? `${base} AND ${containerClause}` : containerClause;
|
||||
};
|
||||
|
||||
/**
|
||||
* Kubernetes emits events against the pod, not the container, so the events tab
|
||||
* is scoped to the container's pod.
|
||||
*/
|
||||
export const k8sContainerInitialEventsExpression = (
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string =>
|
||||
buildEventsExpression({
|
||||
objectKind: 'Pod',
|
||||
objectName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
|
||||
clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
|
||||
namespaceName:
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
|
||||
});
|
||||
|
||||
export const containerWidgetInfo: K8sDetailsWidgetInfo[] = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#cpu-usage-cores-1`,
|
||||
description:
|
||||
'Avg, max and min CPU consumption of the container in cores, showing how bursty it is.',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request, Limit Utilization',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#cpu-request-limit-utilization`,
|
||||
description:
|
||||
'Container CPU usage as a fraction of its own CPU request and limit; limit lines near 100% mean throttling.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#memory-usage-bytes`,
|
||||
description:
|
||||
'Total memory charged to the container, including reclaimable page cache, against the headroom left before its limit.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Request, Limit Utilization',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#memory-request-limit-utilization`,
|
||||
description:
|
||||
'Container memory usage as a fraction of its own memory request and limit; limit lines near 100% risk an OOMKill.',
|
||||
},
|
||||
{
|
||||
title: 'Memory by State',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#memory-by-state`,
|
||||
description:
|
||||
'RSS, working set and cache memory of the container, separating heap growth from file cache.',
|
||||
},
|
||||
{
|
||||
title: 'Memory Major Page Faults',
|
||||
yAxisUnit: '',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#memory-major-page-faults`,
|
||||
description:
|
||||
'Major page fault rate of the container; sustained values mean the working set is paging to disk.',
|
||||
},
|
||||
{
|
||||
title: 'File System (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#file-system-bytes`,
|
||||
description:
|
||||
'Capacity, available and used bytes of the container filesystem.',
|
||||
},
|
||||
{
|
||||
title: 'Container Uptime',
|
||||
yAxisUnit: 's',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#container-uptime`,
|
||||
description:
|
||||
'Time since the container last started; a sawtooth of resets means it is restarting repeatedly.',
|
||||
},
|
||||
{
|
||||
title: 'Node CPU Utilization by Container',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#node-cpu-utilization-by-container`,
|
||||
description:
|
||||
"The container's CPU usage as a fraction of the whole node's capacity, to spot noisy neighbours.",
|
||||
},
|
||||
{
|
||||
title: 'Node Memory Utilization by Container',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: `${CONTAINERS_DOC_PATH}#node-memory-utilization-by-container`,
|
||||
description:
|
||||
"The container's memory usage as a fraction of the whole node's capacity.",
|
||||
},
|
||||
];
|
||||
@@ -1,140 +0,0 @@
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listContainers } from 'api/generated/services/inframonitoring';
|
||||
import {
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { K8sEntityConfig } from '../Base/entity.config.types';
|
||||
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
containerWidgetInfo,
|
||||
k8sContainerDetailsMetadataConfig,
|
||||
k8sContainerGetEntityName,
|
||||
k8sContainerGetSelectedItemExpression,
|
||||
k8sContainerInitialEventsExpression,
|
||||
k8sContainerInitialLogTracesExpression,
|
||||
} from './constants';
|
||||
import { getContainerMetricsQueryPayload } from './metrics';
|
||||
import {
|
||||
getK8sContainerItemKey,
|
||||
getK8sContainerRowKey,
|
||||
k8sContainerColumnsConfig,
|
||||
} from './table.config';
|
||||
|
||||
async function fetchListData(
|
||||
filters: K8sBaseFilters,
|
||||
signal?: AbortSignal,
|
||||
): ReturnType<
|
||||
K8sEntityConfig<
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
SelectedItemParams
|
||||
>['list']['fetchListData']
|
||||
> {
|
||||
try {
|
||||
const response = await listContainers(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
|
||||
offset: filters.offset,
|
||||
limit: filters.limit ?? 10,
|
||||
start: filters.start,
|
||||
end: filters.end,
|
||||
orderBy: filters.orderBy
|
||||
? {
|
||||
key: { name: filters.orderBy.key.name },
|
||||
direction:
|
||||
filters.orderBy.direction === 'asc'
|
||||
? Querybuildertypesv5OrderDirectionDTO.asc
|
||||
: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
return {
|
||||
type:
|
||||
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
|
||||
? ('grouped_list' as const)
|
||||
: ('list' as const),
|
||||
records: data.records,
|
||||
total: data.total,
|
||||
endTimeBeforeRetention: data.endTimeBeforeRetention,
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesContainerRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEntityData(
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
): ReturnType<
|
||||
K8sEntityConfig<InframonitoringtypesContainerRecordDTO>['details']['fetchEntityData']
|
||||
> {
|
||||
try {
|
||||
const response = await listContainers(
|
||||
{
|
||||
filter: { expression: filters.filter.expression },
|
||||
start: filters.start,
|
||||
end: filters.end,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
return {
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const containerEntityConfig: K8sEntityConfig<
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
SelectedItemParams
|
||||
> = {
|
||||
list: {
|
||||
entity: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
tableColumns: k8sContainerColumnsConfig,
|
||||
fetchListData,
|
||||
getRowKey: getK8sContainerRowKey,
|
||||
getItemKey: getK8sContainerItemKey,
|
||||
detailsQueryKeyPrefix: 'container',
|
||||
},
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
queryKeyPrefix: 'container',
|
||||
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
getEntityName: k8sContainerGetEntityName,
|
||||
getInitialLogTracesExpression: k8sContainerInitialLogTracesExpression,
|
||||
getInitialEventsExpression: k8sContainerInitialEventsExpression,
|
||||
metadataConfig: k8sContainerDetailsMetadataConfig,
|
||||
entityWidgetInfo: containerWidgetInfo,
|
||||
getEntityQueryPayload: getContainerMetricsQueryPayload,
|
||||
},
|
||||
};
|
||||
@@ -1,275 +0,0 @@
|
||||
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { getContainerName, getContainerPodUID } from './utils';
|
||||
|
||||
const QUERY_NAMES = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
const STEP_INTERVAL = 60;
|
||||
|
||||
type TimeAggregation = 'avg' | 'max' | 'min' | 'latest';
|
||||
type SpaceAggregation = 'sum' | 'avg' | 'max';
|
||||
|
||||
interface SeriesSpec {
|
||||
metricKey: string;
|
||||
legend: string;
|
||||
timeAggregation: TimeAggregation;
|
||||
spaceAggregation: SpaceAggregation;
|
||||
}
|
||||
|
||||
interface FormulaSpec {
|
||||
expression: string;
|
||||
legend: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every panel is scoped to a single container by the (k8s.pod.uid,
|
||||
* k8s.container.name) pair that identifies its row in the list.
|
||||
*/
|
||||
function buildScopeFilters(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): TagFilter {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: 'pod-uid',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: `k8s_pod_uid--string--tag--false`,
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: getContainerPodUID(container),
|
||||
},
|
||||
{
|
||||
id: 'container-name',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: `k8s_container_name--string--tag--false`,
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: getContainerName(container),
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuery(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
start: number,
|
||||
end: number,
|
||||
series: SeriesSpec[],
|
||||
formulas: FormulaSpec[] = [],
|
||||
): GetQueryResultsProps {
|
||||
const filters = buildScopeFilters(container);
|
||||
|
||||
return {
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: series.map((spec, index) => ({
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: `${spec.metricKey.replace(/\./g, '_')}--float64--Gauge--true`,
|
||||
key: spec.metricKey,
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: spec.timeAggregation,
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: false,
|
||||
expression: QUERY_NAMES[index],
|
||||
filters,
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: spec.legend,
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: QUERY_NAMES[index],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: spec.spaceAggregation,
|
||||
stepInterval: STEP_INTERVAL,
|
||||
timeAggregation: spec.timeAggregation,
|
||||
})),
|
||||
queryFormulas: formulas.map((formula, index) => ({
|
||||
disabled: false,
|
||||
expression: formula.expression,
|
||||
legend: formula.legend,
|
||||
queryName: `F${index + 1}`,
|
||||
})),
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
id: v4(),
|
||||
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: false,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
/** Absolute usage metrics are summed across series, ratios are averaged. */
|
||||
function usageSeries(metricKey: string, legendPrefix = ''): SeriesSpec[] {
|
||||
const prefix = legendPrefix ? `${legendPrefix} - ` : '';
|
||||
return [
|
||||
{
|
||||
metricKey,
|
||||
legend: `${prefix}Avg`,
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey,
|
||||
legend: `${prefix}Max`,
|
||||
timeAggregation: 'max',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey,
|
||||
legend: `${prefix}Min`,
|
||||
timeAggregation: 'min',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function utilizationSeries(
|
||||
metricKey: string,
|
||||
legendPrefix: string,
|
||||
): SeriesSpec[] {
|
||||
return (['avg', 'max', 'min'] as TimeAggregation[]).map((timeAggregation) => ({
|
||||
metricKey,
|
||||
legend: `${legendPrefix} - ${
|
||||
timeAggregation.charAt(0).toUpperCase() + timeAggregation.slice(1)
|
||||
}`,
|
||||
timeAggregation,
|
||||
spaceAggregation: 'avg' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export const getContainerMetricsQueryPayload = (
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
start: number,
|
||||
end: number,
|
||||
): GetQueryResultsProps[] => {
|
||||
const query = (
|
||||
series: SeriesSpec[],
|
||||
formulas?: FormulaSpec[],
|
||||
): GetQueryResultsProps => buildQuery(container, start, end, series, formulas);
|
||||
|
||||
return [
|
||||
query(usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE)),
|
||||
query([
|
||||
...utilizationSeries(
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_REQUEST_UTILIZATION,
|
||||
'Request util %',
|
||||
),
|
||||
...utilizationSeries(
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_LIMIT_UTILIZATION,
|
||||
'Limit util %',
|
||||
),
|
||||
]),
|
||||
query([
|
||||
...usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_USAGE, 'Usage'),
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_AVAILABLE,
|
||||
legend: 'Available',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
...utilizationSeries(
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION,
|
||||
'Request util %',
|
||||
),
|
||||
...utilizationSeries(
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION,
|
||||
'Limit util %',
|
||||
),
|
||||
]),
|
||||
query(
|
||||
[
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_RSS,
|
||||
legend: 'RSS Memory',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_WORKING_SET,
|
||||
legend: 'Working Set Memory',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
[{ expression: 'B - A', legend: 'Cache Memory' }],
|
||||
),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_MAJOR_PAGE_FAULTS,
|
||||
legend: 'Major Page Faults',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_CAPACITY,
|
||||
legend: 'Capacity',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_AVAILABLE,
|
||||
legend: 'Available',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_USAGE,
|
||||
legend: 'Usage',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_UPTIME,
|
||||
legend: 'Uptime',
|
||||
timeAggregation: 'latest',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_NODE_UTILIZATION,
|
||||
legend: 'Node CPU Utilization',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'avg',
|
||||
},
|
||||
]),
|
||||
query([
|
||||
{
|
||||
metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_NODE_UTILIZATION,
|
||||
legend: 'Node Memory Utilization',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'avg',
|
||||
},
|
||||
]),
|
||||
];
|
||||
};
|
||||
@@ -1,470 +0,0 @@
|
||||
import { Container } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
} from '../components';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
CONTAINER_READY_COLORS,
|
||||
CONTAINER_READY_LABELS,
|
||||
CONTAINER_STATUS_COLORS,
|
||||
CONTAINER_STATUS_LABELS,
|
||||
CONTAINERS_DOC_PATH,
|
||||
getContainerImageWithTag,
|
||||
getContainerReadyItems,
|
||||
getContainerStatusItems,
|
||||
} from './utils';
|
||||
|
||||
export function getK8sContainerRowKey(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string {
|
||||
return (
|
||||
[container.podUID, container.containerName].filter(Boolean).join('/') ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function getK8sContainerItemKey(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): SelectedItemParams {
|
||||
return {
|
||||
selectedItem:
|
||||
container.podUID ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
|
||||
null,
|
||||
containerName:
|
||||
container.containerName ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
|
||||
null,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ContainerTableColumnConfig =
|
||||
TableColumnDef<InframonitoringtypesContainerRecordDTO>;
|
||||
|
||||
/**
|
||||
* The grouped table and its nested rows are separate tables, so a column and the
|
||||
* one that replaces it while grouped share a width to keep the two aligned.
|
||||
*/
|
||||
const NAME_COLUMN_WIDTH = 220;
|
||||
const STATUS_COLUMN_WIDTH = 250;
|
||||
|
||||
export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
|
||||
{
|
||||
id: 'containerGroup',
|
||||
header: (): React.ReactNode => <EntityGroupHeader title="Container Group" />,
|
||||
accessorFn: (row): string => row.containerName || '',
|
||||
width: { min: NAME_COLUMN_WIDTH },
|
||||
enableSort: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => (
|
||||
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
|
||||
<K8sGroupCell row={row} />
|
||||
</ExpandButtonWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Container Name"
|
||||
icon={<Container data-hide-expanded="true" size={14} />}
|
||||
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
|
||||
/>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.containerName ||
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
|
||||
'',
|
||||
width: { min: NAME_COLUMN_WIDTH },
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'podName',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#pod-name`}>
|
||||
Pod Name
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
|
||||
width: { min: 260 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const podName = value as string;
|
||||
if (!podName) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return <TanStackTable.Text>{podName}</TanStackTable.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'namespace',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Namespace
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
|
||||
width: { min: 160 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#imagetag`}>
|
||||
Image:Tag
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => getContainerImageWithTag(row),
|
||||
width: { min: 240 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const image = value as string;
|
||||
if (!image) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return <TanStackTable.Text>{image}</TanStackTable.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerStatus',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => row.status,
|
||||
width: { min: STATUS_COLUMN_WIDTH },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
if (
|
||||
!row.status ||
|
||||
row.status === InframonitoringtypesContainerStatusDTO.no_data
|
||||
) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
|
||||
{CONTAINER_STATUS_LABELS[row.status]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerCountsByStatus',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
|
||||
),
|
||||
accessorFn: (
|
||||
row,
|
||||
): InframonitoringtypesContainerRecordDTO['containerCountsByStatus'] =>
|
||||
row.containerCountsByStatus,
|
||||
width: { min: STATUS_COLUMN_WIDTH },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ row, rowId }): React.ReactNode => {
|
||||
if (!row.containerCountsByStatus) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
items={getContainerStatusItems(row.containerCountsByStatus)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerReady',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => row.ready,
|
||||
width: { min: 130 },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
if (
|
||||
!row.ready ||
|
||||
row.ready === InframonitoringtypesContainerReadyDTO.no_data
|
||||
) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color={CONTAINER_READY_COLORS[row.ready]} variant="outline">
|
||||
{CONTAINER_READY_LABELS[row.ready]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerCountsByReady',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
|
||||
),
|
||||
accessorFn: (
|
||||
row,
|
||||
): InframonitoringtypesContainerRecordDTO['containerCountsByReady'] =>
|
||||
row.containerCountsByReady,
|
||||
width: { min: 130 },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ row, rowId }): React.ReactNode => {
|
||||
if (!row.containerCountsByReady) {
|
||||
return <TextNoData type="tanstack" />;
|
||||
}
|
||||
return (
|
||||
<GroupedStatusCounts
|
||||
items={getContainerReadyItems(row.containerCountsByReady)}
|
||||
rowId={rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'containerRestarts',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#restarts`}>
|
||||
Restarts
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.restarts,
|
||||
width: { min: 130 },
|
||||
enableSort: false,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Restarts"
|
||||
>
|
||||
<TanStackTable.Text>{value as number}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#cpu-req-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpuRequestUtilization,
|
||||
width: { min: 210 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#cpu-limit-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpuLimitUtilization,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#cpu-usage-cores`}>
|
||||
CPU Usage (cores)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.cpu,
|
||||
width: { min: 160 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={Number(value)}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="CPU metric"
|
||||
>
|
||||
<TanStackTable.Text>{Number(value).toFixed(2)}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#mem-req-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memoryRequestUtilization,
|
||||
width: { min: 210 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
docPath={`${CONTAINERS_DOC_PATH}#mem-limit-usage-`}
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memoryLimitUtilization,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={value as number} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#mem-usage-wss`}>
|
||||
Memory Usage (WSS)
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.memory,
|
||||
width: { min: 210, default: '100%' },
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => (
|
||||
<ValidateColumnValueWrapper
|
||||
rowId={rowId}
|
||||
value={value as number}
|
||||
entity={InfraMonitoringEntity.CONTAINERS}
|
||||
attribute="memory metric"
|
||||
>
|
||||
<TanStackTable.Text>{formatBytes(value as number)}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'node',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Node
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cluster',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Cluster
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'deployment',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
|
||||
Deployment
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { BadgeColor } from '@signozhq/ui/badge';
|
||||
import {
|
||||
InframonitoringtypesContainerCountsByReadyDTO,
|
||||
InframonitoringtypesContainerCountsByStatusDTO,
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
InframonitoringtypesContainerRecordDTO,
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { StatusCountItem } from '../components/GroupedStatusCounts';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
|
||||
export const CONTAINERS_DOC_PATH =
|
||||
'/infrastructure-monitoring/kubernetes/containers';
|
||||
|
||||
/** Renders as `name:tag`; the tag is dropped when the image is not pinned. */
|
||||
export function getContainerImageWithTag(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string {
|
||||
const name = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME];
|
||||
const tag = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_TAG];
|
||||
|
||||
if (!name) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return tag ? `${name}:${tag}` : name;
|
||||
}
|
||||
|
||||
export function getContainerName(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string {
|
||||
return (
|
||||
container.containerName ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function getContainerPodUID(
|
||||
container: InframonitoringtypesContainerRecordDTO,
|
||||
): string {
|
||||
return (
|
||||
container.podUID ||
|
||||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export const CONTAINER_STATUS_COLORS: Record<
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
BadgeColor
|
||||
> = {
|
||||
[InframonitoringtypesContainerStatusDTO.running]: 'forest',
|
||||
[InframonitoringtypesContainerStatusDTO.completed]: 'robin',
|
||||
[InframonitoringtypesContainerStatusDTO.waiting]: 'amber',
|
||||
[InframonitoringtypesContainerStatusDTO.containercreating]: 'amber',
|
||||
[InframonitoringtypesContainerStatusDTO.terminated]: 'sienna',
|
||||
[InframonitoringtypesContainerStatusDTO.unknown]: 'vanilla',
|
||||
[InframonitoringtypesContainerStatusDTO.no_data]: 'vanilla',
|
||||
[InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.errimagepull]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.oomkilled]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.error]: 'cherry',
|
||||
[InframonitoringtypesContainerStatusDTO.containercannotrun]: 'cherry',
|
||||
};
|
||||
|
||||
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
|
||||
export const CONTAINER_STATUS_LABELS: Record<
|
||||
InframonitoringtypesContainerStatusDTO,
|
||||
string
|
||||
> = {
|
||||
[InframonitoringtypesContainerStatusDTO.running]: 'Running',
|
||||
[InframonitoringtypesContainerStatusDTO.completed]: 'Completed',
|
||||
[InframonitoringtypesContainerStatusDTO.waiting]: 'Waiting',
|
||||
[InframonitoringtypesContainerStatusDTO.containercreating]:
|
||||
'ContainerCreating',
|
||||
[InframonitoringtypesContainerStatusDTO.terminated]: 'Terminated',
|
||||
[InframonitoringtypesContainerStatusDTO.unknown]: 'Unknown',
|
||||
[InframonitoringtypesContainerStatusDTO.no_data]: 'No data',
|
||||
[InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
|
||||
[InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
|
||||
[InframonitoringtypesContainerStatusDTO.errimagepull]: 'ErrImagePull',
|
||||
[InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]:
|
||||
'CreateContainerConfigError',
|
||||
[InframonitoringtypesContainerStatusDTO.oomkilled]: 'OOMKilled',
|
||||
[InframonitoringtypesContainerStatusDTO.error]: 'Error',
|
||||
[InframonitoringtypesContainerStatusDTO.containercannotrun]:
|
||||
'ContainerCannotRun',
|
||||
};
|
||||
|
||||
const CONTAINER_ERROR_STATUS_LABELS: Partial<
|
||||
Record<keyof InframonitoringtypesContainerCountsByStatusDTO, string>
|
||||
> = {
|
||||
crashLoopBackOff: 'CrashLoopBackOff',
|
||||
imagePullBackOff: 'ImagePullBackOff',
|
||||
errImagePull: 'ErrImagePull',
|
||||
createContainerConfigError: 'CreateContainerConfigError',
|
||||
oomKilled: 'OOMKilled',
|
||||
error: 'Error',
|
||||
containerCannotRun: 'ContainerCannotRun',
|
||||
};
|
||||
|
||||
export function getContainerStatusItems(
|
||||
counts: InframonitoringtypesContainerCountsByStatusDTO,
|
||||
): StatusCountItem[] {
|
||||
const errorKeys = Object.keys(CONTAINER_ERROR_STATUS_LABELS) as Array<
|
||||
keyof typeof CONTAINER_ERROR_STATUS_LABELS
|
||||
>;
|
||||
|
||||
return [
|
||||
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
|
||||
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
|
||||
{
|
||||
value: counts.waiting + counts.containerCreating,
|
||||
label: 'Waiting',
|
||||
color: Color.BG_AMBER_500,
|
||||
breakdown: [
|
||||
{ label: 'Waiting', value: counts.waiting },
|
||||
{ label: 'ContainerCreating', value: counts.containerCreating },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: counts.terminated,
|
||||
label: 'Terminated',
|
||||
color: Color.BG_SIENNA_500,
|
||||
},
|
||||
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
|
||||
{
|
||||
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
|
||||
label: 'Error Status',
|
||||
color: Color.BG_CHERRY_500,
|
||||
breakdown: errorKeys.map((key) => ({
|
||||
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
|
||||
value: counts[key],
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const CONTAINER_READY_COLORS: Record<
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
BadgeColor
|
||||
> = {
|
||||
[InframonitoringtypesContainerReadyDTO.ready]: 'forest',
|
||||
[InframonitoringtypesContainerReadyDTO.not_ready]: 'cherry',
|
||||
[InframonitoringtypesContainerReadyDTO.no_data]: 'vanilla',
|
||||
};
|
||||
|
||||
export const CONTAINER_READY_LABELS: Record<
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
string
|
||||
> = {
|
||||
[InframonitoringtypesContainerReadyDTO.ready]: 'Ready',
|
||||
[InframonitoringtypesContainerReadyDTO.not_ready]: 'Not Ready',
|
||||
[InframonitoringtypesContainerReadyDTO.no_data]: 'No data',
|
||||
};
|
||||
|
||||
export function getContainerReadyItems(
|
||||
counts: InframonitoringtypesContainerCountsByReadyDTO,
|
||||
): StatusCountItem[] {
|
||||
return [
|
||||
{ value: counts.ready, label: 'Ready', color: Color.BG_FOREST_500 },
|
||||
{ value: counts.notReady, label: 'Not Ready', color: Color.BG_CHERRY_500 },
|
||||
];
|
||||
}
|
||||
@@ -3,9 +3,8 @@ import { Compass, Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './ChartHeader.module.scss';
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
|
||||
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface ChartHeaderProps {
|
||||
title: string;
|
||||
@@ -34,7 +33,7 @@ function ChartHeader({
|
||||
<>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_ROOT}${docPath}`}
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
|
||||
@@ -37,15 +37,7 @@
|
||||
.title {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
|
||||
--typography-margin: 0px var(--spacing-1) 0px 0px;
|
||||
}
|
||||
|
||||
// Sized to the icon rather than the default tap target, so it sits beside the
|
||||
// entity name instead of a gap away from it.
|
||||
.copyIdButton {
|
||||
--button-padding: 2px;
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
--typography-margin: 0px var(--spacing-4) 0px 0px;
|
||||
}
|
||||
|
||||
.entityDetailsEntity {
|
||||
@@ -53,12 +45,30 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Tracks size to the entity's field count instead of a fixed four, so entities
|
||||
// with more fields stay on one row rather than spilling a near-empty second one.
|
||||
.entityDetailsGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.labelsRow,
|
||||
.valuesRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--spacing-4) var(--spacing-6);
|
||||
grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.labelsRow {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.entityDetailsMetadataLabel {
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.entityDetailsMetadataValue {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
}
|
||||
|
||||
.viewsTabsContainer {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ArrowUpDown,
|
||||
ArrowUpToLine,
|
||||
Bolt,
|
||||
Box,
|
||||
Boxes,
|
||||
Computer,
|
||||
Container,
|
||||
@@ -32,7 +31,6 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { K8sDynamicList } from './Base/K8sDynamicList';
|
||||
import {
|
||||
GetClustersQuickFiltersConfig,
|
||||
GetContainersQuickFiltersConfig,
|
||||
GetDaemonsetsQuickFiltersConfig,
|
||||
GetDeploymentsQuickFiltersConfig,
|
||||
GetJobsQuickFiltersConfig,
|
||||
@@ -154,12 +152,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.CONTAINERS,
|
||||
label: 'Containers',
|
||||
icon: <Box size={14} />,
|
||||
config: GetContainersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
|
||||
@@ -61,26 +61,10 @@ export const INFRA_MONITORING_ATTR_KEYS = {
|
||||
K8S_CONTAINER_CPU_LIMIT: 'k8s.container.cpu_limit',
|
||||
K8S_CONTAINER_MEMORY_REQUEST: 'k8s.container.memory_request',
|
||||
K8S_CONTAINER_MEMORY_LIMIT: 'k8s.container.memory_limit',
|
||||
K8S_CONTAINER_CPU_REQUEST_UTILIZATION: 'k8s.container.cpu_request_utilization',
|
||||
K8S_CONTAINER_CPU_LIMIT_UTILIZATION: 'k8s.container.cpu_limit_utilization',
|
||||
K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION:
|
||||
'k8s.container.memory_request_utilization',
|
||||
K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION:
|
||||
'k8s.container.memory_limit_utilization',
|
||||
K8S_CONTAINER_CPU_NODE_UTILIZATION: 'k8s.container.cpu.node.utilization',
|
||||
K8S_CONTAINER_MEMORY_NODE_UTILIZATION: 'k8s.container.memory.node.utilization',
|
||||
CONTAINER_CPU_USAGE: 'container.cpu.usage',
|
||||
CONTAINER_MEMORY_USAGE: 'container.memory.usage',
|
||||
CONTAINER_MEMORY_AVAILABLE: 'container.memory.available',
|
||||
CONTAINER_MEMORY_WORKING_SET: 'container.memory.working_set',
|
||||
CONTAINER_MEMORY_RSS: 'container.memory.rss',
|
||||
CONTAINER_MEMORY_MAJOR_PAGE_FAULTS: 'container.memory.major_page_faults',
|
||||
CONTAINER_FILESYSTEM_AVAILABLE: 'container.filesystem.available',
|
||||
CONTAINER_FILESYSTEM_CAPACITY: 'container.filesystem.capacity',
|
||||
CONTAINER_FILESYSTEM_USAGE: 'container.filesystem.usage',
|
||||
CONTAINER_UPTIME: 'container.uptime',
|
||||
CONTAINER_IMAGE_NAME: 'container.image.name',
|
||||
CONTAINER_IMAGE_TAG: 'container.image.tag',
|
||||
|
||||
// Deployment
|
||||
K8S_DEPLOYMENT_NAME: 'k8s.deployment.name',
|
||||
@@ -181,9 +165,6 @@ export const K8sCategories = {
|
||||
VOLUMES: 'volumes',
|
||||
};
|
||||
|
||||
/** The section the Kubernetes view opens on when a link names none. */
|
||||
export const DEFAULT_K8S_CATEGORY = K8sCategories.CONTAINERS;
|
||||
|
||||
const dotMap = {
|
||||
[InfraMonitoringEntity.HOSTS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
|
||||
@@ -200,7 +181,7 @@ const dotMap = {
|
||||
[InfraMonitoringEntity.DAEMONSETS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
[InfraMonitoringEntity.CONTAINERS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
[InfraMonitoringEntity.JOBS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_DESIRED_SUCCESSFUL_PODS,
|
||||
[InfraMonitoringEntity.VOLUMES]:
|
||||
@@ -340,161 +321,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
return [
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Container',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Pod',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Namespace',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Node',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Cluster',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Image',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Deployment',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Statefulset',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'DaemonSet',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Job',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME}--string--resource--false`,
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
attributeKey: {
|
||||
key: INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
return [
|
||||
{
|
||||
@@ -941,7 +767,6 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
|
||||
SELECTED_ITEM: 'selectedItem',
|
||||
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
|
||||
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
|
||||
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
|
||||
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
|
||||
DETAIL_START_TIME: 'detailStartTime',
|
||||
DETAIL_END_TIME: 'detailEndTime',
|
||||
@@ -958,7 +783,7 @@ export const METRIC_NAMESPACE_BY_ENTITY: Record<InfraMonitoringEntity, string> =
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.',
|
||||
[InfraMonitoringEntity.STATEFULSETS]: 'k8s.',
|
||||
[InfraMonitoringEntity.DAEMONSETS]: 'k8s.',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'k8s.container.',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.',
|
||||
[InfraMonitoringEntity.JOBS]: 'k8s.',
|
||||
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import {
|
||||
DEFAULT_K8S_CATEGORY,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
K8sCategories,
|
||||
VIEWS,
|
||||
} from './constants';
|
||||
import { orderBySchema, OrderBySchemaType } from './schemas';
|
||||
@@ -130,23 +130,19 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY,
|
||||
parseAsString
|
||||
.withDefault(DEFAULT_K8S_CATEGORY)
|
||||
.withOptions({ ...defaultNuqsOptions, clearOnDefault: false }),
|
||||
parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export interface SelectedItemParams {
|
||||
selectedItem: string | null;
|
||||
clusterName?: string | null;
|
||||
namespaceName?: string | null;
|
||||
containerName?: string | null;
|
||||
}
|
||||
|
||||
const selectedItemParamsParsers = {
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
|
||||
};
|
||||
|
||||
export type UseSelectedItemParamsReturn = [
|
||||
@@ -171,9 +167,6 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
namespaceName:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ??
|
||||
null,
|
||||
containerName:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME] ??
|
||||
null,
|
||||
}),
|
||||
[rawParams],
|
||||
);
|
||||
@@ -185,7 +178,6 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -197,8 +189,6 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
newParams.clusterName ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
|
||||
newParams.namespaceName ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]:
|
||||
newParams.containerName ?? null,
|
||||
});
|
||||
},
|
||||
[setRawParams],
|
||||
|
||||
@@ -38,20 +38,19 @@ import {
|
||||
TEST_ENDPOINT,
|
||||
} from '../../__tests__/fixtures';
|
||||
|
||||
const SAMPLE_SPAN = JSON.parse(SAMPLE_SPAN_JSON) as {
|
||||
attributes: Record<string, unknown>;
|
||||
resource: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const MAPPED_ATTRIBUTE_KEY = 'gen_ai.content.prompt';
|
||||
|
||||
// Deriving from the sample keeps exactly one key added, so the single `populated` badge assertion below stays exact.
|
||||
const RESULT_SPAN = {
|
||||
attributes: {
|
||||
...SAMPLE_SPAN.attributes,
|
||||
[MAPPED_ATTRIBUTE_KEY]: SAMPLE_SPAN.attributes['input.value'],
|
||||
'my_company.llm.input': 'What is quantum computing?',
|
||||
'llm.input_messages': 'What is quantum computing?',
|
||||
'gen_ai.request.model': 'gpt-4',
|
||||
'gen_ai.usage.total_tokens': 1250,
|
||||
'gen_ai.content.completion': 'Quantum computing leverages...',
|
||||
'gen_ai.content.prompt': 'What is quantum computing?',
|
||||
},
|
||||
resource: {
|
||||
'service.name': 'llm-gateway',
|
||||
'deployment.environment': 'production',
|
||||
},
|
||||
resource: SAMPLE_SPAN.resource,
|
||||
};
|
||||
|
||||
const EDITED_SPAN_JSON = `{
|
||||
@@ -98,7 +97,7 @@ describe('TestTab — sample-span flow', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-result-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
|
||||
MAPPED_ATTRIBUTE_KEY,
|
||||
'gen_ai.content.prompt',
|
||||
);
|
||||
expect(screen.getByText('populated')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
|
||||
|
||||
@@ -7,14 +7,11 @@ import { parseSpanInput } from './testPayload';
|
||||
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"attributes": {
|
||||
"llm.model_name": "gpt-4o",
|
||||
"llm.provider": "openai",
|
||||
"llm.token_count.prompt": 1024,
|
||||
"llm.token_count.completion": 226,
|
||||
"llm.token_count.prompt_details.cache_read": 512,
|
||||
"input.value": "What is quantum computing?",
|
||||
"output.value": "Quantum computing leverages superposition and entanglement...",
|
||||
"session.id": "chat-8f2e41"
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "llm-gateway",
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -49,7 +50,7 @@ function BodyTitleRenderer({
|
||||
const { featureFlags } = useAppContext();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
const { viewName } = useGetSavedViewParams();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const cleanedNodeKey = removeObjectFromString(nodeKey);
|
||||
const isBodyJsonQueryEnabled =
|
||||
|
||||
@@ -7,12 +7,13 @@ import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import {
|
||||
@@ -140,7 +141,7 @@ export default function TableViewActions(
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { viewName } = useGetSavedViewParams();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
|
||||
|
||||
// there is no option for where clause in old logs explorer and live logs page or infra monitoring
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
@@ -88,7 +88,7 @@ jest.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
jest.mock('hooks/saveViews/useGetSavedViewParams');
|
||||
jest.mock('hooks/queryBuilder/useGetSearchQueryParam');
|
||||
|
||||
describe('TableViewActions', () => {
|
||||
const TEST_VALUE = 'test value';
|
||||
@@ -140,10 +140,8 @@ describe('TableViewActions', () => {
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// Default mock for useGetSavedViewParams
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
// Default mock for useGetSearchQueryParam
|
||||
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
@@ -251,9 +249,7 @@ describe('TableViewActions', () => {
|
||||
updateQueriesData: mockUpdateQueriesData,
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.mocked(useGetSavedViewParams)
|
||||
.mockReturnValue({ viewName: '', viewKey: '' });
|
||||
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
|
||||
|
||||
render(
|
||||
<TableViewActions
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useLocation } from 'react-router-dom';
|
||||
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
@@ -57,7 +58,7 @@ export function useLogAttributeActions({
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
const { featureFlags } = useAppContext();
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const isBodyJsonQueryEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
|
||||
@@ -111,7 +112,7 @@ export function useLogAttributeActions({
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: viewKey,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
|
||||
@@ -121,7 +122,6 @@ export function useLogAttributeActions({
|
||||
isBodyJsonQueryEnabled,
|
||||
updateQueriesData,
|
||||
viewName,
|
||||
viewKey,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
],
|
||||
@@ -149,7 +149,7 @@ export function useLogAttributeActions({
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: viewKey,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
|
||||
@@ -159,7 +159,6 @@ export function useLogAttributeActions({
|
||||
isBodyJsonQueryEnabled,
|
||||
updateQueriesData,
|
||||
viewName,
|
||||
viewKey,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
@@ -186,7 +185,7 @@ export function useLogAttributeActions({
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: viewKey,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
|
||||
@@ -196,7 +195,6 @@ export function useLogAttributeActions({
|
||||
isBodyJsonQueryEnabled,
|
||||
updateQueriesData,
|
||||
viewName,
|
||||
viewKey,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -27,14 +27,6 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Add default values for optional fields if empty
|
||||
if (parsedCompositeQuery?.builder) {
|
||||
parsedCompositeQuery.builder.queryFormulas =
|
||||
parsedCompositeQuery.builder.queryFormulas ?? [];
|
||||
parsedCompositeQuery.builder.queryTraceOperator =
|
||||
parsedCompositeQuery.builder.queryTraceOperator ?? [];
|
||||
}
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
|
||||
15
frontend/src/hooks/queryBuilder/useGetSearchQueryParam.ts
Normal file
15
frontend/src/hooks/queryBuilder/useGetSearchQueryParam.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { QuerySearchParamNames } from 'components/ExplorerCard/constants';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
export const useGetSearchQueryParam = (
|
||||
searchParams: QuerySearchParamNames,
|
||||
): string | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useMemo(() => {
|
||||
const searchQuery = urlQuery.get(searchParams);
|
||||
|
||||
return searchQuery ? JSON.parse(searchQuery) : null;
|
||||
}, [urlQuery, searchParams]);
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { useGetSavedViewParams } from '../useGetSavedViewParams';
|
||||
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
|
||||
const mockedUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
|
||||
const setSearch = (search: string): void => {
|
||||
mockedUseUrlQuery.mockReturnValue(new URLSearchParams(search));
|
||||
};
|
||||
|
||||
describe('useGetSavedViewParams', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns empty strings when no params are present', () => {
|
||||
setSearch('');
|
||||
|
||||
const { result } = renderHook(() => useGetSavedViewParams());
|
||||
|
||||
expect(result.current).toStrictEqual({ viewName: '', viewKey: '' });
|
||||
});
|
||||
|
||||
it('parses JSON-stringified values', () => {
|
||||
setSearch(
|
||||
`viewName=${encodeURIComponent(
|
||||
JSON.stringify('Hindsight'),
|
||||
)}&viewKey=${encodeURIComponent(JSON.stringify('abc-123'))}`,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGetSavedViewParams());
|
||||
|
||||
expect(result.current).toStrictEqual({
|
||||
viewName: 'Hindsight',
|
||||
viewKey: 'abc-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the raw string when a value is not valid JSON', () => {
|
||||
setSearch('viewName=Hindsight&viewKey=some-uuid-value');
|
||||
|
||||
const { result } = renderHook(() => useGetSavedViewParams());
|
||||
|
||||
expect(result.current).toStrictEqual({
|
||||
viewName: 'Hindsight',
|
||||
viewKey: 'some-uuid-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw and keeps the raw string for non-string JSON', () => {
|
||||
setSearch('viewName=123');
|
||||
|
||||
const { result } = renderHook(() => useGetSavedViewParams());
|
||||
|
||||
expect(result.current).toStrictEqual({ viewName: '123', viewKey: '' });
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
interface SavedViewParams {
|
||||
viewName: string;
|
||||
viewKey: string;
|
||||
}
|
||||
|
||||
const parseViewParam = (value: string | null): string => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === 'string' ? parsed : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const useGetSavedViewParams = (): SavedViewParams => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
viewName: parseViewParam(urlQuery.get(QueryParams.viewName)),
|
||||
viewKey: parseViewParam(urlQuery.get(QueryParams.viewKey)),
|
||||
}),
|
||||
[urlQuery],
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/const
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useGetSavedViewParams } from './saveViews/useGetSavedViewParams';
|
||||
import { useGetSearchQueryParam } from './queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
|
||||
|
||||
export interface ICurrentQueryData {
|
||||
@@ -31,7 +31,9 @@ export const useHandleExplorerTabChange = (): {
|
||||
updateQueriesData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const { viewName, viewKey } = useGetSavedViewParams();
|
||||
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
|
||||
|
||||
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
|
||||
|
||||
const getUpdateQuery = useCallback(
|
||||
(newPanelType: PANEL_TYPES): Query => {
|
||||
|
||||
@@ -11,8 +11,6 @@ import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelType,
|
||||
GoogleChatChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
@@ -62,25 +60,17 @@ function ChannelsEdit(): JSX.Element {
|
||||
|
||||
const prepChannelConfig = (): {
|
||||
type: string;
|
||||
channel: Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
>;
|
||||
channel: SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
GoogleChatChannel;
|
||||
} => {
|
||||
let channel: Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel
|
||||
> = {
|
||||
let channel: SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
GoogleChatChannel = {
|
||||
name: '',
|
||||
};
|
||||
|
||||
@@ -111,19 +101,6 @@ function ChannelsEdit(): JSX.Element {
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'jira_configs' in value) {
|
||||
const [jiraConfig] = value.jira_configs;
|
||||
channel = jiraConfig;
|
||||
if (jiraConfig.http_config?.basic_auth) {
|
||||
channel.username = jiraConfig.http_config.basic_auth.username;
|
||||
channel.password = jiraConfig.http_config.basic_auth.password;
|
||||
}
|
||||
return {
|
||||
type: ChannelType.Jira,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'pagerduty_configs' in value) {
|
||||
const pagerConfig = value.pagerduty_configs[0];
|
||||
channel = pagerConfig;
|
||||
@@ -135,22 +112,6 @@ function ChannelsEdit(): JSX.Element {
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'jsmops_configs' in value) {
|
||||
const [jsmopsConfig] = value.jsmops_configs;
|
||||
channel = jsmopsConfig;
|
||||
// backend stores tags as a comma-separated string; the form uses chips
|
||||
channel.tags = jsmopsConfig.tags
|
||||
? String(jsmopsConfig.tags)
|
||||
.split(',')
|
||||
.map((tag: string) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
type: ChannelType.JsmOps,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'opsgenie_configs' in value) {
|
||||
const opsgenieConfig = value.opsgenie_configs[0];
|
||||
channel = opsgenieConfig;
|
||||
|
||||
@@ -2,10 +2,6 @@ import { TabRoutes } from 'components/RouteTab/types';
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMonitoringHostsV2 from 'container/InfraMonitoringHostsV2';
|
||||
import InfraMonitoringK8sV2 from 'container/InfraMonitoringK8sV2';
|
||||
import {
|
||||
DEFAULT_K8S_CATEGORY,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { Inbox } from '@signozhq/icons';
|
||||
|
||||
function HostsContainer(): JSX.Element {
|
||||
@@ -34,6 +30,6 @@ export const Kubernetes: TabRoutes = {
|
||||
<Inbox size={16} /> Kubernetes
|
||||
</div>
|
||||
),
|
||||
route: `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=${DEFAULT_K8S_CATEGORY}`,
|
||||
route: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
|
||||
key: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
|
||||
};
|
||||
|
||||
@@ -163,23 +163,20 @@ export function QueryBuilderProvider({
|
||||
const prepareQueryBuilderData = useCallback(
|
||||
(query: Query): Query => {
|
||||
const builder: QueryBuilderData = {
|
||||
queryData:
|
||||
query.builder.queryData?.map((item) => ({
|
||||
...initialQueryBuilderFormValuesMap[
|
||||
initialDataSource || DataSource.METRICS
|
||||
],
|
||||
...item,
|
||||
})) ?? [],
|
||||
queryFormulas:
|
||||
query.builder.queryFormulas?.map((item) => ({
|
||||
...initialFormulaBuilderFormValues,
|
||||
...item,
|
||||
})) ?? [],
|
||||
queryTraceOperator:
|
||||
query.builder.queryTraceOperator?.map((item) => ({
|
||||
...initialQueryBuilderFormTraceOperatorValues,
|
||||
...item,
|
||||
})) ?? [],
|
||||
queryData: query.builder.queryData?.map((item) => ({
|
||||
...initialQueryBuilderFormValuesMap[
|
||||
initialDataSource || DataSource.METRICS
|
||||
],
|
||||
...item,
|
||||
})),
|
||||
queryFormulas: query.builder.queryFormulas?.map((item) => ({
|
||||
...initialFormulaBuilderFormValues,
|
||||
...item,
|
||||
})),
|
||||
queryTraceOperator: query.builder.queryTraceOperator?.map((item) => ({
|
||||
...initialQueryBuilderFormTraceOperatorValues,
|
||||
...item,
|
||||
})),
|
||||
};
|
||||
|
||||
const setupedQueryData = builder.queryData.map((item) => {
|
||||
@@ -212,17 +209,15 @@ export function QueryBuilderProvider({
|
||||
return currentElement;
|
||||
});
|
||||
|
||||
const promql: IPromQLQuery[] =
|
||||
query.promql?.map((item) => ({
|
||||
...initialQueryPromQLData,
|
||||
...item,
|
||||
})) ?? [];
|
||||
const promql: IPromQLQuery[] = query.promql.map((item) => ({
|
||||
...initialQueryPromQLData,
|
||||
...item,
|
||||
}));
|
||||
|
||||
const clickHouse: IClickHouseQuery[] =
|
||||
query.clickhouse_sql?.map((item) => ({
|
||||
...initialClickHouseData,
|
||||
...item,
|
||||
})) ?? [];
|
||||
const clickHouse: IClickHouseQuery[] = query.clickhouse_sql.map((item) => ({
|
||||
...initialClickHouseData,
|
||||
...item,
|
||||
}));
|
||||
|
||||
const newQueryState: QueryState = {
|
||||
clickhouse_sql: clickHouse,
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
"factor-api-key",
|
||||
"license",
|
||||
"subscription",
|
||||
"deployment-host",
|
||||
"logs",
|
||||
"traces",
|
||||
"metrics",
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2023 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package jira
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/adf"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const Integration = "jira"
|
||||
|
||||
const (
|
||||
maxSummaryLenRunes = 255
|
||||
maxDescriptionLenRunes = 32767
|
||||
)
|
||||
|
||||
// Notifier implements notify.Notifier for Jira.
|
||||
type Notifier struct {
|
||||
conf *alertmanagertypes.JiraReceiverConfig
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
func New(conf *alertmanagertypes.JiraReceiverConfig, _ *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) {
|
||||
if conf.HTTPConfig == nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira http_config is nil")
|
||||
}
|
||||
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
groupID := key.Hash()
|
||||
firing := types.Alerts(as...).HasFiring()
|
||||
n.logger.DebugContext(ctx, "sending jira notification", slog.String("group_key", key.String()), slog.Bool("firing", firing))
|
||||
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Summary,
|
||||
DefaultBodyTemplate: n.conf.Description,
|
||||
}, as)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
summary := truncateRunes(result.Title, maxSummaryLenRunes)
|
||||
|
||||
var parts []string
|
||||
for _, body := range result.Body {
|
||||
if body != "" {
|
||||
parts = append(parts, body)
|
||||
}
|
||||
}
|
||||
// custom body templates render per alert; join them under ADF rule dividers.
|
||||
// The default body is a single combined part, so the join is a no-op there.
|
||||
descText := truncateRunes(strings.Join(parts, "\n\n---\n\n"), maxDescriptionLenRunes)
|
||||
|
||||
baseURL, retry, err := n.resolveAPIBaseURL(ctx)
|
||||
if err != nil {
|
||||
return retry, err
|
||||
}
|
||||
|
||||
existing, retry, err := n.searchIssue(ctx, baseURL, groupID, firing)
|
||||
if err != nil {
|
||||
return retry, err
|
||||
}
|
||||
|
||||
fields := n.buildFields(groupID, summary, descText, as, firing)
|
||||
|
||||
// No existing issue: create for firing groups; never create for resolved-only.
|
||||
if existing == nil {
|
||||
if !firing {
|
||||
return false, nil
|
||||
}
|
||||
return n.createIssue(ctx, baseURL, fields)
|
||||
}
|
||||
|
||||
// Existing issue: refresh it, then transition + comment based on the new state.
|
||||
if retry, err := n.updateIssue(ctx, baseURL, existing, fields); err != nil {
|
||||
return retry, err
|
||||
}
|
||||
|
||||
// Each state-change comment carries the same rich snapshot as the description
|
||||
// (panel + details + deep-links), so the comment timeline mirrors the card
|
||||
// Google Chat re-posts on every notification.
|
||||
switch {
|
||||
case firing && existing.isDone(): // re-fired after resolution → reopen
|
||||
if retry, err := n.applyTransition(ctx, baseURL, existing.Key, false, n.conf.ReopenTransition); err != nil {
|
||||
return retry, err
|
||||
}
|
||||
case !firing: // resolved (search returns only open issues, so this one is open)
|
||||
if retry, err := n.applyTransition(ctx, baseURL, existing.Key, true, n.conf.ResolveTransition); err != nil {
|
||||
return retry, err
|
||||
}
|
||||
}
|
||||
// firing && !isDone (still firing) needs no transition.
|
||||
return n.addComment(ctx, baseURL, existing.Key, fields.Description)
|
||||
}
|
||||
|
||||
func (n *Notifier) buildFields(groupID, summary, descText string, alerts []*types.Alert, firing bool) *issueFields {
|
||||
f := &issueFields{
|
||||
Project: &idKey{Key: n.conf.Project},
|
||||
Issuetype: &idName{Name: n.conf.IssueType},
|
||||
Summary: summary,
|
||||
Labels: n.labels(groupID),
|
||||
Description: n.buildBoundedDescription(descText, alerts, firing),
|
||||
}
|
||||
if n.conf.Priority != "" {
|
||||
f.Priority = &idName{Name: n.conf.Priority}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// buildBoundedDescription builds the ADF issue body and keeps it within Jira's
|
||||
// description limit, which counts text characters plus per-node overhead — so a
|
||||
// text-only markdown cap is not enough. Over-limit bodies are shrunk at the
|
||||
// markdown level and rebuilt; the panel and deep-links are part of the measured
|
||||
// document, so the result always fits.
|
||||
func (n *Notifier) buildBoundedDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
|
||||
doc := n.buildDescription(descText, alerts, firing)
|
||||
for range 4 {
|
||||
size := adfDocLen(doc)
|
||||
if size <= maxDescriptionLenRunes {
|
||||
return doc
|
||||
}
|
||||
runes := []rune(descText)
|
||||
keep := len(runes) * maxDescriptionLenRunes / size * 9 / 10
|
||||
if keep >= len(runes) {
|
||||
keep = len(runes) - 1
|
||||
}
|
||||
if keep <= 0 {
|
||||
break
|
||||
}
|
||||
descText = string(runes[:keep]) + "…"
|
||||
doc = n.buildDescription(descText, alerts, firing)
|
||||
}
|
||||
if adfDocLen(doc) <= maxDescriptionLenRunes {
|
||||
return doc
|
||||
}
|
||||
// still over after shrinking: keep just the panel and deep-links
|
||||
return n.buildDescription("", alerts, firing)
|
||||
}
|
||||
|
||||
// adfDocLen approximates how Jira measures an ADF document against the 32767
|
||||
// limit: text length in UTF-16 code units, plus per-node overhead (block
|
||||
// boundaries count like newlines), plus link targets. Deliberately counts on
|
||||
// the high side so a passing measurement never 400s.
|
||||
func adfDocLen(node any) int {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
size := 2
|
||||
if text, ok := m["text"].(string); ok {
|
||||
for _, r := range text {
|
||||
size += utf16.RuneLen(r)
|
||||
}
|
||||
}
|
||||
if marks, ok := m["marks"].([]any); ok {
|
||||
for _, mark := range marks {
|
||||
if mm, ok := mark.(map[string]any); ok {
|
||||
if attrs, ok := mm["attrs"].(map[string]any); ok {
|
||||
if href, ok := attrs["href"].(string); ok {
|
||||
size += len(href)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if content, ok := m["content"].([]any); ok {
|
||||
for _, child := range content {
|
||||
size += adfDocLen(child)
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// buildDescription assembles the ADF issue body: a firing/resolved status panel,
|
||||
// the rendered markdown body, and SigNoz deep-links.
|
||||
func (n *Notifier) buildDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
|
||||
content := []any{statusPanel(firing)}
|
||||
content = append(content, adf.Render(descText)...)
|
||||
if links := deepLinks(alerts); links != nil {
|
||||
content = append(content, links)
|
||||
}
|
||||
return map[string]any{"type": "doc", "version": 1, "content": content}
|
||||
}
|
||||
|
||||
func statusPanel(firing bool) map[string]any {
|
||||
panelType, label := "success", "🟢 RESOLVED"
|
||||
if firing {
|
||||
panelType, label = "error", "🔴 FIRING"
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "panel",
|
||||
"attrs": map[string]any{"panelType": panelType},
|
||||
"content": []any{map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{map[string]any{"type": "text", "text": label, "marks": []any{map[string]any{"type": "strong"}}}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// deepLinks builds a paragraph of SigNoz links from the per-rule ruleSource label
|
||||
// and the related-logs/traces annotations. Returns nil when none are present.
|
||||
func deepLinks(alerts []*types.Alert) map[string]any {
|
||||
if len(alerts) == 0 {
|
||||
return nil
|
||||
}
|
||||
a := alerts[0]
|
||||
var parts []any
|
||||
add := func(label, url string) {
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
parts = append(parts, map[string]any{"type": "text", "text": " · "})
|
||||
}
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "text",
|
||||
"text": label,
|
||||
"marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": url}}},
|
||||
})
|
||||
}
|
||||
add("Open in SigNoz", string(a.Labels[ruletypes.LabelRuleSource]))
|
||||
add("View Related Logs", string(a.Annotations[ruletypes.AnnotationRelatedLogs]))
|
||||
add("View Related Traces", string(a.Annotations[ruletypes.AnnotationRelatedTraces]))
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"type": "paragraph", "content": parts}
|
||||
}
|
||||
|
||||
func (n *Notifier) labels(groupID string) []string {
|
||||
out := append([]string{}, n.conf.Labels...)
|
||||
out = append(out, "signoz-alert", fmt.Sprintf("ALERT{%s}", groupID))
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (n *Notifier) searchIssue(ctx context.Context, baseURL, groupID string, firing bool) (*issue, bool, error) {
|
||||
var jql strings.Builder
|
||||
if n.conf.WontFixResolution != "" {
|
||||
// != alone also drops unresolved (EMPTY) issues, so keep those explicitly.
|
||||
fmt.Fprintf(&jql, `(resolution is EMPTY or resolution != %q) and `, n.conf.WontFixResolution)
|
||||
}
|
||||
if reopenMin := int64(time.Duration(n.conf.ReopenDuration).Minutes()); firing && reopenMin > 0 {
|
||||
fmt.Fprintf(&jql, `(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenMin)
|
||||
} else {
|
||||
jql.WriteString(`statusCategory != Done and `)
|
||||
}
|
||||
fmt.Fprintf(&jql, `project=%q and labels=%q order by status ASC, resolutiondate DESC`, n.conf.Project, fmt.Sprintf("ALERT{%s}", groupID))
|
||||
|
||||
body, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/search/jql", searchRequest{
|
||||
JQL: jql.String(), MaxResults: 2, Fields: []string{"status", "labels"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, retry, err
|
||||
}
|
||||
var res searchResult
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(res.Issues) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
// the JQL order is not category-aware, so prefer an open issue over a done
|
||||
// one; all done falls back to the most recently resolved (resolutiondate DESC)
|
||||
for i := range res.Issues {
|
||||
if !res.Issues[i].isDone() {
|
||||
return &res.Issues[i], false, nil
|
||||
}
|
||||
}
|
||||
return &res.Issues[0], false, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) createIssue(ctx context.Context, baseURL string, fields *issueFields) (bool, error) {
|
||||
_, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/issue", issue{Fields: fields})
|
||||
return retry, err
|
||||
}
|
||||
|
||||
func (n *Notifier) updateIssue(ctx context.Context, baseURL string, existing *issue, fields *issueFields) (bool, error) {
|
||||
// project and issue type are set at creation and cannot be edited.
|
||||
upd := *fields
|
||||
upd.Project = nil
|
||||
upd.Issuetype = nil
|
||||
// Jira replaces the labels array wholesale, so union in the labels already
|
||||
// on the issue to keep user-added ones.
|
||||
if existing.Fields != nil {
|
||||
upd.Labels = mergeLabels(existing.Fields.Labels, fields.Labels)
|
||||
}
|
||||
_, retry, err := n.callAPI(ctx, http.MethodPut, n.issueURL(baseURL, existing.Key, ""), issue{Fields: &upd})
|
||||
return retry, err
|
||||
}
|
||||
|
||||
func mergeLabels(existing, ours []string) []string {
|
||||
seen := make(map[string]bool, len(existing)+len(ours))
|
||||
var merged []string
|
||||
for _, label := range append(append([]string{}, existing...), ours...) {
|
||||
if !seen[label] {
|
||||
seen[label] = true
|
||||
merged = append(merged, label)
|
||||
}
|
||||
}
|
||||
sort.Strings(merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
// applyTransition moves the issue into (toDone) or out of (!toDone) the "done"
|
||||
// status category, preferring the named override, else the first matching
|
||||
// transition, else skipping without error when none is available.
|
||||
func (n *Notifier) applyTransition(ctx context.Context, baseURL, key string, toDone bool, override string) (bool, error) {
|
||||
transitions, retry, err := n.getTransitions(ctx, baseURL, key)
|
||||
if err != nil {
|
||||
return retry, err
|
||||
}
|
||||
id := selectTransition(transitions, toDone, override)
|
||||
if id == "" {
|
||||
n.logger.WarnContext(ctx, "jira: no matching transition, leaving issue as-is", slog.String("issue", key), slog.Bool("to_done", toDone))
|
||||
return false, nil
|
||||
}
|
||||
_, retry, err = n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "transitions"), issue{Transition: &idName{ID: id}})
|
||||
return retry, err
|
||||
}
|
||||
|
||||
func (n *Notifier) getTransitions(ctx context.Context, baseURL, key string) ([]jiraTransition, bool, error) {
|
||||
body, retry, err := n.callAPI(ctx, http.MethodGet, n.issueURL(baseURL, key, "transitions"), nil)
|
||||
if err != nil {
|
||||
return nil, retry, err
|
||||
}
|
||||
var tr transitionsResponse
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return tr.Transitions, false, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) addComment(ctx context.Context, baseURL, key string, body any) (bool, error) {
|
||||
_, retry, err := n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "comment"), comment{Body: body})
|
||||
return retry, err
|
||||
}
|
||||
|
||||
func (n *Notifier) issueURL(baseURL, key, sub string) string {
|
||||
u := baseURL + "/issue/" + key
|
||||
if sub != "" {
|
||||
u += "/" + sub
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (n *Notifier) callAPI(ctx context.Context, method, url string, reqBody any) ([]byte, bool, error) {
|
||||
var body io.Reader
|
||||
if reqBody != nil {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(reqBody); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
body = &buf
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := n.client.Do(req) //nolint:bodyclose // notify.Drain closes the body
|
||||
if err != nil {
|
||||
return nil, true, notify.RedactURL(err)
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(respBody))
|
||||
if err != nil {
|
||||
return respBody, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
return respBody, false, nil
|
||||
}
|
||||
|
||||
// resolveAPIBaseURL resolves the service-account cloud id per notification (it
|
||||
// is never persisted); personal API tokens use the site host directly.
|
||||
func (n *Notifier) resolveAPIBaseURL(ctx context.Context) (string, bool, error) {
|
||||
if !n.conf.IsServiceAccount() {
|
||||
return n.conf.APIBaseURL(""), false, nil
|
||||
}
|
||||
cloudID, retry, err := n.resolveCloudID(ctx)
|
||||
if err != nil {
|
||||
return "", retry, err
|
||||
}
|
||||
return n.conf.APIBaseURL(cloudID), false, nil
|
||||
}
|
||||
|
||||
// resolveCloudID fetches the site's cloud id from its unauthenticated
|
||||
// tenant_info endpoint; transport failures are retryable, bad responses are not.
|
||||
func (n *Notifier) resolveCloudID(ctx context.Context) (string, bool, error) {
|
||||
url := strings.TrimRight(n.conf.Site, "/") + "/_edge/tenant_info"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := n.client.Do(req)
|
||||
if err != nil {
|
||||
return "", true, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch jira cloud id")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to resolve jira cloud id from %s: status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
CloudID string `json:"cloudId"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse jira tenant_info response")
|
||||
}
|
||||
if out.CloudID == "" {
|
||||
return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira tenant_info returned an empty cloud id for %s", n.conf.Site)
|
||||
}
|
||||
return out.CloudID, false, nil
|
||||
}
|
||||
|
||||
// selectTransition returns the id of the transition whose target status category
|
||||
// matches toDone, preferring one named override when present.
|
||||
func selectTransition(transitions []jiraTransition, toDone bool, override string) string {
|
||||
if override != "" {
|
||||
for _, t := range transitions {
|
||||
if t.Name == override {
|
||||
return t.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range transitions {
|
||||
if (t.To.StatusCategory.Key == "done") == toDone {
|
||||
return t.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Jira API types.
|
||||
type issue struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Fields *issueFields `json:"fields,omitempty"`
|
||||
Transition *idName `json:"transition,omitempty"`
|
||||
}
|
||||
|
||||
type issueFields struct {
|
||||
Project *idKey `json:"project,omitempty"`
|
||||
Issuetype *idName `json:"issuetype,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Priority *idName `json:"priority,omitempty"`
|
||||
Description any `json:"description,omitempty"`
|
||||
Status *issueStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type idKey struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type idName struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type issueStatus struct {
|
||||
StatusCategory struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"statusCategory"`
|
||||
}
|
||||
|
||||
func (i *issue) isDone() bool {
|
||||
return i.Fields != nil && i.Fields.Status != nil && i.Fields.Status.StatusCategory.Key == "done"
|
||||
}
|
||||
|
||||
type searchRequest struct {
|
||||
JQL string `json:"jql"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
Fields []string `json:"fields"`
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
Issues []issue `json:"issues"`
|
||||
}
|
||||
|
||||
type transitionsResponse struct {
|
||||
Transitions []jiraTransition `json:"transitions"`
|
||||
}
|
||||
|
||||
type jiraTransition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
To struct {
|
||||
StatusCategory struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"statusCategory"`
|
||||
} `json:"to"`
|
||||
}
|
||||
|
||||
type comment struct {
|
||||
Body any `json:"body"`
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockReq struct {
|
||||
method string
|
||||
path string
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
type mockJira struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
reqs []mockReq
|
||||
searchIssues []issue
|
||||
transitions []jiraTransition
|
||||
createStatus int
|
||||
}
|
||||
|
||||
func newMockJira(t *testing.T) *mockJira {
|
||||
t.Helper()
|
||||
m := &mockJira{}
|
||||
m.srv = httptest.NewServer(http.HandlerFunc(m.handle))
|
||||
t.Cleanup(m.srv.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockJira) handle(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
m.mu.Lock()
|
||||
m.reqs = append(m.reqs, mockReq{r.Method, r.URL.Path, body})
|
||||
m.mu.Unlock()
|
||||
|
||||
p := r.URL.Path
|
||||
switch {
|
||||
case strings.HasSuffix(p, "/search/jql"):
|
||||
_ = json.NewEncoder(w).Encode(searchResult{Issues: m.searchIssues})
|
||||
case strings.HasSuffix(p, "/transitions") && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode(transitionsResponse{Transitions: m.transitions})
|
||||
case strings.HasSuffix(p, "/transitions"):
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.HasSuffix(p, "/comment"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"1"}`))
|
||||
case strings.HasSuffix(p, "/issue") && r.Method == http.MethodPost:
|
||||
st := m.createStatus
|
||||
if st == 0 {
|
||||
st = http.StatusCreated
|
||||
}
|
||||
w.WriteHeader(st)
|
||||
_, _ = w.Write([]byte(`{"key":"KAN-1"}`))
|
||||
case r.Method == http.MethodPut:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockJira) countPost(suffix string) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := 0
|
||||
for _, r := range m.reqs {
|
||||
if r.method == http.MethodPost && strings.HasSuffix(r.path, suffix) {
|
||||
c++
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (m *mockJira) countPuts() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := 0
|
||||
for _, r := range m.reqs {
|
||||
if r.method == http.MethodPut {
|
||||
c++
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newNotifier(t *testing.T, m *mockJira) *Notifier {
|
||||
t.Helper()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.JiraReceiverConfig{
|
||||
Site: m.srv.URL,
|
||||
Project: "KAN",
|
||||
IssueType: "Task",
|
||||
Summary: alertmanagertypes.DefaultJiraSummaryTemplate,
|
||||
Description: alertmanagertypes.DefaultJiraDescriptionTemplate,
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
ReopenDuration: model.Duration(3 * 24 * time.Hour),
|
||||
}, tmpl, slog.New(slog.DiscardHandler), alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler)))
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
func alert(firing bool) *types.Alert {
|
||||
a := &types.Alert{Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "HighCPU", "severity": "critical"},
|
||||
Annotations: model.LabelSet{"summary": "cpu high"},
|
||||
StartsAt: time.Now().Add(-time.Minute),
|
||||
}}
|
||||
if firing {
|
||||
a.EndsAt = time.Now().Add(time.Hour)
|
||||
} else {
|
||||
a.EndsAt = time.Now().Add(-time.Minute)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func ctx() context.Context {
|
||||
return notify.WithGroupKey(context.Background(), "test-jira")
|
||||
}
|
||||
|
||||
func doneIssue() issue {
|
||||
i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
|
||||
i.Fields.Status.StatusCategory.Key = "done"
|
||||
return i
|
||||
}
|
||||
|
||||
func openIssue() issue {
|
||||
i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
|
||||
i.Fields.Status.StatusCategory.Key = "new"
|
||||
return i
|
||||
}
|
||||
|
||||
func transition(id, name, category string) jiraTransition {
|
||||
tr := jiraTransition{ID: id, Name: name}
|
||||
tr.To.StatusCategory.Key = category
|
||||
return tr
|
||||
}
|
||||
|
||||
func TestNotifyCreatesWhenNoExistingIssue(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 1, m.countPost("/issue"))
|
||||
assert.Equal(t, 0, m.countPost("/comment")) // no comment on create
|
||||
assert.Equal(t, 0, m.countPuts()) // no update
|
||||
}
|
||||
|
||||
func TestNotifyResolvedOnlyWithNoIssueIsNoop(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 1, m.countPost("/search/jql"))
|
||||
assert.Equal(t, 0, m.countPost("/issue"))
|
||||
}
|
||||
|
||||
func TestNotifyStillFiringUpdatesAndComments(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.searchIssues = []issue{openIssue()}
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 0, m.countPost("/issue")) // no create
|
||||
assert.Equal(t, 1, m.countPuts()) // update
|
||||
assert.Equal(t, 1, m.countPost("/comment"))
|
||||
assert.Equal(t, 0, m.countPost("/transitions")) // still open, no transition
|
||||
|
||||
// comment carries the full rich snapshot (panel + labeled body), not a one-liner.
|
||||
cjs, err := json.Marshal(m.lastBody(t, "/comment"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(cjs), `"panel"`)
|
||||
assert.Contains(t, string(cjs), "Summary:")
|
||||
}
|
||||
|
||||
func TestNotifyResolveTransitionsToDoneAndComments(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.searchIssues = []issue{openIssue()}
|
||||
m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 1, m.countPuts()) // update
|
||||
assert.Equal(t, 1, m.countPost("/transitions")) // resolve transition
|
||||
assert.Equal(t, 1, m.countPost("/comment"))
|
||||
}
|
||||
|
||||
func TestNotifyReopensDoneIssue(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.searchIssues = []issue{doneIssue()}
|
||||
m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 1, m.countPost("/transitions")) // reopen transition
|
||||
assert.Equal(t, 1, m.countPost("/comment"))
|
||||
}
|
||||
|
||||
func TestNotifySafeSkipsWhenNoMatchingTransition(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.searchIssues = []issue{openIssue()}
|
||||
m.transitions = []jiraTransition{transition("11", "To Do", "new")} // no done-category transition
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
|
||||
require.NoError(t, err) // must not error
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 0, m.countPost("/transitions")) // skipped
|
||||
assert.Equal(t, 1, m.countPost("/comment")) // comment still posted
|
||||
}
|
||||
|
||||
func TestNotifyPrefersOpenIssueOverRecentlyDone(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
open := openIssue()
|
||||
open.Key = "KAN-2"
|
||||
// the JQL order can put a recently-done issue first; the open one must win
|
||||
m.searchIssues = []issue{doneIssue(), open}
|
||||
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 0, m.countPost("/issue")) // no duplicate create
|
||||
assert.Equal(t, 0, m.countPost("/transitions")) // open issue → no reopen
|
||||
assert.Equal(t, 1, m.countPuts())
|
||||
assert.Equal(t, 1, m.countPost("/comment"))
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, r := range m.reqs {
|
||||
if r.method == http.MethodPut || strings.HasSuffix(r.path, "/comment") {
|
||||
assert.Contains(t, r.path, "KAN-2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyRetriesOn429(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.createStatus = http.StatusTooManyRequests
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.Error(t, err)
|
||||
assert.True(t, retry)
|
||||
}
|
||||
|
||||
func (m *mockJira) lastBody(t *testing.T, suffix string) map[string]any {
|
||||
t.Helper()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for i := len(m.reqs) - 1; i >= 0; i-- {
|
||||
if m.reqs[i].method == http.MethodPost && strings.HasSuffix(m.reqs[i].path, suffix) {
|
||||
return m.reqs[i].body
|
||||
}
|
||||
}
|
||||
t.Fatalf("no POST request to %s", suffix)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNotifyRichDescriptionPanelAndLinks(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
a := alert(true)
|
||||
a.Labels[ruletypes.LabelRuleSource] = model.LabelValue("https://app.signoz.io/alerts?ruleId=1")
|
||||
a.Annotations[ruletypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs")
|
||||
|
||||
_, err := newNotifier(t, m).Notify(ctx(), a)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := m.lastBody(t, "/issue")
|
||||
js, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
s := string(js)
|
||||
assert.Contains(t, s, `"panel"`) // status panel present
|
||||
assert.Contains(t, s, `"error"`) // firing → error panel
|
||||
assert.Contains(t, s, "Open in SigNoz") // rule deep-link
|
||||
assert.Contains(t, s, "https://app.signoz.io/alerts?ruleId=1") // rule url
|
||||
assert.Contains(t, s, "View Related Logs") // related-logs deep-link
|
||||
assert.Contains(t, s, "Summary:") // labeled body section
|
||||
assert.Contains(t, s, "cpu high") // rendered annotation
|
||||
}
|
||||
|
||||
func TestNotifyCustomTemplateAnnotationsOverrideDefaults(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
a1 := alert(true)
|
||||
a1.Labels["service"] = "payment"
|
||||
a1.Labels["namespace"] = "ns-one"
|
||||
a1.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
|
||||
a1.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
|
||||
a2 := alert(true)
|
||||
a2.Labels["service"] = "payment"
|
||||
a2.Labels["namespace"] = "ns-two"
|
||||
a2.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
|
||||
a2.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
|
||||
|
||||
_, err := newNotifier(t, m).Notify(ctx(), a1, a2)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := m.lastBody(t, "/issue")
|
||||
fields, ok := body["fields"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "High throughput for payment", fields["summary"])
|
||||
|
||||
js, err := json.Marshal(fields["description"])
|
||||
require.NoError(t, err)
|
||||
s := string(js)
|
||||
assert.Contains(t, s, "Firing in NS: ns-one")
|
||||
assert.Contains(t, s, "Firing in NS: ns-two")
|
||||
// per-alert custom bodies are separated by an ADF rule divider
|
||||
assert.Contains(t, s, `"rule"`)
|
||||
assert.NotContains(t, s, "Summary:") // default body template not used
|
||||
}
|
||||
|
||||
// Jira replaces labels wholesale on PUT, so the update must union in the
|
||||
// labels already on the issue or user-added ones get wiped.
|
||||
func TestNotifyUpdatePreservesUserAddedLabels(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
existing := openIssue()
|
||||
existing.Fields.Labels = []string{"user-added-label", "signoz-alert"}
|
||||
m.searchIssues = []issue{existing}
|
||||
|
||||
_, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
|
||||
search := m.lastBody(t, "/search/jql")
|
||||
assert.Contains(t, search["fields"], "labels")
|
||||
|
||||
m.mu.Lock()
|
||||
var putLabels []any
|
||||
for _, r := range m.reqs {
|
||||
if r.method == http.MethodPut {
|
||||
putLabels, _ = r.body["fields"].(map[string]any)["labels"].([]any)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
assert.Contains(t, putLabels, "user-added-label")
|
||||
assert.Contains(t, putLabels, "signoz-alert")
|
||||
assert.Equal(t, 1, strings.Count(fmt.Sprint(putLabels), "signoz-alert")) // no duplicates
|
||||
// the dedup label is re-asserted
|
||||
found := false
|
||||
for _, l := range putLabels {
|
||||
if s, ok := l.(string); ok && strings.HasPrefix(s, "ALERT{") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func TestADFDocLen(t *testing.T) {
|
||||
text := func(s string) map[string]any { return map[string]any{"type": "text", "text": s} }
|
||||
para := func(children ...any) map[string]any {
|
||||
return map[string]any{"type": "paragraph", "content": children}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
node any
|
||||
want int
|
||||
}{
|
||||
{"text node", text("hello"), 7}, // 5 utf16 + 2 overhead
|
||||
{"emoji counts utf16", text("🔴"), 4}, // 2 utf16 units + 2 overhead
|
||||
{"paragraph wraps text", para(text("hi")), 6}, // 2 + (2+2)
|
||||
{"link href counted", map[string]any{"type": "text", "text": "a", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://x"}}}}, 12}, // 1 + 9 href + 2
|
||||
{"non-map is zero", "junk", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, adfDocLen(c.node))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 30 fat custom bodies overflow Jira's description accounting (text + per-node
|
||||
// overhead); the built doc must be shrunk under the limit, never rejected.
|
||||
func TestNotifyDescriptionShrunkUnderJiraLimit(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
filler := strings.Repeat("This is a long runbook detail line used to inflate the alert body. ", 25)
|
||||
alerts := make([]*types.Alert, 0, 30)
|
||||
for i := range 30 {
|
||||
a := alert(true)
|
||||
a.Labels["service"] = model.LabelValue(strings.Repeat("s", 3) + string(rune('a'+i%26)))
|
||||
a.Annotations[ruletypes.AnnotationTitleTemplate] = "overflow probe"
|
||||
a.Annotations[ruletypes.AnnotationBodyTemplate] = model.LabelValue("**Alert in service** $labels.service\n\n" + filler)
|
||||
alerts = append(alerts, a)
|
||||
}
|
||||
|
||||
_, err := newNotifier(t, m).Notify(ctx(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := m.lastBody(t, "/issue")
|
||||
fields, ok := body["fields"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
desc := fields["description"]
|
||||
assert.LessOrEqual(t, adfDocLen(desc), maxDescriptionLenRunes)
|
||||
|
||||
js, err := json.Marshal(desc)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(js), "FIRING") // status panel survives the shrink
|
||||
assert.Contains(t, string(js), "…") // body ends with the shrink marker
|
||||
}
|
||||
|
||||
func TestFiringSearchJQLHasReopenWindow(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
_, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
|
||||
body := m.lastBody(t, "/search/jql")
|
||||
jql, ok := body["jql"].(string)
|
||||
require.True(t, ok)
|
||||
// newNotifier uses a 3d window → 4320 minutes.
|
||||
assert.Contains(t, jql, "resolutiondate >= -4320m")
|
||||
}
|
||||
|
||||
func TestSelectTransition(t *testing.T) {
|
||||
ts := []jiraTransition{
|
||||
transition("41", "Done", "done"),
|
||||
transition("51", "Won't Do", "done"),
|
||||
transition("11", "To Do", "new"),
|
||||
}
|
||||
assert.Equal(t, "41", selectTransition(ts, true, "")) // first done-category
|
||||
assert.Equal(t, "51", selectTransition(ts, true, "Won't Do")) // named override
|
||||
assert.Equal(t, "11", selectTransition(ts, false, "")) // first non-done
|
||||
assert.Equal(t, "41", selectTransition(ts, true, "Nonexistent")) // bad override → fallback
|
||||
assert.Equal(t, "", selectTransition([]jiraTransition{transition("11", "To Do", "new")}, true, "")) // none → skip
|
||||
}
|
||||
|
||||
func TestResolveCloudID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
want string
|
||||
wantErr bool
|
||||
wantRetry bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/_edge/tenant_info", r.URL.Path)
|
||||
_, _ = w.Write([]byte(`{"cloudId":"abc-123"}`))
|
||||
},
|
||||
want: "abc-123",
|
||||
},
|
||||
{
|
||||
name: "non-200 is not retryable",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty cloud id",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"cloudId":""}`)) },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad json",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) },
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(c.handler)
|
||||
defer srv.Close()
|
||||
|
||||
n, err := New(&alertmanagertypes.JiraReceiverConfig{Site: srv.URL, HTTPConfig: &commoncfg.HTTPClientConfig{}}, nil, slog.New(slog.DiscardHandler), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, retry, err := n.resolveCloudID(context.Background())
|
||||
if c.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, c.wantRetry, retry)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package jsmops delivers Jira Service Management Ops alerts by reusing the
|
||||
// Opsgenie notifier: JSM Ops is the ex-Opsgenie alert API, so we map the JSM
|
||||
// config onto config.OpsGenieConfig with APIURL pinned to the JSM native
|
||||
// integration-events gateway.
|
||||
package jsmops
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "jsmops"
|
||||
source = "SigNoz"
|
||||
)
|
||||
|
||||
// New builds an Opsgenie notifier pointed at the JSM native endpoint.
|
||||
// advancedFeatures enables the rich treatment: HTML body and a note timeline
|
||||
// (per fire and on resolve).
|
||||
func New(c *alertmanagertypes.JSMOpsReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool) (*opsgenie.Notifier, error) {
|
||||
conf, err := toOpsGenieConfig(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return opsgenie.New(conf, t, l, templater, advancedFeatures)
|
||||
}
|
||||
|
||||
// toOpsGenieConfig maps the JSM config onto config.OpsGenieConfig with APIURL
|
||||
// pinned to the JSM native gateway.
|
||||
func toOpsGenieConfig(c *alertmanagertypes.JSMOpsReceiverConfig) (*config.OpsGenieConfig, error) {
|
||||
apiURL, err := url.Parse(alertmanagertypes.JSMOpsAPIBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpConfig := c.HTTPConfig
|
||||
if httpConfig == nil {
|
||||
httpConfig = &commoncfg.HTTPClientConfig{}
|
||||
}
|
||||
|
||||
return &config.OpsGenieConfig{
|
||||
NotifierConfig: c.NotifierConfig,
|
||||
HTTPConfig: httpConfig,
|
||||
APIKey: c.APIKey,
|
||||
APIURL: &config.URL{URL: apiURL},
|
||||
Message: c.Message,
|
||||
Description: c.Description,
|
||||
Priority: c.Priority,
|
||||
Tags: c.Tags,
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package jsmops
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestToOpsGenieConfig(t *testing.T) {
|
||||
c := &alertmanagertypes.JSMOpsReceiverConfig{
|
||||
APIKey: "key-123",
|
||||
Message: "msg",
|
||||
Description: "desc",
|
||||
Priority: "P1",
|
||||
Tags: "signoz",
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
|
||||
og, err := toOpsGenieConfig(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Trailing slash is required: the Opsgenie notifier appends "v2/alerts..."
|
||||
// with no separator, yielding /jsm/ops/integration/v2/alerts.
|
||||
assert.Equal(t, "https://api.atlassian.com/jsm/ops/integration/", og.APIURL.String())
|
||||
assert.Equal(t, "key-123", string(og.APIKey))
|
||||
assert.Equal(t, "msg", og.Message)
|
||||
assert.Equal(t, "desc", og.Description)
|
||||
assert.Equal(t, "P1", og.Priority)
|
||||
assert.Equal(t, "signoz", og.Tags)
|
||||
assert.Equal(t, source, og.Source)
|
||||
assert.Same(t, c.HTTPConfig, og.HTTPConfig)
|
||||
}
|
||||
|
||||
func TestToOpsGenieConfigNilHTTPConfig(t *testing.T) {
|
||||
og, err := toOpsGenieConfig(&alertmanagertypes.JSMOpsReceiverConfig{APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, og.HTTPConfig)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -33,13 +32,8 @@ const (
|
||||
Integration = "opsgenie"
|
||||
)
|
||||
|
||||
// https://support.atlassian.com/opsgenie/docs/alert-fields/ - message 130,
|
||||
// description 15000, note 25000 runes.
|
||||
const (
|
||||
maxMessageLenRunes = 130
|
||||
maxDescriptionLenRunes = 15000
|
||||
maxNoteLenRunes = 25000
|
||||
)
|
||||
// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes.
|
||||
const maxMessageLenRunes = 130
|
||||
|
||||
// Notifier implements a Notifier for OpsGenie notifications.
|
||||
type Notifier struct {
|
||||
@@ -49,29 +43,21 @@ type Notifier struct {
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
// advancedFeatures bundles the JSM Ops enrichments: render the default body as
|
||||
// HTML (markdown -> HTML), and post a note per fire and on resolve to build an
|
||||
// immutable timeline. Off for plain OpsGenie. The alert-refresh-on-refire part
|
||||
// rides on the upstream UpdateAlerts config flag, set alongside this.
|
||||
advancedFeatures bool
|
||||
}
|
||||
|
||||
// New returns a new OpsGenie notifier. advancedFeatures enables the JSM Ops
|
||||
// enrichments (HTML default body + a note timeline per fire and on resolve);
|
||||
// pass false for plain OpsGenie.
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
// New returns a new OpsGenie notifier.
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
advancedFeatures: advancedFeatures,
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -108,30 +94,6 @@ type opsGenieUpdateDescriptionMessage struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type opsGenieAddNoteMessage struct {
|
||||
Note string `json:"note"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// noteRequest builds a POST to the alert's notes endpoint (append-only timeline).
|
||||
func (n *Notifier) noteRequest(ctx context.Context, alias, note, source string) (*http.Request, error) {
|
||||
noteEndpointURL := n.conf.APIURL.Copy()
|
||||
noteEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/notes", alias)
|
||||
q := noteEndpointURL.Query()
|
||||
q.Set("identifierType", "alias")
|
||||
noteEndpointURL.RawQuery = q.Encode()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(&opsGenieAddNoteMessage{Note: note, Source: source}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest("POST", noteEndpointURL.String(), &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return req.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
requests, retry, err := n.createRequests(ctx, as...)
|
||||
@@ -148,24 +110,12 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
notify.Drain(resp)
|
||||
if err != nil {
|
||||
// notes are enrichment; a permanently-failed note (e.g. the first-fire
|
||||
// note racing JSM's async alert create) must not fail the notification
|
||||
if !shouldRetry && isNoteRequest(req) {
|
||||
n.logger.WarnContext(ctx, "dropping failed note", slog.Int("status_code", resp.StatusCode), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isNoteRequest reports whether req targets the notes endpoint, the only one
|
||||
// built by noteRequest.
|
||||
func isNoteRequest(req *http.Request) bool {
|
||||
return strings.HasSuffix(req.URL.Path, "/notes")
|
||||
}
|
||||
|
||||
// Like Split but filter out empty strings.
|
||||
func safeSplit(s, sep string) []string {
|
||||
a := strings.Split(strings.TrimSpace(s), sep)
|
||||
@@ -195,13 +145,28 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
|
||||
}
|
||||
|
||||
var description string
|
||||
if result.IsDefaultBody && !n.advancedFeatures {
|
||||
if result.IsDefaultBody {
|
||||
description = strings.Join(result.Body, "\n")
|
||||
} else {
|
||||
description, err = buildHTMLDescription(result.Body, maxDescriptionLenRunes)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for _, part := range result.Body {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
rendered, renderErr := markdownrenderer.RenderHTML(part)
|
||||
if renderErr != nil {
|
||||
return "", "", renderErr
|
||||
}
|
||||
if !first {
|
||||
b.WriteString("<hr>")
|
||||
}
|
||||
b.WriteString("<div>")
|
||||
b.WriteString(rendered)
|
||||
b.WriteString("</div>")
|
||||
first = false
|
||||
}
|
||||
description = b.String()
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
|
||||
@@ -209,141 +174,9 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
|
||||
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
// The API silently truncates over-limit descriptions, which would drop the
|
||||
// trailing SigNoz link; cap here with an ellipsis instead. The HTML path is
|
||||
// pre-fitted above, so this only ever cuts the plain-text default body.
|
||||
description, descTruncated := notify.TruncateInRunes(description, maxDescriptionLenRunes)
|
||||
if descTruncated {
|
||||
n.logger.WarnContext(ctx, "Truncated description", slog.Int("max_runes", maxDescriptionLenRunes))
|
||||
}
|
||||
|
||||
return title, description, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// room reserved for the "+N more" trailer appended when parts are dropped.
|
||||
descriptionTrailerReserveRunes = 80
|
||||
// below this rendering budget a shrunk part carries no signal; drop it instead.
|
||||
minShrinkBudgetRunes = 64
|
||||
)
|
||||
|
||||
// buildHTMLDescription renders each markdown part to HTML (<div>-wrapped,
|
||||
// <hr>-joined) while keeping the total within budget runes. An over-budget part
|
||||
// is shrunk at the markdown level and re-rendered so the HTML stays well-formed;
|
||||
// fully dropped parts are summarized by a "+N more" trailer.
|
||||
func buildHTMLDescription(parts []string, budget int) (string, error) {
|
||||
rendering := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
rendering = append(rendering, part)
|
||||
}
|
||||
}
|
||||
|
||||
budget -= descriptionTrailerReserveRunes
|
||||
var b strings.Builder
|
||||
used, included := 0, 0
|
||||
for _, part := range rendering {
|
||||
rendered, err := markdownrenderer.RenderHTML(part)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
overhead := len("<div></div>")
|
||||
if included > 0 {
|
||||
overhead += len("<hr>")
|
||||
}
|
||||
if used+overhead+utf8.RuneCountInString(rendered) > budget {
|
||||
rendered, err = shrinkMarkdownToFit(part, budget-used-overhead)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rendered == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if included > 0 {
|
||||
b.WriteString("<hr>")
|
||||
}
|
||||
b.WriteString("<div>")
|
||||
b.WriteString(rendered)
|
||||
b.WriteString("</div>")
|
||||
used += overhead + utf8.RuneCountInString(rendered)
|
||||
included++
|
||||
}
|
||||
if dropped := len(rendering) - included; dropped > 0 {
|
||||
fmt.Fprintf(&b, "<hr><div><i>…and %d more alerts. Open in SigNoz for the full list.</i></div>", dropped)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// shrinkMarkdownToFit cuts markdown until its rendered HTML fits within budget
|
||||
// runes, returning "" when the budget is too small to carry anything useful.
|
||||
// Only the markdown is ever cut, never the rendered HTML, so goldmark always
|
||||
// emits balanced markup.
|
||||
func shrinkMarkdownToFit(md string, budget int) (string, error) {
|
||||
if budget < minShrinkBudgetRunes {
|
||||
return "", nil
|
||||
}
|
||||
for range 4 {
|
||||
rendered, err := markdownrenderer.RenderHTML(md)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
renderedLen := utf8.RuneCountInString(rendered)
|
||||
if renderedLen <= budget {
|
||||
return rendered, nil
|
||||
}
|
||||
runes := []rune(md)
|
||||
keep := len(runes) * budget / renderedLen * 9 / 10
|
||||
if keep >= len(runes) {
|
||||
keep = len(runes) - 1
|
||||
}
|
||||
if keep < minShrinkBudgetRunes {
|
||||
return "", nil
|
||||
}
|
||||
md = string(runes[:keep]) + "…"
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// prepareNote renders the same body template as plain text for a timeline note.
|
||||
// JSM Ops notes render neither HTML nor markdown, so links flatten to
|
||||
// "text (url)" and all markers are stripped.
|
||||
func (n *Notifier) prepareNote(ctx context.Context, alerts []*types.Alert) (string, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Message,
|
||||
DefaultBodyTemplate: n.conf.Description,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for _, part := range result.Body {
|
||||
text, renderErr := markdownrenderer.RenderPlainText(part)
|
||||
if renderErr != nil {
|
||||
return "", renderErr
|
||||
}
|
||||
if text = strings.TrimSpace(text); text == "" {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(text)
|
||||
first = false
|
||||
}
|
||||
|
||||
note, truncated := notify.TruncateInRunes(b.String(), maxNoteLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated note", slog.Int("max_runes", maxNoteLenRunes))
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// Create requests for a list of alerts.
|
||||
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
@@ -373,21 +206,6 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
|
||||
)
|
||||
switch alerts.Status() {
|
||||
case model.AlertResolved:
|
||||
// Post the resolved snapshot to the timeline before closing (closed alerts
|
||||
// reject notes), so the note lands first.
|
||||
if n.advancedFeatures {
|
||||
note, err := n.prepareNote(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return nil, false, err
|
||||
}
|
||||
noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, noteReq)
|
||||
}
|
||||
|
||||
resolvedEndpointURL := n.conf.APIURL.Copy()
|
||||
resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias)
|
||||
q := resolvedEndpointURL.Query()
|
||||
@@ -504,21 +322,6 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// Append this fire's snapshot to the timeline (every fire, including the
|
||||
// first, so no datapoint is lost when the description is overwritten).
|
||||
// Notes are plain text, so this uses the plain-text render, not the HTML body.
|
||||
if n.advancedFeatures {
|
||||
note, err := n.prepareNote(ctx, as)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, noteReq)
|
||||
}
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
|
||||
@@ -6,18 +6,14 @@ package opsgenie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
@@ -48,7 +44,6 @@ func TestOpsGenieRetry(t *testing.T) {
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -74,7 +69,6 @@ func TestOpsGenieRedactedURL(t *testing.T) {
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -102,7 +96,6 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -223,7 +216,7 @@ func TestOpsGenie(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl), false)
|
||||
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -299,7 +292,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
@@ -331,111 +324,6 @@ func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
assert.JSONEq(t, `{"description":"new description"}`, body2)
|
||||
}
|
||||
|
||||
func TestOpsGenieAdvancedFeatures(t *testing.T) {
|
||||
u, err := url.Parse("https://test-opsgenie-url")
|
||||
require.NoError(t, err)
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := notify.WithGroupKey(context.Background(), "1")
|
||||
key, _ := notify.ExtractGroupKey(ctx)
|
||||
alias := key.Hash()
|
||||
|
||||
cfg := &config.OpsGenieConfig{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: true},
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
UpdateAlerts: true,
|
||||
APIKey: "k",
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifier, err := New(cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
firing := &types.Alert{Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Labels: model.LabelSet{"Message": "m", "Description": "**Alert:** d [View](https://s.io/a)"},
|
||||
}}
|
||||
|
||||
// Fire: create + update message + update description + a timeline note.
|
||||
reqs, _, err := notifier.createRequests(ctx, firing)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reqs, 4)
|
||||
assert.Equal(t, "https://test-opsgenie-url/v2/alerts", reqs[0].URL.String())
|
||||
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[3].URL.String())
|
||||
assert.Equal(t, http.MethodPost, reqs[3].Method)
|
||||
|
||||
// the note body is the plain-text render: markers stripped, link flattened
|
||||
var noteMsg opsGenieAddNoteMessage
|
||||
require.NoError(t, json.Unmarshal([]byte(readBody(t, reqs[3])), ¬eMsg))
|
||||
assert.Equal(t, "Alert: d View (https://s.io/a)", noteMsg.Note)
|
||||
|
||||
// Resolve: note posted before the close.
|
||||
resolved := &types.Alert{Alert: model.Alert{
|
||||
StartsAt: time.Now().Add(-time.Hour),
|
||||
EndsAt: time.Now().Add(-time.Minute),
|
||||
Labels: model.LabelSet{"Message": "m", "Description": "d"},
|
||||
}}
|
||||
reqs, _, err = notifier.createRequests(ctx, resolved)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reqs, 2)
|
||||
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[0].URL.String())
|
||||
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/close?identifierType=alias", alias), reqs[1].URL.String())
|
||||
}
|
||||
|
||||
func TestOpsGenieNotifyBestEffortNote(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := notify.WithGroupKey(context.Background(), "1")
|
||||
|
||||
firing := &types.Alert{Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Labels: model.LabelSet{"Message": "m", "Description": "d"},
|
||||
}}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
createStatus int
|
||||
noteStatus int
|
||||
wantErr bool
|
||||
wantRetry bool
|
||||
}{
|
||||
{name: "note_404_is_dropped", createStatus: http.StatusAccepted, noteStatus: http.StatusNotFound, wantErr: false, wantRetry: true},
|
||||
{name: "note_429_still_retries", createStatus: http.StatusAccepted, noteStatus: http.StatusTooManyRequests, wantErr: true, wantRetry: true},
|
||||
{name: "create_404_still_fails", createStatus: http.StatusNotFound, noteStatus: http.StatusAccepted, wantErr: true, wantRetry: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/notes") {
|
||||
w.WriteHeader(tc.noteStatus)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(tc.createStatus)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, err := url.Parse(srv.URL)
|
||||
require.NoError(t, err)
|
||||
notifier, err := New(&config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
APIKey: "k",
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
retry, err := notifier.Notify(ctx, firing)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tc.wantRetry, retry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
u, err := url.Parse("https://test-opsgenie-url")
|
||||
require.NoError(t, err)
|
||||
@@ -447,7 +335,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
|
||||
require.NoError(t, err)
|
||||
requests, _, err := notifierWithUpdate.createRequests(ctx)
|
||||
@@ -549,109 +437,6 @@ func TestPrepareContent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestShrinkMarkdownToFit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
md string
|
||||
budget int
|
||||
wantEmpty bool
|
||||
}{
|
||||
{"fits untouched", "**bold** text", 1000, false},
|
||||
{"shrinks to fit", strings.Repeat("lorem ipsum ", 500), 1000, false},
|
||||
{"budget too small", strings.Repeat("lorem ipsum ", 500), 10, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := shrinkMarkdownToFit(c.md, c.budget)
|
||||
require.NoError(t, err)
|
||||
if c.wantEmpty {
|
||||
assert.Empty(t, got)
|
||||
return
|
||||
}
|
||||
assert.NotEmpty(t, got)
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
|
||||
assert.Equal(t, strings.Count(got, "<p>"), strings.Count(got, "</p>"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLDescriptionOverflow(t *testing.T) {
|
||||
bigPart := strings.Repeat("alpha beta gamma ", 100)
|
||||
cases := []struct {
|
||||
name string
|
||||
parts []string
|
||||
budget int
|
||||
wantTrailer bool
|
||||
}{
|
||||
{"all parts fit", []string{"**a**", "**b**"}, maxDescriptionLenRunes, false},
|
||||
{"empty parts skipped", []string{"", "hello", ""}, maxDescriptionLenRunes, false},
|
||||
{"overflow drops parts with trailer", repeatParts(bigPart, 12), maxDescriptionLenRunes, true},
|
||||
{"single huge part shrunk without trailer", []string{strings.Repeat(bigPart, 20)}, maxDescriptionLenRunes, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := buildHTMLDescription(c.parts, c.budget)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
|
||||
assert.Equal(t, strings.Count(got, "<div>"), strings.Count(got, "</div>"))
|
||||
assert.True(t, strings.HasSuffix(got, "</div>"))
|
||||
if c.wantTrailer {
|
||||
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, got)
|
||||
} else {
|
||||
assert.NotContains(t, got, "more alerts")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// prepareContent end-to-end: 40 custom-template alerts overflow the description
|
||||
// budget yet the posted HTML stays within limits and well-formed.
|
||||
func TestPrepareContentDescriptionOverflow(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier := &Notifier{
|
||||
conf: &config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.alertname }}`,
|
||||
Description: `{{ .CommonLabels.alertname }}`,
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: promslog.NewNopLogger(),
|
||||
templater: newTestTemplater(tmpl),
|
||||
advancedFeatures: true,
|
||||
}
|
||||
|
||||
bodyTemplate := "**Alert in** $labels.namespace\n\n" + strings.Repeat("detail line for the runbook ", 30)
|
||||
alerts := make([]*types.Alert, 0, 40)
|
||||
for i := range 40 {
|
||||
alerts = append(alerts, &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"alertname": "overflow",
|
||||
"namespace": model.LabelValue(fmt.Sprintf("ns-%d", i)),
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
_, desc, err := notifier.prepareContent(notify.WithGroupKey(context.Background(), "1"), alerts)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(desc), maxDescriptionLenRunes)
|
||||
assert.Equal(t, strings.Count(desc, "<div>"), strings.Count(desc, "</div>"))
|
||||
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, desc)
|
||||
}
|
||||
|
||||
func repeatParts(part string, n int) []string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = part
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jira"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jsmops"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
@@ -28,8 +26,6 @@ var customNotifierIntegrations = []string{
|
||||
slack.Integration,
|
||||
msteamsv2.Integration,
|
||||
googlechat.Integration,
|
||||
jira.Integration,
|
||||
jsmops.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
@@ -70,7 +66,7 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
|
||||
}
|
||||
for i, c := range nc.OpsGenieConfigs {
|
||||
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater, false) })
|
||||
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) })
|
||||
}
|
||||
for i, c := range nc.SlackConfigs {
|
||||
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
|
||||
@@ -85,16 +81,6 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
return googlechat.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.JiraConfigs {
|
||||
add(jira.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return jira.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.JSMOpsConfigs {
|
||||
add(jsmops.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return jsmops.New(c, tmpl, l, templater, true)
|
||||
})
|
||||
}
|
||||
|
||||
if errs.Len() > 0 {
|
||||
return nil, &errs
|
||||
|
||||
@@ -332,33 +332,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/system/{name}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetSystemDashboard, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetSystemDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get system dashboard",
|
||||
Description: "Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableSystemDashboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: provider.systemDashboardID(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pinning mutates the calling user's pin list, not the dashboard, so it rides
|
||||
// on the collection-level list permission rather than a per-dashboard check.
|
||||
// The id is still extracted, for audit.
|
||||
@@ -745,23 +718,3 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// systemDashboardID resolves the {name} path param to the dashboard's id. Authz
|
||||
// tuples and audit records are written against ids, so the name has to be
|
||||
// resolved before either runs.
|
||||
func (provider *provider) systemDashboardID() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
ctx := ec.Request.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
systemDashboard, err := provider.dashboardModule.GetSystemDashboard(ctx, valuer.MustNewUUID(claims.OrgID), mux.Vars(ec.Request)["name"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return systemDashboard.ID.StringValue(), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
openapi "github.com/swaggest/openapi-go"
|
||||
)
|
||||
|
||||
// prometheusOpenAPIHandler skips the default handler wrapper: that wraps
|
||||
// every response in the house envelope, and these endpoints follow
|
||||
// Prometheus' wire contract, described by the prometheus package's *Schema
|
||||
// types.
|
||||
type prometheusOpenAPIHandler struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
id string
|
||||
summary string
|
||||
params any
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
h.handlerFunc.ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
// One route serves GET and POST; operation IDs must stay unique.
|
||||
id := h.id
|
||||
if strings.EqualFold(opCtx.Method(), http.MethodPost) {
|
||||
id += "Post"
|
||||
}
|
||||
opCtx.SetID(id)
|
||||
opCtx.SetTags("prometheus")
|
||||
opCtx.SetSummary(h.summary)
|
||||
opCtx.SetDescription("Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.")
|
||||
|
||||
for _, scheme := range newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}) {
|
||||
opCtx.AddSecurity(scheme.Name, scheme.Scopes...)
|
||||
}
|
||||
|
||||
opCtx.AddReqStructure(h.params)
|
||||
|
||||
opCtx.AddRespStructure(
|
||||
prometheus.SuccessResponseSchema{},
|
||||
openapi.WithContentType("application/json"),
|
||||
openapi.WithHTTPStatus(http.StatusOK),
|
||||
)
|
||||
for _, statusCode := range []int{http.StatusBadRequest, http.StatusUnprocessableEntity, http.StatusServiceUnavailable, http.StatusInternalServerError} {
|
||||
opCtx.AddRespStructure(
|
||||
prometheus.ErrorResponseSchema{},
|
||||
openapi.WithContentType("application/json"),
|
||||
openapi.WithHTTPStatus(statusCode),
|
||||
)
|
||||
}
|
||||
// The auth middleware answers before the handler and uses the house
|
||||
// envelope, not Prometheus'.
|
||||
for _, statusCode := range []int{http.StatusUnauthorized, http.StatusForbidden} {
|
||||
opCtx.AddRespStructure(
|
||||
render.ErrorResponse{Status: render.StatusError.String(), Error: &errors.JSON{}},
|
||||
openapi.WithContentType("application/json"),
|
||||
openapi.WithHTTPStatus(statusCode),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
|
||||
return []handler.ResourceDef{handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.PromQLResources,
|
||||
}}
|
||||
}
|
||||
|
||||
func (provider *provider) addPrometheusRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/prometheus/api/v1/query", &prometheusOpenAPIHandler{
|
||||
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.Query, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
id: "PrometheusQuery",
|
||||
summary: "Prometheus instant query",
|
||||
params: new(prometheus.QueryParamsSchema),
|
||||
}).Methods(http.MethodGet, http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/prometheus/api/v1/query_range", &prometheusOpenAPIHandler{
|
||||
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.QueryRange, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
id: "PrometheusQueryRange",
|
||||
summary: "Prometheus range query",
|
||||
params: new(prometheus.QueryRangeParamsSchema),
|
||||
}).Methods(http.MethodGet, http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
@@ -76,7 +75,6 @@ type provider struct {
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
spanMapperHandler spanmapper.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
prometheusHandler prometheus.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
@@ -115,7 +113,6 @@ func NewFactory(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
@@ -157,7 +154,6 @@ func NewFactory(
|
||||
ruleStateHistoryHandler,
|
||||
spanMapperHandler,
|
||||
alertmanagerHandler,
|
||||
prometheusHandler,
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
@@ -201,7 +197,6 @@ func newProvider(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
@@ -244,7 +239,6 @@ func newProvider(
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
spanMapperHandler: spanMapperHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
prometheusHandler: prometheusHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
@@ -346,10 +340,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addPrometheusRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addServiceAccountRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -29,7 +27,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.CheckResources(provider.zeusHandler.GetHosts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.ViewAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
|
||||
ID: "GetHosts",
|
||||
Tags: []string{"zeus"},
|
||||
Summary: "Get host info from Zeus.",
|
||||
@@ -41,17 +39,12 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDeploymentHost.Scope(coretypes.VerbList)}),
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.CheckResources(provider.zeusHandler.PutHost, authtypes.SigNozAdminRoleName), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.AdminAccess(provider.zeusHandler.PutHost), handler.OpenAPIDef{
|
||||
ID: "PutHost",
|
||||
Tags: []string{"zeus"},
|
||||
Summary: "Put host in Zeus for a deployment.",
|
||||
@@ -63,14 +56,8 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDeploymentHost.Scope(coretypes.VerbUpdate)}),
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.BodyJSONPath("name"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPut).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/tracehandler"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
@@ -109,7 +108,7 @@ func New(ctx context.Context, cfg Config, build version.Build, serviceName strin
|
||||
}
|
||||
|
||||
// Set the global tracer provider to the sdk tracer provider so that external packages can use this
|
||||
otel.SetTracerProvider(tracehandler.New(sdk.TracerProvider(), tracehandler.NewPromQL()))
|
||||
otel.SetTracerProvider(sdk.TracerProvider())
|
||||
|
||||
return &SDK{
|
||||
sdk: sdk,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package tracehandler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// TODO(srikanthccv): replace with the tracer scope filter (per-scope
|
||||
// "enabled") when the otel-go trace SDK ships it
|
||||
// (https://github.com/open-telemetry/opentelemetry-go/issues/8411).
|
||||
func NewPromQL() Wrapper {
|
||||
noop := tracenoop.NewTracerProvider().Tracer("")
|
||||
return WrapperFunc(func(scope string, next StartFunc) StartFunc {
|
||||
if scope != "" {
|
||||
return next
|
||||
}
|
||||
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||
if strings.HasPrefix(spanName, "promql") {
|
||||
return noop.Start(ctx, spanName)
|
||||
}
|
||||
return next(ctx, spanName, opts...)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package tracehandler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/embedded"
|
||||
)
|
||||
|
||||
// StartFunc is to trace.Tracer.Start as loghandler.LogHandlerFunc is to
|
||||
// loghandler.LogHandler.
|
||||
type StartFunc func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span)
|
||||
|
||||
// Wrapper is an interface implemented by all trace handlers. scope is the
|
||||
// instrumentation scope name of the tracer being wrapped; a wrapper that
|
||||
// does not apply to a scope returns next unchanged.
|
||||
type Wrapper interface {
|
||||
Wrap(scope string, next StartFunc) StartFunc
|
||||
}
|
||||
|
||||
type WrapperFunc func(scope string, next StartFunc) StartFunc
|
||||
|
||||
func (m WrapperFunc) Wrap(scope string, next StartFunc) StartFunc {
|
||||
return m(scope, next)
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
embedded.TracerProvider
|
||||
base trace.TracerProvider
|
||||
wrappers []Wrapper
|
||||
}
|
||||
|
||||
func New(base trace.TracerProvider, wrappers ...Wrapper) trace.TracerProvider {
|
||||
return &provider{base: base, wrappers: wrappers}
|
||||
}
|
||||
|
||||
func (p *provider) Tracer(name string, opts ...trace.TracerOption) trace.Tracer {
|
||||
base := p.base.Tracer(name, opts...)
|
||||
start := StartFunc(base.Start)
|
||||
for i := len(p.wrappers) - 1; i >= 0; i-- {
|
||||
start = p.wrappers[i].Wrap(name, start)
|
||||
}
|
||||
return &tracer{start: start}
|
||||
}
|
||||
|
||||
type tracer struct {
|
||||
embedded.Tracer
|
||||
start StartFunc
|
||||
}
|
||||
|
||||
func (t *tracer) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||
return t.start(ctx, spanName, opts...)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package tracehandler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
func TestWrappersChainInOrder(t *testing.T) {
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
var order []string
|
||||
observer := func(name string) Wrapper {
|
||||
return WrapperFunc(func(_ string, next StartFunc) StartFunc {
|
||||
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||
order = append(order, name)
|
||||
return next(ctx, spanName, opts...)
|
||||
}
|
||||
})
|
||||
}
|
||||
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), observer("first"), observer("second"))
|
||||
|
||||
_, span := provider.Tracer("test").Start(context.Background(), "op")
|
||||
span.End()
|
||||
|
||||
assert.Equal(t, []string{"first", "second"}, order)
|
||||
require.Len(t, recorder.Ended(), 1)
|
||||
assert.Equal(t, "op", recorder.Ended()[0].Name())
|
||||
}
|
||||
|
||||
func TestScopedWrapperSkipsOtherScopes(t *testing.T) {
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
noop := tracenoop.NewTracerProvider().Tracer("")
|
||||
dropAnonymous := WrapperFunc(func(scope string, next StartFunc) StartFunc {
|
||||
if scope != "" {
|
||||
return next
|
||||
}
|
||||
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||
return noop.Start(ctx, spanName)
|
||||
}
|
||||
})
|
||||
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), dropAnonymous)
|
||||
|
||||
_, dropped := provider.Tracer("").Start(context.Background(), "anon")
|
||||
dropped.End()
|
||||
_, kept := provider.Tracer("named").Start(context.Background(), "op")
|
||||
kept.End()
|
||||
|
||||
require.Len(t, recorder.Ended(), 1)
|
||||
assert.Equal(t, "op", recorder.Ended()[0].Name())
|
||||
}
|
||||
|
||||
func TestPromQL(t *testing.T) {
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), NewPromQL())
|
||||
|
||||
ctx, root := provider.Tracer("http").Start(context.Background(), "GET /api")
|
||||
|
||||
engineCtx, engineSpan := provider.Tracer("").Start(ctx, "promqlInnerEval eval *promql.BinaryExpr")
|
||||
assert.False(t, engineSpan.IsRecording(), "promql engine spans must not record")
|
||||
assert.Equal(t, root.SpanContext().SpanID(), engineSpan.SpanContext().SpanID(), "the filtered span must keep the parent's span context")
|
||||
|
||||
_, child := provider.Tracer("clickhouse").Start(engineCtx, "clickhouse.query")
|
||||
child.End()
|
||||
|
||||
_, other := provider.Tracer("").Start(ctx, "http.request")
|
||||
other.End()
|
||||
root.End()
|
||||
|
||||
var names []string
|
||||
var childParent string
|
||||
for _, span := range recorder.Ended() {
|
||||
names = append(names, span.Name())
|
||||
if span.Name() == "clickhouse.query" {
|
||||
childParent = span.Parent().SpanID().String()
|
||||
}
|
||||
}
|
||||
require.ElementsMatch(t, []string{"clickhouse.query", "http.request", "GET /api"}, names)
|
||||
assert.Equal(t, root.SpanContext().SpanID().String(), childParent, "descendants of a filtered span must attach to the surrounding span")
|
||||
assert.False(t, strings.HasPrefix(recorder.Ended()[0].Name(), "promql"))
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package implaiobservability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
|
||||
@@ -17,11 +19,13 @@ import (
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
}
|
||||
|
||||
func NewHandler(telemetryMetadataStore telemetrytypes.MetadataStore) aiobservability.Handler {
|
||||
func NewHandler(providerSettings factory.ProviderSettings, telemetryMetadataStore telemetrytypes.MetadataStore) aiobservability.Handler {
|
||||
return &handler{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/aiobservability/implaiobservability"),
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
}
|
||||
}
|
||||
@@ -66,13 +70,6 @@ func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Reques
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// binding ignores query params the struct does not declare, so an unsupported
|
||||
// existingQuery would silently return values it did not narrow
|
||||
if req.URL.Query().Has("existingQuery") {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "existingQuery is not supported"))
|
||||
return
|
||||
}
|
||||
|
||||
var params aiobservabilitytypes.PostableFieldValueParams
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), ¶ms); err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -84,18 +81,32 @@ func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Reques
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
scopedQuery, err := aitelemetryschema.ScopedExistingQuery(params.ExistingQuery)
|
||||
if err != nil {
|
||||
handler.settings.Logger().WarnContext(ctx, "dropping unparseable existing query", slog.String("query", params.ExistingQuery), errors.Attr(err))
|
||||
}
|
||||
params.ExistingQuery = scopedQuery
|
||||
fieldValueSelector := aiobservabilitytypes.NewFieldValueSelectorFromPostableFieldValueParams(params)
|
||||
|
||||
values := &telemetrytypes.TelemetryFieldValues{}
|
||||
complete := true
|
||||
// the trace context names the computed per-trace aggregates, which are never ingested
|
||||
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextTrace {
|
||||
values, complete, err = handler.telemetryMetadataStore.GetAllValues(ctx, valuer.MustNewUUID(claims.OrgID), fieldValueSelector)
|
||||
values, complete, err = handler.telemetryMetadataStore.GetAllValues(ctx, orgID, fieldValueSelector)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// related values are best-effort: on failure the plain values still serve the filter bar
|
||||
relatedValues, relatedComplete, err := handler.telemetryMetadataStore.GetRelatedValues(ctx, orgID, fieldValueSelector)
|
||||
if err != nil {
|
||||
relatedValues = []string{}
|
||||
}
|
||||
values.RelatedValues = relatedValues
|
||||
complete = complete && relatedComplete
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, &telemetrytypes.GettableFieldValues{
|
||||
|
||||
@@ -22,7 +22,7 @@ func newConfig() factory.Config {
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.14",
|
||||
Version: "v0.0.13",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,14 +99,6 @@ type Module interface {
|
||||
DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// System dashboard methods
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error
|
||||
|
||||
GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
@@ -170,6 +162,4 @@ type Handler interface {
|
||||
UpdateView(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteView(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetSystemDashboard(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"schemaVersion": "v6",
|
||||
"name": "signoz---ai-o11y-overview",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "AI Observability Overview",
|
||||
"description": "Overview of LLM traffic. Panels ship in an upcoming release."
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"layouts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,25 +21,23 @@ import (
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store dashboardtypes.Store
|
||||
settings factory.ScopedProviderSettings
|
||||
analytics analytics.Analytics
|
||||
orgGetter organization.Getter
|
||||
queryParser queryparser.QueryParser
|
||||
tagModule tag.Module
|
||||
systemDashboardRegistry dashboardtypes.SystemDashboardRegistry
|
||||
store dashboardtypes.Store
|
||||
settings factory.ScopedProviderSettings
|
||||
analytics analytics.Analytics
|
||||
orgGetter organization.Getter
|
||||
queryParser queryparser.QueryParser
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module) dashboard.Module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard")
|
||||
return &module{
|
||||
store: store,
|
||||
settings: scopedProviderSettings,
|
||||
analytics: analytics,
|
||||
orgGetter: orgGetter,
|
||||
queryParser: queryParser,
|
||||
tagModule: tagModule,
|
||||
systemDashboardRegistry: systemDashboardRegistry,
|
||||
store: store,
|
||||
settings: scopedProviderSettings,
|
||||
analytics: analytics,
|
||||
orgGetter: orgGetter,
|
||||
queryParser: queryParser,
|
||||
tagModule: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package impldashboard
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
@@ -65,23 +64,6 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByName(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableDashboard, error) {
|
||||
storableDashboard := new(dashboardtypes.StorableDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storableDashboard).
|
||||
Where("name = ?", name).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with name %s doesn't exist", name)
|
||||
}
|
||||
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
|
||||
// spec calls for. Aliases:
|
||||
//
|
||||
@@ -631,60 +613,3 @@ func (store *store) DeleteDashboardView(ctx context.Context, orgID valuer.UUID,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateSystemDashboard(ctx context.Context, storable *dashboardtypes.StorableSystemDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, dashboardtypes.ErrCodeSystemDashboardAlreadyProvisioned, "system dashboard %s is already provisioned", storable.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableSystemDashboard, error) {
|
||||
storable := new(dashboardtypes.StorableSystemDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateSystemDashboardVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error {
|
||||
result, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(new(dashboardtypes.StorableSystemDashboard)).
|
||||
Set("version = ?", version).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
)
|
||||
|
||||
const definitionsRoot = "fs/definitions"
|
||||
|
||||
//go:embed fs/definitions/*.json
|
||||
var definitionFiles embed.FS
|
||||
|
||||
// NewSystemDashboardRegistry parses every embedded definition. Definitions are
|
||||
// build-time assets validated by a test, so a failure here means the binary
|
||||
// shipped broken JSON.
|
||||
func NewSystemDashboardRegistry() (dashboardtypes.SystemDashboardRegistry, error) {
|
||||
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
|
||||
if err != nil {
|
||||
return dashboardtypes.SystemDashboardRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read system dashboard definitions")
|
||||
}
|
||||
|
||||
definitions := make([]dashboardtypes.SystemDashboardDefinition, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
file := path.Join(definitionsRoot, entry.Name())
|
||||
raw, err := definitionFiles.ReadFile(file)
|
||||
if err != nil {
|
||||
return dashboardtypes.SystemDashboardRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
|
||||
}
|
||||
|
||||
definition, err := dashboardtypes.NewSystemDashboardDefinition(raw)
|
||||
if err != nil {
|
||||
return dashboardtypes.SystemDashboardRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
|
||||
return dashboardtypes.NewSystemDashboardRegistry(definitions)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
)
|
||||
|
||||
const reconcileRetryInterval = 30 * time.Second
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
module dashboard.Module
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
healthyC chan struct{}
|
||||
}
|
||||
|
||||
// NewService reconciles every org's system dashboards once at startup. Orgs
|
||||
// created later are reconciled by the organization setter instead.
|
||||
func NewService(providerSettings factory.ProviderSettings, module dashboard.Module, orgGetter organization.Getter) factory.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"),
|
||||
module: module,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
healthyC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(reconcileRetryInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
err := service.reconcile(ctx)
|
||||
if err == nil {
|
||||
close(service.healthyC)
|
||||
<-service.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
service.settings.Logger().WarnContext(ctx, "system dashboard reconciliation failed, retrying", errors.Attr(err))
|
||||
|
||||
select {
|
||||
case <-service.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Healthy() <-chan struct{} {
|
||||
return service.healthyC
|
||||
}
|
||||
|
||||
func (service *service) Stop(_ context.Context) error {
|
||||
close(service.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *service) reconcile(ctx context.Context) error {
|
||||
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := service.module.ReconcileSystemDashboards(ctx, org.ID); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile system dashboards for org %s", org.ID.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
service.settings.Logger().InfoContext(ctx, "system dashboard reconciliation completed", slog.Int("orgs", len(orgs)))
|
||||
return nil
|
||||
}
|
||||
@@ -502,28 +502,3 @@ func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *h
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeResults)
|
||||
}
|
||||
|
||||
func (handler *handler) GetSystemDashboard(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
name := mux.Vars(r)["name"]
|
||||
if name == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is missing in the path"))
|
||||
return
|
||||
}
|
||||
|
||||
systemDashboard, err := handler.module.GetSystemDashboard(ctx, valuer.MustNewUUID(claims.OrgID), name)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, systemDashboard.ToGettableSystemDashboard())
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
@@ -21,12 +19,9 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboard, err := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
|
||||
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -125,20 +120,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) getByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
storable, err := module.store.GetByName(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, storable.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
|
||||
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
@@ -198,33 +179,13 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.Update)
|
||||
}
|
||||
|
||||
// updateUnsafeV2 updates a dashboard bypassing the guards. Intended for internal system callers.
|
||||
func (module *module) updateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.UpdateUnsafe)
|
||||
}
|
||||
|
||||
// apply is existing.Update or existing.UpdateUnsafe, so the gated path keeps its
|
||||
// in-transaction checks and only updateUnsafeV2 skips them.
|
||||
func (module *module) updateV2(ctx context.Context, orgID valuer.UUID, existing *dashboardtypes.DashboardV2, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2, apply func(dashboardtypes.UpdatableDashboardV2, string, []*tagtypes.Tag) error) (*dashboardtypes.DashboardV2, error) {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, existing.ID, updatable.Tags)
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = apply(updatable, updatedBy, resolvedTags)
|
||||
err = existing.Update(updatable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -335,98 +296,3 @@ func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID val
|
||||
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
|
||||
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
func (m *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
|
||||
for _, definition := range m.systemDashboardRegistry.List() {
|
||||
if err := m.reconcileSystemDashboard(ctx, orgID, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) reconcileSystemDashboard(ctx context.Context, orgID valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
|
||||
existing, err := m.getByNameV2(ctx, orgID, definition.Name())
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
return m.provisionSystemDashboard(ctx, orgID, definition)
|
||||
}
|
||||
|
||||
state, err := m.store.GetSystemDashboard(ctx, orgID, definition.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only ever move forward: a downgrade must not rewrite the newer content.
|
||||
if state.Version >= definition.Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.upgradeSystemDashboard(ctx, orgID, existing.ID, definition)
|
||||
}
|
||||
|
||||
// provisionSystemDashboard creates the dashboard and its state row in one transaction,
|
||||
// so a system dashboard can never exist without the version it was provisioned at.
|
||||
// A concurrent provisioner (another replica, or the org-creation hook racing the
|
||||
// startup sweep) loses on the state row's unique (org_id, name) index and rolls back.
|
||||
func (m *module) provisionSystemDashboard(ctx context.Context, orgID valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
created, err := m.CreateV2(
|
||||
ctx,
|
||||
orgID,
|
||||
dashboardtypes.ProvisionerIdentity,
|
||||
valuer.UUID{},
|
||||
dashboardtypes.SourceSystem,
|
||||
definition.Dashboard,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.store.CreateSystemDashboard(ctx, dashboardtypes.NewStorableSystemDashboard(orgID, created.ID, definition.Name(), definition.Version))
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
m.settings.Logger().DebugContext(ctx, "system dashboard already provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
m.settings.Logger().InfoContext(ctx, "provisioned system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) upgradeSystemDashboard(ctx context.Context, orgID valuer.UUID, id valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if _, err := m.updateUnsafeV2(ctx, orgID, id, dashboardtypes.ProvisionerIdentity, definition.ToUpdatable()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.store.UpdateSystemDashboardVersion(ctx, orgID, definition.Name(), definition.Version)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.settings.Logger().InfoContext(ctx, "upgraded system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
if strings.HasPrefix(name, dashboardtypes.SystemDashboardNamePrefix) {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "name must not carry the %q prefix", dashboardtypes.SystemDashboardNamePrefix)
|
||||
}
|
||||
|
||||
existing, err := m.getByNameV2(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := existing.ErrIfNotSystem(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testDashboardName = "test-overview"
|
||||
|
||||
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, model := range []any{
|
||||
(*dashboardtypes.StorableDashboard)(nil),
|
||||
(*tagtypes.Tag)(nil),
|
||||
(*tagtypes.TagRelation)(nil),
|
||||
(*dashboardtypes.StorableSystemDashboard)(nil),
|
||||
} {
|
||||
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_system_dashboard_org_name ON system_dashboard (org_id, name)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...dashboardtypes.SystemDashboardDefinition) *module {
|
||||
t.Helper()
|
||||
|
||||
registry, err := dashboardtypes.NewSystemDashboardRegistry(definitions)
|
||||
require.NoError(t, err)
|
||||
|
||||
providerSettings := factorytest.NewSettings()
|
||||
return NewModule(
|
||||
NewStore(sqlStore),
|
||||
providerSettings,
|
||||
analyticstest.New(),
|
||||
nil,
|
||||
queryparser.New(providerSettings),
|
||||
impltag.NewModule(impltag.NewStore(sqlStore)),
|
||||
registry,
|
||||
).(*module)
|
||||
}
|
||||
|
||||
func newTestDefinition(t *testing.T, version int, displayName string) dashboardtypes.SystemDashboardDefinition {
|
||||
t.Helper()
|
||||
|
||||
raw := `{
|
||||
"version": ` + strconv.Itoa(version) + `,
|
||||
"definition": {
|
||||
"schemaVersion": "` + dashboardtypes.SchemaVersion + `",
|
||||
"name": "` + dashboardtypes.SystemDashboardNamePrefix + testDashboardName + `",
|
||||
"tags": [],
|
||||
"spec": {"display": {"name": "` + displayName + `"}, "variables": [], "panels": {}, "layouts": []}
|
||||
}
|
||||
}`
|
||||
|
||||
definition, err := dashboardtypes.NewSystemDashboardDefinition([]byte(raw))
|
||||
require.NoError(t, err)
|
||||
|
||||
return definition
|
||||
}
|
||||
|
||||
func TestReconcileProvisionsThenUpgrades(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
|
||||
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
provisioned, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dashboardtypes.SourceSystem, provisioned.Source)
|
||||
assert.Equal(t, dashboardtypes.ProvisionerIdentity, provisioned.CreatedBy)
|
||||
assert.Equal(t, "v1", provisioned.Spec.Display.Name)
|
||||
assert.Equal(t, 1, stateVersion(t, dashboardModule, ctx, orgID))
|
||||
|
||||
// Reconciling the same version again is a no-op.
|
||||
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
unchanged, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provisioned.UpdatedAt, unchanged.UpdatedAt)
|
||||
|
||||
// An unmodified copy is upgraded in place, keeping its id.
|
||||
upgradingModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
|
||||
require.NoError(t, upgradingModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
upgraded, err := upgradingModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provisioned.ID, upgraded.ID)
|
||||
assert.Equal(t, "v2", upgraded.Spec.Display.Name)
|
||||
assert.Equal(t, 2, stateVersion(t, upgradingModule, ctx, orgID))
|
||||
}
|
||||
|
||||
func stateVersion(t *testing.T, module *module, ctx context.Context, orgID valuer.UUID) int {
|
||||
t.Helper()
|
||||
|
||||
state, err := module.store.GetSystemDashboard(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
|
||||
require.NoError(t, err)
|
||||
|
||||
return state.Version
|
||||
}
|
||||
|
||||
func TestSystemDashboardsAreImmutableToUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
|
||||
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
provisioned, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = dashboardModule.UpdateV2(ctx, orgID, provisioned.ID, "user@signoz.io", newTestDefinition(t, 1, "edited").ToUpdatable())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be modified")
|
||||
}
|
||||
|
||||
func TestReconcileDoesNotDowngrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
newerModule := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
|
||||
require.NoError(t, newerModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
olderModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
|
||||
require.NoError(t, olderModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
got, err := newerModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v3", got.Spec.Display.Name)
|
||||
assert.Equal(t, 3, stateVersion(t, newerModule, ctx, orgID))
|
||||
}
|
||||
|
||||
func TestGetRejectsANonSystemDashboard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
dashboardModule := newTestModule(t, sqlStore)
|
||||
|
||||
var postable dashboardtypes.PostableDashboardV2
|
||||
require.NoError(t, postable.UnmarshalJSON([]byte(`{
|
||||
"schemaVersion": "`+dashboardtypes.SchemaVersion+`",
|
||||
"name": "a-user-dashboard",
|
||||
"tags": [],
|
||||
"spec": {"display": {"name": "user"}, "variables": [], "panels": {}, "layouts": []}
|
||||
}`)))
|
||||
_, err := dashboardModule.CreateV2(ctx, orgID, "user@signoz.io", valuer.GenerateUUID(), dashboardtypes.SourceUser, postable)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The server-side prefix makes user names structurally unreachable here.
|
||||
_, err = dashboardModule.GetSystemDashboard(ctx, orgID, "a-user-dashboard")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = dashboardModule.GetSystemDashboard(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must not carry")
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
@@ -15,11 +14,10 @@ type setter struct {
|
||||
store types.OrganizationStore
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
dashboard dashboard.Module
|
||||
}
|
||||
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
|
||||
}
|
||||
|
||||
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
|
||||
@@ -39,10 +37,6 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.dashboard.ReconcileSystemDashboards(ctx, organization.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ func (c *captureQuerier) LabelNames(context.Context, *storage.LabelHints, ...*la
|
||||
}
|
||||
|
||||
// metricNamesFromMatchers extracts the statically known metric name, if any.
|
||||
// Only a __name__ equality contributes; a regex selector needs a series
|
||||
// lookup to learn the concrete names.
|
||||
// The live path derives names from the matched series; the capture path has
|
||||
// no execution results, so only a __name__ equality contributes.
|
||||
func metricNamesFromMatchers(matchers []*labels.Matcher) []string {
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && m.Value != "" {
|
||||
|
||||
@@ -88,8 +88,7 @@ func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end ti
|
||||
}
|
||||
|
||||
// Evaluate every unit concurrently on its own grid (the query grid, or a
|
||||
// subquery grid); each is one grid query (see executeUnit for when a
|
||||
// series lookup precedes it).
|
||||
// subquery grid); each is one series lookup plus one grid query.
|
||||
results := make([][]transpiledSeries, len(plan.units))
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
for i, unit := range plan.units {
|
||||
@@ -143,27 +142,19 @@ func (e *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridCon
|
||||
dataStart := startMs - unit.offsetMs - windowMs
|
||||
dataEnd := endMs - unit.offsetMs
|
||||
|
||||
// The group-key join resolves the matchers on its own, so the unit
|
||||
// statement only needs concrete metric names for the samples
|
||||
// primary-key prefix. A selector without a static __name__ learns them
|
||||
// through the series lookup; every other selector skips the roundtrip.
|
||||
metricNames := metricNamesFromMatchers(unit.matchers)
|
||||
if metricNames == nil {
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
metricNames = lookup.metricNames
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query, args, err := buildUnitSQL(unit, metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
|
||||
query, args, err := buildUnitSQL(unit, lookup.metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -27,15 +27,9 @@ func newTestClient(t *testing.T) (*client, *telemetrystoretest.Provider) {
|
||||
return newClient(settings, store, prometheus.Config{}), store
|
||||
}
|
||||
|
||||
var unitCols = []cmock.ColumnType{
|
||||
{Name: "gkey", Type: "String"},
|
||||
{Name: "grid", Type: "Array(Nullable(Float64))"},
|
||||
}
|
||||
|
||||
// anyArgs matches a bound-argument list by count alone: the mock treats a
|
||||
// nil expected argument as a wildcard.
|
||||
func anyArgs(n int) []any {
|
||||
return make([]any, n)
|
||||
var seriesCols = []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "labels", Type: "String"},
|
||||
}
|
||||
|
||||
func parse(t *testing.T, q string) parser.Expr {
|
||||
@@ -559,7 +553,7 @@ func TestTryExecuteRange_WindowedGateFallsBack(t *testing.T) {
|
||||
|
||||
// 1m range at 5m step: the windows are disjoint slivers — no
|
||||
// divisibility or width requirement, so this transpiles.
|
||||
store.Mock().ExpectQuery("FROM signoz_metrics\\.distributed_samples_v4").WithArgs(anyArgs(9)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
_, ok, err = e.TryExecuteRange(context.Background(), `avg_over_time(up[1m])`, start, end, 5*time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "range below step is the disjoint form and must transpile")
|
||||
@@ -643,12 +637,12 @@ func TestTryExecuteRange_LastStyleWindowBelowStepTranspiles(t *testing.T) {
|
||||
start := time.UnixMilli(1_700_000_000_000)
|
||||
end := time.UnixMilli(1_700_003_600_000)
|
||||
|
||||
store.Mock().ExpectQuery("timeSeriesLastToGrid").WithArgs(anyArgs(10)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
_, ok, err := e.TryExecuteRange(context.Background(), `sum by (pod) (up)`, start, end, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "instant selection at step > lookback must transpile")
|
||||
|
||||
store.Mock().ExpectQuery("timeSeriesLastToGrid").WithArgs(anyArgs(9)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
_, ok, err = e.TryExecuteRange(context.Background(), `last_over_time(up[10m])`, start, end, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "last_over_time at range < step must transpile")
|
||||
|
||||
@@ -2,7 +2,6 @@ package prometheus
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
)
|
||||
@@ -24,11 +23,5 @@ func NewEngine(logger *slog.Logger, cfg Config) *Engine {
|
||||
Timeout: cfg.Timeout,
|
||||
ActiveQueryTracker: activeQueryTracker,
|
||||
LookbackDelta: cfg.LookbackDelta,
|
||||
// The engine calls this for subqueries that do not set a step, such as
|
||||
// `metric[5m:]`, and segfaults if it is nil. 1m matches the default
|
||||
// global evaluation_interval that Prometheus wires here.
|
||||
NoStepSubqueryIntervalFn: func(int64) int64 {
|
||||
return time.Minute.Milliseconds()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNoStepSubqueryDoesNotPanic(t *testing.T) {
|
||||
engine := NewEngine(slog.New(slog.DiscardHandler), Config{Timeout: time.Minute})
|
||||
queryable := storage.QueryableFunc(func(int64, int64) (storage.Querier, error) {
|
||||
return storage.NoopQuerier(), nil
|
||||
})
|
||||
|
||||
qry, err := engine.NewRangeQuery(
|
||||
context.Background(),
|
||||
queryable,
|
||||
nil,
|
||||
"max_over_time(some_metric[5m:])",
|
||||
time.Now().Add(-time.Hour),
|
||||
time.Now(),
|
||||
time.Minute,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer qry.Close()
|
||||
|
||||
res := qry.Exec(context.Background())
|
||||
require.NoError(t, res.Err)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
promModel "github.com/prometheus/common/model"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/util/stats"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// Handler serves the Prometheus HTTP query API over a Prometheus provider:
|
||||
// /query and /query_range in the shape of Prometheus' /api/v1 endpoints
|
||||
// (https://prometheus.io/docs/prometheus/latest/querying/api/), intended to
|
||||
// be mounted under a distinguishing prefix (/prometheus/api/v1) so
|
||||
// PromQL-only endpoints are separate from the SigNoz query APIs. The request
|
||||
// and response contracts follow Prometheus: form-encoded GET/POST params,
|
||||
// {"status":"success","data":{resultType,result}} on success and
|
||||
// {"status":"error","errorType","error"} with Prometheus' status codes on
|
||||
// failure — so Prometheus-compatible clients can point at the prefix. The
|
||||
// wire shapes are documented as OpenAPI schemas in render.go.
|
||||
type Handler interface {
|
||||
Query(http.ResponseWriter, *http.Request)
|
||||
|
||||
QueryRange(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
logger *slog.Logger
|
||||
prom Prometheus
|
||||
}
|
||||
|
||||
func NewHandler(logger *slog.Logger, prom Prometheus) Handler {
|
||||
return &handler{logger: logger, prom: prom}
|
||||
}
|
||||
|
||||
// QueryRange evaluates an expression over a grid: query, start, end, step,
|
||||
// and optional timeout/stats params, all in Prometheus' formats.
|
||||
func (h *handler) QueryRange(w http.ResponseWriter, r *http.Request) {
|
||||
start, err := parseTime(r.FormValue("start"))
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
end, err := parseTime(r.FormValue("end"))
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
if end.Before(start) {
|
||||
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "end timestamp must not be before start time"))
|
||||
return
|
||||
}
|
||||
step, err := parseDuration(r.FormValue("step"))
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
if step <= 0 {
|
||||
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "zero or negative query resolution step widths are not accepted. Try a positive integer"))
|
||||
return
|
||||
}
|
||||
// The engine materializes every point of every series; an unbounded
|
||||
// grid is an unbounded allocation. 11,000 points covers 60s resolution
|
||||
// for a week or 1h resolution for a year.
|
||||
if end.Sub(start)/step > 11000 {
|
||||
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "exceeded maximum resolution of 11,000 points per timeseries. Try decreasing the query resolution (?step=XX)"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel, err := h.contextWithTimeout(r)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if h.tryRangeExecutor(ctx, w, r, start, end, step) {
|
||||
return
|
||||
}
|
||||
|
||||
qry, err := h.prom.Engine().NewRangeQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), start, end, step)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
h.exec(ctx, w, r, qry)
|
||||
}
|
||||
|
||||
// tryRangeExecutor serves the query the way a RangeExecutor provider is
|
||||
// designed to serve: evaluated inside the datastore when the shape allows.
|
||||
// It reports whether the response was written.
|
||||
func (h *handler) tryRangeExecutor(ctx context.Context, w http.ResponseWriter, r *http.Request, start, end time.Time, step time.Duration) bool {
|
||||
re, ok := h.prom.(RangeExecutor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
matrix, served, err := re.TryExecuteRange(ctx, r.FormValue("query"), start, end, step)
|
||||
if err != nil {
|
||||
h.respondError(ctx, w, errExec, err)
|
||||
return true
|
||||
}
|
||||
if !served {
|
||||
return false
|
||||
}
|
||||
h.respond(ctx, w, &queryData{ResultType: matrix.Type(), Result: matrix}, nil, nil)
|
||||
return true
|
||||
}
|
||||
|
||||
// Query evaluates an expression at a single instant: query and optional
|
||||
// time/timeout/stats params. A missing time evaluates at the server's now,
|
||||
// as in Prometheus.
|
||||
func (h *handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||
ts := time.Now()
|
||||
if t := r.FormValue("time"); t != "" {
|
||||
var err error
|
||||
ts, err = parseTime(t)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel, err := h.contextWithTimeout(r)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
qry, err := h.prom.Engine().NewInstantQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), ts)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
h.exec(ctx, w, r, qry)
|
||||
}
|
||||
|
||||
func (h *handler) exec(ctx context.Context, w http.ResponseWriter, r *http.Request, qry promql.Query) {
|
||||
defer qry.Close()
|
||||
res := qry.Exec(ctx)
|
||||
if res.Err != nil {
|
||||
h.logger.ErrorContext(ctx, "error evaluating promql query", errors.Attr(res.Err))
|
||||
switch res.Err.(type) {
|
||||
case promql.ErrQueryCanceled:
|
||||
h.respondError(ctx, w, errCanceled, res.Err)
|
||||
case promql.ErrQueryTimeout:
|
||||
h.respondError(ctx, w, errTimeout, res.Err)
|
||||
case promql.ErrStorage:
|
||||
h.respondError(ctx, w, errInternal, res.Err)
|
||||
default:
|
||||
h.respondError(ctx, w, errExec, res.Err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data := &queryData{ResultType: res.Value.Type(), Result: res.Value}
|
||||
if r.FormValue("stats") != "" {
|
||||
data.Stats = stats.NewQueryStats(qry.Stats())
|
||||
}
|
||||
warnings, infos := res.Warnings.AsStrings(r.FormValue("query"), 10, 10)
|
||||
h.respond(ctx, w, data, warnings, infos)
|
||||
}
|
||||
|
||||
func (h *handler) contextWithTimeout(r *http.Request) (context.Context, context.CancelFunc, error) {
|
||||
ctx := r.Context()
|
||||
if to := r.FormValue("timeout"); to != "" {
|
||||
timeout, err := parseDuration(to)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
return ctx, cancel, nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return ctx, cancel, nil
|
||||
}
|
||||
|
||||
// parseTime accepts Prometheus' time formats: float unix seconds or RFC3339.
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
if t, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
sec, ns := math.Modf(t)
|
||||
return time.Unix(int64(sec), int64(ns*float64(time.Second))), nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.Time{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid timestamp", s)
|
||||
}
|
||||
|
||||
// parseDuration accepts Prometheus' duration formats: float seconds or a
|
||||
// duration string like 5m.
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
if d, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
ts := d * float64(time.Second)
|
||||
if ts > float64(math.MaxInt64) || ts < float64(math.MinInt64) {
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration. It overflows int64", s)
|
||||
}
|
||||
return time.Duration(ts), nil
|
||||
}
|
||||
if d, err := promModel.ParseDuration(s); err == nil {
|
||||
return time.Duration(d), nil
|
||||
}
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration", s)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user