mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-26 20:00:33 +01:00
Compare commits
19 Commits
refactor/i
...
feat/assis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f89b2ef1 | ||
|
|
99102baa94 | ||
|
|
4122bea16b | ||
|
|
1e326159b0 | ||
|
|
ceb1b4871b | ||
|
|
d48a238e15 | ||
|
|
2ca6ff7719 | ||
|
|
0671c5f416 | ||
|
|
33b455406a | ||
|
|
804ea2a7f8 | ||
|
|
a3a7fc4081 | ||
|
|
3c8c318925 | ||
|
|
bb471848cc | ||
|
|
bd55e70882 | ||
|
|
6cf22e98dd | ||
|
|
39957d322f | ||
|
|
d1f143f675 | ||
|
|
1355b13504 | ||
|
|
22d6d5248f |
17
.github/workflows/goci.yaml
vendored
17
.github/workflows/goci.yaml
vendored
@@ -123,3 +123,20 @@ jobs:
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate authz
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in authz permissions. Run go run cmd/enterprise/*.go generate authz locally and commit."; exit 1)
|
||||
web-settings:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)
|
||||
|
||||
23
.github/workflows/jsci.yaml
vendored
23
.github/workflows/jsci.yaml
vendored
@@ -90,3 +90,26 @@ jobs:
|
||||
run: |
|
||||
cd frontend && pnpm generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
|
||||
web-settings:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: node-install
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: install-frontend
|
||||
run: cd frontend && pnpm install
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
cd frontend && pnpm generate:config:web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
||||
|
||||
|
||||
COPY ./target/${OS}-${TARGETARCH}/signoz-community /root/signoz
|
||||
COPY ./templates/email /root/templates
|
||||
COPY ./templates /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN apk update && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY ./target/${OS}-${ARCH}/signoz-community /root/signoz-community
|
||||
COPY ./templates/email /root/templates
|
||||
COPY ./templates /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz-community
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
||||
|
||||
|
||||
COPY ./target/${OS}-${TARGETARCH}/signoz /root/signoz
|
||||
COPY ./templates/email /root/templates
|
||||
COPY ./templates /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN go mod download
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates/email /root/templates
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN apk update && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY ./target/${OS}-${ARCH}/signoz /root/signoz
|
||||
COPY ./templates/email /root/templates
|
||||
COPY ./templates /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN go mod download
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates/email /root/templates
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
|
||||
61
cmd/genconfig.go
Normal file
61
cmd/genconfig.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
const webSettingsSchemaPath = "docs/config/web-settings.json"
|
||||
|
||||
func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
configCmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Generate JSON Schema for config",
|
||||
}
|
||||
|
||||
configCmd.AddCommand(&cobra.Command{
|
||||
Use: "web-settings",
|
||||
Short: "Generate JSON Schema for web settings",
|
||||
RunE: func(currCmd *cobra.Command, args []string) error {
|
||||
return generateWebSettings()
|
||||
},
|
||||
})
|
||||
|
||||
parentCmd.AddCommand(configCmd)
|
||||
}
|
||||
|
||||
func generateWebSettings() error {
|
||||
falseVal := false
|
||||
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
|
||||
|
||||
reflector := jsonschema.Reflector{}
|
||||
reflector.DefaultOptions = append(reflector.DefaultOptions,
|
||||
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
|
||||
if params.Value.Kind() == reflect.Struct {
|
||||
params.Schema.AdditionalProperties = &noAdditional
|
||||
}
|
||||
return false, nil
|
||||
}),
|
||||
jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string {
|
||||
return strings.TrimPrefix(defaultDefName, "Web")
|
||||
}),
|
||||
)
|
||||
|
||||
schema, err := reflector.Reflect(web.Settings{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
|
||||
|
||||
registerGenerateOpenAPI(generateCmd)
|
||||
registerGenerateAuthz(generateCmd)
|
||||
registerGenerateConfig(generateCmd)
|
||||
|
||||
parentCmd.AddCommand(generateCmd)
|
||||
}
|
||||
|
||||
@@ -182,6 +182,11 @@ alertmanager:
|
||||
poll_interval: 1m
|
||||
# The URL under which Alertmanager is externally reachable (for example, if Alertmanager is served via a reverse proxy). Used for generating relative and absolute links back to Alertmanager itself.
|
||||
external_url: http://localhost:8080
|
||||
# The list of globs from which SigNoz's alertmanager notification templates are loaded (e.g. the email.signoz.html layout).
|
||||
# This mirrors the upstream alertmanager `templates` config option. The upstream default templates (default.tmpl, email.tmpl)
|
||||
# are always loaded from the embedded alertmanager assets, so only SigNoz's own templates need to be listed here.
|
||||
templates:
|
||||
- /opt/signoz/conf/templates/alertmanager/*.gotmpl
|
||||
# The global configuration for the alertmanager. All the exahustive fields can be found in the upstream: https://github.com/prometheus/alertmanager/blob/efa05feffd644ba4accb526e98a8c6545d26a783/config/config.go#L833
|
||||
global:
|
||||
# ResolveTimeout is the time after which an alert is declared resolved if it has not been updated.
|
||||
|
||||
@@ -129,6 +129,8 @@ components:
|
||||
type: string
|
||||
schedule:
|
||||
$ref: '#/components/schemas/AlertmanagertypesSchedule'
|
||||
scope:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
|
||||
updatedAt:
|
||||
@@ -272,6 +274,8 @@ components:
|
||||
type: string
|
||||
schedule:
|
||||
$ref: '#/components/schemas/AlertmanagertypesSchedule'
|
||||
scope:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- schedule
|
||||
@@ -18944,6 +18948,77 @@ paths:
|
||||
summary: Get waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v4/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans including all spans if total
|
||||
spans are under a limit, a max count otherwise. Aggregations are dropped compared
|
||||
to v3
|
||||
operationId: GetWaterfallV4
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableWaterfall'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v5/query_range:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
42
docs/config/web-settings.json
Normal file
42
docs/config/web-settings.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"required": [
|
||||
"posthog",
|
||||
"appcues"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Appcues": {
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Posthog": {
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"appcues": {
|
||||
"$ref": "#/definitions/Appcues"
|
||||
},
|
||||
"posthog": {
|
||||
"$ref": "#/definitions/Posthog"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
@@ -535,7 +535,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
|
||||
func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, provider cloudintegrationtypes.CloudProviderType, service *cloudintegrationtypes.CloudIntegrationService, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
// TODO: DB calls are in for loop, can be optimized later.
|
||||
for _, dashboard := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.IntegrationDashboardSlug(provider, service.Type, dashboard.ID)
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, service.Type, dashboard.ID)
|
||||
|
||||
existing, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
@@ -562,7 +562,7 @@ func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID
|
||||
// deprovisionDashboards deletes all dashboard and integration_dashboard rows for the given service.
|
||||
// make sure to call this within a transaction.
|
||||
func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID) error {
|
||||
slugPrefix := cloudintegrationtypes.IntegrationDashboardSlugPrefix(provider, serviceID)
|
||||
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
|
||||
rows, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -588,7 +588,7 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
|
||||
// TODO: remove this hack and send idiomatic response to client.
|
||||
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
for i, d := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.IntegrationDashboardSlug(provider, serviceID, d.ID)
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
|
||||
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
@@ -24,7 +23,6 @@ type APIHandlerOptions struct {
|
||||
DataConnector interfaces.Reader
|
||||
UsageManager *usage.Manager
|
||||
IntegrationsController *integrations.Controller
|
||||
CloudIntegrationsController *cloudintegrations.Controller
|
||||
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
|
||||
GatewayUrl string
|
||||
// Querier Influx Interval
|
||||
@@ -42,7 +40,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
Reader: opts.DataConnector,
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
CloudIntegrationsController: opts.CloudIntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
@@ -91,17 +88,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
}
|
||||
|
||||
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)
|
||||
|
||||
router.HandleFunc(
|
||||
"/api/v1/cloud-integrations/{cloudProvider}/accounts/generate-connection-params",
|
||||
am.EditAccess(ah.CloudIntegrationsGenerateConnectionParams),
|
||||
).Methods(http.MethodGet)
|
||||
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
versionResponse := basemodel.GetVersionResponse{
|
||||
Version: version.Info.Version(),
|
||||
|
||||
@@ -89,6 +89,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
useDashboardV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseDashboardV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
|
||||
Active: useDashboardV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
@@ -86,20 +85,13 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
// initiate opamp
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
integrationsController, err := integrations.NewController(signoz.SQLStore)
|
||||
integrationsController, err := integrations.NewController(signoz.SQLStore, signoz.Modules.Dashboard)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"couldn't create integrations controller: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"couldn't create cloud provider integrations controller: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// ingestion pipelines manager
|
||||
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
|
||||
signoz.SQLStore,
|
||||
@@ -134,7 +126,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
DataConnector: reader,
|
||||
UsageManager: usageManager,
|
||||
IntegrationsController: integrationsController,
|
||||
CloudIntegrationsController: cloudIntegrationsController,
|
||||
LogsParsingPipelineController: logParsingPipelineController,
|
||||
FluxInterval: config.Querier.FluxInterval,
|
||||
GatewayUrl: config.Gateway.URL.String(),
|
||||
@@ -200,7 +191,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterCloudIntegrationsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterInfraMetricsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
|
||||
@@ -94,6 +94,19 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="application/json" id="signoz-boot-settings">
|
||||
[[.Settings]]
|
||||
</script>
|
||||
<script>
|
||||
try {
|
||||
var _el = document.getElementById('signoz-boot-settings');
|
||||
window.signozBootData = {
|
||||
settings: _el ? JSON.parse(_el.textContent) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
window.signozBootData = { settings: null };
|
||||
}
|
||||
</script>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -135,7 +148,10 @@
|
||||
</script>
|
||||
<script>
|
||||
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
|
||||
if (APPCUES_APP_ID) {
|
||||
var appcuesSettings =
|
||||
((window.signozBootData || {}).settings || {}).appcues || {};
|
||||
var appcuesEnabled = appcuesSettings.enabled !== false;
|
||||
if (APPCUES_APP_ID && appcuesEnabled) {
|
||||
(function (d, t) {
|
||||
var a = d.createElement(t);
|
||||
a.async = 1;
|
||||
|
||||
@@ -47,10 +47,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)/)',
|
||||
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"commitlint": "commitlint --edit $1",
|
||||
"test": "jest",
|
||||
"test:changedsince": "jest --changedSince=main --coverage --silent",
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
|
||||
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
@@ -49,7 +50,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.21",
|
||||
"@signozhq/ui": "0.0.22",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -160,8 +161,8 @@
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
@@ -187,6 +188,7 @@
|
||||
"is-ci": "^3.0.1",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jest-styled-components": "^7.2.0",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"lint-staged": "^17.0.4",
|
||||
"msw": "1.3.2",
|
||||
"orval": "8.9.1",
|
||||
@@ -241,4 +243,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
58
frontend/pnpm-lock.yaml
generated
58
frontend/pnpm-lock.yaml
generated
@@ -77,8 +77,8 @@ importers:
|
||||
specifier: 0.0.2
|
||||
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@signozhq/ui':
|
||||
specifier: 0.0.21
|
||||
version: 0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
specifier: 0.0.22
|
||||
version: 0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -449,6 +449,9 @@ importers:
|
||||
jest-styled-components:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0(styled-components@5.3.11(react-dom@18.2.0(react@18.2.0))(react-is@19.2.6)(react@18.2.0))
|
||||
json-schema-to-typescript:
|
||||
specifier: ^15.0.4
|
||||
version: 15.0.4
|
||||
lint-staged:
|
||||
specifier: ^17.0.4
|
||||
version: 17.0.4
|
||||
@@ -457,7 +460,7 @@ importers:
|
||||
version: 1.3.2(typescript@5.9.3)
|
||||
orval:
|
||||
specifier: 8.9.1
|
||||
version: 8.9.1(typescript@5.9.3)
|
||||
version: 8.9.1(prettier@3.8.3)(typescript@5.9.3)
|
||||
oxfmt:
|
||||
specifier: 0.47.0
|
||||
version: 0.47.0
|
||||
@@ -545,6 +548,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.9.0'
|
||||
|
||||
'@apidevtools/json-schema-ref-parser@11.9.3':
|
||||
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1991,6 +1998,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@jsdevtools/ono@7.1.3':
|
||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||
|
||||
'@keyv/bigmap@1.3.1':
|
||||
resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -3269,8 +3279,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
|
||||
'@signozhq/ui@0.0.21':
|
||||
resolution: {integrity: sha512-uLM3Vqwxlk2USXbwtb3qRLpjZR9b9QSHFQq/jtcfYNMDmIE/sNjSj0nRkEhX4RqqRgsLRt2PVA33aeWxDOLO3g==}
|
||||
'@signozhq/ui@0.0.22':
|
||||
resolution: {integrity: sha512-CJDyA4H+uXG/U2/d7/nRMNY6WIW0YWc843mfzUQALjm+xOhbO4T+qt67THjV4s1wTMs1cZLkmScbMddf+hXLIQ==}
|
||||
peerDependencies:
|
||||
'@signozhq/icons': 0.3.0
|
||||
react: ^18.2.0
|
||||
@@ -6066,6 +6076,11 @@ packages:
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-schema-to-typescript@15.0.4:
|
||||
resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
hasBin: true
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
@@ -7104,6 +7119,11 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier@3.8.3:
|
||||
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
pretty-format@27.5.1:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
@@ -9044,6 +9064,12 @@ snapshots:
|
||||
resize-observer-polyfill: 1.5.1
|
||||
throttle-debounce: 5.0.0
|
||||
|
||||
'@apidevtools/json-schema-ref-parser@11.9.3':
|
||||
dependencies:
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 4.1.1
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -10798,6 +10824,8 @@ snapshots:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
optional: true
|
||||
|
||||
'@jsdevtools/ono@7.1.3': {}
|
||||
|
||||
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
|
||||
dependencies:
|
||||
hashery: 1.5.1
|
||||
@@ -12013,7 +12041,7 @@ snapshots:
|
||||
- react-dom
|
||||
- tailwindcss
|
||||
|
||||
'@signozhq/ui@0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@chenglou/pretext': 0.0.5
|
||||
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -15374,6 +15402,18 @@ snapshots:
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-to-typescript@15.0.4:
|
||||
dependencies:
|
||||
'@apidevtools/json-schema-ref-parser': 11.9.3
|
||||
'@types/json-schema': 7.0.15
|
||||
'@types/lodash': 4.17.24
|
||||
is-glob: 4.0.3
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.18.1
|
||||
minimist: 1.2.8
|
||||
prettier: 3.8.3
|
||||
tinyglobby: 0.2.15
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -16290,7 +16330,7 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wcwidth: 1.0.1
|
||||
|
||||
orval@8.9.1(typescript@5.9.3):
|
||||
orval@8.9.1(prettier@3.8.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@commander-js/extra-typings': 14.0.0(commander@14.0.2)
|
||||
'@orval/angular': 8.9.1(typescript@5.9.3)
|
||||
@@ -16321,6 +16361,8 @@ snapshots:
|
||||
typedoc: 0.28.19(typescript@5.9.3)
|
||||
typedoc-plugin-coverage: 4.0.2(typedoc@0.28.19(typescript@5.9.3))
|
||||
typedoc-plugin-markdown: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
|
||||
optionalDependencies:
|
||||
prettier: 3.8.3
|
||||
transitivePeerDependencies:
|
||||
- '@faker-js/faker'
|
||||
- supports-color
|
||||
@@ -16581,6 +16623,8 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.8.3: {}
|
||||
|
||||
pretty-format@27.5.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
@@ -102,7 +102,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
|
||||
if (
|
||||
(pathname === ROUTES.AI_ASSISTANT_BASE ||
|
||||
pathname.startsWith('/ai-assistant/')) &&
|
||||
!isAIAssistantEnabled
|
||||
) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { extractDomain } from 'utils/app';
|
||||
import { bootSettings } from 'utils/bootData';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import PrivateRoute from './Private';
|
||||
@@ -228,18 +229,18 @@ function App(): JSX.Element {
|
||||
}
|
||||
|
||||
setRoutes((prev) => {
|
||||
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
const hasAi = prev.some((r) => r.key === 'AI_ASSISTANT');
|
||||
if (isAIAssistantEnabled === hasAi) {
|
||||
return prev;
|
||||
}
|
||||
if (isAIAssistantEnabled) {
|
||||
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
const aiRoute = defaultRoutes.find((r) => r.key === 'AI_ASSISTANT');
|
||||
if (!aiRoute) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
|
||||
return [...prev.filter((r) => r.key !== 'AI_ASSISTANT'), aiRoute];
|
||||
}
|
||||
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
|
||||
return prev.filter((r) => r.key !== 'AI_ASSISTANT');
|
||||
});
|
||||
}, [isLoggedInState, isAIAssistantEnabled]);
|
||||
|
||||
@@ -253,6 +254,7 @@ function App(): JSX.Element {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname === '/ai-assistant' ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
@@ -332,7 +334,7 @@ function App(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (process.env.POSTHOG_KEY) {
|
||||
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
|
||||
@@ -501,7 +501,7 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
|
||||
exact: true,
|
||||
component: AIAssistantPage,
|
||||
key: 'AI_ASSISTANT',
|
||||
|
||||
@@ -40,6 +40,7 @@ export function setAIBackendUrl(url: string | null): void {
|
||||
if (aiBackendUrl === url) {
|
||||
return;
|
||||
}
|
||||
|
||||
aiBackendUrl = url;
|
||||
AIAssistantInstance.defaults.baseURL = url ? `${url}${AI_API_PATH}` : '';
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ export enum ApplyFilterSignalDTO {
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
}
|
||||
export enum ApprovalStateDTO {
|
||||
pending = 'pending',
|
||||
approved = 'approved',
|
||||
rejected = 'rejected',
|
||||
superseded = 'superseded',
|
||||
}
|
||||
export enum ApprovalActionTypeDTO {
|
||||
modify = 'modify',
|
||||
delete = 'delete',
|
||||
}
|
||||
/**
|
||||
* Resolved approval (approved/rejected/superseded) anchored on the assistant message that proposed it. Pending approvals never appear here - they live at the top-level pendingApproval slot.
|
||||
*/
|
||||
@@ -63,16 +73,6 @@ export interface ApprovalActionSummaryDTO {
|
||||
resolvedAt: string;
|
||||
}
|
||||
|
||||
export enum ApprovalActionTypeDTO {
|
||||
modify = 'modify',
|
||||
delete = 'delete',
|
||||
}
|
||||
export enum ApprovalStateDTO {
|
||||
pending = 'pending',
|
||||
approved = 'approved',
|
||||
rejected = 'rejected',
|
||||
superseded = 'superseded',
|
||||
}
|
||||
export type ApprovalSummaryDTODiff = { [key: string]: unknown };
|
||||
|
||||
export interface ApprovalSummaryDTO {
|
||||
@@ -139,6 +139,16 @@ export interface CancelRequestDTO {
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export enum ExecutionStateDTO {
|
||||
queued = 'queued',
|
||||
running = 'running',
|
||||
awaiting_approval = 'awaiting_approval',
|
||||
awaiting_clarification = 'awaiting_clarification',
|
||||
resumed = 'resumed',
|
||||
completed = 'completed',
|
||||
failed = 'failed',
|
||||
canceled = 'canceled',
|
||||
}
|
||||
export interface CancelResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -153,6 +163,13 @@ export type ClarificationFieldDTOOptions = string[] | null;
|
||||
|
||||
export type ClarificationFieldDTODefault = string | string[] | null;
|
||||
|
||||
export enum ClarificationFieldTypeDTO {
|
||||
text = 'text',
|
||||
number = 'number',
|
||||
select = 'select',
|
||||
multi_select = 'multi_select',
|
||||
boolean = 'boolean',
|
||||
}
|
||||
export interface ClarificationFieldDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -175,13 +192,6 @@ export interface ClarificationFieldDTO {
|
||||
default?: ClarificationFieldDTODefault;
|
||||
}
|
||||
|
||||
export enum ClarificationFieldTypeDTO {
|
||||
text = 'text',
|
||||
number = 'number',
|
||||
select = 'select',
|
||||
multi_select = 'multi_select',
|
||||
boolean = 'boolean',
|
||||
}
|
||||
export enum ClarificationStateDTO {
|
||||
pending = 'pending',
|
||||
submitted = 'submitted',
|
||||
@@ -252,178 +262,21 @@ export interface ClarifyResponseDTO {
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export type CreateMessageRequestDTOContexts = MessageContextDTO[] | null;
|
||||
|
||||
export type CreateMessageRequestDTOForkFromMessageId = string | null;
|
||||
|
||||
export interface CreateMessageRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @maxLength 20000
|
||||
* @minLength 1
|
||||
*/
|
||||
content: string;
|
||||
contexts?: CreateMessageRequestDTOContexts;
|
||||
forkFromMessageId?: CreateMessageRequestDTOForkFromMessageId;
|
||||
}
|
||||
|
||||
export interface CreateMessageResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format uuid
|
||||
*/
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export type CreateThreadRequestDTOTitle = string | null;
|
||||
|
||||
export interface CreateThreadRequestDTO {
|
||||
title?: CreateThreadRequestDTOTitle;
|
||||
}
|
||||
|
||||
export interface CreateThreadResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format uuid
|
||||
*/
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
|
||||
|
||||
export type ErrorBodyDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Inner error object — matches Go ErrorsJSON.
|
||||
*/
|
||||
export interface ErrorBodyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @pattern ^[a-z_]+$
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
errors?: ErrorBodyDTOErrors;
|
||||
url?: ErrorBodyDTOUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level error envelope — matches Go RenderErrorResponse.
|
||||
*/
|
||||
export interface ErrorResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
error: ErrorBodyDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single sub-error entry — matches Go ErrorsResponseerroradditional.
|
||||
*/
|
||||
export interface ErrorResponseAdditionalDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
export enum ExecutionStateDTO {
|
||||
queued = 'queued',
|
||||
running = 'running',
|
||||
awaiting_approval = 'awaiting_approval',
|
||||
awaiting_clarification = 'awaiting_clarification',
|
||||
resumed = 'resumed',
|
||||
completed = 'completed',
|
||||
failed = 'failed',
|
||||
canceled = 'canceled',
|
||||
}
|
||||
export enum FeedbackRatingDTO {
|
||||
positive = 'positive',
|
||||
negative = 'negative',
|
||||
}
|
||||
export type FeedbackRequestDTOComment = string | null;
|
||||
|
||||
export interface FeedbackRequestDTO {
|
||||
rating: FeedbackRatingDTO;
|
||||
comment?: FeedbackRequestDTOComment;
|
||||
}
|
||||
|
||||
export interface FeedbackResponseDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HTTPValidationErrorDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
detail?: ValidationErrorDTO[];
|
||||
}
|
||||
|
||||
export const HealthResponseDTOValue = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: 'ok',
|
||||
} as const;
|
||||
export type HealthResponseDTO = typeof HealthResponseDTOValue;
|
||||
|
||||
export type MessageActionDTOActionMetadataId = string | null;
|
||||
|
||||
export type MessageActionDTOResourceType = string | null;
|
||||
|
||||
export type MessageActionDTOResourceId = string | null;
|
||||
|
||||
export type MessageActionDTOState = string | null;
|
||||
|
||||
export type MessageActionDTOInputAnyOf = { [key: string]: unknown };
|
||||
|
||||
export type MessageActionDTOInput = MessageActionDTOInputAnyOf | null;
|
||||
|
||||
export type MessageActionDTOTooltip = string | null;
|
||||
|
||||
export type MessageActionDTOSignal = ApplyFilterSignalDTO | null;
|
||||
|
||||
export type MessageActionDTOQueryAnyOf = { [key: string]: unknown };
|
||||
|
||||
export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
|
||||
|
||||
export type MessageActionDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
|
||||
*/
|
||||
export interface MessageActionDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label: string;
|
||||
actionMetadataId?: MessageActionDTOActionMetadataId;
|
||||
resourceType?: MessageActionDTOResourceType;
|
||||
resourceId?: MessageActionDTOResourceId;
|
||||
state?: MessageActionDTOState;
|
||||
input?: MessageActionDTOInput;
|
||||
tooltip?: MessageActionDTOTooltip;
|
||||
signal?: MessageActionDTOSignal;
|
||||
query?: MessageActionDTOQuery;
|
||||
url?: MessageActionDTOUrl;
|
||||
}
|
||||
|
||||
export enum MessageActionKindDTO {
|
||||
undo = 'undo',
|
||||
revert = 'revert',
|
||||
restore = 'restore',
|
||||
follow_up = 'follow_up',
|
||||
open_resource = 'open_resource',
|
||||
open_docs = 'open_docs',
|
||||
apply_filter = 'apply_filter',
|
||||
}
|
||||
export enum MessageContentTypeDTO {
|
||||
markdown = 'markdown',
|
||||
* Identifier exposed on the wire for each counter row.
|
||||
|
||||
Mirrors the ``RateLimitCounterType`` model enum minus the cost
|
||||
counter. The daily-cost limit is enforced internally (Redis
|
||||
counter + 429 from the pre-flight gate) but never surfaced on the
|
||||
customer-facing API: shipping the raw provider cost to tenant users
|
||||
pins our public pricing model to what we pay Anthropic and forecloses
|
||||
markup, per-seat bundling, or tiered pricing. Cost stays internal on
|
||||
``assistant_executions`` + Redis for billing.
|
||||
*/
|
||||
export enum CounterTypeNameDTO {
|
||||
hourly_message = 'hourly_message',
|
||||
daily_message = 'daily_message',
|
||||
daily_token = 'daily_token',
|
||||
}
|
||||
/**
|
||||
* "auto" if derived from current page; "mention" if explicitly @-picked.
|
||||
@@ -482,6 +335,193 @@ export interface MessageContextDTO {
|
||||
metadata?: MessageContextDTOMetadata;
|
||||
}
|
||||
|
||||
export type CreateMessageRequestDTOContexts = MessageContextDTO[] | null;
|
||||
|
||||
export type CreateMessageRequestDTOForkFromMessageId = string | null;
|
||||
|
||||
export interface CreateMessageRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @maxLength 20000
|
||||
* @minLength 1
|
||||
*/
|
||||
content: string;
|
||||
contexts?: CreateMessageRequestDTOContexts;
|
||||
forkFromMessageId?: CreateMessageRequestDTOForkFromMessageId;
|
||||
}
|
||||
|
||||
export interface CreateMessageResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format uuid
|
||||
*/
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export type CreateThreadRequestDTOTitle = string | null;
|
||||
|
||||
export interface CreateThreadRequestDTO {
|
||||
title?: CreateThreadRequestDTOTitle;
|
||||
}
|
||||
|
||||
export interface CreateThreadResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format uuid
|
||||
*/
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single sub-error entry — matches Go ErrorsResponseerroradditional.
|
||||
*/
|
||||
export interface ErrorResponseAdditionalDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
|
||||
|
||||
export type ErrorBodyDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Inner error object — matches Go ErrorsJSON.
|
||||
*/
|
||||
export interface ErrorBodyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @pattern ^[a-z_]+$
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
errors?: ErrorBodyDTOErrors;
|
||||
url?: ErrorBodyDTOUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level error envelope — matches Go RenderErrorResponse.
|
||||
*/
|
||||
export interface ErrorResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
error: ErrorBodyDTO;
|
||||
}
|
||||
|
||||
export enum FeedbackRatingDTO {
|
||||
positive = 'positive',
|
||||
negative = 'negative',
|
||||
}
|
||||
export type FeedbackRequestDTOComment = string | null;
|
||||
|
||||
export interface FeedbackRequestDTO {
|
||||
rating: FeedbackRatingDTO;
|
||||
comment?: FeedbackRequestDTOComment;
|
||||
}
|
||||
|
||||
export interface FeedbackResponseDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ValidationErrorDTOLocItem = string | number;
|
||||
|
||||
export type ValidationErrorDTOCtx = { [key: string]: unknown };
|
||||
|
||||
export interface ValidationErrorDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
loc: ValidationErrorDTOLocItem[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
msg: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
input?: unknown;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
ctx?: ValidationErrorDTOCtx;
|
||||
}
|
||||
|
||||
export interface HTTPValidationErrorDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
detail?: ValidationErrorDTO[];
|
||||
}
|
||||
|
||||
export const HealthResponseDTOValue = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: 'ok',
|
||||
} as const;
|
||||
export type HealthResponseDTO = typeof HealthResponseDTOValue;
|
||||
|
||||
export type MessageActionDTOActionMetadataId = string | null;
|
||||
|
||||
export type MessageActionDTOResourceType = string | null;
|
||||
|
||||
export type MessageActionDTOResourceId = string | null;
|
||||
|
||||
export type MessageActionDTOState = string | null;
|
||||
|
||||
export type MessageActionDTOInputAnyOf = { [key: string]: unknown };
|
||||
|
||||
export type MessageActionDTOInput = MessageActionDTOInputAnyOf | null;
|
||||
|
||||
export type MessageActionDTOTooltip = string | null;
|
||||
|
||||
export type MessageActionDTOSignal = ApplyFilterSignalDTO | null;
|
||||
|
||||
export type MessageActionDTOQueryAnyOf = { [key: string]: unknown };
|
||||
|
||||
export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
|
||||
|
||||
export type MessageActionDTOUrl = string | null;
|
||||
|
||||
export enum MessageActionKindDTO {
|
||||
undo = 'undo',
|
||||
revert = 'revert',
|
||||
restore = 'restore',
|
||||
follow_up = 'follow_up',
|
||||
open_resource = 'open_resource',
|
||||
open_docs = 'open_docs',
|
||||
apply_filter = 'apply_filter',
|
||||
}
|
||||
/**
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
|
||||
*/
|
||||
export interface MessageActionDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label: string;
|
||||
actionMetadataId?: MessageActionDTOActionMetadataId;
|
||||
resourceType?: MessageActionDTOResourceType;
|
||||
resourceId?: MessageActionDTOResourceId;
|
||||
state?: MessageActionDTOState;
|
||||
input?: MessageActionDTOInput;
|
||||
tooltip?: MessageActionDTOTooltip;
|
||||
signal?: MessageActionDTOSignal;
|
||||
query?: MessageActionDTOQuery;
|
||||
url?: MessageActionDTOUrl;
|
||||
}
|
||||
|
||||
export enum MessageContentTypeDTO {
|
||||
markdown = 'markdown',
|
||||
}
|
||||
export enum MessageRoleDTO {
|
||||
user = 'user',
|
||||
assistant = 'assistant',
|
||||
@@ -616,6 +656,10 @@ export interface RevertRequestDTO {
|
||||
actionMetadataId: string;
|
||||
}
|
||||
|
||||
export enum ScopeDTO {
|
||||
user = 'user',
|
||||
org = 'org',
|
||||
}
|
||||
export type ThreadDetailResponseDTOTitle = string | null;
|
||||
|
||||
export type ThreadDetailResponseDTOState = ExecutionStateDTO | null;
|
||||
@@ -663,18 +707,6 @@ export interface ThreadDetailResponseDTO {
|
||||
|
||||
export type ThreadListResponseDTONextCursor = string | null;
|
||||
|
||||
export interface ThreadListResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
threads: ThreadSummaryDTO[];
|
||||
nextCursor?: ThreadListResponseDTONextCursor;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export type ThreadSummaryDTOTitle = string | null;
|
||||
|
||||
export type ThreadSummaryDTOState = ExecutionStateDTO | null;
|
||||
@@ -709,6 +741,18 @@ export interface ThreadSummaryDTO {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadListResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
threads: ThreadSummaryDTO[];
|
||||
nextCursor?: ThreadListResponseDTONextCursor;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export interface UndoRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -726,28 +770,29 @@ export interface UpdateThreadRequestDTO {
|
||||
archived?: UpdateThreadRequestDTOArchived;
|
||||
}
|
||||
|
||||
export type ValidationErrorDTOLocItem = string | number;
|
||||
export type UsageResponseDTONextPage = string | null;
|
||||
|
||||
export type ValidationErrorDTOCtx = { [key: string]: unknown };
|
||||
/**
|
||||
* One row in the ``GET /usage`` response.
|
||||
*/
|
||||
export interface UsageRowDTO {
|
||||
type: CounterTypeNameDTO;
|
||||
scope: ScopeDTO;
|
||||
used: number;
|
||||
limit: number;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
resetsAt: string;
|
||||
}
|
||||
|
||||
export interface ValidationErrorDTO {
|
||||
export interface UsageResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
loc: ValidationErrorDTOLocItem[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
msg: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
input?: unknown;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
ctx?: ValidationErrorDTOCtx;
|
||||
data: UsageRowDTO[];
|
||||
nextPage?: UsageResponseDTONextPage;
|
||||
}
|
||||
|
||||
export type ApprovalEventDTODiff = { [key: string]: unknown };
|
||||
@@ -909,6 +954,20 @@ export interface ErrorEventDTO {
|
||||
retryAction?: RetryActionDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection SSE keep-alive emitted every `sse_heartbeat_interval_seconds`.
|
||||
|
||||
Carries no `executionId` and no `eventId` — heartbeats are wire-level
|
||||
keep-alives, not part of the replayable event log.
|
||||
*/
|
||||
export const HeartbeatEventDTOValue = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: 'heartbeat',
|
||||
} as const;
|
||||
export type HeartbeatEventDTO = typeof HeartbeatEventDTOValue;
|
||||
|
||||
export type MessageActionEventDTOActionMetadataId = string | null;
|
||||
|
||||
export type MessageActionEventDTOResourceType = string | null;
|
||||
@@ -1315,3 +1374,14 @@ export type SubmitFeedbackApiV1AssistantMessagesMessageIdFeedbackPostHeaders = {
|
||||
*/
|
||||
'X-SigNoz-URL'?: string | null;
|
||||
};
|
||||
|
||||
export type GetUsageApiV1AssistantUsageGetHeaders = {
|
||||
/**
|
||||
* @description SigNoz auth token (Bearer or raw JWT)
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* @description SigNoz instance base URL for multi-tenant deployments. Falls back to SIGNOZ_API_URL env var when omitted.
|
||||
*/
|
||||
'X-SigNoz-URL'?: string | null;
|
||||
};
|
||||
|
||||
@@ -225,6 +225,10 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
|
||||
*/
|
||||
name: string;
|
||||
schedule: AlertmanagertypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
scope?: string;
|
||||
status: AlertmanagertypesMaintenanceStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -1714,6 +1718,10 @@ export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
|
||||
*/
|
||||
name: string;
|
||||
schedule: AlertmanagertypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesPostableRoutePolicyDTO {
|
||||
@@ -9224,6 +9232,17 @@ export type GetWaterfall200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallV4PathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetWaterfallV4200 = {
|
||||
data: SpantypesGettableWaterfallTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangeV5200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -120,3 +122,102 @@ export const useGetWaterfall = <
|
||||
> => {
|
||||
return useMutation(getGetWaterfallMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const getWaterfallV4 = (
|
||||
{ traceID }: GetWaterfallV4PathParameters,
|
||||
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfallV4200>({
|
||||
url: `/api/v4/traces/${traceID}/waterfall`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableWaterfallDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetWaterfallV4MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getWaterfallV4'];
|
||||
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 getWaterfallV4>>,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getWaterfallV4(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetWaterfallV4MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>
|
||||
>;
|
||||
export type GetWaterfallV4MutationBody =
|
||||
| BodyType<SpantypesPostableWaterfallDTO>
|
||||
| undefined;
|
||||
export type GetWaterfallV4MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const useGetWaterfallV4 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetWaterfallV4MutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.duration-input-slider {
|
||||
padding: 12px 0px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
||||
@@ -10,9 +10,6 @@ export const DEFAULT_AUTH0_APP_REDIRECTION_PATH = ROUTES.APPLICATION;
|
||||
|
||||
export const INVITE_MEMBERS_HASH = '#invite-team-members';
|
||||
|
||||
export const SIGNOZ_UPGRADE_PLAN_URL =
|
||||
'https://upgrade.signoz.io/upgrade-from-app';
|
||||
|
||||
export const DASHBOARD_TIME_IN_DURATION = 'refreshInterval';
|
||||
|
||||
export const DEFAULT_ENTITY_VERSION = 'v3';
|
||||
|
||||
@@ -11,4 +11,5 @@ export enum FeatureKeys {
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const ROUTES = {
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
AI_ASSISTANT: '/ai-assistant/:conversationId',
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Collapsed activity summary — one row that hides the underlying
|
||||
// thinking + tool-call steps. Reuses the same quiet treatment as
|
||||
// ThinkingStep / ToolCallStep so it sits flush in the assistant bubble.
|
||||
.activityGroup {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.activityHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--l3-foreground);
|
||||
user-select: none;
|
||||
transition: color 0.12s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.sparkleIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-primary);
|
||||
|
||||
&.iconPulsing {
|
||||
animation: activityGroupPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes activityGroupPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.activitySummary {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.toggleChevron {
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.activityBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { ChevronDown, ChevronRight, Sparkles } from '@signozhq/icons';
|
||||
|
||||
import { formatTime } from 'utils/timeUtils';
|
||||
|
||||
import { StreamingToolCall } from '../../types';
|
||||
import ThinkingStep, { ThinkingContent, thinkingLabel } from '../ThinkingStep';
|
||||
import ToolCallStep, {
|
||||
getToolDisplayLabel,
|
||||
ToolCallContent,
|
||||
} from '../ToolCallStep';
|
||||
|
||||
import styles from './ActivityGroup.module.scss';
|
||||
|
||||
export type ActivityItem =
|
||||
| { id: string; kind: 'thinking'; content: string }
|
||||
| { id: string; kind: 'tool'; toolCall: StreamingToolCall };
|
||||
|
||||
interface ActivityGroupProps {
|
||||
items: ActivityItem[];
|
||||
/**
|
||||
* True only for the trailing activity group of an active stream — drives
|
||||
* the live "Working…" label and the elapsed-time tick (which re-stamps on
|
||||
* approval/clarification resume so wait time isn't counted).
|
||||
*/
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-item groups get a step-specific summary so the user doesn't see a
|
||||
* pointless "Worked through 1 step". Multi-item groups roll up into the
|
||||
* generic "Working… / Worked through N steps" treatment.
|
||||
*/
|
||||
function buildSummary(
|
||||
items: ActivityItem[],
|
||||
isLive: boolean,
|
||||
elapsed: number,
|
||||
): string {
|
||||
if (items.length === 1) {
|
||||
const [only] = items;
|
||||
if (only.kind === 'thinking') {
|
||||
return thinkingLabel(isLive);
|
||||
}
|
||||
return getToolDisplayLabel(only.toolCall);
|
||||
}
|
||||
const stepLabel = `${items.length} steps`;
|
||||
if (!isLive) {
|
||||
return `Worked through ${stepLabel}`;
|
||||
}
|
||||
// Suppress the elapsed token until ≥ 1s — the first tick fires after
|
||||
// 1s anyway, and showing "0s" or "<1s" briefly adds noise.
|
||||
return elapsed >= 1000
|
||||
? `Working… · ${formatTime(elapsed / 1000)} · ${stepLabel}`
|
||||
: `Working… · ${stepLabel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single collapsed summary row that hides a run of thinking + tool-call steps.
|
||||
* Expands to show each underlying step inline. Used for every activity row
|
||||
* (including single-item ones) so all "what the agent did" rows share a
|
||||
* consistent ✨-led visual contract.
|
||||
*/
|
||||
export default function ActivityGroup({
|
||||
items,
|
||||
isLive = false,
|
||||
}: ActivityGroupProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Captures the moment this live phase started. Re-stamped on every
|
||||
// false→true transition so a stream that pauses on
|
||||
// approval/clarification and resumes doesn't roll the user's wait time
|
||||
// into the elapsed counter.
|
||||
const startedAtRef = useRef<number>(Date.now());
|
||||
const wasLiveRef = useRef<boolean>(isLive);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLive && !wasLiveRef.current) {
|
||||
startedAtRef.current = Date.now();
|
||||
setElapsed(0);
|
||||
}
|
||||
wasLiveRef.current = isLive;
|
||||
|
||||
if (!isLive) {
|
||||
return undefined;
|
||||
}
|
||||
// Tick once per second — the display is integer-second precision, so
|
||||
// faster ticks would just re-render the bubble for no visible change.
|
||||
const id = window.setInterval(() => {
|
||||
setElapsed(Date.now() - startedAtRef.current);
|
||||
}, 1000);
|
||||
return (): void => window.clearInterval(id);
|
||||
}, [isLive]);
|
||||
|
||||
const summary = buildSummary(items, isLive, elapsed);
|
||||
const isSingle = items.length === 1;
|
||||
|
||||
const toggle = (): void => setExpanded((v) => !v);
|
||||
|
||||
return (
|
||||
<div className={styles.activityGroup}>
|
||||
<div className={styles.activityHeader} onClick={toggle}>
|
||||
<Sparkles
|
||||
size={12}
|
||||
className={cx(styles.sparkleIcon, { [styles.iconPulsing]: isLive })}
|
||||
/>
|
||||
<span className={styles.activitySummary}>{summary}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown size={12} className={styles.toggleChevron} />
|
||||
) : (
|
||||
<ChevronRight size={12} className={styles.toggleChevron} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.activityBody}>
|
||||
{isSingle ? (
|
||||
// Single-item: the outer chevron already provides disclosure,
|
||||
// so render the underlying content directly instead of wrapping
|
||||
// it in a second collapsible step row.
|
||||
items[0].kind === 'thinking' ? (
|
||||
<ThinkingContent content={items[0].content} />
|
||||
) : (
|
||||
<ToolCallContent toolCall={items[0].toolCall} />
|
||||
)
|
||||
) : (
|
||||
items.map((item, i) => {
|
||||
// A thinking step is live only while it's the trailing item
|
||||
// in a trailing live group — once any later event (text or
|
||||
// tool) arrives, the pass is done.
|
||||
const isLastItem = i === items.length - 1;
|
||||
return item.kind === 'thinking' ? (
|
||||
<ThinkingStep
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
isLive={isLive && isLastItem}
|
||||
/>
|
||||
) : (
|
||||
<ToolCallStep key={item.id} toolCall={item.toolCall} />
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './ActivityGroup';
|
||||
export type { ActivityItem } from './ActivityGroup';
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -9,11 +9,10 @@ import '../blocks';
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import { Message, MessageBlock } from '../../types';
|
||||
import ActionsSection from '../ActionsSection';
|
||||
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import { MessageContext } from '../MessageContext';
|
||||
import MessageFeedback from '../MessageFeedback';
|
||||
import ThinkingStep from '../ThinkingStep';
|
||||
import ToolCallStep from '../ToolCallStep';
|
||||
import UserMessageActions from '../UserMessageActions';
|
||||
|
||||
import styles from './MessageBubble.module.scss';
|
||||
@@ -40,38 +39,61 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
/** Renders a single MessageBlock by type. */
|
||||
function renderBlock(block: MessageBlock, index: number): JSX.Element {
|
||||
switch (block.type) {
|
||||
case 'thinking':
|
||||
return <ThinkingStep key={index} content={block.content} />;
|
||||
case 'tool_call':
|
||||
// Blocks in a persisted message are always complete — done is always true.
|
||||
return (
|
||||
<ToolCallStep
|
||||
key={index}
|
||||
toolCall={{
|
||||
toolName: block.toolName,
|
||||
input: block.toolInput,
|
||||
result: block.result,
|
||||
done: true,
|
||||
displayText: block.displayText,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={index}
|
||||
className={styles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{block.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
| { kind: 'activity'; id: string; items: ActivityItem[] };
|
||||
|
||||
/**
|
||||
* Partition message blocks into render groups so consecutive thinking and
|
||||
* tool_call blocks collapse into a single ActivityGroup row. Text blocks
|
||||
* stand alone, mirroring the streaming view.
|
||||
*/
|
||||
function groupBlocks(blocks: MessageBlock[]): RenderGroup[] {
|
||||
const groups: RenderGroup[] = [];
|
||||
blocks.forEach((block, i) => {
|
||||
if (block.type === 'text') {
|
||||
groups.push({ kind: 'text', id: `text-${i}`, content: block.content });
|
||||
return;
|
||||
}
|
||||
const item: ActivityItem =
|
||||
block.type === 'thinking'
|
||||
? { id: `t-${i}`, kind: 'thinking', content: block.content }
|
||||
: {
|
||||
id: `c-${block.toolCallId}`,
|
||||
kind: 'tool',
|
||||
// Persisted blocks are always complete.
|
||||
toolCall: {
|
||||
toolName: block.toolName,
|
||||
input: block.toolInput,
|
||||
result: block.result,
|
||||
done: true,
|
||||
displayText: block.displayText,
|
||||
},
|
||||
};
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.kind === 'activity') {
|
||||
last.items.push(item);
|
||||
} else {
|
||||
groups.push({ kind: 'activity', id: `a-${i}`, items: [item] });
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderGroup(group: RenderGroup): JSX.Element {
|
||||
if (group.kind === 'text') {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={group.id}
|
||||
className={styles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{group.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
return <ActivityGroup key={group.id} items={group.items} />;
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -90,6 +112,14 @@ export default function MessageBubble({
|
||||
const isUser = message.role === 'user';
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
|
||||
// Recompute groups only when the blocks array identity changes — store
|
||||
// updates that don't touch this message's blocks should not re-render the
|
||||
// underlying ThinkingStep/ToolCallStep children.
|
||||
const groups = useMemo(
|
||||
() => (hasBlocks ? groupBlocks(message.blocks!) : []),
|
||||
[hasBlocks, message.blocks],
|
||||
);
|
||||
|
||||
const messageClass = cx(
|
||||
styles.message,
|
||||
isUser ? styles.user : styles.assistant,
|
||||
@@ -128,8 +158,7 @@ export default function MessageBubble({
|
||||
<p className={styles.text}>{message.content}</p>
|
||||
) : hasBlocks ? (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
{/* eslint-disable-next-line react/no-array-index-key */}
|
||||
{message.blocks!.map((block, i) => renderBlock(block, i))}
|
||||
{groups.map((g) => renderGroup(g))}
|
||||
</MessageContext.Provider>
|
||||
) : (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
@@ -149,7 +178,7 @@ export default function MessageBubble({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUser && (
|
||||
{!isUser && !message.isRateLimitError && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -10,11 +10,10 @@ import type {
|
||||
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import { StreamingEventItem } from '../../types';
|
||||
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
|
||||
import ApprovalCard from '../ApprovalCard';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import ClarificationForm from '../ClarificationForm';
|
||||
import ThinkingStep from '../ThinkingStep';
|
||||
import ToolCallStep from '../ToolCallStep';
|
||||
|
||||
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
|
||||
import styles from './StreamingMessage.module.scss';
|
||||
@@ -33,6 +32,59 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
| {
|
||||
kind: 'activity';
|
||||
id: string;
|
||||
items: ActivityItem[];
|
||||
isTrailing: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Partition the streaming event timeline into render groups: runs of
|
||||
* consecutive thinking/tool events fold into a single activity group, text
|
||||
* events stay standalone. The last group is flagged as trailing so the
|
||||
* caller can drive a "live" indicator on it.
|
||||
*
|
||||
* Invariant relied on by the ActivityGroup elapsed-time timer: once a
|
||||
* group exists at a given array index, later events only extend its
|
||||
* `items` — they never shrink the array or re-key existing groups. That
|
||||
* keeps each ActivityGroup React instance stable across re-renders so the
|
||||
* timer's `wasLive` → `isLive` re-stamp captures the right transition.
|
||||
* The id fields below piggyback on that invariant: each event's position in
|
||||
* `events` is stable, so the derived id stays stable across re-renders.
|
||||
*/
|
||||
function groupStreamingEvents(events: StreamingEventItem[]): RenderGroup[] {
|
||||
const groups: RenderGroup[] = [];
|
||||
events.forEach((event, i) => {
|
||||
if (event.kind === 'text') {
|
||||
groups.push({ kind: 'text', id: `text-${i}`, content: event.content });
|
||||
return;
|
||||
}
|
||||
const item: ActivityItem =
|
||||
event.kind === 'thinking'
|
||||
? { id: `t-${i}`, kind: 'thinking', content: event.content }
|
||||
: { id: `c-${i}`, kind: 'tool', toolCall: event.toolCall };
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.kind === 'activity') {
|
||||
last.items.push(item);
|
||||
} else {
|
||||
groups.push({
|
||||
kind: 'activity',
|
||||
id: `a-${i}`,
|
||||
items: [item],
|
||||
isTrailing: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.kind === 'activity') {
|
||||
last.isTrailing = true;
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Human-readable labels for execution status codes shown before any events arrive. */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
queued: 'Queued…',
|
||||
@@ -79,6 +131,11 @@ export default function StreamingMessage({
|
||||
[messageStyles.compact]: isCompact,
|
||||
});
|
||||
|
||||
// Recompute groups only when the events array identity changes. The
|
||||
// streaming reducer pushes new entries into the same array reference
|
||||
// once per tick, so this naturally invalidates as events arrive.
|
||||
const groups = useMemo(() => groupStreamingEvents(events), [events]);
|
||||
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
<div className={messageStyles.bubble}>
|
||||
@@ -88,27 +145,28 @@ export default function StreamingMessage({
|
||||
)}
|
||||
{isEmpty && !statusLabel && <TypingDots />}
|
||||
|
||||
{/* eslint-disable react/no-array-index-key */}
|
||||
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
|
||||
{events.map((event, i) => {
|
||||
if (event.kind === 'tool') {
|
||||
return <ToolCallStep key={i} toolCall={event.toolCall} />;
|
||||
}
|
||||
if (event.kind === 'thinking') {
|
||||
return <ThinkingStep key={i} content={event.content} />;
|
||||
{/* Runs of consecutive thinking + tool events collapse into a
|
||||
single ActivityGroup; text events render inline between
|
||||
them. The trailing group is "live" while streaming is
|
||||
active and not blocked on the user. */}
|
||||
{groups.map((group) => {
|
||||
if (group.kind === 'text') {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={group.id}
|
||||
className={messageStyles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{group.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
const groupIsLive = group.isTrailing && !isWaitingOnUser;
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={i}
|
||||
className={messageStyles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{event.content}
|
||||
</ReactMarkdown>
|
||||
<ActivityGroup key={group.id} items={group.items} isLive={groupIsLive} />
|
||||
);
|
||||
})}
|
||||
{/* eslint-enable react/no-array-index-key */}
|
||||
|
||||
{/* While events are still streaming, append the typing dots so the
|
||||
user has a clear "more is coming" signal. Hidden when the agent
|
||||
|
||||
@@ -5,11 +5,31 @@ import styles from './ThinkingStep.module.scss';
|
||||
|
||||
interface ThinkingStepProps {
|
||||
content: string;
|
||||
/**
|
||||
* When false, label reads "Thought for a few seconds" — intentionally
|
||||
* vague because the API doesn't persist precise timing, so showing
|
||||
* seconds would be inconsistent between fresh and reloaded threads.
|
||||
*/
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
/** Body of a thinking step — extracted so ActivityGroup can render it directly. */
|
||||
export function ThinkingContent({ content }: { content: string }): JSX.Element {
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<p className={styles.content}>{content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function thinkingLabel(isLive: boolean): string {
|
||||
return isLive ? 'Thinking…' : 'Thought for a few seconds';
|
||||
}
|
||||
|
||||
/** Collapsible thinking row — chevron + label, content in the expanded body. */
|
||||
export default function ThinkingStep({
|
||||
content,
|
||||
isLive = false,
|
||||
}: ThinkingStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -23,14 +43,10 @@ export default function ThinkingStep({
|
||||
) : (
|
||||
<ChevronRight size={12} className={styles.chevron} />
|
||||
)}
|
||||
<span className={styles.label}>Thinking</span>
|
||||
<span className={styles.label}>{thinkingLabel(isLive)}</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.body}>
|
||||
<p className={styles.content}>{content}</p>
|
||||
</div>
|
||||
)}
|
||||
{expanded && <ThinkingContent content={content} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,24 +10,58 @@ interface ToolCallStepProps {
|
||||
toolCall: StreamingToolCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-supplied `displayText` is the human-friendly title the backend
|
||||
* wants surfaced. Falls back to a derived label
|
||||
* ("signoz_get_dashboard" → "Get Dashboard") when missing.
|
||||
*/
|
||||
export function getToolDisplayLabel(toolCall: StreamingToolCall): string {
|
||||
const { toolName, displayText } = toolCall;
|
||||
if (displayText && displayText.trim().length > 0) {
|
||||
return displayText;
|
||||
}
|
||||
return toolName
|
||||
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Body of a tool-call step — extracted so ActivityGroup can render it directly. */
|
||||
export function ToolCallContent({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: StreamingToolCall;
|
||||
}): JSX.Element {
|
||||
const { toolName, input, result, done } = toolCall;
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Tool</span>
|
||||
<span className={styles.toolName}>{toolName}</span>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Input</span>
|
||||
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
|
||||
</div>
|
||||
{done && result !== undefined && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Output</span>
|
||||
<pre className={styles.json}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapsible tool-call row — chevron + label, in/out detail in the body. */
|
||||
export default function ToolCallStep({
|
||||
toolCall,
|
||||
}: ToolCallStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { toolName, input, result, done, displayText } = toolCall;
|
||||
|
||||
// Prefer the server-supplied `displayText` from `ToolCallEventDTO` —
|
||||
// it's the human-friendly title the backend wants surfaced. Fall back
|
||||
// to a derived label ("signoz_get_dashboard" → "Get Dashboard") when
|
||||
// the field is empty / null / missing.
|
||||
const label =
|
||||
displayText && displayText.trim().length > 0
|
||||
? displayText
|
||||
: toolName
|
||||
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const { done } = toolCall;
|
||||
const label = getToolDisplayLabel(toolCall);
|
||||
|
||||
const toggle = (): void => setExpanded((v) => !v);
|
||||
|
||||
@@ -44,26 +78,7 @@ export default function ToolCallStep({
|
||||
<span className={styles.label}>{label}</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Tool</span>
|
||||
<span className={styles.toolName}>{toolName}</span>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Input</span>
|
||||
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
|
||||
</div>
|
||||
{done && result !== undefined && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Output</span>
|
||||
<pre className={styles.json}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && <ToolCallContent toolCall={toolCall} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
|
||||
import type {
|
||||
ErrorResponseDTO,
|
||||
MessageActionDTO,
|
||||
MessageSummaryDTOBlocksAnyOfItem,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
regenerateMessage,
|
||||
rejectExecution,
|
||||
sendMessage as sendMessageToThread,
|
||||
SSEStreamError,
|
||||
streamEvents,
|
||||
submitFeedback,
|
||||
ThreadSummary,
|
||||
@@ -193,13 +194,75 @@ function resetStreamingState(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker thrown by `runStreamingLoop` when an SSE event reports
|
||||
* `invalid_token`. Callers that own an originating action (sendMessage /
|
||||
* approve / clarify / regenerate) catch this and re-issue that action via
|
||||
* `streamWithAuthRetry`; the retry's first REST call will 401, at which point
|
||||
* the shared axios `interceptorRejected` rotates the access token and replays.
|
||||
*/
|
||||
class AuthExpiredError extends Error {
|
||||
constructor() {
|
||||
super('Access token expired during execution');
|
||||
this.name = 'AuthExpiredError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the originating action (e.g. sendMessage POST) and streams the
|
||||
* resulting execution. On `AuthExpiredError`, re-issues `start` once — the
|
||||
* retry's REST call hits 401, the shared axios interceptor rotates the
|
||||
* access token and replays, and the new SSE picks up the rotated token from
|
||||
* localStorage. Backend signals `retryAction: 'manual'` for `invalid_token`,
|
||||
* so the dead execution can't be resumed — only a fresh one helps.
|
||||
*/
|
||||
async function streamWithAuthRetry(
|
||||
conversationId: string,
|
||||
start: () => Promise<string>,
|
||||
set: StoreSetter,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt <= 1; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
// Drop any partial content/events from the previous attempt so the
|
||||
// retried execution's stream isn't concatenated with the dead one.
|
||||
set((s) => {
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const executionId = await start();
|
||||
const ctrl = newStreamController(conversationId);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await runStreamingLoop(executionId, {
|
||||
conversationId,
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(conversationId);
|
||||
return;
|
||||
} catch (err) {
|
||||
streamControllers.delete(conversationId);
|
||||
if (err instanceof AuthExpiredError && attempt < 1) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one SSE execution stream, updating the per-conversation stream state.
|
||||
*
|
||||
* Breaks early and sets pendingApproval / pendingClarification when the
|
||||
* agent needs user input before it can continue.
|
||||
*
|
||||
* Throws on `error` events — the caller's catch block handles UI feedback.
|
||||
* On an `invalid_token` error event (e.g. MCP auth expired mid-execution),
|
||||
* throws `AuthExpiredError` so the caller can re-issue the originating
|
||||
* action via `streamWithAuthRetry`. We don't refresh here ourselves — the
|
||||
* retry's REST call will 401 and the shared axios `interceptorRejected`
|
||||
* handles rotation + replay. Throws on any other `error` event — the
|
||||
* caller's catch block handles UI feedback.
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
async function runStreamingLoop(
|
||||
@@ -325,6 +388,15 @@ async function runStreamingLoop(
|
||||
});
|
||||
break;
|
||||
} else if (event.type === 'error') {
|
||||
// MCP/SigNoz auth expired mid-execution — signal the caller to
|
||||
// re-issue the originating action. The retry's REST call will hit
|
||||
// 401 and the shared axios `interceptorRejected` will rotate the
|
||||
// access token + replay, so we don't refresh here ourselves.
|
||||
// (Backend sets `retryAction: 'manual'`, so the failed execution
|
||||
// can't itself be resumed — only a fresh one helps.)
|
||||
if (event.error.code === 'invalid_token') {
|
||||
throw new AuthExpiredError();
|
||||
}
|
||||
throw Object.assign(new Error(event.error.message), {
|
||||
retryAction: event.retryAction,
|
||||
});
|
||||
@@ -412,13 +484,41 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
|
||||
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
|
||||
}
|
||||
|
||||
function parseErrorBody(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return parseErrorBody(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const message = (value as ErrorResponseDTO | undefined)?.error?.message;
|
||||
return typeof message === 'string' && message.length > 0 ? message : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits an error message and removes the stream entry.
|
||||
* Returns the backend's `error.message` when `err` is a 429 axios response
|
||||
* (typically from the threads API surface — createThread, sendMessage, approve,
|
||||
* clarify, regenerate). Returns null for any other error so callers fall
|
||||
* through to their generic copy.
|
||||
*/
|
||||
function rateLimitMessage(err: unknown): string | null {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 429) {
|
||||
return parseErrorBody(err.response.data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits an error message and removes the stream entry. When `isRateLimit`
|
||||
* is true, the committed message is flagged so the feedback/regenerate bar
|
||||
* is hidden — clicking regenerate would just 429 again.
|
||||
*/
|
||||
function finalizeStreamingError(
|
||||
conversationId: string,
|
||||
errorContent: string,
|
||||
set: StoreSetter,
|
||||
isRateLimit = false,
|
||||
): void {
|
||||
set((s) => {
|
||||
const conv = s.conversations[conversationId];
|
||||
@@ -428,6 +528,7 @@ function finalizeStreamingError(
|
||||
role: 'assistant',
|
||||
content: errorContent,
|
||||
createdAt: Date.now(),
|
||||
...(isRateLimit ? { isRateLimitError: true } : {}),
|
||||
});
|
||||
conv.updatedAt = Date.now();
|
||||
}
|
||||
@@ -801,7 +902,12 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
});
|
||||
|
||||
// Reconnect to SSE if backend execution is still running
|
||||
// and we don't already have an active SSE reader for this thread
|
||||
// and we don't already have an active SSE reader for this
|
||||
// thread. No auth-retry wrapper here: on `invalid_token`
|
||||
// there's no "originating action" to redo — reopening the
|
||||
// same dead executionId would just re-emit the failure.
|
||||
// Let the error bubble; the user can send a new message,
|
||||
// which will go through `streamWithAuthRetry`.
|
||||
if (
|
||||
detail.activeExecutionId &&
|
||||
!streamControllers.has(threadId) &&
|
||||
@@ -1052,14 +1158,12 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
}
|
||||
});
|
||||
}
|
||||
const executionId = await sendMessageToThread(threadId, text, contexts);
|
||||
const ctrl = newStreamController(convId);
|
||||
await runStreamingLoop(executionId, {
|
||||
conversationId: convId,
|
||||
const tid = threadId;
|
||||
await streamWithAuthRetry(
|
||||
convId,
|
||||
() => sendMessageToThread(tid, text, contexts),
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(convId);
|
||||
);
|
||||
|
||||
if (!hasPendingInput(convId, get)) {
|
||||
finalizeStreamingMessage(convId, set, get);
|
||||
@@ -1070,11 +1174,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const message =
|
||||
err instanceof SSEStreamError && err.status === 429
|
||||
? 'You sent that a bit too quickly. Please wait a moment and try again.'
|
||||
: 'Something went wrong while fetching the response. Please try again.';
|
||||
finalizeStreamingError(convId, message, set);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
convId,
|
||||
rateLimit ??
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1094,14 +1201,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
});
|
||||
|
||||
try {
|
||||
const executionId = await approveExecution(approvalId);
|
||||
const ctrl = newStreamController(conversationId);
|
||||
await runStreamingLoop(executionId, {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => approveExecution(approvalId),
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(conversationId);
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
@@ -1110,10 +1214,13 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] approveAction failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
rateLimit ??
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -1176,14 +1283,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
});
|
||||
|
||||
try {
|
||||
const executionId = await regenerateMessage(messageId);
|
||||
const ctrl = newStreamController(conversationId);
|
||||
await runStreamingLoop(executionId, {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => regenerateMessage(messageId),
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(conversationId);
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
@@ -1192,10 +1296,13 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
rateLimit ??
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -1245,14 +1352,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
});
|
||||
|
||||
try {
|
||||
const executionId = await clarifyExecution(clarificationId, answers);
|
||||
const ctrl = newStreamController(conversationId);
|
||||
await runStreamingLoop(executionId, {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => clarifyExecution(clarificationId, answers),
|
||||
set,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
streamControllers.delete(conversationId);
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
@@ -1261,10 +1365,13 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] submitClarification failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
rateLimit ??
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -86,6 +86,11 @@ export interface Message {
|
||||
actions?: MessageActionDTO[];
|
||||
/** Persisted feedback rating — set after user votes and the API confirms. */
|
||||
feedbackRating?: FeedbackRating | null;
|
||||
/**
|
||||
* Set on client-side rate-limit error messages so the feedback/regenerate
|
||||
* bar (copy/vote/regenerate) is hidden — retrying would just 429 again.
|
||||
*/
|
||||
isRateLimitError?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SIGNOZ_UPGRADE_PLAN_URL } from 'constants/app';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import {
|
||||
@@ -313,16 +312,6 @@ describe('Create Alert Channel (Normal User)', () => {
|
||||
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.skip('Should check if the upgrade plan message is shown', () => {
|
||||
expect(screen.getByText('Upgrade to a Paid Plan')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/This feature is available for paid plans only./),
|
||||
).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: 'Click here' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', SIGNOZ_UPGRADE_PLAN_URL);
|
||||
expect(screen.getByText(/to Upgrade/)).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'button_save_channel' }),
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import {
|
||||
MetricRangePayloadProps,
|
||||
MetricRangePayloadV3,
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PanelMode } from '../../types';
|
||||
import { prepareBarPanelConfig } from '../utils';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
getLegend: jest.fn(
|
||||
(_queryData: unknown, _query: unknown, labelName: string) =>
|
||||
`legend-${labelName}`,
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getLabelName', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(
|
||||
(_metric: unknown, _queryName: string, _legend: string) => 'baseLabel',
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
() => ({
|
||||
getInitialStackedBands: jest.fn().mockReturnValue([]),
|
||||
}),
|
||||
);
|
||||
|
||||
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
|
||||
.getLegend as jest.Mock;
|
||||
const getLabelNameMock = jest.requireMock('lib/getLabelName')
|
||||
.default as jest.Mock;
|
||||
const getInitialStackedBandsMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
).getInitialStackedBands as jest.Mock;
|
||||
|
||||
const createApiResponse = (
|
||||
result: MetricRangePayloadProps['data']['result'] = [],
|
||||
): MetricRangePayloadProps => ({
|
||||
data: {
|
||||
result,
|
||||
resultType: 'matrix',
|
||||
newResult: null as unknown as MetricRangePayloadV3,
|
||||
},
|
||||
});
|
||||
|
||||
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
|
||||
({
|
||||
id: 'widget-1',
|
||||
yAxisUnit: 'ms',
|
||||
isLogScale: false,
|
||||
thresholds: [],
|
||||
customLegendColors: {},
|
||||
...overrides,
|
||||
}) as Widgets;
|
||||
|
||||
const defaultTimezone = {
|
||||
name: 'UTC',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
};
|
||||
|
||||
describe('BarPanel utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getLabelNameMock.mockReturnValue('baseLabel');
|
||||
getLegendMock.mockImplementation(
|
||||
(_queryData: unknown, _query: unknown, labelName: string) =>
|
||||
`legend-${labelName}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('prepareBarPanelData', () => {
|
||||
it('returns aligned data with timestamps and empty series when result is empty', () => {
|
||||
const data = prepareChartData(createApiResponse([]));
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns timestamps and one series of y values for single series', () => {
|
||||
const data = prepareChartData(
|
||||
createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q',
|
||||
legend: 'Series A',
|
||||
values: [
|
||||
[1000, '10'],
|
||||
[2000, '20'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]),
|
||||
);
|
||||
expect(data).toHaveLength(2);
|
||||
expect(data[0]).toStrictEqual([1000, 2000]);
|
||||
expect(data[1]).toStrictEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('merges timestamps and fills missing values with null for multiple series', () => {
|
||||
const data = prepareChartData(
|
||||
createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[3000, '3'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [
|
||||
[1000, '10'],
|
||||
[2000, '20'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]),
|
||||
);
|
||||
expect(data[0]).toStrictEqual([1000, 2000, 3000]);
|
||||
expect(data[1]).toStrictEqual([1, null, 3]);
|
||||
expect(data[2]).toStrictEqual([10, 20, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareBarPanelConfig', () => {
|
||||
const baseParams = {
|
||||
widget: createWidget(),
|
||||
isDarkMode: true,
|
||||
currentQuery: {} as Query,
|
||||
onClick: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
apiResponse: createApiResponse(),
|
||||
timezone: defaultTimezone,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
};
|
||||
|
||||
it('adds no series when apiResponse has empty result', () => {
|
||||
const config = prepareBarPanelConfig(baseParams).getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds one series per result item', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [[1000, '2']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
const config = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig();
|
||||
expect(config.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
legend: 'L1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
const config = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: {} as Query,
|
||||
}).getConfig();
|
||||
expect(getLegendMock).toHaveBeenCalled();
|
||||
expect(config.series?.[1]).toMatchObject({ label: 'legend-baseLabel' });
|
||||
});
|
||||
|
||||
it('uses getLabelName for label when currentQuery is null', () => {
|
||||
getLegendMock.mockReset();
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: { __name__: 'requests' },
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: null as unknown as Query,
|
||||
});
|
||||
expect(getLabelNameMock).toHaveBeenCalled();
|
||||
expect(getLegendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes result metric to each series for cross-panel sync', () => {
|
||||
const metric = { host: 'server1', __name__: 'http_requests' };
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric,
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
const config = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig();
|
||||
expect(config.series?.[1]).toMatchObject({ metric });
|
||||
});
|
||||
|
||||
it('uses widget customLegendColors for series stroke', () => {
|
||||
const widget = createWidget({
|
||||
customLegendColors: { 'legend-baseLabel': '#ff0000' },
|
||||
});
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
const config = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
widget,
|
||||
apiResponse,
|
||||
}).getConfig();
|
||||
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
|
||||
});
|
||||
|
||||
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
|
||||
const widget = createWidget({ stackedBarChart: true });
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [[1000, '2']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
|
||||
// seriesCount = result.length + 1 = 3
|
||||
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('does not call getInitialStackedBands for non-stacked chart', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, apiResponse });
|
||||
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -303,6 +303,27 @@ describe('TimeSeriesPanel utils', () => {
|
||||
expect(seriesConfig!.stroke).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('passes result metric to each series for cross-panel sync', () => {
|
||||
const metric = { host: 'server1', __name__: 'cpu' };
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric,
|
||||
queryName: 'Q',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[2000, '2'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const config = prepareUPlotConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig();
|
||||
|
||||
expect(config.series?.[1]).toMatchObject({ metric });
|
||||
});
|
||||
|
||||
it('adds multiple series when result has multiple items', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
|
||||
@@ -295,37 +295,6 @@
|
||||
|
||||
.slider-container {
|
||||
width: calc(100% - 16px);
|
||||
|
||||
.ant-slider .ant-slider-mark {
|
||||
margin-top: 12px;
|
||||
|
||||
.ant-slider-mark-text {
|
||||
color: var(--l3-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on,
|
||||
'cpsp' on,
|
||||
'case' on;
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.logs-slider-container {
|
||||
.ant-slider .ant-slider-mark {
|
||||
.ant-slider-mark-text {
|
||||
&:last-child {
|
||||
left: calc(100% - 8px) !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.do-later-container {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Slider } from 'antd';
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ArrowRight, LoaderCircle, Minus } from '@signozhq/icons';
|
||||
@@ -204,23 +204,23 @@ function OptimiseSignozNeeds({
|
||||
<label className="question-slider" htmlFor="organisationName">
|
||||
Logs / Day
|
||||
</label>
|
||||
<div className="slider-container logs-slider-container">
|
||||
<div className="slider-container">
|
||||
<div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={sliderValues.logsPerDay}
|
||||
marks={marks}
|
||||
onChange={(value: number): void =>
|
||||
handleSliderChange('logsPerDay', value)
|
||||
onChange={(value): void =>
|
||||
handleSliderChange('logsPerDay', value as number)
|
||||
}
|
||||
styles={{
|
||||
track: {
|
||||
background: '#4E74F8',
|
||||
range: {
|
||||
backgroundColor: '#4E74F8',
|
||||
},
|
||||
}}
|
||||
tooltip={{
|
||||
formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`, // Show whole number
|
||||
formatter: (): string => `${logsPerDayValue.toLocaleString()} GB`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -238,16 +238,16 @@ function OptimiseSignozNeeds({
|
||||
max={100}
|
||||
value={sliderValues.hostsPerDay}
|
||||
marks={hostMarks}
|
||||
onChange={(value: number): void =>
|
||||
handleSliderChange('hostsPerDay', value)
|
||||
onChange={(value): void =>
|
||||
handleSliderChange('hostsPerDay', value as number)
|
||||
}
|
||||
styles={{
|
||||
track: {
|
||||
background: '#4E74F8',
|
||||
range: {
|
||||
backgroundColor: '#4E74F8',
|
||||
},
|
||||
}}
|
||||
tooltip={{
|
||||
formatter: (): string => `${hostsPerDayValue.toLocaleString()}`, // Show whole number
|
||||
formatter: (): string => `${hostsPerDayValue.toLocaleString()}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -265,16 +265,16 @@ function OptimiseSignozNeeds({
|
||||
max={100}
|
||||
value={sliderValues.services}
|
||||
marks={serviceMarks}
|
||||
onChange={(value: number): void =>
|
||||
handleSliderChange('services', value)
|
||||
onChange={(value): void =>
|
||||
handleSliderChange('services', value as number)
|
||||
}
|
||||
styles={{
|
||||
track: {
|
||||
background: '#4E74F8',
|
||||
range: {
|
||||
backgroundColor: '#4E74F8',
|
||||
},
|
||||
}}
|
||||
tooltip={{
|
||||
formatter: (): string => `${servicesValue.toLocaleString()}`, // Show whole number
|
||||
formatter: (): string => `${servicesValue.toLocaleString()}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert-rule-scope {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ant-radio-wrapper {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-rule-all-warning {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.formItemWithBullet {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { Check, Info } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
FormInstance,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
SelectProps,
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
@@ -70,14 +72,18 @@ const TZ_OPTIONS: DefaultOptionType[] = ALL_TIME_ZONES.map(
|
||||
}),
|
||||
);
|
||||
|
||||
type AlertRuleScope = 'all' | 'specific';
|
||||
|
||||
interface PlannedDowntimeFormData {
|
||||
name: string;
|
||||
startTime: dayjs.Dayjs | null;
|
||||
endTime: dayjs.Dayjs | null;
|
||||
recurrence?: AlertmanagertypesRecurrenceDTO;
|
||||
alertRuleScope: AlertRuleScope;
|
||||
alertRules: DefaultOptionType[];
|
||||
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
|
||||
timezone?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
|
||||
@@ -127,6 +133,12 @@ export function PlannedDowntimeForm(
|
||||
recurrenceOptions.doesNotRepeat.value,
|
||||
);
|
||||
|
||||
const [alertRuleScope, setAlertRuleScope] = useState<AlertRuleScope>(
|
||||
initialValues.id && (initialValues.alertIds || []).length === 0
|
||||
? 'all'
|
||||
: 'specific',
|
||||
);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -140,10 +152,14 @@ export function PlannedDowntimeForm(
|
||||
const saveHandler = useCallback(
|
||||
async (values: PlannedDowntimeFormData) => {
|
||||
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
|
||||
alertIds: values.alertRules
|
||||
.map((alert) => alert.value)
|
||||
.filter((alert) => alert !== undefined) as string[],
|
||||
alertIds:
|
||||
values.alertRuleScope === 'all'
|
||||
? []
|
||||
: (values.alertRules
|
||||
.map((alert) => alert.value)
|
||||
.filter((alert) => alert !== undefined) as string[]),
|
||||
name: values.name,
|
||||
scope: values.scope,
|
||||
schedule: {
|
||||
startTime: values.startTime?.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
@@ -262,12 +278,13 @@ export function PlannedDowntimeForm(
|
||||
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
|
||||
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
|
||||
|
||||
const initialAlertIds = initialValues.alertIds || [];
|
||||
|
||||
return {
|
||||
name: defaultTo(initialValues.name, ''),
|
||||
alertRules: getAlertOptionsFromIds(
|
||||
initialValues.alertIds || [],
|
||||
alertOptions,
|
||||
),
|
||||
alertRuleScope:
|
||||
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
|
||||
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
|
||||
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
|
||||
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
|
||||
recurrence: {
|
||||
@@ -278,11 +295,13 @@ export function PlannedDowntimeForm(
|
||||
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
|
||||
} as AlertmanagertypesRecurrenceDTO,
|
||||
timezone: schedule?.timezone as string,
|
||||
scope: initialValues.scope || '',
|
||||
};
|
||||
}, [initialValues, alertOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTags(formattedInitialValues.alertRules);
|
||||
setAlertRuleScope(formattedInitialValues.alertRuleScope);
|
||||
form.setFieldsValue({ ...formattedInitialValues });
|
||||
}, [form, formattedInitialValues, initialValues]);
|
||||
|
||||
@@ -311,7 +330,7 @@ export function PlannedDowntimeForm(
|
||||
default:
|
||||
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
|
||||
}
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
}, [formData, recurrenceType]);
|
||||
|
||||
const endTimeText = useMemo((): string => {
|
||||
const endTime = formData.endTime;
|
||||
@@ -322,7 +341,7 @@ export function PlannedDowntimeForm(
|
||||
const formattedEndTime = endTime.format(TIME_FORMAT);
|
||||
const formattedEndDate = endTime.format(DATE_FORMAT);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
}, [formData, recurrenceType]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -345,6 +364,7 @@ export function PlannedDowntimeForm(
|
||||
onFinish={onFinish}
|
||||
onValuesChange={(): void => {
|
||||
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
|
||||
setAlertRuleScope(form.getFieldValue('alertRuleScope') as AlertRuleScope);
|
||||
handleFormData(form.getFieldsValue());
|
||||
}}
|
||||
autoComplete="off"
|
||||
@@ -444,50 +464,107 @@ export function PlannedDowntimeForm(
|
||||
<div className="scheduleTimeInfoText">{endTimeText}</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="alert-rule-form">
|
||||
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
|
||||
<Typography style={{ marginBottom: 8 }} className="alert-rule-info">
|
||||
(Leave empty to silence all alerts)
|
||||
</Typography>
|
||||
</div>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
<AlertRuleTags
|
||||
closable
|
||||
selectedTags={selectedTags}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={alertRuleFormName}>
|
||||
<Select
|
||||
placeholder="Search for alerts rules or groups..."
|
||||
mode="multiple"
|
||||
status={isError ? 'error' : undefined}
|
||||
loading={isLoading}
|
||||
tagRender={noTagRenderer}
|
||||
onChange={handleAlertRulesChange}
|
||||
showSearch
|
||||
options={alertOptions}
|
||||
filterOption={(input, option): boolean =>
|
||||
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
isLoading ? (
|
||||
<span>
|
||||
<Spin size="small" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>No alert available.</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{alertOptions?.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
|
||||
<Form.Item
|
||||
name="alertRuleScope"
|
||||
initialValue="specific"
|
||||
className="alert-rule-scope"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="all">All alert rules</Radio>
|
||||
<Radio value="specific">Specific alert rules</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{alertRuleScope === 'specific' && (
|
||||
<>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
<AlertRuleTags
|
||||
closable
|
||||
selectedTags={selectedTags}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={alertRuleFormName}
|
||||
rules={[
|
||||
{
|
||||
validator: async (
|
||||
_rule,
|
||||
value: DefaultOptionType[] | undefined,
|
||||
): Promise<void> => {
|
||||
if (!value || value.length === 0) {
|
||||
throw new Error(
|
||||
'Select at least one alert rule, or choose "All alert rules" to silence everything.',
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Search for alert rules or groups..."
|
||||
mode="multiple"
|
||||
status={isError ? 'error' : undefined}
|
||||
loading={isLoading}
|
||||
tagRender={noTagRenderer}
|
||||
onChange={handleAlertRulesChange}
|
||||
showSearch
|
||||
options={alertOptions}
|
||||
filterOption={(input, option): boolean =>
|
||||
(option?.label as string)
|
||||
?.toLowerCase()
|
||||
?.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
isLoading ? (
|
||||
<span>
|
||||
<Spin size="small" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>No alert available.</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{alertOptions?.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Scope
|
||||
<Tooltip
|
||||
mouseLeaveDelay={0.3}
|
||||
title={
|
||||
<span>
|
||||
Scope the planned downtime by alert labels.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/alerts-management/planned-maintenance/#scoping-with-label-expressions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Info size={13} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="scope"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder='e.g. env = "prod" AND region = "us-east-1"'
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<ModalButtonWrapper>
|
||||
<Button
|
||||
|
||||
@@ -204,7 +204,7 @@ export function CollapseListContent({
|
||||
selectedTags={alertOptions}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
<Tag className="all-alerts-tag">All alert rules</Tag>
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getViewQuery } from '../drilldownUtils';
|
||||
import { AggregateData } from '../useAggregateDrilldown';
|
||||
import useBaseDrilldownNavigate, {
|
||||
buildDrilldownUrl,
|
||||
getRoute,
|
||||
} from '../useBaseDrilldownNavigate';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../drilldownUtils', () => ({
|
||||
...jest.requireActual('../drilldownUtils'),
|
||||
getViewQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetViewQuery = getViewQuery as jest.Mock;
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_QUERY: Query = {
|
||||
id: 'q1',
|
||||
queryType: 'builder' as any,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics' as any,
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
functions: [],
|
||||
legend: '',
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: undefined,
|
||||
orderBy: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
const MOCK_VIEW_QUERY: Query = {
|
||||
...MOCK_QUERY,
|
||||
builder: {
|
||||
...MOCK_QUERY.builder,
|
||||
queryData: [
|
||||
{
|
||||
...MOCK_QUERY.builder.queryData[0],
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_AGGREGATE_DATA: AggregateData = {
|
||||
queryName: 'A',
|
||||
filters: [{ filterKey: 'service_name', filterValue: 'auth', operator: '=' }],
|
||||
timeRange: { startTime: 1000000, endTime: 2000000 },
|
||||
};
|
||||
|
||||
// ─── getRoute ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getRoute', () => {
|
||||
it.each([
|
||||
['view_logs', ROUTES.LOGS_EXPLORER],
|
||||
['view_metrics', ROUTES.METRICS_EXPLORER],
|
||||
['view_traces', ROUTES.TRACES_EXPLORER],
|
||||
])('maps %s to the correct explorer route', (key, expected) => {
|
||||
expect(getRoute(key)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns empty string for an unknown key', () => {
|
||||
expect(getRoute('view_dashboard')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildDrilldownUrl ────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildDrilldownUrl', () => {
|
||||
beforeEach(() => {
|
||||
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns null for an unknown drilldown key', () => {
|
||||
const url = buildDrilldownUrl(
|
||||
MOCK_QUERY,
|
||||
MOCK_AGGREGATE_DATA,
|
||||
'view_dashboard',
|
||||
);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when getViewQuery returns null', () => {
|
||||
mockGetViewQuery.mockReturnValue(null);
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a URL starting with the logs explorer route for view_logs', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).not.toBeNull();
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
});
|
||||
|
||||
it('returns a URL starting with the traces explorer route for view_traces', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_traces');
|
||||
expect(url).toContain(ROUTES.TRACES_EXPLORER);
|
||||
});
|
||||
|
||||
it('includes compositeQuery param in the URL', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('includes startTime and endTime when aggregateData has a timeRange', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).toContain('startTime=1000000');
|
||||
expect(url).toContain('endTime=2000000');
|
||||
});
|
||||
|
||||
it('omits startTime and endTime when aggregateData has no timeRange', () => {
|
||||
const { timeRange: _, ...withoutTimeRange } = MOCK_AGGREGATE_DATA;
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, withoutTimeRange, 'view_logs');
|
||||
expect(url).not.toContain('startTime=');
|
||||
expect(url).not.toContain('endTime=');
|
||||
});
|
||||
|
||||
it('includes summaryFilters param for view_metrics', () => {
|
||||
const url = buildDrilldownUrl(
|
||||
MOCK_QUERY,
|
||||
MOCK_AGGREGATE_DATA,
|
||||
'view_metrics',
|
||||
);
|
||||
expect(url).toContain(ROUTES.METRICS_EXPLORER);
|
||||
expect(url).toContain('summaryFilters=');
|
||||
});
|
||||
|
||||
it('does not include summaryFilters param for non-metrics routes', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(url).not.toContain('summaryFilters=');
|
||||
});
|
||||
|
||||
it('handles null aggregateData by passing empty filters and empty queryName', () => {
|
||||
const url = buildDrilldownUrl(MOCK_QUERY, null, 'view_logs');
|
||||
expect(url).not.toBeNull();
|
||||
expect(mockGetViewQuery).toHaveBeenCalledWith(
|
||||
MOCK_QUERY,
|
||||
[],
|
||||
'view_logs',
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes aggregateData filters and queryName to getViewQuery', () => {
|
||||
buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
|
||||
expect(mockGetViewQuery).toHaveBeenCalledWith(
|
||||
MOCK_QUERY,
|
||||
MOCK_AGGREGATE_DATA.filters,
|
||||
'view_logs',
|
||||
MOCK_AGGREGATE_DATA.queryName,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── useBaseDrilldownNavigate ─────────────────────────────────────────────────
|
||||
|
||||
describe('useBaseDrilldownNavigate', () => {
|
||||
beforeEach(() => {
|
||||
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls safeNavigate with the built URL on a valid key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('opens the explorer in a new tab', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_traces');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(expect.any(String), {
|
||||
newTab: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls callback after successful navigation', () => {
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call safeNavigate for an unknown key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_dashboard');
|
||||
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still calls callback when the key is unknown', () => {
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_dashboard');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still calls callback when getViewQuery returns null', () => {
|
||||
mockGetViewQuery.mockReturnValue(null);
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: MOCK_AGGREGATE_DATA,
|
||||
callback,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current('view_logs');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles null aggregateData without throwing', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBaseDrilldownNavigate({
|
||||
resolvedQuery: MOCK_QUERY,
|
||||
aggregateData: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => result.current('view_logs')).not.toThrow();
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -168,7 +168,7 @@ export const getAggregateColumnHeader = (
|
||||
};
|
||||
};
|
||||
|
||||
const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||
export const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||
Object.keys(metric).map((key) => ({
|
||||
filterKey: key,
|
||||
filterValue: metric[key],
|
||||
|
||||
@@ -2,14 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Link, Loader } from '@signozhq/icons';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import useContextVariables from 'hooks/dashboard/useContextVariables';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||
@@ -18,9 +14,10 @@ import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { ContextMenuItem } from './contextConfig';
|
||||
import { getDataLinks } from './dataLinksUtils';
|
||||
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
|
||||
import { getAggregateColumnHeader } from './drilldownUtils';
|
||||
import { getBaseContextConfig } from './menuOptions';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
import useBaseDrilldownNavigate from './useBaseDrilldownNavigate';
|
||||
|
||||
interface UseBaseAggregateOptionsProps {
|
||||
query: Query;
|
||||
@@ -38,19 +35,6 @@ interface BaseAggregateOptionsConfig {
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
const getRoute = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'view_logs':
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case 'view_metrics':
|
||||
return ROUTES.METRICS_EXPLORER;
|
||||
case 'view_traces':
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const useBaseAggregateOptions = ({
|
||||
query,
|
||||
onClose,
|
||||
@@ -86,8 +70,6 @@ const useBaseAggregateOptions = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, aggregateData, panelType]);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
// Use the new useContextVariables hook
|
||||
const { processedVariables } = useContextVariables({
|
||||
maxValues: 2,
|
||||
@@ -121,50 +103,16 @@ const useBaseAggregateOptions = ({
|
||||
{label}
|
||||
</ContextMenu.Item>
|
||||
));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
|
||||
|
||||
const handleBaseDrilldown = useCallback(
|
||||
(key: string): void => {
|
||||
const route = getRoute(key);
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
const filtersToAdd = aggregateData?.filters || [];
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
filtersToAdd,
|
||||
key,
|
||||
aggregateData?.queryName || '',
|
||||
);
|
||||
|
||||
let queryParams = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange?.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange?.endTime.toString(),
|
||||
}),
|
||||
} as Record<string, string>;
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery?.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (route) {
|
||||
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||
newTab: true,
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
[resolvedQuery, safeNavigate, onClose, aggregateData],
|
||||
);
|
||||
const handleBaseDrilldown = useBaseDrilldownNavigate({
|
||||
resolvedQuery,
|
||||
aggregateData,
|
||||
callback: onClose,
|
||||
});
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback } from 'react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getViewQuery } from './drilldownUtils';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
type DrilldownKey = 'view_logs' | 'view_metrics' | 'view_traces';
|
||||
|
||||
const DRILLDOWN_ROUTE_MAP: Record<DrilldownKey, string> = {
|
||||
view_logs: ROUTES.LOGS_EXPLORER,
|
||||
view_metrics: ROUTES.METRICS_EXPLORER,
|
||||
view_traces: ROUTES.TRACES_EXPLORER,
|
||||
};
|
||||
|
||||
const getRoute = (key: string): string =>
|
||||
DRILLDOWN_ROUTE_MAP[key as DrilldownKey] ?? '';
|
||||
|
||||
interface UseBaseDrilldownNavigateProps {
|
||||
resolvedQuery: Query;
|
||||
aggregateData: AggregateData | null;
|
||||
callback?: () => void;
|
||||
}
|
||||
|
||||
const useBaseDrilldownNavigate = ({
|
||||
resolvedQuery,
|
||||
aggregateData,
|
||||
callback,
|
||||
}: UseBaseDrilldownNavigateProps): ((key: string) => void) => {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return useCallback(
|
||||
(key: string): void => {
|
||||
const route = getRoute(key);
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
aggregateData?.filters ?? [],
|
||||
key,
|
||||
aggregateData?.queryName ?? '',
|
||||
);
|
||||
|
||||
if (!viewQuery || !route) {
|
||||
callback?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
}),
|
||||
};
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||
newTab: true,
|
||||
});
|
||||
|
||||
callback?.();
|
||||
},
|
||||
[resolvedQuery, safeNavigate, callback, aggregateData],
|
||||
);
|
||||
};
|
||||
|
||||
export function buildDrilldownUrl(
|
||||
resolvedQuery: Query,
|
||||
aggregateData: AggregateData | null,
|
||||
key: string,
|
||||
): string | null {
|
||||
const route = getRoute(key);
|
||||
const viewQuery = getViewQuery(
|
||||
resolvedQuery,
|
||||
aggregateData?.filters ?? [],
|
||||
key,
|
||||
aggregateData?.queryName ?? '',
|
||||
);
|
||||
|
||||
if (!viewQuery || !route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
let queryParams: Record<string, string> = {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
}),
|
||||
};
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return `${route}?${createQueryParams(queryParams)}`;
|
||||
}
|
||||
|
||||
export { getRoute };
|
||||
export default useBaseDrilldownNavigate;
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Slider } from 'antd';
|
||||
import type { SliderRangeProps } from 'antd/lib/slider';
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
import getFilters from 'api/trace/getFilters';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -169,16 +168,15 @@ function Duration(): JSX.Element {
|
||||
debouncedFunction(min, max);
|
||||
};
|
||||
|
||||
const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => {
|
||||
const onRangeHandler = (value: number | number[]): void => {
|
||||
const [min, max] = value as number[];
|
||||
updatedUrl(min, max);
|
||||
};
|
||||
|
||||
const TipComponent = useCallback((value: undefined | number) => {
|
||||
if (value === undefined) {
|
||||
return <div />;
|
||||
}
|
||||
return <div>{`${value?.toString()}ms`}</div>;
|
||||
}, []);
|
||||
const TipComponent = useCallback(
|
||||
(value: number) => <div>{`${value.toString()}ms`}</div>,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -210,7 +208,8 @@ function Duration(): JSX.Element {
|
||||
max={Number(getMs(String(preLocalMaxDuration.current || 0)))}
|
||||
range
|
||||
tooltip={{ formatter: TipComponent }}
|
||||
onChange={([min, max]): void => {
|
||||
onChange={(value): void => {
|
||||
const [min, max] = value as number[];
|
||||
onRangeSliderHandler([String(min), String(max)]);
|
||||
}}
|
||||
onAfterChange={onRangeHandler}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { syncCursorRegistry } from '../syncCursorRegistry';
|
||||
|
||||
describe('syncCursorRegistry', () => {
|
||||
describe('metadata', () => {
|
||||
it('returns undefined for unknown key', () => {
|
||||
expect(syncCursorRegistry.getMetadata('unknown-meta')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores and retrieves metadata by syncKey', () => {
|
||||
const metadata = { yAxisUnit: 'ms', groupBy: [] };
|
||||
syncCursorRegistry.setMetadata('meta-key', metadata);
|
||||
expect(syncCursorRegistry.getMetadata('meta-key')).toBe(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeSeriesMetric', () => {
|
||||
it('returns null (not undefined) for unknown key', () => {
|
||||
expect(
|
||||
syncCursorRegistry.getActiveSeriesMetric('unknown-metric'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves metric by syncKey', () => {
|
||||
const metric = { host: 'server1', __name__: 'cpu' };
|
||||
syncCursorRegistry.setActiveSeriesMetric('metric-key', metric);
|
||||
expect(syncCursorRegistry.getActiveSeriesMetric('metric-key')).toBe(metric);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,627 @@
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { syncCursorRegistry } from '../syncCursorRegistry';
|
||||
import { createSyncDisplayHook } from '../syncDisplayHook';
|
||||
import {
|
||||
SyncTooltipFilterMode,
|
||||
type TooltipControllerState,
|
||||
type TooltipSyncMetadata,
|
||||
} from '../types';
|
||||
|
||||
jest.mock('../syncCursorRegistry', () => ({
|
||||
syncCursorRegistry: {
|
||||
setMetadata: jest.fn(),
|
||||
getMetadata: jest.fn(),
|
||||
setActiveSeriesMetric: jest.fn(),
|
||||
getActiveSeriesMetric: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockRegistry = syncCursorRegistry as {
|
||||
setMetadata: jest.Mock;
|
||||
getMetadata: jest.Mock;
|
||||
setActiveSeriesMetric: jest.Mock;
|
||||
getActiveSeriesMetric: jest.Mock;
|
||||
};
|
||||
|
||||
const SYNC_KEY = 'test-sync-key';
|
||||
|
||||
// Builds a single-query groupByPerQuery from a list of dimension keys.
|
||||
const makeGroupByPerQuery = (
|
||||
...keys: string[]
|
||||
): Record<string, BaseAutocompleteData[]> => ({
|
||||
A: keys.map((key) => ({ key, type: 'tag' as const })),
|
||||
});
|
||||
|
||||
function makeUPlotRoot(includeCrosshair = true): HTMLElement {
|
||||
const root = document.createElement('div');
|
||||
if (includeCrosshair) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'u-cursor-y';
|
||||
root.append(el);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
type FakeSeries = { metric?: Record<string, string>; show?: boolean };
|
||||
|
||||
function makeFakeUPlot(opts: {
|
||||
cursorEvent?: MouseEvent | null;
|
||||
cursorLeft?: number;
|
||||
series?: FakeSeries[];
|
||||
includeCrosshair?: boolean;
|
||||
}): uPlot {
|
||||
return {
|
||||
root: makeUPlotRoot(opts.includeCrosshair ?? true),
|
||||
cursor: {
|
||||
event: opts.cursorEvent !== undefined ? opts.cursorEvent : null,
|
||||
left: opts.cursorLeft ?? 50,
|
||||
},
|
||||
series: opts.series ?? [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
],
|
||||
setSeries: jest.fn(),
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function makeController(
|
||||
focusedSeriesIndex: number | null = null,
|
||||
): TooltipControllerState {
|
||||
return {
|
||||
focusedSeriesIndex,
|
||||
syncedSeriesIndexes: null,
|
||||
} as TooltipControllerState;
|
||||
}
|
||||
|
||||
// Convenience cast used throughout assertions.
|
||||
function mockSetSeries(u: uPlot): jest.Mock {
|
||||
return (u as unknown as { setSeries: jest.Mock }).setSeries;
|
||||
}
|
||||
|
||||
function getCrosshair(u: uPlot): HTMLElement {
|
||||
const el = u.root.querySelector<HTMLElement>('.u-cursor-y');
|
||||
if (!el) {
|
||||
throw new Error('crosshair element missing');
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createSyncDisplayHook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── guard ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no crosshair element', () => {
|
||||
it('returns early without calling registry when .u-cursor-y absent', () => {
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
|
||||
const u = makeFakeUPlot({ includeCrosshair: false });
|
||||
hook(u);
|
||||
expect(mockRegistry.setMetadata).not.toHaveBeenCalled();
|
||||
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
|
||||
expect(mockSetSeries(u)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── source panel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('source behavior (cursor.event != null)', () => {
|
||||
it('writes syncMetadata to registry', () => {
|
||||
const syncMetadata: TooltipSyncMetadata = { yAxisUnit: 'ms' };
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, syncMetadata, makeController());
|
||||
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
|
||||
hook(u);
|
||||
expect(mockRegistry.setMetadata).toHaveBeenCalledWith(
|
||||
SYNC_KEY,
|
||||
syncMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
it('writes focused series metric when focusedSeriesIndex is set', () => {
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
];
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController(1));
|
||||
const u = makeFakeUPlot({
|
||||
cursorEvent: new MouseEvent('mousemove'),
|
||||
series,
|
||||
});
|
||||
hook(u);
|
||||
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(SYNC_KEY, {
|
||||
host: 'server1',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes null metric when focusedSeriesIndex is null', () => {
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
undefined,
|
||||
makeController(null),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
|
||||
hook(u);
|
||||
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(
|
||||
SYNC_KEY,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('clears controller.syncedSeriesIndexes', () => {
|
||||
const controller = makeController();
|
||||
controller.syncedSeriesIndexes = [1, 2];
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, undefined, controller);
|
||||
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
});
|
||||
|
||||
it('shows crosshair and does not read from registry', () => {
|
||||
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
|
||||
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
|
||||
hook(u);
|
||||
expect(getCrosshair(u).style.display).toBe('');
|
||||
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── receiver panel ───────────────────────────────────────────────────────
|
||||
|
||||
describe('receiver behavior (cursor.event is null)', () => {
|
||||
describe('crosshair visibility', () => {
|
||||
it('shows crosshair when yAxisUnit matches source', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms' },
|
||||
makeController(),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(getCrosshair(u).style.display).toBe('');
|
||||
});
|
||||
|
||||
it('hides crosshair when yAxisUnit differs from source', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'bytes' });
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms' },
|
||||
makeController(),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(getCrosshair(u).style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
// ── exact groupBy match ───────────────────────────────────────────────
|
||||
|
||||
describe('exact groupBy match', () => {
|
||||
const groupByPerQuery = makeGroupByPerQuery('host');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
];
|
||||
|
||||
it('focuses the matching series and records it on the controller', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server2' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
|
||||
});
|
||||
|
||||
it('unfocuses all and emits empty matches (Filtered) when active metric is null', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('unfocuses all when metric matches no series', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({
|
||||
host: 'unknown-server',
|
||||
});
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('clears syncedSeriesIndexes when cursor is off-plot (left < 0)', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: -1, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
expect(mockRegistry.getActiveSeriesMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never focuses series at index 0 (x-axis)', () => {
|
||||
const sameMetric = { host: 'server1' };
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(sameMetric);
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({
|
||||
cursorEvent: null,
|
||||
cursorLeft: 50,
|
||||
// Index 0 has the same metric — it must always be skipped.
|
||||
series: [{ metric: sameMetric }, { metric: { host: 'other' } }],
|
||||
});
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('skips hidden series (show === false)', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({
|
||||
cursorEvent: null,
|
||||
cursorLeft: 50,
|
||||
series: [
|
||||
{},
|
||||
{ metric: { host: 'server1' }, show: false },
|
||||
{ metric: { host: 'server1' } },
|
||||
],
|
||||
});
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── partial groupBy overlap ───────────────────────────────────────────
|
||||
|
||||
describe('partial groupBy overlap', () => {
|
||||
it('subset — records every receiver series matching on the common key', () => {
|
||||
// Source groupBy=[host], receiver groupBy=[host, service].
|
||||
// Hook focuses the first match; the rest are surfaced via controller.syncedSeriesIndexes.
|
||||
const sourceGroupBy = makeGroupByPerQuery('host');
|
||||
const receiverGroupBy = makeGroupByPerQuery('host', 'service');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1', service: 'api' } },
|
||||
{ metric: { host: 'server1', service: 'frontend' } },
|
||||
{ metric: { host: 'server2', service: 'api' } },
|
||||
];
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: sourceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('superset — records the one receiver series matching on the common key', () => {
|
||||
// Source groupBy=[host, service], receiver groupBy=[host].
|
||||
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
|
||||
const receiverGroupBy = makeGroupByPerQuery('host');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
];
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: sourceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({
|
||||
host: 'server1',
|
||||
service: 'api',
|
||||
});
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
|
||||
});
|
||||
|
||||
it('partial — matches on the intersecting key only', () => {
|
||||
// Source groupBy=[host, service], receiver groupBy=[service, region].
|
||||
// Common key is [service]. Both receiver series with service=api match.
|
||||
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
|
||||
const receiverGroupBy = makeGroupByPerQuery('service', 'region');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { service: 'api', region: 'us-east' } },
|
||||
{ metric: { service: 'api', region: 'eu-west' } },
|
||||
{ metric: { service: 'frontend', region: 'us-east' } },
|
||||
];
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: sourceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({
|
||||
host: 'server1',
|
||||
service: 'api',
|
||||
});
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── union across queries in groupByPerQuery ───────────────────────────
|
||||
|
||||
describe('union across queries', () => {
|
||||
it("treats the panel's effective groupBy as the union across its queries", () => {
|
||||
// Source has query A=[host]; receiver has A=[host], B=[service].
|
||||
// The shared key is `host` — receiver matches on that.
|
||||
const sourceGroupBy: Record<string, BaseAutocompleteData[]> = {
|
||||
A: [{ key: 'host', type: 'tag' }],
|
||||
};
|
||||
const receiverGroupBy: Record<string, BaseAutocompleteData[]> = {
|
||||
A: [{ key: 'host', type: 'tag' }],
|
||||
B: [{ key: 'service', type: 'tag' }],
|
||||
};
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: sourceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({
|
||||
cursorEvent: null,
|
||||
cursorLeft: 50,
|
||||
series: [
|
||||
{},
|
||||
{ metric: { host: 'server1' } },
|
||||
{ metric: { host: 'server2' } },
|
||||
],
|
||||
});
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── no overlap (Filtered mode default) ────────────────────────────────
|
||||
|
||||
describe('no overlap → Filtered mode emits []', () => {
|
||||
it('emits [] when groupBy keys are completely different', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: makeGroupByPerQuery('host'),
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('service') },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('emits [] when receiver groupBy is empty', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: makeGroupByPerQuery('host'),
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: {} },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('emits [] when source groupBy is absent', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('host') },
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── filterMode: All ──────────────────────────────────────────────────
|
||||
|
||||
describe('filterMode All', () => {
|
||||
it('emits null (no filter) when there is no overlap in groupBy', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: makeGroupByPerQuery('host'),
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery: makeGroupByPerQuery('service'),
|
||||
filterMode: SyncTooltipFilterMode.All,
|
||||
},
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
});
|
||||
|
||||
it('emits null when metric matches no series', () => {
|
||||
const groupByPerQuery = makeGroupByPerQuery('host');
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'unknown' });
|
||||
const controller = makeController();
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{
|
||||
yAxisUnit: 'ms',
|
||||
groupByPerQuery,
|
||||
filterMode: SyncTooltipFilterMode.All,
|
||||
},
|
||||
controller,
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50 });
|
||||
hook(u);
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── caching ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('caching optimizations', () => {
|
||||
it('reuses the crosshair element across multiple invocations', () => {
|
||||
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ yAxisUnit: 'ms' },
|
||||
makeController(),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null });
|
||||
const spy = jest.spyOn(u.root, 'querySelector');
|
||||
hook(u);
|
||||
hook(u);
|
||||
hook(u);
|
||||
// querySelector should only be called once regardless of invocation count.
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recomputes common keys when source groupByPerQuery reference changes', () => {
|
||||
const hostGroupBy = makeGroupByPerQuery('host');
|
||||
const serviceGroupBy = makeGroupByPerQuery('service');
|
||||
const series: FakeSeries[] = [
|
||||
{},
|
||||
{ metric: { host: 'server1', service: 'api' } },
|
||||
{ metric: { host: 'server2', service: 'frontend' } },
|
||||
];
|
||||
const hook = createSyncDisplayHook(
|
||||
SYNC_KEY,
|
||||
{ groupByPerQuery: makeGroupByPerQuery('host', 'service') },
|
||||
makeController(),
|
||||
);
|
||||
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
|
||||
|
||||
// First call: source groups by host → matches series 1.
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
groupByPerQuery: hostGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Second call: source now groups by service → matches series 2.
|
||||
mockRegistry.getMetadata.mockReturnValue({
|
||||
groupByPerQuery: serviceGroupBy,
|
||||
});
|
||||
mockRegistry.getActiveSeriesMetric.mockReturnValue({
|
||||
service: 'frontend',
|
||||
});
|
||||
hook(u);
|
||||
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Dock, PanelBottom, PanelRight } from '@signozhq/icons';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
|
||||
import { SpanDetailVariant } from './constants';
|
||||
|
||||
interface DockOption {
|
||||
value: SpanDetailVariant;
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const DOCK_OPTIONS: DockOption[] = [
|
||||
{
|
||||
value: SpanDetailVariant.DIALOG,
|
||||
icon: <Dock size={14} />,
|
||||
tooltip: 'Open as floating panel',
|
||||
},
|
||||
{
|
||||
value: SpanDetailVariant.DOCKED,
|
||||
icon: <PanelBottom size={14} />,
|
||||
tooltip: 'Dock at the bottom',
|
||||
},
|
||||
{
|
||||
value: SpanDetailVariant.DOCKED_RIGHT,
|
||||
icon: <PanelRight size={14} />,
|
||||
tooltip: 'Dock on the right',
|
||||
},
|
||||
];
|
||||
|
||||
interface DockModeSwitcherProps {
|
||||
value: SpanDetailVariant;
|
||||
onChange: (value: SpanDetailVariant) => void;
|
||||
tooltipClassName?: string;
|
||||
}
|
||||
|
||||
function DockModeSwitcher({
|
||||
value,
|
||||
onChange,
|
||||
tooltipClassName,
|
||||
}: DockModeSwitcherProps): JSX.Element {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={value}
|
||||
onChange={(v): void => {
|
||||
if (v) {
|
||||
onChange(v as SpanDetailVariant);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{DOCK_OPTIONS.map((option) => (
|
||||
<TooltipRoot key={option.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={tooltipClassName}>
|
||||
{option.tooltip}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DockModeSwitcher;
|
||||
@@ -3,6 +3,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
:global(.details-header) {
|
||||
height: 39px;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
Bookmark,
|
||||
CalendarClock,
|
||||
ChartColumnBig,
|
||||
Dock,
|
||||
Link2,
|
||||
List,
|
||||
PanelBottom,
|
||||
ScrollText,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
@@ -61,6 +52,7 @@ import {
|
||||
SpanDetailVariant,
|
||||
VISIBLE_ACTIONS,
|
||||
} from './constants';
|
||||
import DockModeSwitcher from './DockModeSwitcher';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import {
|
||||
@@ -492,31 +484,14 @@ function SpanDetailsPanel({
|
||||
];
|
||||
|
||||
if (onVariantChange) {
|
||||
const isDocked = variant === SpanDetailVariant.DOCKED;
|
||||
actions.push({
|
||||
key: 'dock-toggle',
|
||||
key: 'dock-mode',
|
||||
component: (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
onVariantChange(
|
||||
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.dockToggleTooltip}>
|
||||
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
<DockModeSwitcher
|
||||
value={variant}
|
||||
onChange={onVariantChange}
|
||||
tooltipClassName={styles.dockToggleTooltip}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -553,7 +528,10 @@ function SpanDetailsPanel({
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === SpanDetailVariant.DOCKED) {
|
||||
if (
|
||||
variant === SpanDetailVariant.DOCKED ||
|
||||
variant === SpanDetailVariant.DOCKED_RIGHT
|
||||
) {
|
||||
return <div className={styles.root}>{content}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export enum SpanDetailVariant {
|
||||
DRAWER = 'drawer',
|
||||
DIALOG = 'dialog',
|
||||
DOCKED = 'docked',
|
||||
DOCKED_RIGHT = 'right',
|
||||
}
|
||||
|
||||
export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
|
||||
|
||||
@@ -4,13 +4,28 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rightDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Shared Ant Collapse chrome reset for both the flamegraph and waterfall
|
||||
// collapse panels.
|
||||
.flameCollapse,
|
||||
|
||||
@@ -893,7 +893,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
/>
|
||||
{/* Left panel - table with horizontal scroll */}
|
||||
<ResizableBox
|
||||
direction="horizontal"
|
||||
handle="right"
|
||||
defaultWidth={DEFAULT_SIDEBAR_WIDTH}
|
||||
minWidth={MIN_SIDEBAR_WIDTH}
|
||||
maxWidth={MAX_SIDEBAR_WIDTH}
|
||||
|
||||
@@ -242,9 +242,13 @@ function TraceDetailsV3(): JSX.Element {
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DIALOG,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
@@ -291,7 +295,9 @@ function TraceDetailsV3(): JSX.Element {
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
@@ -332,94 +338,118 @@ function TraceDetailsV3(): JSX.Element {
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
</span>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall')
|
||||
? waterfallChildren
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && !isDocked && (
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Input, Slider } from 'antd';
|
||||
import type { SliderRangeProps } from 'antd/es/slider';
|
||||
import { Input } from 'antd';
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
|
||||
@@ -88,16 +88,15 @@ export function DurationSection(props: DurationProps): JSX.Element {
|
||||
debouncedFunction(min, max);
|
||||
};
|
||||
|
||||
const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => {
|
||||
const onRangeHandler = (value: number | number[]): void => {
|
||||
const [min, max] = value as number[];
|
||||
updateDurationFilter(min.toString(), max.toString());
|
||||
};
|
||||
|
||||
const TipComponent = useCallback((value: undefined | number) => {
|
||||
if (value === undefined) {
|
||||
return <div />;
|
||||
}
|
||||
return <div>{`${value?.toString()}ms`}</div>;
|
||||
}, []);
|
||||
const TipComponent = useCallback(
|
||||
(value: number) => <div>{`${value.toString()}ms`}</div>,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -123,13 +122,14 @@ export function DurationSection(props: DurationProps): JSX.Element {
|
||||
addonAfter="ms"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="duration-input-slider">
|
||||
<Slider
|
||||
min={0}
|
||||
max={100000}
|
||||
range
|
||||
tooltip={{ formatter: TipComponent }}
|
||||
onChange={([min, max]): void => {
|
||||
onChange={(value): void => {
|
||||
const [min, max] = value as number[];
|
||||
onRangeSliderHandler([String(min), String(max)]);
|
||||
}}
|
||||
onAfterChange={onRangeHandler}
|
||||
|
||||
@@ -23,20 +23,36 @@
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&--vertical {
|
||||
bottom: 0;
|
||||
&--top,
|
||||
&--bottom {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
&--horizontal {
|
||||
right: 0;
|
||||
&--left,
|
||||
&--right {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
&--top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&--right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@ import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import './ResizableBox.styles.scss';
|
||||
|
||||
export type ResizableBoxHandle = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
export interface ResizableBoxProps {
|
||||
children: React.ReactNode;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
// Which edge the resize handle sits on. The edge determines the axis:
|
||||
// 'top'/'bottom' → vertical resize (height), 'left'/'right' → horizontal
|
||||
// resize (width). Dragging the handle away from the content grows the box;
|
||||
// dragging it toward the content shrinks it.
|
||||
handle?: ResizableBoxHandle;
|
||||
defaultHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
@@ -18,7 +24,7 @@ export interface ResizableBoxProps {
|
||||
|
||||
function ResizableBox({
|
||||
children,
|
||||
direction = 'vertical',
|
||||
handle = 'bottom',
|
||||
defaultHeight = 200,
|
||||
minHeight = 50,
|
||||
maxHeight = Infinity,
|
||||
@@ -29,7 +35,8 @@ function ResizableBox({
|
||||
disabled = false,
|
||||
className,
|
||||
}: ResizableBoxProps): JSX.Element {
|
||||
const isHorizontal = direction === 'horizontal';
|
||||
const isHorizontal = handle === 'left' || handle === 'right';
|
||||
const isStartHandle = handle === 'top' || handle === 'left';
|
||||
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -40,10 +47,13 @@ function ResizableBox({
|
||||
const startSize = size;
|
||||
const min = isHorizontal ? minWidth : minHeight;
|
||||
const max = isHorizontal ? maxWidth : maxHeight;
|
||||
// Start-edge handle: pointer moving away from content (negative delta)
|
||||
// grows the box, so invert the sign.
|
||||
const deltaSign = isStartHandle ? -1 : 1;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent): void => {
|
||||
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
|
||||
const delta = currentPos - startPos;
|
||||
const delta = (currentPos - startPos) * deltaSign;
|
||||
const newSize = Math.min(max, Math.max(min, startSize + delta));
|
||||
setSize(newSize);
|
||||
onResize?.(newSize);
|
||||
@@ -61,7 +71,16 @@ function ResizableBox({
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
|
||||
[
|
||||
size,
|
||||
isHorizontal,
|
||||
isStartHandle,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onResize,
|
||||
],
|
||||
);
|
||||
|
||||
const containerStyle = disabled
|
||||
@@ -69,7 +88,7 @@ function ResizableBox({
|
||||
: isHorizontal
|
||||
? { width: size }
|
||||
: { height: size };
|
||||
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
|
||||
const handleClass = `resizable-box__handle resizable-box__handle--${handle}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -825,4 +825,5 @@ body.ai-assistant-panel-open {
|
||||
// overrides
|
||||
:root {
|
||||
--input-focus-outline-width: 0;
|
||||
--radius-2: 4px;
|
||||
}
|
||||
|
||||
12
frontend/src/types/generated/webSettings.ts
Normal file
12
frontend/src/types/generated/webSettings.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */
|
||||
|
||||
export interface WebSettings {
|
||||
appcues: Appcues;
|
||||
posthog: Posthog;
|
||||
}
|
||||
export interface Appcues {
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface Posthog {
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { compose, Store } from 'redux';
|
||||
import type { WebSettings } from 'types/generated/webSettings';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -7,6 +8,7 @@ declare global {
|
||||
pylon: any;
|
||||
Appcues: Record<string, any>;
|
||||
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose;
|
||||
signozBootData?: { settings: WebSettings | null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
frontend/src/utils/__tests__/bootData.test.ts
Normal file
74
frontend/src/utils/__tests__/bootData.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export {};
|
||||
|
||||
type BootData = typeof import('../bootData');
|
||||
|
||||
function loadModule(settings?: object | null): BootData {
|
||||
(window as any).signozBootData =
|
||||
settings !== undefined ? { settings } : undefined;
|
||||
let mod!: BootData;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../bootData');
|
||||
});
|
||||
return mod;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).signozBootData;
|
||||
});
|
||||
|
||||
describe('when window.signozBootData is absent', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
const { bootSettings } = loadModule();
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData.settings is null (injection failed)', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
const { bootSettings } = loadModule(null);
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData.settings is populated', () => {
|
||||
it('reads posthog enabled: true', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: true } });
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('reads posthog enabled: false', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: false } });
|
||||
expect(bootSettings.posthog.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('reads appcues enabled: true', () => {
|
||||
const { bootSettings } = loadModule({ appcues: { enabled: true } });
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('reads appcues enabled: false', () => {
|
||||
const { bootSettings } = loadModule({ appcues: { enabled: false } });
|
||||
expect(bootSettings.appcues.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('missing sub-namespace defaults to enabled', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: false } });
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData exists but settings is undefined', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
(window as any).signozBootData = {};
|
||||
let mod!: BootData;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../bootData');
|
||||
});
|
||||
expect(mod.bootSettings.posthog.enabled).toBe(true);
|
||||
expect(mod.bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
11
frontend/src/utils/bootData.ts
Normal file
11
frontend/src/utils/bootData.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { WebSettings } from 'types/generated/webSettings';
|
||||
|
||||
const raw = window.signozBootData?.settings as
|
||||
| Partial<WebSettings>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export const bootSettings: Readonly<WebSettings> = {
|
||||
posthog: { enabled: raw?.posthog?.enabled ?? true },
|
||||
appcues: { enabled: raw?.appcues?.enabled ?? true },
|
||||
};
|
||||
@@ -135,4 +135,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
AI_ASSISTANT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
@@ -23,6 +23,20 @@ function devBasePathPlugin(basePath: string): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
function devBootDataPlugin(env: Record<string, string>): Plugin {
|
||||
return {
|
||||
name: 'dev-boot-data',
|
||||
apply: 'serve',
|
||||
transformIndexHtml(html): string {
|
||||
const settings = {
|
||||
posthog: { enabled: env.VITE_POSTHOG_ENABLED !== 'false' },
|
||||
appcues: { enabled: env.VITE_APPCUES_ENABLED !== 'false' },
|
||||
};
|
||||
return html.replaceAll('[[.Settings]]', JSON.stringify(settings));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rawMarkdownPlugin(): Plugin {
|
||||
return {
|
||||
name: 'raw-markdown',
|
||||
@@ -47,6 +61,7 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
tsconfigPaths(),
|
||||
rawMarkdownPlugin(),
|
||||
devBasePathPlugin(basePath),
|
||||
devBootDataPlugin(env),
|
||||
react(),
|
||||
createHtmlPlugin({
|
||||
inject: {
|
||||
|
||||
@@ -23,7 +23,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
htmltemplate "html/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
@@ -34,20 +40,39 @@ import (
|
||||
|
||||
const (
|
||||
Integration = "email"
|
||||
|
||||
// alertEmailLayoutTemplate is the name of the HTML layout template that
|
||||
// wraps the rendered alert bodies. It is loaded into the notification
|
||||
// template (n.tmpl) from the alertmanager templates config and lives at
|
||||
// templates/alertmanager/email.gotmpl.
|
||||
alertEmailLayoutTemplate = "email.signoz.html"
|
||||
)
|
||||
|
||||
// Email implements a Notifier for email notifications.
|
||||
type Email struct {
|
||||
conf *config.EmailConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
hostname string
|
||||
conf *config.EmailConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
hostname string
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// layoutData is the value passed to the email.signoz.html layout
|
||||
// template. It embeds NotificationTemplateData so templates can reference
|
||||
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalResolved`,
|
||||
// `.NotificationTemplateData.ExternalURL`, etc. alongside the rendered
|
||||
// Title and per-alert Bodies.
|
||||
type layoutData struct {
|
||||
alertmanagertypes.NotificationTemplateData
|
||||
Title string
|
||||
Bodies []htmltemplate.HTML
|
||||
}
|
||||
|
||||
var errNoAuthUsernameConfigured = errors.NewInternalf(errors.CodeInternal, "no auth username configured")
|
||||
|
||||
// New returns a new Email notifier.
|
||||
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
|
||||
// New returns a new Email notifier. When the email.signoz.html layout is
|
||||
// not defined in t, custom-body alerts fall back to plain <div>-wrapped HTML.
|
||||
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) *Email {
|
||||
if _, ok := c.Headers["Subject"]; !ok {
|
||||
c.Headers["Subject"] = config.DefaultEmailSubject
|
||||
}
|
||||
@@ -63,7 +88,7 @@ func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
|
||||
if err != nil {
|
||||
h = "localhost.localdomain"
|
||||
}
|
||||
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
|
||||
return &Email{conf: c, tmpl: t, logger: l, hostname: h, templater: templater}
|
||||
}
|
||||
|
||||
// auth resolves a string of authentication mechanisms.
|
||||
@@ -199,9 +224,9 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
|
||||
if ok, mech := c.Extension("AUTH"); ok {
|
||||
auth, err := n.auth(mech)
|
||||
if err != nil && err != errNoAuthUsernameConfigured {
|
||||
if err != nil && !errors.Is(err, errNoAuthUsernameConfigured) {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "find auth mechanism")
|
||||
} else if err == errNoAuthUsernameConfigured {
|
||||
} else if errors.Is(err, errNoAuthUsernameConfigured) {
|
||||
n.logger.DebugContext(ctx, "no auth username configured. Attempting to send email without authenticating")
|
||||
}
|
||||
if auth != nil {
|
||||
@@ -245,6 +270,16 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the content for the email. subject, when non-empty, overrides
|
||||
// the configured Subject header for this notification only. We deliberately
|
||||
// do not mutate n.conf.Headers here: the config map is shared across
|
||||
// concurrent notifications to the same receiver.
|
||||
subject, htmlBody, err := n.prepareContent(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Send the email headers and body.
|
||||
message, err := c.Data()
|
||||
if err != nil {
|
||||
@@ -262,6 +297,10 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
for header, t := range n.conf.Headers {
|
||||
if header == "Subject" {
|
||||
fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", subject))
|
||||
continue
|
||||
}
|
||||
value, err := n.tmpl.ExecuteTextString(t, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute %q header template", header)
|
||||
@@ -336,7 +375,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(n.conf.HTML) > 0 {
|
||||
if htmlBody != "" {
|
||||
// Html template
|
||||
// Preferred alternative placed last per section 5.1.4 of RFC 2046
|
||||
// https://www.ietf.org/rfc/rfc2046.txt
|
||||
@@ -347,12 +386,8 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for html template")
|
||||
}
|
||||
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
|
||||
}
|
||||
qw := quotedprintable.NewWriter(w)
|
||||
_, err = qw.Write([]byte(body))
|
||||
_, err = qw.Write([]byte(htmlBody))
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "write HTML part")
|
||||
}
|
||||
@@ -381,6 +416,124 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// prepareContent returns a subject override (empty when the default config
|
||||
// Subject should be used) and the HTML body for the email. Callers must treat
|
||||
// the subject as local state and never write it back to n.conf.Headers.
|
||||
func (n *Email) prepareContent(ctx context.Context, alerts []*types.Alert) (string, string, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Headers["Subject"],
|
||||
DefaultBodyTemplate: n.conf.HTML,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
subject := result.Title
|
||||
|
||||
if !result.IsDefaultBody {
|
||||
// Custom-body path: render each expanded markdown body to HTML, then
|
||||
// wrap the whole thing in the email.signoz.html layout (or fall
|
||||
// back to plain <div> wrapping when the layout template is not loaded).
|
||||
for i, body := range result.Body {
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
rendered, err := markdownrenderer.RenderHTML(body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
result.Body[i] = rendered
|
||||
}
|
||||
appendRelatedLinkButtons(alerts, result.Body)
|
||||
html, err := n.renderLayout(result)
|
||||
if err != nil {
|
||||
n.logger.WarnContext(ctx, "custom email template rendering failed, falling back to plain <div> wrap", errors.Attr(err))
|
||||
return subject, wrapBodiesAsDivs(result.Body), nil
|
||||
}
|
||||
return subject, html, nil
|
||||
}
|
||||
|
||||
return subject, result.Body[0], nil
|
||||
}
|
||||
|
||||
// renderLayout wraps result in the email.signoz.html HTML layout loaded
|
||||
// into n.tmpl from the alertmanager templates config. Returns an error when the
|
||||
// layout template is not defined (e.g. in tests where no templates are loaded)
|
||||
// so prepareContent can fall back to plain <div> wrapping.
|
||||
func (n *Email) renderLayout(result *alertmanagertypes.ExpandResult) (string, error) {
|
||||
bodies := make([]htmltemplate.HTML, 0, len(result.Body))
|
||||
for _, b := range result.Body {
|
||||
bodies = append(bodies, htmltemplate.HTML(b))
|
||||
}
|
||||
data := layoutData{Title: result.Title, Bodies: bodies}
|
||||
if result.NotificationData != nil {
|
||||
data.NotificationTemplateData = *result.NotificationData
|
||||
}
|
||||
html, err := n.tmpl.ExecuteHTMLString(`{{ template "`+alertEmailLayoutTemplate+`" . }}`, data)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to render email layout")
|
||||
}
|
||||
return html, nil
|
||||
}
|
||||
|
||||
// appendRelatedLinkButtons appends "View Related Logs/Traces" buttons to each
|
||||
// per-alert body when the rule manager attached the corresponding annotation.
|
||||
// bodies is positionally aligned with alerts (see alertmanagertemplate.Prepare);
|
||||
// empty bodies are skipped so we never attach a button to an alert that produced
|
||||
// no visible content.
|
||||
func appendRelatedLinkButtons(alerts []*types.Alert, bodies []string) {
|
||||
for i := range bodies {
|
||||
if i >= len(alerts) || bodies[i] == "" {
|
||||
continue
|
||||
}
|
||||
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
|
||||
bodies[i] += htmlButton("View Related Logs", string(link))
|
||||
}
|
||||
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
|
||||
bodies[i] += htmlButton("View Related Traces", string(link))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wrapBodiesAsDivs(bodies []string) string {
|
||||
var b strings.Builder
|
||||
for _, part := range bodies {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<div>")
|
||||
b.WriteString(part)
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func htmlButton(text, url string) string {
|
||||
return fmt.Sprintf(`
|
||||
<a href="%s" target="_blank" style="text-decoration: none;">
|
||||
<button style="
|
||||
padding: 6px 16px;
|
||||
/* Default System Font */
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
/* Light Theme & Dynamic Background (Solid) */
|
||||
color: #111827;
|
||||
background-color: #f9fafb;
|
||||
/* Static Outline */
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
">
|
||||
%s
|
||||
</button>
|
||||
</a>`, url, text)
|
||||
}
|
||||
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -17,7 +18,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/emersion/go-smtp"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
@@ -42,6 +46,11 @@ const (
|
||||
emailFrom = "alertmanager@example.com"
|
||||
)
|
||||
|
||||
// testTemplater returns a Templater bound to tmpl with a discard logger.
|
||||
func testTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
// email represents an email returned by the MailDev REST API.
|
||||
// See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md.
|
||||
type email struct {
|
||||
@@ -162,7 +171,7 @@ func notifyEmailWithContext(ctx context.Context, t *testing.T, cfg *config.Email
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
email := New(cfg, tmpl, promslog.NewNopLogger())
|
||||
email := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
retry, err := email.Notify(ctx, firingAlert)
|
||||
if err != nil {
|
||||
@@ -706,7 +715,7 @@ func TestEmailRejected(t *testing.T) {
|
||||
tmpl, firingAlert, err := prepare(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
e := New(cfg, tmpl, promslog.NewNopLogger())
|
||||
e := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
// Send the alert to mock SMTP server.
|
||||
retry, err := e.Notify(context.Background(), firingAlert)
|
||||
@@ -1030,6 +1039,135 @@ func TestEmailImplicitTLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
t.Run("default title template; custom body template", func(t *testing.T) {
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL, _ = url.Parse("http://am")
|
||||
|
||||
bodyTpl := "line $labels.instance"
|
||||
a1 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
model.LabelName("instance"): model.LabelValue("one"),
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
|
||||
},
|
||||
},
|
||||
}
|
||||
a2 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
model.LabelName("instance"): model.LabelValue("two"),
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
|
||||
},
|
||||
},
|
||||
}
|
||||
alerts := []*types.Alert{a1, a2}
|
||||
|
||||
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "subj"}}
|
||||
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "subj", subject)
|
||||
require.Equal(t, "<div><p>line one</p>\n</div><div><p>line two</p>\n</div>", htmlBody)
|
||||
})
|
||||
|
||||
t.Run("custom title template; default body HTML template", func(t *testing.T) {
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL, _ = url.Parse("http://am")
|
||||
|
||||
firingAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{},
|
||||
Annotations: model.LabelSet{
|
||||
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
alerts := []*types.Alert{firingAlert}
|
||||
cfg := &config.EmailConfig{
|
||||
Headers: map[string]string{},
|
||||
HTML: "Status: {{ .Status }}",
|
||||
}
|
||||
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Status: firing", htmlBody)
|
||||
require.Equal(t, "fixed from firing", subject)
|
||||
})
|
||||
|
||||
t.Run("default template without HTML", func(t *testing.T) {
|
||||
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "the email subject"}}
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL, _ = url.Parse("http://am")
|
||||
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
firingAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
alerts := []*types.Alert{firingAlert}
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", htmlBody)
|
||||
require.Equal(t, "the email subject", subject)
|
||||
})
|
||||
|
||||
t.Run("custom title template; custom body template", func(t *testing.T) {
|
||||
// Load the email.signoz.html layout into the notification template
|
||||
// the same way the alertmanager server does via the templates config.
|
||||
tmpl, err := template.FromGlobs([]string{"../../../../templates/alertmanager/*.gotmpl"})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL, _ = url.Parse("http://am")
|
||||
|
||||
firingAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
model.LabelName("instance"): model.LabelValue("two"),
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
|
||||
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue("line $labels.instance"),
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
alerts := []*types.Alert{firingAlert}
|
||||
cfg := &config.EmailConfig{
|
||||
Headers: map[string]string{"Subject": "subject"},
|
||||
HTML: "Well, what are you?",
|
||||
}
|
||||
|
||||
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl))
|
||||
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, htmlBody, "<!DOCTYPE html>")
|
||||
require.Contains(t, htmlBody, "<p>line two</p>")
|
||||
require.NotContains(t, htmlBody, "Well, what are you?")
|
||||
require.Equal(t, subject, "fixed from firing")
|
||||
require.NotContains(t, subject, "subject")
|
||||
})
|
||||
}
|
||||
|
||||
func ptrTo(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
@@ -44,6 +46,7 @@ type Notifier struct {
|
||||
retrier *notify.Retrier
|
||||
webhookURL *config.SecretURL
|
||||
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1#adaptivecarditemschema
|
||||
@@ -52,7 +55,7 @@ type Content struct {
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
Body []Body `json:"body"`
|
||||
Msteams Msteams `json:"msteams,omitempty"`
|
||||
Msteams Msteams `json:"msteams,omitzero"`
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
@@ -94,7 +97,7 @@ type teamsMessage struct {
|
||||
}
|
||||
|
||||
// New returns a new notifier that uses the Microsoft Teams Power Platform connector.
|
||||
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, 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
|
||||
@@ -109,6 +112,7 @@ func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *s
|
||||
retrier: ¬ify.Retrier{},
|
||||
webhookURL: c.WebhookURL,
|
||||
postJSONFunc: notify.PostJSON,
|
||||
templater: templater,
|
||||
}
|
||||
|
||||
return n, nil
|
||||
@@ -128,25 +132,11 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
return false, err
|
||||
}
|
||||
|
||||
title := tmpl(n.conf.Title)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
titleLink := tmpl(n.titleLink)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
alerts := types.Alerts(as...)
|
||||
color := colorGrey
|
||||
switch alerts.Status() {
|
||||
case model.AlertFiring:
|
||||
color = colorRed
|
||||
case model.AlertResolved:
|
||||
color = colorGreen
|
||||
}
|
||||
|
||||
var url string
|
||||
if n.conf.WebhookURL != nil {
|
||||
url = n.conf.WebhookURL.String()
|
||||
@@ -158,6 +148,12 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
url = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
bodyBlocks, err := n.prepareContent(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
// A message as referenced in https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1%2Cdotnet#request-body-schema
|
||||
t := teamsMessage{
|
||||
Type: "message",
|
||||
@@ -169,17 +165,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
Type: "AdaptiveCard",
|
||||
Version: "1.2",
|
||||
Body: []Body{
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: title,
|
||||
Weight: "Bolder",
|
||||
Size: "Medium",
|
||||
Wrap: true,
|
||||
Style: "heading",
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
Body: bodyBlocks,
|
||||
Actions: []Action{
|
||||
{
|
||||
Type: "Action.OpenUrl",
|
||||
@@ -195,20 +181,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
},
|
||||
}
|
||||
|
||||
// add labels and annotations to the body of all alerts
|
||||
for _, alert := range as {
|
||||
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, Body{
|
||||
Type: "TextBlock",
|
||||
Text: "Alerts",
|
||||
Weight: "Bolder",
|
||||
Size: "Medium",
|
||||
Wrap: true,
|
||||
Color: color,
|
||||
})
|
||||
|
||||
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, n.createLabelsAndAnnotationsBody(alert)...)
|
||||
}
|
||||
|
||||
var payload bytes.Buffer
|
||||
if err = json.NewEncoder(&payload).Encode(t); err != nil {
|
||||
return false, err
|
||||
@@ -228,6 +200,75 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
return shouldRetry, err
|
||||
}
|
||||
|
||||
// prepareContent builds the Adaptive Card body blocks for the notification.
|
||||
// The first block is always the title; the remainder depends on whether the
|
||||
// alerts carried a custom body template.
|
||||
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) ([]Body, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Title,
|
||||
DefaultBodyTemplate: n.conf.Text,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
color := colorGrey
|
||||
switch types.Alerts(alerts...).Status() {
|
||||
case model.AlertFiring:
|
||||
color = colorRed
|
||||
case model.AlertResolved:
|
||||
color = colorGreen
|
||||
}
|
||||
|
||||
blocks := []Body{{
|
||||
Type: "TextBlock",
|
||||
Text: result.Title,
|
||||
Weight: "Bolder",
|
||||
Size: "Medium",
|
||||
Wrap: true,
|
||||
Style: "heading",
|
||||
Color: color,
|
||||
}}
|
||||
|
||||
if result.IsDefaultBody {
|
||||
for _, alert := range alerts {
|
||||
blocks = append(blocks, Body{
|
||||
Type: "TextBlock",
|
||||
Text: "Alerts",
|
||||
Weight: "Bolder",
|
||||
Size: "Medium",
|
||||
Wrap: true,
|
||||
Color: color,
|
||||
})
|
||||
blocks = append(blocks, n.createLabelsAndAnnotationsBody(alert)...)
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// Custom body path: result.Body is positionally aligned with alerts;
|
||||
// entries for alerts whose template rendered empty are kept as "" so we
|
||||
// can skip them here without shifting the per-alert color index.
|
||||
for i, body := range result.Body {
|
||||
if body == "" || i >= len(alerts) {
|
||||
continue
|
||||
}
|
||||
perAlertColor := colorRed
|
||||
if alerts[i].Resolved() {
|
||||
perAlertColor = colorGreen
|
||||
}
|
||||
blocks = append(blocks, Body{
|
||||
Type: "TextBlock",
|
||||
Text: body,
|
||||
Wrap: true,
|
||||
Color: perAlertColor,
|
||||
})
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
|
||||
bodies := []Body{}
|
||||
bodies = append(bodies, Body{
|
||||
@@ -258,7 +299,8 @@ func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
|
||||
|
||||
annotationsFacts := []Fact{}
|
||||
for k, v := range alert.Annotations {
|
||||
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) {
|
||||
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) ||
|
||||
alertmanagertypes.IsPrivateAnnotation(string(k)) {
|
||||
continue
|
||||
}
|
||||
annotationsFacts = append(annotationsFacts, Fact{Title: string(k), Value: string(v)})
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -15,6 +16,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -23,21 +27,28 @@ import (
|
||||
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
// This is a test URL that has been modified to not be valid.
|
||||
var testWebhookURL, _ = url.Parse("https://example.westeurope.logic.azure.com:443/workflows/xxx/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=xxx")
|
||||
|
||||
func TestMSTeamsV2Retry(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: testWebhookURL},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -64,14 +75,16 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: testWebhookURL},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -153,7 +166,8 @@ func TestMSTeamsV2Templating(t *testing.T) {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
tc.cfg.WebhookURL = &config.SecretURL{URL: u}
|
||||
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
|
||||
pd, err := New(tc.cfg, test.CreateTmpl(t), tc.titleLink, promslog.NewNopLogger())
|
||||
tmpl := test.CreateTmpl(t)
|
||||
pd, err := New(tc.cfg, tmpl, tc.titleLink, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -186,20 +200,124 @@ func TestMSTeamsV2RedactedURL(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
secret := "secret"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
t.Run("default template - firing alerts", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: testWebhookURL},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
Title: "Alertname: {{ .CommonLabels.alertname }}",
|
||||
},
|
||||
tmpl,
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "test"},
|
||||
// Custom body template
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "Firing alert: $alertname",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
blocks, err := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blocks)
|
||||
// First block should be the title with color (firing = red)
|
||||
require.Equal(t, "Bolder", blocks[0].Weight)
|
||||
require.Equal(t, colorRed, blocks[0].Color)
|
||||
// verify title text
|
||||
require.Equal(t, "Alertname: test", blocks[0].Text)
|
||||
// verify body text
|
||||
require.Equal(t, "Firing alert: test", blocks[1].Text)
|
||||
})
|
||||
|
||||
t.Run("custom template - per-alert color", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURL: &config.SecretURL{URL: testWebhookURL},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "test1"},
|
||||
Annotations: model.LabelSet{
|
||||
"summary": "test",
|
||||
ruletypes.AnnotationTitleTemplate: "Custom Title",
|
||||
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "test2"},
|
||||
Annotations: model.LabelSet{
|
||||
"summary": "test",
|
||||
ruletypes.AnnotationTitleTemplate: "Custom Title",
|
||||
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
|
||||
},
|
||||
StartsAt: time.Now().Add(-time.Hour),
|
||||
EndsAt: time.Now().Add(-time.Minute),
|
||||
},
|
||||
},
|
||||
}
|
||||
blocks, err := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blocks)
|
||||
// total 3 blocks: title and 2 body blocks
|
||||
require.True(t, len(blocks) == 3)
|
||||
// First block: title color is overall color of the alerts
|
||||
require.Equal(t, colorRed, blocks[0].Color)
|
||||
// verify title text
|
||||
require.Equal(t, "Custom Title", blocks[0].Text)
|
||||
// Body blocks should have per-alert color
|
||||
require.Equal(t, colorRed, blocks[1].Color) // firing
|
||||
require.Equal(t, colorGreen, blocks[2].Color) // resolved
|
||||
})
|
||||
}
|
||||
|
||||
func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
@@ -209,14 +327,16 @@ func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(u.String() + "\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.MSTeamsV2Config{
|
||||
WebhookURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
`{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
@@ -34,25 +37,27 @@ const maxMessageLenRunes = 130
|
||||
|
||||
// Notifier implements a Notifier for OpsGenie notifications.
|
||||
type Notifier struct {
|
||||
conf *config.OpsGenieConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
conf *config.OpsGenieConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// New returns a new OpsGenie notifier.
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -123,6 +128,55 @@ func safeSplit(s, sep string) []string {
|
||||
return b
|
||||
}
|
||||
|
||||
// prepareContent expands alert templates and returns the OpsGenie-ready title
|
||||
// (truncated to the 130-rune limit) and HTML description. Custom bodies are
|
||||
// rendered to HTML and stitched together with <hr> dividers; default bodies
|
||||
// are joined with newlines (OpsGenie's legacy plain-text description).
|
||||
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (string, 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 description string
|
||||
if result.IsDefaultBody {
|
||||
description = strings.Join(result.Body, "\n")
|
||||
} else {
|
||||
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)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
return title, description, 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)
|
||||
@@ -168,9 +222,10 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
default:
|
||||
message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "Truncated message", slog.Any("alert", key), slog.Int("max_runes", maxMessageLenRunes))
|
||||
message, description, err := n.prepareContent(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
createEndpointURL := n.conf.APIURL.Copy()
|
||||
@@ -209,7 +264,7 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
|
||||
msg := &opsGenieCreateMessage{
|
||||
Alias: alias,
|
||||
Message: message,
|
||||
Description: tmpl(n.conf.Description),
|
||||
Description: description,
|
||||
Details: details,
|
||||
Source: tmpl(n.conf.Source),
|
||||
Responders: responders,
|
||||
|
||||
@@ -8,12 +8,16 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -22,16 +26,23 @@ import (
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
func TestOpsGenieRetry(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -47,14 +58,16 @@ func TestOpsGenieRedactedURL(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
key := "key"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
APIURL: &config.URL{URL: u},
|
||||
APIKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -72,14 +85,16 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(key)
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
APIURL: &config.URL{URL: u},
|
||||
APIKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -202,7 +217,7 @@ func TestOpsGenie(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
notifier, err := New(tc.cfg, tmpl, logger)
|
||||
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -278,7 +293,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
@@ -321,7 +336,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
|
||||
require.NoError(t, err)
|
||||
requests, _, err := notifierWithUpdate.createRequests(ctx)
|
||||
@@ -329,6 +344,99 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
t.Run("default template", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
logger := promslog.NewNopLogger()
|
||||
|
||||
notifier := &Notifier{
|
||||
conf: &config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: logger,
|
||||
templater: newTestTemplater(tmpl),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"Message": "Firing alert: test",
|
||||
"Description": "Check runbook for more details",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
alerts := []*types.Alert{alert}
|
||||
|
||||
title, desc, prepErr := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, prepErr)
|
||||
require.Equal(t, "Firing alert: test", title)
|
||||
require.Equal(t, "Check runbook for more details", desc)
|
||||
})
|
||||
|
||||
t.Run("custom template", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
logger := promslog.NewNopLogger()
|
||||
|
||||
notifier := &Notifier{
|
||||
conf: &config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: logger,
|
||||
templater: newTestTemplater(tmpl),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alert1 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"service": "payment",
|
||||
"namespace": "potter-the-harry",
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
|
||||
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
alert2 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"service": "payment",
|
||||
"namespace": "smart-the-rat",
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
|
||||
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
alerts := []*types.Alert{alert1, alert2}
|
||||
|
||||
title, desc, err := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "High request throughput for payment", title)
|
||||
// Each alert body wrapped in <div>, separated by <hr>
|
||||
require.Equal(t, "<div><p>Alert firing in NS: potter-the-harry</p>\n</div><hr><div><p>Alert firing in NS: smart-the-rat</p>\n</div>", desc)
|
||||
})
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/alecthomas/units"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
@@ -40,21 +42,22 @@ const (
|
||||
|
||||
// Notifier implements a Notifier for PagerDuty notifications.
|
||||
type Notifier struct {
|
||||
conf *config.PagerdutyConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
apiV1 string // for tests.
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
conf *config.PagerdutyConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
apiV1 string // for tests.
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// New returns a new PagerDuty notifier.
|
||||
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(c *config.PagerdutyConfig, 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
|
||||
}
|
||||
n := &Notifier{conf: c, tmpl: t, logger: l, client: client}
|
||||
n := &Notifier{conf: c, tmpl: t, logger: l, client: client, templater: templater}
|
||||
if c.ServiceKey != "" || c.ServiceKeyFile != "" {
|
||||
n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
|
||||
// Retrying can solve the issue on 403 (rate limiting) and 5xx response codes.
|
||||
@@ -143,11 +146,12 @@ func (n *Notifier) notifyV1(
|
||||
key notify.Key,
|
||||
data *template.Data,
|
||||
details map[string]any,
|
||||
title string,
|
||||
) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
|
||||
description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes)
|
||||
description, truncated := notify.TruncateInRunes(title, maxV1DescriptionLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated description", slog.Any("key", key), slog.Int("max_runes", maxV1DescriptionLenRunes))
|
||||
}
|
||||
@@ -203,6 +207,7 @@ func (n *Notifier) notifyV2(
|
||||
key notify.Key,
|
||||
data *template.Data,
|
||||
details map[string]any,
|
||||
title string,
|
||||
) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
@@ -211,7 +216,7 @@ func (n *Notifier) notifyV2(
|
||||
n.conf.Severity = "error"
|
||||
}
|
||||
|
||||
summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes)
|
||||
summary, truncated := notify.TruncateInRunes(title, maxV2SummaryLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated summary", slog.Any("key", key), slog.Int("max_runes", maxV2SummaryLenRunes))
|
||||
}
|
||||
@@ -294,6 +299,22 @@ func (n *Notifier) notifyV2(
|
||||
return retry, err
|
||||
}
|
||||
|
||||
// prepareTitle expands the notification title. PagerDuty has no body surface
|
||||
// we care about — the description/summary field is what users see as the
|
||||
// incident headline, so we feed the configured Description as the default
|
||||
// title template and ignore any custom body_template entirely.
|
||||
func (n *Notifier) prepareTitle(ctx context.Context, alerts []*types.Alert) (string, error) {
|
||||
customTitle, _ := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
DefaultTitleTemplate: n.conf.Description,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Title, nil
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
@@ -302,6 +323,12 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
}
|
||||
logger := n.logger.With(slog.Any("group_key", key))
|
||||
|
||||
title, err := n.prepareTitle(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
var (
|
||||
alerts = types.Alerts(as...)
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
@@ -329,7 +356,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
if n.apiV1 != "" {
|
||||
nf = n.notifyV1
|
||||
}
|
||||
retry, err := nf(ctx, eventType, key, data, details)
|
||||
retry, err := nf(ctx, eventType, key, data, details, title)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = errors.WrapInternalf(err, errors.CodeInternal, "failed to notify PagerDuty: %v", context.Cause(ctx))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -17,7 +18,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -30,14 +34,20 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
func TestPagerDutyRetryV1(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -49,13 +59,15 @@ func TestPagerDutyRetryV1(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPagerDutyRetryV2(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -71,13 +83,15 @@ func TestPagerDutyRedactedURLV1(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
key := "01234567890123456789012345678901"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.apiV1 = u.String()
|
||||
@@ -90,14 +104,16 @@ func TestPagerDutyRedactedURLV2(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
key := "01234567890123456789012345678901"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
URL: &config.URL{URL: u},
|
||||
RoutingKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -114,13 +130,15 @@ func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.apiV1 = u.String()
|
||||
@@ -138,14 +156,16 @@ func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
URL: &config.URL{URL: u},
|
||||
RoutingKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -302,7 +322,8 @@ func TestPagerDutyTemplating(t *testing.T) {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
tc.cfg.URL = &config.URL{URL: u}
|
||||
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
|
||||
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
tmpl := test.CreateTmpl(t)
|
||||
pd, err := New(tc.cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
if pd.apiV1 != "" {
|
||||
pd.apiV1 = u.String()
|
||||
@@ -392,13 +413,15 @@ func TestEventSizeEnforcement(t *testing.T) {
|
||||
Details: bigDetailsV1,
|
||||
}
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifierV1, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -420,8 +443,9 @@ func TestEventSizeEnforcement(t *testing.T) {
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -536,7 +560,8 @@ func TestPagerDutyEmptySrcHref(t *testing.T) {
|
||||
Links: links,
|
||||
}
|
||||
|
||||
pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
pdTmpl := test.CreateTmpl(t)
|
||||
pagerDuty, err := New(&pagerDutyConfig, pdTmpl, promslog.NewNopLogger(), newTestTemplater(pdTmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -603,7 +628,8 @@ func TestPagerDutyTimeout(t *testing.T) {
|
||||
Timeout: tt.timeout,
|
||||
}
|
||||
|
||||
pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
tmpl := test.CreateTmpl(t)
|
||||
pd, err := New(&cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -881,3 +907,79 @@ func TestRenderDetails(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
prepareContext := func() context.Context {
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
ctx = notify.WithReceiverName(ctx, "test-receiver")
|
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "HighCPU for Payment service"})
|
||||
return ctx
|
||||
}
|
||||
t.Run("default template uses go text template config for title", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
Description: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
|
||||
},
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := prepareContext()
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "HighCPU for Payment service"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
title, err := notifier.prepareTitle(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HighCPU for Payment service (FIRING)", title)
|
||||
})
|
||||
|
||||
t.Run("custom template uses $variable annotation for title", func(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := prepareContext()
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"alertname": "HighCPU",
|
||||
"service": "api-server",
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "$rule.name on $service is in $alert.status state",
|
||||
},
|
||||
StartsAt: time.Now().Add(-time.Hour),
|
||||
EndsAt: time.Now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
title, err := notifier.prepareTitle(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HighCPU on api-server is in resolved state", title)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ var customNotifierIntegrations = []string{
|
||||
msteamsv2.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger) ([]notify.Integration, error) {
|
||||
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -53,23 +53,25 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
|
||||
}
|
||||
|
||||
for i, c := range nc.WebhookConfigs {
|
||||
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) })
|
||||
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l, templater) })
|
||||
}
|
||||
for i, c := range nc.EmailConfigs {
|
||||
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil })
|
||||
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return email.New(c, tmpl, l, templater), nil
|
||||
})
|
||||
}
|
||||
for i, c := range nc.PagerdutyConfigs {
|
||||
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) })
|
||||
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) })
|
||||
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) })
|
||||
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
|
||||
}
|
||||
for i, c := range nc.MSTeamsV2Configs {
|
||||
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
|
||||
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l, templater)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
@@ -25,6 +29,8 @@ import (
|
||||
|
||||
const (
|
||||
Integration = "slack"
|
||||
colorRed = "#FF0000"
|
||||
colorGreen = "#00FF00"
|
||||
)
|
||||
|
||||
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
|
||||
@@ -32,17 +38,18 @@ const maxTitleLenRunes = 1024
|
||||
|
||||
// Notifier implements a Notifier for Slack notifications.
|
||||
type Notifier struct {
|
||||
conf *config.SlackConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
conf *config.SlackConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
|
||||
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
|
||||
}
|
||||
|
||||
// New returns a new Slack notification handler.
|
||||
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(c *config.SlackConfig, 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
|
||||
@@ -54,6 +61,7 @@ func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts .
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{},
|
||||
templater: templater,
|
||||
postJSONFunc: notify.PostJSON,
|
||||
}, nil
|
||||
}
|
||||
@@ -81,9 +89,10 @@ type attachment struct {
|
||||
Actions []config.SlackAction `json:"actions,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
Footer string `json:"footer"`
|
||||
Footer string `json:"footer,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
|
||||
Blocks []any `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
@@ -100,79 +109,15 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
tmplText = notify.TmplText(n.tmpl, data, &err)
|
||||
)
|
||||
var markdownIn []string
|
||||
|
||||
if len(n.conf.MrkdwnIn) == 0 {
|
||||
markdownIn = []string{"fallback", "pretext", "text"}
|
||||
} else {
|
||||
markdownIn = n.conf.MrkdwnIn
|
||||
attachments, err := n.prepareContent(ctx, as, tmplText)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
|
||||
}
|
||||
att := &attachment{
|
||||
Title: title,
|
||||
TitleLink: tmplText(n.conf.TitleLink),
|
||||
Pretext: tmplText(n.conf.Pretext),
|
||||
Text: tmplText(n.conf.Text),
|
||||
Fallback: tmplText(n.conf.Fallback),
|
||||
CallbackID: tmplText(n.conf.CallbackID),
|
||||
ImageURL: tmplText(n.conf.ImageURL),
|
||||
ThumbURL: tmplText(n.conf.ThumbURL),
|
||||
Footer: tmplText(n.conf.Footer),
|
||||
Color: tmplText(n.conf.Color),
|
||||
MrkdwnIn: markdownIn,
|
||||
}
|
||||
|
||||
numFields := len(n.conf.Fields)
|
||||
if numFields > 0 {
|
||||
fields := make([]config.SlackField, numFields)
|
||||
for index, field := range n.conf.Fields {
|
||||
// Check if short was defined for the field otherwise fallback to the global setting
|
||||
var short bool
|
||||
if field.Short != nil {
|
||||
short = *field.Short
|
||||
} else {
|
||||
short = n.conf.ShortFields
|
||||
}
|
||||
|
||||
// Rebuild the field by executing any templates and setting the new value for short
|
||||
fields[index] = config.SlackField{
|
||||
Title: tmplText(field.Title),
|
||||
Value: tmplText(field.Value),
|
||||
Short: &short,
|
||||
}
|
||||
}
|
||||
att.Fields = fields
|
||||
}
|
||||
|
||||
numActions := len(n.conf.Actions)
|
||||
if numActions > 0 {
|
||||
actions := make([]config.SlackAction, numActions)
|
||||
for index, action := range n.conf.Actions {
|
||||
slackAction := config.SlackAction{
|
||||
Type: tmplText(action.Type),
|
||||
Text: tmplText(action.Text),
|
||||
URL: tmplText(action.URL),
|
||||
Style: tmplText(action.Style),
|
||||
Name: tmplText(action.Name),
|
||||
Value: tmplText(action.Value),
|
||||
}
|
||||
|
||||
if action.ConfirmField != nil {
|
||||
slackAction.ConfirmField = &config.SlackConfirmationField{
|
||||
Title: tmplText(action.ConfirmField.Title),
|
||||
Text: tmplText(action.ConfirmField.Text),
|
||||
OkText: tmplText(action.ConfirmField.OkText),
|
||||
DismissText: tmplText(action.ConfirmField.DismissText),
|
||||
}
|
||||
}
|
||||
|
||||
actions[index] = slackAction
|
||||
}
|
||||
att.Actions = actions
|
||||
if len(attachments) > 0 {
|
||||
n.addFieldsAndActions(&attachments[0], tmplText)
|
||||
}
|
||||
|
||||
req := &request{
|
||||
@@ -182,7 +127,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
IconURL: tmplText(n.conf.IconURL),
|
||||
LinkNames: n.conf.LinkNames,
|
||||
Text: tmplText(n.conf.MessageText),
|
||||
Attachments: []attachment{*att},
|
||||
Attachments: attachments,
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -238,6 +183,150 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
return retry, nil
|
||||
}
|
||||
|
||||
// prepareContent expands alert templates and returns the Slack attachment(s)
|
||||
// ready to send. When alerts carry a custom body template, one title-only
|
||||
// attachment plus one body attachment per alert is returned so that each alert
|
||||
// can get its own firing/resolved color and per-alert action buttons.
|
||||
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert, tmplText func(string) string) ([]attachment, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Title,
|
||||
DefaultBodyTemplate: n.conf.Text,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(result.Title, maxTitleLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
|
||||
}
|
||||
|
||||
if result.IsDefaultBody {
|
||||
var markdownIn []string
|
||||
if len(n.conf.MrkdwnIn) == 0 {
|
||||
markdownIn = []string{"fallback", "pretext", "text"}
|
||||
} else {
|
||||
markdownIn = n.conf.MrkdwnIn
|
||||
}
|
||||
return []attachment{
|
||||
{
|
||||
Title: title,
|
||||
TitleLink: tmplText(n.conf.TitleLink),
|
||||
Pretext: tmplText(n.conf.Pretext),
|
||||
Text: result.Body[0],
|
||||
Fallback: tmplText(n.conf.Fallback),
|
||||
CallbackID: tmplText(n.conf.CallbackID),
|
||||
ImageURL: tmplText(n.conf.ImageURL),
|
||||
ThumbURL: tmplText(n.conf.ThumbURL),
|
||||
Footer: tmplText(n.conf.Footer),
|
||||
Color: tmplText(n.conf.Color),
|
||||
MrkdwnIn: markdownIn,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Custom template path: one title attachment + one attachment per
|
||||
// non-empty alert body. result.Body is positionally aligned with alerts,
|
||||
// so we index alerts[i] directly and skip empty entries.
|
||||
attachments := make([]attachment, 0, 1+len(result.Body))
|
||||
attachments = append(attachments, attachment{
|
||||
Title: title,
|
||||
TitleLink: tmplText(n.conf.TitleLink),
|
||||
})
|
||||
|
||||
for i, body := range result.Body {
|
||||
if body == "" || i >= len(alerts) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Custom bodies are authored in markdown; render each non-empty body to
|
||||
// Slack's mrkdwn flavour. Default bodies skip this because the Text
|
||||
// template is already channel-ready.
|
||||
rendered, renderErr := markdownrenderer.RenderSlackMrkdwn(body)
|
||||
if renderErr != nil {
|
||||
return nil, renderErr
|
||||
}
|
||||
|
||||
color := colorRed
|
||||
if alerts[i].Resolved() {
|
||||
color = colorGreen
|
||||
}
|
||||
attachments = append(attachments, attachment{
|
||||
Text: rendered,
|
||||
Color: color,
|
||||
MrkdwnIn: []string{"text"},
|
||||
Actions: buildRelatedLinkActions(alerts[i]),
|
||||
})
|
||||
}
|
||||
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
// buildRelatedLinkActions returns the "View Related Logs/Traces" action
|
||||
// buttons for an alert, or nil when no related-link annotations are present.
|
||||
func buildRelatedLinkActions(alert *types.Alert) []config.SlackAction {
|
||||
var actions []config.SlackAction
|
||||
if link := alert.Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
|
||||
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Logs", URL: string(link)})
|
||||
}
|
||||
if link := alert.Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
|
||||
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Traces", URL: string(link)})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// addFieldsAndActions populates fields and actions on the attachment from the Slack config.
|
||||
func (n *Notifier) addFieldsAndActions(att *attachment, tmplText func(string) string) {
|
||||
numFields := len(n.conf.Fields)
|
||||
if numFields > 0 {
|
||||
fields := make([]config.SlackField, numFields)
|
||||
for index, field := range n.conf.Fields {
|
||||
var short bool
|
||||
if field.Short != nil {
|
||||
short = *field.Short
|
||||
} else {
|
||||
short = n.conf.ShortFields
|
||||
}
|
||||
fields[index] = config.SlackField{
|
||||
Title: tmplText(field.Title),
|
||||
Value: tmplText(field.Value),
|
||||
Short: &short,
|
||||
}
|
||||
}
|
||||
att.Fields = fields
|
||||
}
|
||||
|
||||
numActions := len(n.conf.Actions)
|
||||
if numActions > 0 {
|
||||
actions := make([]config.SlackAction, numActions)
|
||||
for index, action := range n.conf.Actions {
|
||||
slackAction := config.SlackAction{
|
||||
Type: tmplText(action.Type),
|
||||
Text: tmplText(action.Text),
|
||||
URL: tmplText(action.URL),
|
||||
Style: tmplText(action.Style),
|
||||
Name: tmplText(action.Name),
|
||||
Value: tmplText(action.Value),
|
||||
}
|
||||
|
||||
if action.ConfirmField != nil {
|
||||
slackAction.ConfirmField = &config.SlackConfirmationField{
|
||||
Title: tmplText(action.ConfirmField.Title),
|
||||
Text: tmplText(action.ConfirmField.Text),
|
||||
OkText: tmplText(action.ConfirmField.OkText),
|
||||
DismissText: tmplText(action.ConfirmField.DismissText),
|
||||
}
|
||||
}
|
||||
|
||||
actions[index] = slackAction
|
||||
}
|
||||
att.Actions = actions
|
||||
}
|
||||
}
|
||||
|
||||
// checkResponseError parses out the error message from Slack API response.
|
||||
func checkResponseError(resp *http.Response) (bool, error) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -29,13 +32,19 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
|
||||
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
func TestSlackRetry(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -49,13 +58,15 @@ func TestSlackRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURL: &config.SecretURL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -71,13 +82,15 @@ func TestGettingSlackURLFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(u.String())
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -93,13 +106,15 @@ func TestTrimmingSlackURLFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(u.String() + "\n\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -184,6 +199,7 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
apiurl, _ := url.Parse("https://slack.com/post.Message")
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
NotifierConfig: config.NotifierConfig{},
|
||||
@@ -191,8 +207,9 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
APIURL: &config.SecretURL{URL: apiurl},
|
||||
Channel: "channelname",
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -242,6 +259,7 @@ func TestSlackTimeout(t *testing.T) {
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
u, _ := url.Parse("https://slack.com/post.Message")
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
NotifierConfig: config.NotifierConfig{},
|
||||
@@ -250,8 +268,9 @@ func TestSlackTimeout(t *testing.T) {
|
||||
Channel: "channelname",
|
||||
Timeout: tt.timeout,
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
|
||||
@@ -282,6 +301,225 @@ func TestSlackTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestContext creates a context with group key, receiver name, and group labels
|
||||
// required by the notification processor.
|
||||
func setupTestContext() context.Context {
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "test-group")
|
||||
ctx = notify.WithReceiverName(ctx, "slack")
|
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{
|
||||
"alertname": "TestAlert",
|
||||
"severity": "critical",
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestPrepareContent(t *testing.T) {
|
||||
t.Run("default template uses go text template config for title and body", func(t *testing.T) {
|
||||
// When alerts have no custom annotation templates (title_template / body_template),
|
||||
tmpl := test.CreateTmpl(t)
|
||||
templater := newTestTemplater(tmpl)
|
||||
notifier := &Notifier{
|
||||
conf: &config.SlackConfig{
|
||||
Title: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
|
||||
Text: `{{ range .Alerts }}Alert: {{ .Labels.alertname }} - severity {{ .Labels.severity }}{{ end }}`,
|
||||
Color: `{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}`,
|
||||
TitleLink: "https://alertmanager.signoz.com",
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
templater: templater,
|
||||
}
|
||||
|
||||
ctx := setupTestContext()
|
||||
alerts := []*types.Alert{
|
||||
{Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
}},
|
||||
}
|
||||
|
||||
// Build tmplText the same way Notify does
|
||||
var err error
|
||||
data := notify.GetTemplateData(ctx, tmpl, alerts, slog.New(slog.DiscardHandler))
|
||||
tmplText := notify.TmplText(tmpl, data, &err)
|
||||
|
||||
atts, attErr := notifier.prepareContent(ctx, alerts, tmplText)
|
||||
require.NoError(t, attErr)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, atts, 1)
|
||||
|
||||
require.Equal(t, "HighCPU (FIRING)", atts[0].Title)
|
||||
require.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
|
||||
// Color is templated — firing alert should be "danger"
|
||||
require.Equal(t, "danger", atts[0].Color)
|
||||
// No BlockKit blocks for default template
|
||||
require.Nil(t, atts[0].Blocks)
|
||||
// Default markdownIn when config has none
|
||||
require.Equal(t, []string{"fallback", "pretext", "text"}, atts[0].MrkdwnIn)
|
||||
})
|
||||
|
||||
t.Run("custom template produces 1+N attachments with per-alert color", func(t *testing.T) {
|
||||
// When alerts carry custom $variable annotation templates (title_template / body_template)
|
||||
tmpl := test.CreateTmpl(t)
|
||||
templater := newTestTemplater(tmpl)
|
||||
notifier := &Notifier{
|
||||
conf: &config.SlackConfig{
|
||||
Title: "default title fallback",
|
||||
Text: "default text fallback",
|
||||
TitleLink: "https://alertmanager.signoz.com",
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
templater: templater,
|
||||
}
|
||||
tmplText := func(s string) string { return s }
|
||||
|
||||
bodyTemplate := `## $rule.name
|
||||
|
||||
**Service:** *$labels.service*
|
||||
**Instance:** *$labels.instance*
|
||||
**Region:** *$labels.region*
|
||||
**Method:** *$labels.http_method*
|
||||
|
||||
---
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Current** | *$value* |
|
||||
| **Threshold** | *$threshold.value* |
|
||||
|
||||
**Status:** $alert.status | **Severity:** $labels.severity`
|
||||
titleTemplate := "[$alert.status] $rule.name — $labels.service"
|
||||
|
||||
ctx := setupTestContext()
|
||||
firingAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
"value": "100",
|
||||
"threshold.value": "200",
|
||||
},
|
||||
},
|
||||
}
|
||||
resolvedAlert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
|
||||
StartsAt: time.Now().Add(-2 * time.Hour),
|
||||
EndsAt: time.Now().Add(-time.Hour),
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
"value": "50",
|
||||
"threshold.value": "200",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
atts, err := notifier.prepareContent(ctx, []*types.Alert{firingAlert, resolvedAlert}, tmplText)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 1 title attachment + 2 body attachments (one per alert)
|
||||
require.Len(t, atts, 3)
|
||||
|
||||
// First attachment: title-only, no color, no blocks
|
||||
require.Equal(t, "[firing] HighCPU — api-server", atts[0].Title)
|
||||
require.Empty(t, atts[0].Color)
|
||||
require.Nil(t, atts[0].Blocks)
|
||||
require.Equal(t, "https://alertmanager.signoz.com", atts[0].TitleLink)
|
||||
|
||||
expectedFiringBody := "*HighCPU*\n\n" +
|
||||
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
|
||||
"---\n\n" +
|
||||
"```\nMetric | Value\n----------|------\nCurrent | 100 \nThreshold | 200 \n```\n\n" +
|
||||
"*Status:* firing | *Severity:* critical\n\n"
|
||||
|
||||
expectedResolvedBody := "*HighCPU*\n\n" +
|
||||
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
|
||||
"---\n\n" +
|
||||
"```\nMetric | Value\n----------|------\nCurrent | 50 \nThreshold | 200 \n```\n\n" +
|
||||
"*Status:* resolved | *Severity:* critical\n\n"
|
||||
|
||||
// Second attachment: firing alert body rendered as slack mrkdwn text, red color
|
||||
require.Nil(t, atts[1].Blocks)
|
||||
require.Equal(t, "#FF0000", atts[1].Color)
|
||||
require.Equal(t, []string{"text"}, atts[1].MrkdwnIn)
|
||||
require.Equal(t, expectedFiringBody, atts[1].Text)
|
||||
|
||||
// Third attachment: resolved alert body rendered as slack mrkdwn text, green color
|
||||
require.Nil(t, atts[2].Blocks)
|
||||
require.Equal(t, "#00FF00", atts[2].Color)
|
||||
require.Equal(t, []string{"text"}, atts[2].MrkdwnIn)
|
||||
require.Equal(t, expectedResolvedBody, atts[2].Text)
|
||||
})
|
||||
|
||||
t.Run("default template with fields and actions", func(t *testing.T) {
|
||||
// Verifies that addFieldsAndActions (called from Notify after prepareContent)
|
||||
// correctly populates fields and actions on the attachment from config.
|
||||
tmpl := test.CreateTmpl(t)
|
||||
templater := newTestTemplater(tmpl)
|
||||
short := true
|
||||
notifier := &Notifier{
|
||||
conf: &config.SlackConfig{
|
||||
Title: `{{ .CommonLabels.alertname }}`,
|
||||
Text: "alert text",
|
||||
Color: "warning",
|
||||
Fields: []*config.SlackField{
|
||||
{Title: "Severity", Value: "critical", Short: &short},
|
||||
{Title: "Service", Value: "api-server", Short: &short},
|
||||
},
|
||||
Actions: []*config.SlackAction{
|
||||
{Type: "button", Text: "View Alert", URL: "https://alertmanager.signoz.com"},
|
||||
},
|
||||
TitleLink: "https://alertmanager.signoz.com",
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
templater: templater,
|
||||
}
|
||||
tmplText := func(s string) string { return s }
|
||||
|
||||
ctx := setupTestContext()
|
||||
alerts := []*types.Alert{
|
||||
{Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "TestAlert"},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
}},
|
||||
}
|
||||
atts, err := notifier.prepareContent(ctx, alerts, tmplText)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, atts, 1)
|
||||
|
||||
// prepareContent does not populate fields/actions — that's done by
|
||||
// addFieldsAndActions which is called from Notify.
|
||||
require.Nil(t, atts[0].Fields)
|
||||
require.Nil(t, atts[0].Actions)
|
||||
|
||||
// Simulate what Notify does after prepareContent
|
||||
notifier.addFieldsAndActions(&atts[0], tmplText)
|
||||
|
||||
// Verify fields
|
||||
require.Len(t, atts[0].Fields, 2)
|
||||
require.Equal(t, "Severity", atts[0].Fields[0].Title)
|
||||
require.Equal(t, "critical", atts[0].Fields[0].Value)
|
||||
require.True(t, *atts[0].Fields[0].Short)
|
||||
require.Equal(t, "Service", atts[0].Fields[1].Title)
|
||||
require.Equal(t, "api-server", atts[0].Fields[1].Value)
|
||||
|
||||
// Verify actions
|
||||
require.Len(t, atts[0].Actions, 1)
|
||||
require.Equal(t, "button", atts[0].Actions[0].Type)
|
||||
require.Equal(t, "View Alert", atts[0].Actions[0].Text)
|
||||
require.Equal(t, "https://alertmanager.signoz.com", atts[0].Actions[0].URL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlackMessageField(t *testing.T) {
|
||||
// 1. Setup a fake Slack server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -329,7 +567,7 @@ func TestSlackMessageField(t *testing.T) {
|
||||
tmpl.ExternalURL = u
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
notifier, err := New(conf, tmpl, logger)
|
||||
notifier, err := New(conf, tmpl, logger, newTestTemplater(tmpl))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
@@ -28,15 +29,16 @@ const (
|
||||
|
||||
// Notifier implements a Notifier for generic webhooks.
|
||||
type Notifier struct {
|
||||
conf *config.WebhookConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
conf *config.WebhookConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
// New returns a new Webhook.
|
||||
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -48,7 +50,8 @@ func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpO
|
||||
client: client,
|
||||
// Webhooks are assumed to respond with 2xx response codes on a successful
|
||||
// request and 5xx response codes are assumed to be recoverable.
|
||||
retrier: ¬ify.Retrier{},
|
||||
retrier: ¬ify.Retrier{},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
@@ -27,13 +29,15 @@ import (
|
||||
)
|
||||
|
||||
func TestWebhookRetry(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL("http://example.com"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
alertmanagertemplate.New(tmpl, slog.Default()),
|
||||
)
|
||||
if err != nil {
|
||||
require.NoError(t, err)
|
||||
@@ -96,13 +100,16 @@ func TestWebhookRedactedURL(t *testing.T) {
|
||||
defer fn()
|
||||
|
||||
secret := "secret"
|
||||
tmpl := test.CreateTmpl(t)
|
||||
templater := alertmanagertemplate.New(tmpl, slog.Default())
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL(u.String()),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
templater,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -118,13 +125,15 @@ func TestWebhookReadingURLFromFile(t *testing.T) {
|
||||
_, err = f.WriteString(u.String() + "\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
alertmanagertemplate.New(tmpl, slog.Default()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -178,13 +187,15 @@ func TestWebhookURLTemplating(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calledURL = "" // Reset for each test
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL(tc.url),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
alertmanagertemplate.New(tmpl, slog.Default()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ type Config struct {
|
||||
|
||||
// Configuration for the notification log.
|
||||
NFLog NFLogConfig `mapstructure:"nflog"`
|
||||
|
||||
// Templates is the list of globs from which SigNoz's alertmanager notification
|
||||
// templates are loaded (e.g. the email.signoz.html layout). This mirrors the
|
||||
// upstream alertmanager `templates` config option (https://github.com/prometheus/alertmanager/blob/3b06b97af4d146e141af92885a185891eb79a5b0/config/config.go#L412).
|
||||
// The upstream default templates (default.tmpl, email.tmpl) are always loaded
|
||||
// from the embedded alertmanager assets, so only SigNoz's own templates are listed here.
|
||||
Templates []string `mapstructure:"templates"`
|
||||
}
|
||||
|
||||
type AlertsConfig struct {
|
||||
@@ -100,5 +107,6 @@ func NewConfig() Config {
|
||||
MaintenanceInterval: 15 * time.Minute,
|
||||
Retention: 120 * time.Hour,
|
||||
},
|
||||
Templates: []string{"/root/templates/alertmanager/*.gotmpl"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,14 @@ func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool
|
||||
}
|
||||
now := time.Now()
|
||||
for _, mw := range m.getMaintenances(ctx) {
|
||||
if mw.ShouldSkip(ruleID, now) {
|
||||
skip, err := mw.ShouldSkip(ruleID, now, lset)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
|
||||
slog.String("maintenance_id", mw.ID.StringValue()),
|
||||
slog.String("scope", mw.Scope),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if skip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -61,7 +68,14 @@ func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []s
|
||||
var ids []string
|
||||
now := time.Now()
|
||||
for _, mw := range m.getMaintenances(ctx) {
|
||||
if mw.ShouldSkip(ruleID, now) {
|
||||
skip, err := mw.ShouldSkip(ruleID, now, lset)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
|
||||
slog.String("maintenance_id", mw.ID.StringValue()),
|
||||
slog.String("scope", mw.Scope),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if skip {
|
||||
ids = append(ids, mw.ID.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/dispatch"
|
||||
"github.com/prometheus/alertmanager/featurecontrol"
|
||||
"github.com/prometheus/alertmanager/inhibit"
|
||||
@@ -23,8 +24,8 @@ import (
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
)
|
||||
|
||||
@@ -65,6 +66,7 @@ type Server struct {
|
||||
muter *MaintenanceMuter
|
||||
marker *types.MemMarker
|
||||
tmpl *template.Template
|
||||
templater alertmanagertypes.Templater
|
||||
wg sync.WaitGroup
|
||||
stopc chan struct{}
|
||||
notificationManager nfmanager.NotificationManager
|
||||
@@ -242,13 +244,21 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
config := alertmanagerConfig.AlertmanagerConfig()
|
||||
|
||||
var err error
|
||||
server.tmpl, err = alertmanagertypes.FromGlobs(config.Templates)
|
||||
// Load SigNoz's alertmanager notification templates from the configured
|
||||
// globs. The upstream default templates (default.tmpl, email.tmpl) are
|
||||
// always loaded from the embedded alertmanager assets inside FromGlobs, so
|
||||
// only SigNoz's own templates (e.g. the email.signoz.html layout) are listed
|
||||
// here. The upstream config.Templates field is not used: SigNoz never
|
||||
// populates it (there is no per-org template configuration).
|
||||
server.tmpl, err = alertmanagertypes.FromGlobs(server.srvConfig.Templates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server.tmpl.ExternalURL = server.srvConfig.ExternalURL
|
||||
|
||||
server.templater = alertmanagertemplate.New(server.tmpl, server.logger)
|
||||
|
||||
// Build the routing tree and record which receivers are used.
|
||||
routes := dispatch.NewRoute(config.Route, nil)
|
||||
activeReceivers := make(map[string]struct{})
|
||||
@@ -265,7 +275,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
|
||||
continue
|
||||
}
|
||||
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger)
|
||||
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger, server.templater)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -342,7 +352,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
|
||||
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
|
||||
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
|
||||
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, testAlert.Labels, testAlert)
|
||||
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, server.templater, testAlert.Labels, testAlert)
|
||||
}
|
||||
|
||||
func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmanagertypes.PostableAlert][]string, config *alertmanagertypes.NotificationConfig) error {
|
||||
@@ -425,6 +435,7 @@ func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmana
|
||||
server.alertmanagerConfig,
|
||||
server.tmpl,
|
||||
server.logger,
|
||||
server.templater,
|
||||
group.groupLabels,
|
||||
group.alerts...,
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Expression: `ruleId == "high-cpu-usage" && severity == "critical"`,
|
||||
Expression: `ruleId = "high-cpu-usage" AND severity = "critical"`,
|
||||
ExpressionKind: alertmanagertypes.RuleBasedExpression,
|
||||
Name: "high-cpu-usage",
|
||||
Description: "High CPU critical alerts to webhook",
|
||||
@@ -53,7 +53,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Expression: `ruleId == "high-cpu-usage" && severity == "warning"`,
|
||||
Expression: `ruleId = "high-cpu-usage" AND severity = "warning"`,
|
||||
ExpressionKind: alertmanagertypes.RuleBasedExpression,
|
||||
Name: "high-cpu-usage",
|
||||
Description: "High CPU warning alerts to webhook",
|
||||
@@ -87,18 +87,25 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
err = notificationManager.SetNotificationConfig(orgID, "high-cpu-usage", ¬ifConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
mwID := valuer.GenerateUUID()
|
||||
activeSchedule := &alertmanagertypes.Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
}
|
||||
// mwRuleIDAndScope: only critical high-cpu-usage alerts.
|
||||
mwRuleIDAndScope := valuer.GenerateUUID()
|
||||
// mwRuleIDOnly: all high-cpu-usage alerts regardless of severity.
|
||||
mwRuleIDOnly := valuer.GenerateUUID()
|
||||
// mwScopeOnly: all critical alerts regardless of rule ID.
|
||||
mwScopeOnly := valuer.GenerateUUID()
|
||||
|
||||
maintenanceStore := alertmanagertypestest.NewMockMaintenanceStore(t)
|
||||
maintenanceStore.On("ListPlannedMaintenance", mock.Anything, orgID).Return(
|
||||
[]*alertmanagertypes.PlannedMaintenance{{
|
||||
ID: mwID,
|
||||
Schedule: &alertmanagertypes.Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
},
|
||||
RuleIDs: []string{"high-cpu-usage"},
|
||||
}}, nil,
|
||||
[]*alertmanagertypes.PlannedMaintenance{
|
||||
{ID: mwRuleIDAndScope, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}, Scope: `severity = "critical"`},
|
||||
{ID: mwRuleIDOnly, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}},
|
||||
{ID: mwScopeOnly, Schedule: activeSchedule, Scope: `severity = "critical"`},
|
||||
}, nil,
|
||||
)
|
||||
|
||||
srvCfg := NewConfig()
|
||||
@@ -249,18 +256,42 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
|
||||
})
|
||||
|
||||
t.Run("verify_muting", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
|
||||
require.NoError(t, err)
|
||||
params, err := alertmanagertypes.NewGettableAlertsParams(req)
|
||||
require.NoError(t, err)
|
||||
alerts, err := server.GetAlerts(ctx, params)
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
|
||||
require.NoError(t, err)
|
||||
params, err := alertmanagertypes.NewGettableAlertsParams(req)
|
||||
require.NoError(t, err)
|
||||
alerts, err := server.GetAlerts(ctx, params)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("verify_muting_ruleid_and_scope", func(t *testing.T) {
|
||||
// Window with ruleID + scope mutes only alerts matching both.
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["ruleId"] == "high-cpu-usage" && alert.Labels["severity"] == "critical" {
|
||||
require.Contains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
|
||||
} else {
|
||||
require.NotContains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify_muting_ruleid_only", func(t *testing.T) {
|
||||
// Window with ruleID but no scope mutes all severities for that rule.
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["ruleId"] == "high-cpu-usage" {
|
||||
require.Equal(t, []string{mwID.String()}, alert.Status.MutedBy)
|
||||
require.Contains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
|
||||
} else {
|
||||
require.Empty(t, alert.Status.MutedBy)
|
||||
require.NotContains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify_muting_scope_only", func(t *testing.T) {
|
||||
// Window with scope but no ruleIDs mutes all critical alerts regardless of rule.
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["severity"] == "critical" {
|
||||
require.Contains(t, alert.Status.MutedBy, mwScopeOnly.String())
|
||||
} else {
|
||||
require.NotContains(t, alert.Status.MutedBy, mwScopeOnly.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,6 +89,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
Scope: maintenance.Scope,
|
||||
}
|
||||
|
||||
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
|
||||
@@ -123,7 +124,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
NewInsert().
|
||||
Model(&maintenanceRules).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,6 +141,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
Description: storablePlannedMaintenance.Description,
|
||||
Schedule: storablePlannedMaintenance.Schedule,
|
||||
RuleIDs: maintenance.AlertIds,
|
||||
Scope: maintenance.Scope,
|
||||
CreatedAt: storablePlannedMaintenance.CreatedAt,
|
||||
CreatedBy: storablePlannedMaintenance.CreatedBy,
|
||||
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
|
||||
@@ -189,6 +190,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
Scope: maintenance.Scope,
|
||||
}
|
||||
|
||||
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
|
||||
@@ -224,7 +226,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
|
||||
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -241,7 +242,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -15,13 +15,6 @@ import (
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
// Templater expands user-authored title and body templates against a group
|
||||
// of alerts and returns channel-ready strings along with the aggregate data
|
||||
// a caller might reuse (e.g. to render an email layout around the body).
|
||||
type Templater interface {
|
||||
Expand(ctx context.Context, req alertmanagertypes.ExpandRequest, alerts []*types.Alert) (*alertmanagertypes.ExpandResult, error)
|
||||
}
|
||||
|
||||
type templater struct {
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
@@ -29,7 +22,7 @@ type templater struct {
|
||||
|
||||
// New returns a Templater bound to the given Prometheus alertmanager
|
||||
// template and logger.
|
||||
func New(tmpl *template.Template, logger *slog.Logger) Templater {
|
||||
func New(tmpl *template.Template, logger *slog.Logger) alertmanagertypes.Templater {
|
||||
return &templater{tmpl: tmpl, logger: logger}
|
||||
}
|
||||
|
||||
@@ -137,6 +130,9 @@ func (at *templater) expandTitle(
|
||||
}
|
||||
|
||||
// expandBody expands the body template for each individual alert. Returns nil if the template is empty.
|
||||
// Non-nil results are positionally aligned with ntd.Alerts: sb[i] corresponds to alerts[i], and
|
||||
// entries for alerts whose template expands to empty are kept as "" so callers can index per-alert
|
||||
// metadata (related links, firing/resolved color) by the same index.
|
||||
func (at *templater) expandBody(
|
||||
bodyTemplate string,
|
||||
ntd *alertmanagertypes.NotificationTemplateData,
|
||||
@@ -144,7 +140,7 @@ func (at *templater) expandBody(
|
||||
if bodyTemplate == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var sb []string
|
||||
sb := make([]string, len(ntd.Alerts))
|
||||
missingVars := make(map[string]bool)
|
||||
for i := range ntd.Alerts {
|
||||
processRes, err := preProcessTemplateAndData(bodyTemplate, &ntd.Alerts[i])
|
||||
@@ -155,13 +151,10 @@ func (at *templater) expandBody(
|
||||
if err != nil {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom body template: %s", err.Error())
|
||||
}
|
||||
// add unknown variables and templated text to the result
|
||||
for k := range processRes.UnknownVars {
|
||||
missingVars[k] = true
|
||||
}
|
||||
if strings.TrimSpace(part) != "" {
|
||||
sb = append(sb, strings.TrimSpace(part))
|
||||
}
|
||||
sb[i] = strings.TrimSpace(part)
|
||||
}
|
||||
return sb, missingVars, nil
|
||||
}
|
||||
@@ -189,17 +182,20 @@ func (at *templater) buildNotificationTemplateData(
|
||||
externalURL = at.tmpl.ExternalURL.String()
|
||||
}
|
||||
|
||||
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
|
||||
// Raw (including private `_*`) kv first so buildRuleInfo can read the
|
||||
// private rule annotations. The filtered copies are what ends up
|
||||
// on the template-visible surfaces.
|
||||
rawCommonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
|
||||
commonLabels := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
|
||||
|
||||
// aggregate labels and annotations from all alerts
|
||||
labels := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
|
||||
annotations := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
|
||||
|
||||
// Strip private annotations from surfaces visible to templates or
|
||||
// notifications; the structured fields on AlertInfo/RuleInfo already hold
|
||||
// anything a template needs from them.
|
||||
commonAnnotations = alertmanagertypes.FilterPublicAnnotations(commonAnnotations)
|
||||
// Strip private annotations from template-visible surfaces; the structured
|
||||
// fields on AlertInfo/RuleInfo already hold anything a template needs from
|
||||
// them.
|
||||
commonAnnotations := alertmanagertypes.FilterPublicAnnotations(rawCommonAnnotations)
|
||||
annotations = alertmanagertypes.FilterPublicAnnotations(annotations)
|
||||
|
||||
// build the alert data slice
|
||||
@@ -233,7 +229,7 @@ func (at *templater) buildNotificationTemplateData(
|
||||
TotalFiring: firing,
|
||||
TotalResolved: resolved,
|
||||
},
|
||||
Rule: buildRuleInfo(commonLabels, commonAnnotations),
|
||||
Rule: buildRuleInfo(commonLabels, rawCommonAnnotations),
|
||||
GroupLabels: gl,
|
||||
CommonLabels: commonLabels,
|
||||
CommonAnnotations: commonAnnotations,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
// testSetup returns an AlertTemplater and a context pre-populated with group key,
|
||||
// receiver name, and group labels for use in tests.
|
||||
func testSetup(t *testing.T) (Templater, context.Context) {
|
||||
func testSetup(t *testing.T) (alertmanagertypes.Templater, context.Context) {
|
||||
t.Helper()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -3,7 +3,6 @@ package rulebasednotification
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
@@ -235,65 +234,13 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
|
||||
return matchedChannels, nil
|
||||
}
|
||||
|
||||
// convertLabelSetToEnv converts a flat label set with dotted keys into a nested map structure for expr env.
|
||||
// when both a leaf and a deeper nested path exist (e.g. "foo" and "foo.bar"),
|
||||
// the nested structure takes precedence. That means we will replace an existing leaf at any
|
||||
// intermediate path with a map so we can materialize the deeper structure.
|
||||
// TODO(srikanthccv): we need a better solution to handle this, remove the following
|
||||
// when we update the expr to support dotted keys.
|
||||
// convertLabelSetToEnv delegates to alertmanagertypes.ConvertLabelSetToEnv and
|
||||
// logs when a key is a prefix of another (e.g. "foo" alongside "foo.bar").
|
||||
func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.LabelSet) map[string]interface{} {
|
||||
env := make(map[string]interface{})
|
||||
|
||||
logForReview := false
|
||||
|
||||
for lk, lv := range labelSet {
|
||||
key := strings.TrimSpace(string(lk))
|
||||
value := string(lv)
|
||||
|
||||
if strings.Contains(key, ".") {
|
||||
parts := strings.Split(key, ".")
|
||||
current := env
|
||||
|
||||
for i, raw := range parts {
|
||||
part := strings.TrimSpace(raw)
|
||||
|
||||
last := i == len(parts)-1
|
||||
if last {
|
||||
if _, isMap := current[part].(map[string]interface{}); isMap {
|
||||
logForReview = true
|
||||
// deeper structure already exists; do not overwrite.
|
||||
break
|
||||
}
|
||||
current[part] = value
|
||||
break
|
||||
}
|
||||
|
||||
// ensure a map so we can keep descending.
|
||||
if nextMap, ok := current[part].(map[string]interface{}); ok {
|
||||
current = nextMap
|
||||
continue
|
||||
}
|
||||
|
||||
// if absent or a leaf, replace it with a map.
|
||||
newMap := make(map[string]interface{})
|
||||
current[part] = newMap
|
||||
current = newMap
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// if a map already sits here (due to nested keys), keep the map (nested wins).
|
||||
if _, isMap := env[key].(map[string]interface{}); isMap {
|
||||
logForReview = true
|
||||
continue
|
||||
}
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
if logForReview {
|
||||
env, conflict := alertmanagertypes.ConvertLabelSetToEnv(labelSet)
|
||||
if conflict {
|
||||
r.settings.Logger().InfoContext(ctx, "found label set with conflicting prefix dotted keys", slog.Any("labels", labelSet))
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -925,72 +925,3 @@ func TestProvider_CreateRoutes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLabelSetToEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labelSet model.LabelSet
|
||||
expected map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "simple keys",
|
||||
labelSet: model.LabelSet{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested keys",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar": "value1",
|
||||
"foo.baz": "value2",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "value1",
|
||||
"baz": "value2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conflict - nested structure wins",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar.baz": "deep",
|
||||
"foo.bar": "shallow",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": map[string]interface{}{
|
||||
"baz": "deep",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conflict - leaf value vs nested",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar": "value",
|
||||
"foo": "should_be_ignored",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := &provider{
|
||||
settings: factory.NewScopedProviderSettings(createTestProviderSettings(), "provider_test"),
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := provider.convertLabelSetToEnv(context.Background(), tt.labelSet)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,24 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetWaterfallV4",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get waterfall view for a trace",
|
||||
Description: "Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3",
|
||||
Request: new(spantypes.PostableWaterfall),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableWaterfallTrace),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package envprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/config"
|
||||
@@ -9,7 +11,21 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
|
||||
func clearSignozEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, prefix) {
|
||||
key := strings.SplitN(kv, "=", 2)[0]
|
||||
orig, _ := os.LookupEnv(key)
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() { os.Setenv(key, orig) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithStrings(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("SIGNOZ_K1_K2", "string")
|
||||
t.Setenv("SIGNOZ_K3__K4", "string")
|
||||
t.Setenv("SIGNOZ_K5__K6_K7__K8", "string")
|
||||
@@ -31,6 +47,7 @@ func TestGetWithStrings(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetWithNoPrefix(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("K1_K2", "string")
|
||||
t.Setenv("K3_K4", "string")
|
||||
expected := map[string]any{}
|
||||
@@ -43,6 +60,7 @@ func TestGetWithNoPrefix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetWithGoTypes(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("SIGNOZ_BOOL", "true")
|
||||
t.Setenv("SIGNOZ_STRING", "string")
|
||||
t.Setenv("SIGNOZ_INT", "1")
|
||||
|
||||
@@ -65,7 +65,7 @@ func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Enabled: false,
|
||||
Templates: Templates{
|
||||
Directory: "/root/templates",
|
||||
Directory: "/root/templates/email",
|
||||
Format: Format{
|
||||
Header: Header{
|
||||
Enabled: false,
|
||||
|
||||
@@ -3,14 +3,15 @@ package flagger
|
||||
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
|
||||
var (
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -79,6 +80,14 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseDashboardV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether dashboard v2 is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -38,3 +38,24 @@ func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans, req.Limit)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user