Compare commits

..

8 Commits

Author SHA1 Message Date
vikrantgupta25
0d5c288dd6 feat(licensing): add resource authz to license endpoints 2026-08-26 16:32:14 +05:30
vikrantgupta25
35abee54b1 chore(licensing): regenerate frontend api clients 2026-08-26 16:17:32 +05:30
vikrantgupta25
6a21345203 fix(licensing): advertise api key auth on get active license 2026-08-26 16:11:22 +05:30
vikrantgupta25
9f128ee00b refactor(licensing): rename api interface to handler 2026-08-26 16:03:12 +05:30
vikrantgupta25
ad06c14557 refactor(licensing): rename licensing api wiring to licensing handler 2026-08-26 16:01:50 +05:30
vikrantgupta25
92734c0c2b chore(licensing): remove unused community licenses list stub 2026-08-26 15:58:53 +05:30
vikrantgupta25
4c752655f3 feat(licensing): serve license endpoints from apiserver 2026-08-26 15:56:21 +05:30
vikrantgupta25
76211b3233 feat(zeus): add api/v2/zeus/licenses endpoints 2026-08-26 14:00:39 +05:30
239 changed files with 1862 additions and 17099 deletions

View File

@@ -58,12 +58,10 @@ jobs:
- querierai
- rawexportdata
- promqlconformance
- promapiconformance
- querierauthz
- role
- rootuser
- savedview
- semconvfamilies
- serviceaccount
- spanmapper
- querier_json_body

View File

@@ -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()

View File

@@ -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)

View File

@@ -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:
@@ -5743,6 +5641,15 @@ components:
- total
- endTimeBeforeRetention
type: object
LicensetypesGettableLicense:
additionalProperties: {}
nullable: true
type: object
LicensetypesPostableLicense:
properties:
key:
type: string
type: object
LlmpricingruletypesGettablePricingRules:
properties:
items:
@@ -6561,148 +6468,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:
@@ -8254,7 +8019,6 @@ components:
- logs
- metrics
- meter
- ai_observability
type: string
SavedviewtypesUpdatableSavedView:
properties:
@@ -9045,7 +8809,6 @@ components:
- span
- trace
- resource
- scope
- attribute
- body
- ""
@@ -15602,73 +15365,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
@@ -15780,8 +15476,10 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key: []
- tokenizer: []
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get features
tags:
- features
@@ -24253,9 +23951,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 +24007,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
@@ -24372,6 +24070,166 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/licenses:
post:
deprecated: false
description: This endpoint validates the license key with upstream and activates
the license for the organization.
operationId: ActivateLicense
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LicensetypesPostableLicense'
responses:
"202":
description: Accepted
"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
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- license:create
- tokenizer:
- license:create
summary: Activate a license.
tags:
- licenses
put:
deprecated: false
description: This endpoint refreshes the active license of the organization
from upstream.
operationId: RefreshLicense
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- license:update
- tokenizer:
- license:update
summary: Refresh the active license.
tags:
- licenses
/api/v3/licenses/active:
get:
deprecated: false
description: This endpoint gets the active license of the organization.
operationId: GetActiveLicense
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LicensetypesGettableLicense'
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: []
- tokenizer: []
summary: Get the active license.
tags:
- licenses
/api/v3/metrics/dashboards:
get:
deprecated: false
@@ -25121,374 +24979,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}

View File

@@ -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

View File

@@ -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) {

View File

@@ -4,10 +4,10 @@ import (
"net/http"
"time"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/licensing"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
@@ -42,7 +42,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
IntegrationsController: opts.IntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
LicensingHandler: licensing.NewHandler(signoz.Licensing),
Signoz: signoz,
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
}, config)
@@ -67,19 +67,14 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// note: add ee override methods first
// routes available only in ee version
router.HandleFunc("/api/v1/features", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingAPI.Checkout)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingHandler.Checkout)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// v3
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.LicensingAPI.GetActive)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingHandler.Portal)).Methods(http.MethodPost)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

View File

@@ -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 P1P5. 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",

View File

@@ -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 P1P5. 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",

View File

@@ -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)

View File

@@ -0,0 +1,268 @@
/**
* ! 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 {
GetActiveLicense200,
LicensetypesPostableLicenseDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint validates the license key with upstream and activates the license for the organization.
* @summary Activate a license.
*/
export const activateLicense = (
licensetypesPostableLicenseDTO?: BodyType<LicensetypesPostableLicenseDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v3/licenses`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: licensetypesPostableLicenseDTO,
signal,
});
};
export const getActivateLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
const mutationKey = ['activateLicense'];
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 activateLicense>>,
{ data?: BodyType<LicensetypesPostableLicenseDTO> }
> = (props) => {
const { data } = props ?? {};
return activateLicense(data);
};
return { mutationFn, ...mutationOptions };
};
export type ActivateLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof activateLicense>>
>;
export type ActivateLicenseMutationBody =
| BodyType<LicensetypesPostableLicenseDTO>
| undefined;
export type ActivateLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Activate a license.
*/
export const useActivateLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
return useMutation(getActivateLicenseMutationOptions(options));
};
/**
* This endpoint refreshes the active license of the organization from upstream.
* @summary Refresh the active license.
*/
export const refreshLicense = (signal?: AbortSignal) => {
return GeneratedAPIInstance<void>({
url: `/api/v3/licenses`,
method: 'PUT',
signal,
});
};
export const getRefreshLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
void,
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
void,
TContext
> => {
const mutationKey = ['refreshLicense'];
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 refreshLicense>>,
void
> = () => {
return refreshLicense();
};
return { mutationFn, ...mutationOptions };
};
export type RefreshLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof refreshLicense>>
>;
export type RefreshLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Refresh the active license.
*/
export const useRefreshLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
void,
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
void,
TContext
> => {
return useMutation(getRefreshLicenseMutationOptions(options));
};
/**
* This endpoint gets the active license of the organization.
* @summary Get the active license.
*/
export const getActiveLicense = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetActiveLicense200>({
url: `/api/v3/licenses/active`,
method: 'GET',
signal,
});
};
export const getGetActiveLicenseQueryKey = () => {
return [`/api/v3/licenses/active`] as const;
};
export const getGetActiveLicenseQueryOptions = <
TData = Awaited<ReturnType<typeof getActiveLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetActiveLicenseQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getActiveLicense>>> = ({
signal,
}) => getActiveLicense(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetActiveLicenseQueryResult = NonNullable<
Awaited<ReturnType<typeof getActiveLicense>>
>;
export type GetActiveLicenseQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get the active license.
*/
export function useGetActiveLicense<
TData = Awaited<ReturnType<typeof getActiveLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetActiveLicenseQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get the active license.
*/
export const invalidateGetActiveLicense = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetActiveLicenseQueryKey() },
options,
);
return queryClient;
};

View File

@@ -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));
};

View File

@@ -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;
/**
@@ -3586,7 +3492,6 @@ export enum TelemetrytypesFieldContextDTO {
span = 'span',
trace = 'trace',
resource = 'resource',
scope = 'scope',
attribute = 'attribute',
body = 'body',
'' = '',
@@ -5053,53 +4958,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',
@@ -7313,6 +7171,21 @@ export interface InframonitoringtypesVolumesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type LicensetypesGettableLicenseDTOAnyOf = { [key: string]: unknown };
/**
* @nullable
*/
export type LicensetypesGettableLicenseDTO =
LicensetypesGettableLicenseDTOAnyOf | null;
export interface LicensetypesPostableLicenseDTO {
/**
* @type string
*/
key?: string;
}
/**
* @nullable
*/
@@ -8114,164 +7987,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;
/**
@@ -9321,7 +9036,6 @@ export enum SavedviewtypesSourceDTO {
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
ai_observability = 'ai_observability',
}
export interface SavedviewtypesSavedViewSpecDTO {
display?: SavedviewtypesDisplayDTO;
@@ -11611,17 +11325,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
@@ -12711,6 +12414,14 @@ export type GetHosts200 = {
status: string;
};
export type GetActiveLicense200 = {
data: LicensetypesGettableLicenseDTO | null;
/**
* @type string
*/
status: string;
};
export type GetMetricDashboardsV2Params = {
/**
* @type string
@@ -12780,115 +12491,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;
};

View File

@@ -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:

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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();

View File

@@ -29,7 +29,6 @@ export enum InfraMonitoringEvents {
MetricsView = 'metrics',
Total = 'total',
Cluster = 'cluster',
Container = 'container',
DaemonSet = 'daemonSet',
Deployment = 'deployment',
Job = 'job',

View File

@@ -10,7 +10,6 @@ const fieldContextToSuggestionMap: Record<
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
// no maps for the following values on suggestion context
[TelemetrytypesFieldContextDTO.trace]: undefined,
[TelemetrytypesFieldContextDTO.scope]: undefined,
[TelemetrytypesFieldContextDTO.body]: undefined,
[TelemetrytypesFieldContextDTO.metric]: undefined,
[TelemetrytypesFieldContextDTO.log]: undefined,

View File

@@ -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>,

View File

@@ -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(

View File

@@ -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[];
}

View File

@@ -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,

View File

@@ -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,
],

View File

@@ -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],
};
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
>
>
>;

View File

@@ -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,
}));
};

View File

@@ -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}

View File

@@ -2,6 +2,7 @@
display: flex;
align-items: center;
gap: var(--spacing-5);
padding-left: 4px;
}
.infoIcon {

View File

@@ -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()}

View File

@@ -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;

View File

@@ -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 &&

View File

@@ -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,

View File

@@ -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 {

View File

@@ -214,7 +214,6 @@ export function K8sExpandedRow<
params.selectedItem,
params.clusterName,
params.namespaceName,
params.containerName,
);
queryClient.setQueryData(detailQueryKey, { data: row });
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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();
});
});

View File

@@ -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 {

View File

@@ -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' }),
]);
});
});

View File

@@ -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.",
},
];

View File

@@ -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,
},
};

View File

@@ -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',
},
]),
];
};

View File

@@ -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>
),
},
];

View File

@@ -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 },
];
}

View File

@@ -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()}

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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.',
};

View File

@@ -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],

View File

@@ -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();

View File

@@ -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",

View File

@@ -0,0 +1,11 @@
.explorer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-0);
}
.placeholder {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,106 +0,0 @@
.trace-explorer-header {
.trace-explorer-run-query {
display: flex;
flex-direction: row-reverse;
align-items: center;
margin: 8px 16px;
gap: 8px;
}
.filter-outlined-btn {
border-radius: 0px 2px 2px 0px;
border-top: 1px solid var(--l1-border);
border-right: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
}
.trace-explorer-header.single-child {
justify-content: flex-end;
}
.traces-explorer-views {
padding: 8px;
padding-bottom: 60px;
margin-bottom: 24px;
.ant-tabs-tabpane {
padding: 0 8px;
}
}
.qb-search-view-container {
padding: 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l2-background) !important;
height: 34px !important;
box-sizing: border-box !important;
}
}
.trace-explorer-list-view {
flex: 1;
}
.trace-explorer-traces-view {
flex: 1;
}
.trace-explorer-table-view {
flex: 1;
}
.trace-explorer-time-series-view {
flex: 1;
}
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
width: 260px;
height: 100%;
min-height: 100vh;
border-right: 0px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
> .ant-card-body {
padding: 0;
width: 258px;
}
}
.trace-explorer {
width: 100%;
background: var(--l1-background);
> .ant-card-body {
padding: 0;
}
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
width: calc(100% - 260px);
}
}

View File

@@ -1,373 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import {
getExportQueryData,
getQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
ICurrentQueryData,
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import {
tracesAddFilterAction,
tracesChangeViewAction,
tracesRunQueryAction,
tracesSaveViewAction,
} from 'pages/TracesExplorer/aiActions';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
explorerViewToPanelType,
getExplorerViewFromUrl,
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
import TableView from './TableView/TableView';
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
import TracesView from './TracesView/TracesView';
import './Explorer.styles.scss';
import styles from './Explorer.module.scss';
// Shell for the AI Observability Explorer tab. Owns the
// /ai-observability/explorer route and is intentionally empty for now: the
// query builder + results surface land in a follow-up.
function Explorer(): JSX.Element {
const {
panelType,
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
handleSetConfig,
currentQuery,
handleSetQueryData,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { options } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
useEffect(() => {
if (isLoadingQueries) {
setIsCancelled(false);
}
}, [isLoadingQueries]);
const handleCancelQuery = useCallback(() => {
if (listQueryKeyRef.current) {
queryClient.cancelQueries(listQueryKeyRef.current);
}
setIsCancelled(true);
// Reset loading state — the active view unmounts when cancelled, so no
// child will call setIsLoadingQueries(false) otherwise.
setIsLoadingQueries(false);
}, [queryClient]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
);
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueriesMap.traces,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
setSelectedView(view);
handleExplorerTabChange(
explorerViewToPanelType[view],
querySearchParameters,
);
},
[handleExplorerTabChange, handleSetConfig],
);
// ─── AI Assistant page actions (only when license feature is on) ───────────
const aiActions = useMemo(
() =>
isAIAssistantEnabled
? [
tracesRunQueryAction({
currentQuery,
handleSetQueryData,
redirectWithQueryBuilderData,
}),
tracesAddFilterAction({
currentQuery,
handleSetQueryData,
redirectWithQueryBuilderData,
}),
tracesChangeViewAction({
onChangeView: (view) => handleChangeSelectedView(view as ExplorerViews),
}),
tracesSaveViewAction({
// POC stub — logs a save request; wire to real API when available
onSaveView: async (name) => {
// eslint-disable-next-line no-console
console.info('[AI Assistant] Save view requested:', name);
},
}),
]
: [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[
isAIAssistantEnabled,
currentQuery,
handleSetQueryData,
redirectWithQueryBuilderData,
handleChangeSelectedView,
],
);
usePageActions('traces-explorer', aiActions);
// ───────────────────────────────────────────────────────────────────────────
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueriesMap.traces,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
return;
}
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
const widgetId = v4();
const query = getExportQueryData(
exportDefaultQuery,
panelTypeParam,
options,
);
logEvent('Traces Explorer: Add to dashboard successful', {
panelType,
isNewDashboard,
dashboardName: dashboard?.title,
});
const dashboardEditView = getExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current) {
logEvent('Traces Explorer: Page visited', {});
logEventCalledRef.current = true;
}
}, []);
const isFilterApplied = useMemo(() => {
// if any of the non-disabled queries has filters applied, return true
const result = stagedQuery?.builder?.queryData?.filter(
(item) => !isEmpty(item.filters?.items) && !item.disabled,
);
return !!result?.length;
}, [stagedQuery]);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
className="trace-explorer-page"
data-testid="llm-observability-explorer"
>
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,
})}
>
<div className="trace-explorer-header">
<Toolbar
showAutoRefresh
leftActions={
<LeftToolbarActions
showFilter={isOpen}
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
items={TOOLBAR_VIEWS}
selectedView={selectedView}
onChangeSelectedView={handleChangeSelectedView}
/>
}
warningElement={
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
}
rightActions={
<RightToolbarActions
onStageRunQuery={(): void => {
setIsCancelled(false);
handleRunQuery();
}}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
}
/>
</div>
<ExplorerCard sourcepage={DataSource.TRACES}>
<div className="query-section-container">
<QuerySection />
</div>
</ExplorerCard>
<div className="traces-explorer-views">
{isCancelled && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
)}
{!isCancelled && selectedView === ExplorerViews.LIST && (
<div className="trace-explorer-list-view">
<ListView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TRACE && (
<div className="trace-explorer-traces-view">
<TracesView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
<div className="trace-explorer-time-series-view">
<TimeSeriesView
dataSource={DataSource.TRACES}
isFilterApplied={isFilterApplied}
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
{!isCancelled && selectedView === ExplorerViews.TABLE && (
<div className="trace-explorer-table-view">
<TableView
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
/>
</div>
)}
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.TRACES}
onExport={handleExport}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</Sentry.ErrorBoundary>
<div className={styles.explorer} data-testid="llm-observability-explorer">
<div className={styles.placeholder}>Explorer coming soon.</div>
</div>
);
}

View File

@@ -1,8 +0,0 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: calc(100vh - 240px);
min-height: 400px;
}

View File

@@ -1,34 +0,0 @@
.trace-explorer-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
.order-by-container {
display: flex;
align-items: center;
gap: 8px;
.order-by-label {
color: var(--muted-foreground);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
display: flex;
align-items: center;
gap: 4px;
}
.order-by-select {
width: 100px;
.ant-select-selector {
border: none;
box-shadow: none;
background-color: transparent;
}
}
}
}

View File

@@ -1,272 +0,0 @@
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import {
getTraceLink,
transformSpanRows,
} from 'container/TracesExplorer/ListView/utils';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
interface ListViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
}
function ListView({
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
orderBy,
],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
{
query: requestQuery,
graphType: panelType,
selectedTime: 'GLOBAL_TIME' as const,
globalSelectedInterval: globalSelectedTime as CustomTimeType,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
const queryTableData = useMemo(
() => queryTableDataResult || [],
[queryTableDataResult],
);
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
),
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleColumnOrderChange = useCallback(
(reordered: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(reordered.map((column) => column.id));
},
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType,
});
}
}, [isLoading, isFetching, isError, rows, panelType]);
return (
<div className={styles.container}>
<div className="trace-explorer-controls">
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={handleOrderChange}
dataSource={DataSource.TRACES}
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={rows.length}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
<TracesTable
data={rows}
columns={columns}
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);
}
ListView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(ListView);

View File

@@ -1,19 +0,0 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;

View File

@@ -1,61 +0,0 @@
import { memo, useCallback, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);
}
export default memo(QuerySection);

View File

@@ -1,7 +0,0 @@
.traces-table-view-header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -1,130 +0,0 @@
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Space } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { QueryTable } from 'container/QueryTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import './TableView.styles.scss';
function TableView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
}): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
],
[globalSelectedTime, maxTime, minTime, stagedQuery],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap.traces,
graphType: panelType || PANEL_TYPES.TABLE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: 'traces',
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
},
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const queryTableData = useMemo(
() =>
data?.payload?.data?.newResult?.data?.result ||
data?.payload.data.result ||
[],
[data],
);
useEffect(() => {
if (data?.payload) {
setWarning(data.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
return (
<Space.Compact block direction="vertical">
{isError && error && <ErrorInPlace error={error as APIError} />}
{!isError && data && (
<div className="traces-table-view-header">
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
</div>
)}
{!isError && (
<QueryTable
query={stagedQuery || initialQueriesMap.traces}
queryTableData={queryTableData as QueryDataV3[]}
loading={isLoading}
sticky
/>
)}
</Space.Compact>
);
}
TableView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(TableView);

View File

@@ -1,8 +0,0 @@
.trace-explorer-time-series-view-container {
&-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
}
}

View File

@@ -1,147 +0,0 @@
import {
Dispatch,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import './TimeSeriesView.styles.scss';
function TimeSeriesViewContainer({
dataSource = DataSource.TRACES,
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: TimeSeriesViewProps): JSX.Element {
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||
aggregateAttribute?.key === 'duration_nano';
const isCountOperator =
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
},
);
return isValid.every(Boolean);
}, [currentQuery]);
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
],
[globalSelectedTime, maxTime, minTime, stagedQuery],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap[dataSource],
graphType: panelType || PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource,
},
},
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
const responseData = useMemo(
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
[data, isValidToConvertToMs],
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
return (
<div className="trace-explorer-time-series-view-container">
<TimeSeriesView
isFilterApplied={isFilterApplied}
isError={isError}
error={error as APIError}
isLoading={isLoading || isFetching}
data={responseData}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
dataSource={dataSource}
setWarning={setWarning}
allowExport
/>
</div>
);
}
interface TimeSeriesViewProps {
dataSource?: DataSource;
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
}
TimeSeriesViewContainer.defaultProps = {
dataSource: DataSource.TRACES,
queryKeyRef: undefined,
};
export default TimeSeriesViewContainer;

View File

@@ -1,15 +0,0 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
height: calc(100vh - 240px);
min-height: 400px;
}
.actionsContainer {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -1,190 +0,0 @@
import {
Dispatch,
memo,
MutableRefObject,
SetStateAction,
useEffect,
useMemo,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { columns, PER_PAGE_OPTIONS } from './configs';
import styles from './TracesView.module.scss';
interface TracesViewProps {
isFilterApplied: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
}
function TracesView({
isFilterApplied,
setWarning,
setIsLoadingQueries,
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
[stagedQuery],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationQueryData,
],
[
globalSelectedTime,
maxTime,
minTime,
stagedQuery,
panelType,
paginationQueryData,
],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: transformedQuery,
graphType: panelType || PANEL_TYPES.TRACE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: paginationQueryData,
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
},
);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
const rows = useMemo<TracesTableRow[]>(
() =>
(responseData ?? []).map((item) => {
const row = item.data;
return { ...row, id: row.trace_id };
}) as TracesTableRow[],
[responseData],
);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);
} else {
setIsLoadingQueries(false);
}
}, [isLoading, isFetching, setIsLoadingQueries]);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
panelType: 'TRACE',
});
}
}, [isLoading, isFetching, isError, rows.length]);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
<Typography>
This tab only shows Root Spans. More details
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
{' '}
here
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</div>
<TracesTable
data={rows}
columns={columns}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
/>
</div>
);
}
TracesView.defaultProps = {
queryKeyRef: undefined,
};
export default memo(TracesView);

View File

@@ -1,25 +0,0 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name' },
{ name: 'duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
] as TelemetryFieldKey[];
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
(field) => ({
...getFieldColumn(field),
enableRemove: false,
canBeHidden: false,
}),
);

View File

@@ -1,36 +0,0 @@
export const TOOLBAR_VIEWS = {
list: {
name: 'list',
label: 'List',
show: true,
key: 'list',
},
timeseries: {
name: 'timeseries',
label: 'Timeseries',
disabled: false,
show: true,
key: 'timeseries',
},
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
table: {
name: 'table',
label: 'Table',
disabled: false,
show: true,
key: 'table',
},
clickhouse: {
name: 'clickhouse',
label: 'Clickhouse',
disabled: false,
show: false,
key: 'clickhouse',
},
};

View File

@@ -18,12 +18,6 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
}));
// Same data-router gap as the dashboard above: the Explorer toolbar calls useNavigationType.
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
}));
function setupList(items = mockRules): void {
server.use(
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>

View File

@@ -4,6 +4,13 @@
padding: 0px 8px;
.logs-frequency-chart {
.ant-card-body {
height: 140px;
min-height: 140px;
padding: 0 16px 22px 16px;
font-family: 'Geist Mono';
}
margin-bottom: 0px;
}
}

View File

@@ -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 =

View File

@@ -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

View File

@@ -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

View File

@@ -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 } = useGetSavedViewParams();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)

View File

@@ -3,6 +3,13 @@
min-height: 200px;
border-bottom: 1px solid var(--l1-border);
.ant-card-body {
height: 200px;
min-height: 200px;
padding: 0 16px 16px 16px;
font-family: 'Geist Mono';
}
.logs-frequency-chart-loading {
height: 100%;
display: flex;

View File

@@ -1,29 +1,25 @@
import { memo, useCallback, useMemo, useRef } from 'react';
import { memo, useCallback, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import Graph from 'components/Graph';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { useResizeObserver } from 'hooks/useDimensions';
import { themeColors } from 'constants/theme';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import getChartData, { GetChartDataProps } from 'lib/getChartData';
import GetMinMax from 'lib/getMinMax';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import { useTimezone } from 'providers/Timezone';
import { colors } from 'lib/getRandomColor';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces';
import { useLogsExplorerChartConfig } from './useLogsExplorerChartConfig';
import { getColorsForSeverityLabels } from './utils';
import './LogsExplorerChart.styles.scss';
// Axis and tooltip format separately; both need this or only one abbreviates.
const Y_AXIS_UNIT = 'short';
function LogsExplorerChart({
data,
isLoading,
@@ -41,6 +37,24 @@ function LogsExplorerChart({
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] =
useCallback(
(element, index, allLabels) => ({
data: element,
backgroundColor: isLogsExplorerViews
? getColorsForSeverityLabels(allLabels[index], index)
: colors[index % colors.length] || themeColors.red,
borderColor: isLogsExplorerViews
? getColorsForSeverityLabels(allLabels[index], index)
: colors[index % colors.length] || themeColors.red,
...(isLabelEnabled
? {
label: allLabels[index],
}
: {}),
}),
[isLabelEnabled, isLogsExplorerViews],
);
const onDragSelect = useCallback(
(start: number, end: number): void => {
@@ -72,47 +86,44 @@ function LogsExplorerChart({
[dispatch, location.pathname, safeNavigate, urlQuery, isShowingLiveLogs],
);
// uPlot plots the series on a seconds-based x scale
const { minTimeScale, maxTimeScale } = useMemo(
const graphData = useMemo(
() =>
getChartData({
queryData: [
{
queryData: data,
},
],
createDataset: handleCreateDatasets,
}),
[data, handleCreateDatasets],
);
// Convert nanosecond timestamps to milliseconds for Chart.js
const { chartMinTime, chartMaxTime } = useMemo(
() => ({
minTimeScale: minTime ? Math.floor(minTime / 1e9) : undefined,
maxTimeScale: maxTime ? Math.floor(maxTime / 1e9) : undefined,
chartMinTime: minTime ? Math.floor(minTime / 1e6) : undefined,
chartMaxTime: maxTime ? Math.floor(maxTime / 1e6) : undefined,
}),
[minTime, maxTime],
);
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { config, chartData } = useLogsExplorerChartConfig({
data,
isLogsExplorerViews,
isLabelEnabled,
onDragSelect,
minTimeScale,
maxTimeScale,
yAxisUnit: Y_AXIS_UNIT,
});
return (
<div ref={graphRef} className={`${className} logs-frequency-chart-container`}>
<div className={`${className} logs-frequency-chart-container`}>
{isLoading ? (
<div className="logs-frequency-chart-loading">
<Spinner size="default" height="100%" />
</div>
) : (
<BarChart
config={config}
data={chartData}
width={dimensions.width}
height={dimensions.height}
stack={isLogsExplorerViews ? StackMode.Normal : StackMode.None}
showLegend={isLabelEnabled}
legendConfig={{ position: LegendPosition.BOTTOM }}
timezone={timezone}
data-testid="logs-frequency-chart"
yAxisUnit={Y_AXIS_UNIT}
<Graph
name="logsExplorerChart"
data={graphData.data}
isStacked={isLogsExplorerViews}
type="bar"
animate
onDragSelect={onDragSelect}
minTime={chartMinTime}
maxTime={chartMaxTime}
/>
)}
</div>

View File

@@ -1,105 +0,0 @@
import { useMemo } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import getLabelName from 'lib/getLabelName';
import { colors } from 'lib/getRandomColor';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import uPlot from 'uplot';
import { getColorsForSeverityLabels } from './utils';
export interface UseLogsExplorerChartConfigParams {
data: QueryData[];
isLogsExplorerViews?: boolean;
isLabelEnabled?: boolean;
onDragSelect: (start: number, end: number) => void;
minTimeScale?: number;
maxTimeScale?: number;
yAxisUnit?: string;
}
export interface UseLogsExplorerChartConfigResult {
config: UPlotConfigBuilder;
chartData: uPlot.AlignedData;
}
export function useLogsExplorerChartConfig({
data,
isLogsExplorerViews = false,
isLabelEnabled = true,
onDragSelect,
minTimeScale,
maxTimeScale,
yAxisUnit,
}: UseLogsExplorerChartConfigParams): UseLogsExplorerChartConfigResult {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// getUPlotChartData / buildBaseConfig both consume the legacy query-range payload
// shape, so the raw series list is wrapped instead of being plotted directly.
const apiResponse = useMemo(
() =>
({
data: { result: data, resultType: '' },
}) as unknown as MetricRangePayloadProps,
[data],
);
const chartData = useMemo(() => getUPlotChartData(apiResponse), [apiResponse]);
const config = useMemo(() => {
const builder = buildBaseConfig({
id: 'logs-explorer-frequency-chart',
isDarkMode,
onDragSelect,
timezone,
minTimeScale,
maxTimeScale,
yAxisUnit,
panelType: PANEL_TYPES.BAR,
});
data.forEach((series, index) => {
const label = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const color = isLogsExplorerViews
? getColorsForSeverityLabels(label, index)
: colors[index % colors.length] || themeColors.red;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
// No group-by yields query name "A"; use ' ' not '' so uPlot does not default the label to "Value".
label: isLabelEnabled && label.trim() ? label : ' ',
lineColor: color,
colorMapping: {},
isDarkMode,
});
});
return builder;
}, [
data,
isDarkMode,
isLabelEnabled,
isLogsExplorerViews,
maxTimeScale,
minTimeScale,
onDragSelect,
timezone,
yAxisUnit,
]);
return { config, chartData };
}

View File

@@ -217,6 +217,13 @@
padding: 0px 8px;
.logs-frequency-chart {
.ant-card-body {
height: 140px;
min-height: 140px;
padding: 0 16px 22px 16px;
font-family: 'Geist Mono';
}
margin-bottom: 0px;
}
}

View File

@@ -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 =

View 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]);
};

View File

@@ -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: '' });
});
});

View File

@@ -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],
);
};

View File

@@ -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 => {

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -66,7 +66,6 @@
"factor-api-key",
"license",
"subscription",
"deployment-host",
"logs",
"traces",
"metrics",

View File

@@ -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: &notify.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])
}

View File

@@ -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)
})
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
advancedFeatures: advancedFeatures,
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.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

View File

@@ -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])), &noteMsg))
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)

View File

@@ -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

View File

@@ -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
})
}

Some files were not shown because too many files have changed in this diff Show More