diff --git a/docs/components/Prometheus.mdx b/docs/components/Prometheus.mdx index 790a614fcf..81a9638572 100644 --- a/docs/components/Prometheus.mdx +++ b/docs/components/Prometheus.mdx @@ -15,6 +15,8 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Actions + + @@ -24,7 +26,8 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; Configure this integration with: - **Prometheus Base URL**: URL of your Prometheus server (e.g., `https://prometheus.example.com`) -- **API Auth**: `none`, `basic`, or `bearer` for Prometheus API requests +- **Alertmanager Base URL** (optional): URL of your Alertmanager instance (e.g., `https://alertmanager.example.com`). Required for Silence components. If omitted, the Prometheus Base URL is used. +- **API Auth**: `none`, `basic`, or `bearer` for API requests - **Webhook Secret** (recommended): If set, Alertmanager must send `Authorization: Bearer ` on webhook requests ### Alertmanager Setup (manual) @@ -98,6 +101,85 @@ After updating Alertmanager config, reload it (for example `POST /-/reload` when } ``` + + +## Create Silence + +The Create Silence component creates a silence in Alertmanager (`POST /api/v2/silences`) to suppress matching alerts. + +### Configuration + +- **Matchers**: Required list of matchers. Each matcher has: + - **Name**: Label name to match + - **Value**: Label value to match + - **Is Regex**: Whether value is a regex pattern (default: false) + - **Is Equal**: Whether to match equality (true) or inequality (false) (default: true) +- **Duration**: Required duration string (e.g. `1h`, `30m`, `2h30m`) +- **Created By**: Required name of who is creating the silence +- **Comment**: Required reason for the silence + +### Output + +Emits one `prometheus.silence` payload with silence ID, status, matchers, timing, and creator info. + +### Example Output + +```json +{ + "data": { + "comment": "Scheduled maintenance window for database migration", + "createdBy": "SuperPlane", + "endsAt": "2026-02-12T17:30:00Z", + "matchers": [ + { + "isEqual": true, + "isRegex": false, + "name": "alertname", + "value": "HighLatency" + }, + { + "isEqual": true, + "isRegex": false, + "name": "severity", + "value": "critical" + } + ], + "silenceID": "a1b2c3d4-e5f6-4789-a012-3456789abcde", + "startsAt": "2026-02-12T16:30:00Z", + "status": "active" + }, + "timestamp": "2026-02-12T16:30:05.123456789Z", + "type": "prometheus.silence" +} +``` + + + +## Expire Silence + +The Expire Silence component expires an active silence in Alertmanager (`DELETE /api/v2/silence/{silenceID}`). + +### Configuration + +- **Silence**: Required silence to expire. Supports expressions so users can reference `$['Create Silence'].silenceID`. + +### Output + +Emits one `prometheus.silence.expired` payload with silence ID and status. + +### Example Output + +```json +{ + "data": { + "silenceID": "a1b2c3d4-e5f6-4789-a012-3456789abcde", + "status": "expired" + }, + "timestamp": "2026-02-12T17:45:10.987654321Z", + "type": "prometheus.silence.expired" +} +``` + ## Get Alert diff --git a/pkg/integrations/prometheus/client.go b/pkg/integrations/prometheus/client.go index 7eb22f4ead..a11008f9ba 100644 --- a/pkg/integrations/prometheus/client.go +++ b/pkg/integrations/prometheus/client.go @@ -14,12 +14,13 @@ import ( const MaxResponseSize = 1 * 1024 * 1024 // 1MB type Client struct { - baseURL string - authType string - username string - password string - bearerToken string - http core.HTTPContext + baseURL string + alertmanagerURL string + authType string + username string + password string + bearerToken string + http core.HTTPContext } type prometheusResponse[T any] struct { @@ -41,6 +42,35 @@ type PrometheusAlert struct { Value string `json:"value,omitempty"` } +type Matcher struct { + Name string `json:"name"` + Value string `json:"value"` + IsRegex bool `json:"isRegex"` + IsEqual bool `json:"isEqual"` +} + +type SilencePayload struct { + Matchers []Matcher `json:"matchers"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + CreatedBy string `json:"createdBy"` + Comment string `json:"comment"` +} + +type silenceResponse struct { + SilenceID string `json:"silenceID"` +} + +type AlertmanagerSilence struct { + ID string `json:"id"` + Status AlertmanagerSilenceStatus `json:"status"` + Comment string `json:"comment"` +} + +type AlertmanagerSilenceStatus struct { + State string `json:"state"` +} + func NewClient(httpContext core.HTTPContext, integration core.IntegrationContext) (*Client, error) { baseURL, err := requiredConfig(integration, "baseURL") if err != nil { @@ -52,10 +82,13 @@ func NewClient(httpContext core.HTTPContext, integration core.IntegrationContext return nil, err } + alertmanagerURL := optionalConfig(integration, "alertmanagerURL") + client := &Client{ - baseURL: normalizeBaseURL(baseURL), - authType: authType, - http: httpContext, + baseURL: normalizeBaseURL(baseURL), + alertmanagerURL: normalizeBaseURL(alertmanagerURL), + authType: authType, + http: httpContext, } switch authType { @@ -87,6 +120,14 @@ func NewClient(httpContext core.HTTPContext, integration core.IntegrationContext } } +func optionalConfig(ctx core.IntegrationContext, name string) string { + value, err := ctx.GetConfig(name) + if err != nil { + return "" + } + return string(value) +} + func requiredConfig(ctx core.IntegrationContext, name string) (string, error) { value, err := ctx.GetConfig(name) if err != nil { @@ -150,7 +191,76 @@ func (c *Client) Query(query string) (map[string]any, error) { return response.Data, nil } +func (c *Client) alertmanagerBaseURL() string { + if c.alertmanagerURL != "" { + return c.alertmanagerURL + } + return c.baseURL +} + +func (c *Client) CreateSilence(silence SilencePayload) (string, error) { + jsonBody, err := json.Marshal(silence) + if err != nil { + return "", fmt.Errorf("failed to marshal silence payload: %w", err) + } + + apiURL := c.alertmanagerBaseURL() + "/api/v2/silences" + body, err := c.execRequestWithBodyAndURL(http.MethodPost, apiURL, strings.NewReader(string(jsonBody))) + if err != nil { + return "", err + } + + response := silenceResponse{} + if err := decodeResponse(body, &response); err != nil { + return "", err + } + + return response.SilenceID, nil +} + +func (c *Client) ListSilences() ([]AlertmanagerSilence, error) { + apiURL := c.alertmanagerBaseURL() + "/api/v2/silences" + body, err := c.execRequestWithBodyAndURL(http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + + response := []AlertmanagerSilence{} + if err := decodeResponse(body, &response); err != nil { + return nil, err + } + + return response, nil +} + +func (c *Client) ExpireSilence(silenceID string) error { + apiURL := fmt.Sprintf("%s/api/v2/silence/%s", c.alertmanagerBaseURL(), silenceID) + _, err := c.execRequestWithBodyAndURL(http.MethodDelete, apiURL, nil) + return err +} + func (c *Client) execRequest(method string, path string) ([]byte, error) { + return c.execRequestWithBody(method, path, nil) +} + +func (c *Client) execRequestWithBodyAndURL(method string, fullURL string, body io.Reader) ([]byte, error) { + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if err := c.setAuth(req); err != nil { + return nil, err + } + + return c.doRequest(req) +} + +func (c *Client) execRequestWithBody(method string, path string, body io.Reader) ([]byte, error) { apiURL := c.baseURL if strings.HasPrefix(path, "/") { apiURL += path @@ -158,16 +268,23 @@ func (c *Client) execRequest(method string, path string) ([]byte, error) { apiURL += "/" + path } - req, err := http.NewRequest(method, apiURL, nil) + req, err := http.NewRequest(method, apiURL, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } if err := c.setAuth(req); err != nil { return nil, err } + return c.doRequest(req) +} + +func (c *Client) doRequest(req *http.Request) ([]byte, error) { res, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) @@ -175,20 +292,20 @@ func (c *Client) execRequest(method string, path string) ([]byte, error) { defer res.Body.Close() limitedReader := io.LimitReader(res.Body, MaxResponseSize+1) - body, err := io.ReadAll(limitedReader) + responseBody, err := io.ReadAll(limitedReader) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - if len(body) > MaxResponseSize { + if len(responseBody) > MaxResponseSize { return nil, fmt.Errorf("response too large: exceeds maximum size of %d bytes", MaxResponseSize) } if res.StatusCode < 200 || res.StatusCode >= 300 { - return nil, fmt.Errorf("request failed with status %d: %s", res.StatusCode, string(body)) + return nil, fmt.Errorf("request failed with status %d: %s", res.StatusCode, string(responseBody)) } - return body, nil + return responseBody, nil } func (c *Client) setAuth(req *http.Request) error { diff --git a/pkg/integrations/prometheus/create_silence.go b/pkg/integrations/prometheus/create_silence.go new file mode 100644 index 0000000000..8c53e63e23 --- /dev/null +++ b/pkg/integrations/prometheus/create_silence.go @@ -0,0 +1,300 @@ +package prometheus + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type CreateSilence struct{} + +type CreateSilenceConfiguration struct { + Matchers []MatcherConfiguration `json:"matchers" mapstructure:"matchers"` + Duration string `json:"duration" mapstructure:"duration"` + CreatedBy string `json:"createdBy" mapstructure:"createdBy"` + Comment string `json:"comment" mapstructure:"comment"` +} + +type MatcherConfiguration struct { + Name string `json:"name" mapstructure:"name"` + Value string `json:"value" mapstructure:"value"` + IsRegex *bool `json:"isRegex,omitempty" mapstructure:"isRegex"` + IsEqual *bool `json:"isEqual,omitempty" mapstructure:"isEqual"` +} + +type CreateSilenceNodeMetadata struct { + SilenceID string `json:"silenceID"` +} + +func (c *CreateSilence) Name() string { + return "prometheus.createSilence" +} + +func (c *CreateSilence) Label() string { + return "Create Silence" +} + +func (c *CreateSilence) Description() string { + return "Create a silence in Alertmanager to suppress alerts" +} + +func (c *CreateSilence) Documentation() string { + return `The Create Silence component creates a silence in Alertmanager (` + "`POST /api/v2/silences`" + `) to suppress matching alerts. + +## Configuration + +- **Matchers**: Required list of matchers. Each matcher has: + - **Name**: Label name to match + - **Value**: Label value to match + - **Is Regex**: Whether value is a regex pattern (default: false) + - **Is Equal**: Whether to match equality (true) or inequality (false) (default: true) +- **Duration**: Required duration string (e.g. ` + "`1h`" + `, ` + "`30m`" + `, ` + "`2h30m`" + `) +- **Created By**: Required name of who is creating the silence +- **Comment**: Required reason for the silence + +## Output + +Emits one ` + "`prometheus.silence`" + ` payload with silence ID, status, matchers, timing, and creator info.` +} + +func (c *CreateSilence) Icon() string { + return "prometheus" +} + +func (c *CreateSilence) Color() string { + return "gray" +} + +func (c *CreateSilence) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *CreateSilence) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "matchers", + Label: "Matchers", + Type: configuration.FieldTypeList, + Required: true, + Default: `[{"name":"alertname","value":"Watchdog","isRegex":false,"isEqual":true}]`, + Description: "List of label matchers to select alerts", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Matcher", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "name", + Label: "Name", + Type: configuration.FieldTypeString, + Required: true, + }, + { + Name: "value", + Label: "Value", + Type: configuration.FieldTypeString, + Required: true, + }, + { + Name: "isRegex", + Label: "Is Regex", + Type: configuration.FieldTypeBool, + Required: false, + Default: false, + }, + { + Name: "isEqual", + Label: "Is Equal", + Type: configuration.FieldTypeBool, + Required: false, + Default: true, + }, + }, + }, + }, + }, + }, + { + Name: "duration", + Label: "Duration", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "1h", + Description: "Duration for the silence (e.g. 1h, 30m, 2h30m)", + }, + { + Name: "createdBy", + Label: "Created By", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "SuperPlane", + Description: "Name of the person or system creating the silence", + }, + { + Name: "comment", + Label: "Comment", + Type: configuration.FieldTypeString, + Required: true, + Description: "Reason for creating the silence", + }, + } +} + +func (c *CreateSilence) Setup(ctx core.SetupContext) error { + config := CreateSilenceConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeCreateSilenceConfiguration(config) + + if len(config.Matchers) == 0 { + return fmt.Errorf("at least one matcher is required") + } + + for i, matcher := range config.Matchers { + if matcher.Name == "" { + return fmt.Errorf("matcher %d: name is required", i+1) + } + if matcher.Value == "" { + return fmt.Errorf("matcher %d: value is required", i+1) + } + } + + if config.Duration == "" { + return fmt.Errorf("duration is required") + } + + if _, err := time.ParseDuration(config.Duration); err != nil { + return fmt.Errorf("invalid duration %q: %w", config.Duration, err) + } + + if config.CreatedBy == "" { + return fmt.Errorf("createdBy is required") + } + + if config.Comment == "" { + return fmt.Errorf("comment is required") + } + + return nil +} + +func (c *CreateSilence) Execute(ctx core.ExecutionContext) error { + config := CreateSilenceConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeCreateSilenceConfiguration(config) + + duration, err := time.ParseDuration(config.Duration) + if err != nil { + return fmt.Errorf("invalid duration: %w", err) + } + + now := time.Now().UTC() + startsAt := now.Format(time.RFC3339) + endsAt := now.Add(duration).Format(time.RFC3339) + + matchers := make([]Matcher, len(config.Matchers)) + for i, m := range config.Matchers { + isRegex := false + if m.IsRegex != nil { + isRegex = *m.IsRegex + } + isEqual := true + if m.IsEqual != nil { + isEqual = *m.IsEqual + } + matchers[i] = Matcher{ + Name: m.Name, + Value: m.Value, + IsRegex: isRegex, + IsEqual: isEqual, + } + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create Prometheus client: %w", err) + } + + silenceID, err := client.CreateSilence(SilencePayload{ + Matchers: matchers, + StartsAt: startsAt, + EndsAt: endsAt, + CreatedBy: config.CreatedBy, + Comment: config.Comment, + }) + if err != nil { + return fmt.Errorf("failed to create silence: %w", err) + } + + ctx.Metadata.Set(CreateSilenceNodeMetadata{SilenceID: silenceID}) + + matchersData := make([]map[string]any, len(matchers)) + for i, m := range matchers { + matchersData[i] = map[string]any{ + "name": m.Name, + "value": m.Value, + "isRegex": m.IsRegex, + "isEqual": m.IsEqual, + } + } + + payload := map[string]any{ + "silenceID": silenceID, + "status": "active", + "matchers": matchersData, + "startsAt": startsAt, + "endsAt": endsAt, + "createdBy": config.CreatedBy, + "comment": config.Comment, + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "prometheus.silence", + []any{payload}, + ) +} + +func (c *CreateSilence) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *CreateSilence) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return 200, nil +} + +func (c *CreateSilence) Actions() []core.Action { + return []core.Action{} +} + +func (c *CreateSilence) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *CreateSilence) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *CreateSilence) Cleanup(ctx core.SetupContext) error { + return nil +} + +func sanitizeCreateSilenceConfiguration(config CreateSilenceConfiguration) CreateSilenceConfiguration { + for i := range config.Matchers { + config.Matchers[i].Name = strings.TrimSpace(config.Matchers[i].Name) + config.Matchers[i].Value = strings.TrimSpace(config.Matchers[i].Value) + } + config.Duration = strings.TrimSpace(config.Duration) + config.CreatedBy = strings.TrimSpace(config.CreatedBy) + config.Comment = strings.TrimSpace(config.Comment) + return config +} diff --git a/pkg/integrations/prometheus/create_silence_test.go b/pkg/integrations/prometheus/create_silence_test.go new file mode 100644 index 0000000000..023e8fbc2d --- /dev/null +++ b/pkg/integrations/prometheus/create_silence_test.go @@ -0,0 +1,267 @@ +package prometheus + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__CreateSilence__Setup(t *testing.T) { + component := &CreateSilence{} + + t.Run("matchers are required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "matchers": []any{}, + "duration": "1h", + "createdBy": "SuperPlane", + "comment": "Test", + }, + }) + require.ErrorContains(t, err, "at least one matcher is required") + }) + + t.Run("matcher name is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "", "value": "test"}, + }, + "duration": "1h", + "createdBy": "SuperPlane", + "comment": "Test", + }, + }) + require.ErrorContains(t, err, "name is required") + }) + + t.Run("matcher value is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "alertname", "value": ""}, + }, + "duration": "1h", + "createdBy": "SuperPlane", + "comment": "Test", + }, + }) + require.ErrorContains(t, err, "value is required") + }) + + t.Run("duration is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "alertname", "value": "test"}, + }, + "duration": "", + "createdBy": "SuperPlane", + "comment": "Test", + }, + }) + require.ErrorContains(t, err, "duration is required") + }) + + t.Run("invalid duration returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "alertname", "value": "test"}, + }, + "duration": "invalid", + "createdBy": "SuperPlane", + "comment": "Test", + }, + }) + require.ErrorContains(t, err, "invalid duration") + }) + + t.Run("createdBy is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "alertname", "value": "test"}, + }, + "duration": "1h", + "createdBy": "", + "comment": "Test", + }, + }) + require.ErrorContains(t, err, "createdBy is required") + }) + + t.Run("comment is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "alertname", "value": "test"}, + }, + "duration": "1h", + "createdBy": "SuperPlane", + "comment": "", + }, + }) + require.ErrorContains(t, err, "comment is required") + }) + + t.Run("valid setup", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "alertname", "value": "HighLatency"}, + }, + "duration": "1h", + "createdBy": "SuperPlane", + "comment": "Maintenance window", + }, + }) + require.NoError(t, err) + }) +} + +func Test__CreateSilence__Execute(t *testing.T) { + component := &CreateSilence{} + + t.Run("silence is created and emitted", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"silenceID":"abc123"}`)), + }, + }, + } + + metadataCtx := &contexts.MetadataContext{} + executionCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "alertname", "value": "HighLatency", "isRegex": false, "isEqual": true}, + }, + "duration": "1h", + "createdBy": "SuperPlane", + "comment": "Maintenance window", + }, + HTTP: httpCtx, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{ + "baseURL": "https://prometheus.example.com", + "authType": AuthTypeNone, + }}, + Metadata: metadataCtx, + ExecutionState: executionCtx, + }) + + require.NoError(t, err) + assert.True(t, executionCtx.Finished) + assert.True(t, executionCtx.Passed) + assert.Equal(t, "prometheus.silence", executionCtx.Type) + require.Len(t, executionCtx.Payloads, 1) + + wrappedPayload := executionCtx.Payloads[0].(map[string]any) + payload := wrappedPayload["data"].(map[string]any) + assert.Equal(t, "abc123", payload["silenceID"]) + assert.Equal(t, "active", payload["status"]) + assert.Equal(t, "SuperPlane", payload["createdBy"]) + assert.Equal(t, "Maintenance window", payload["comment"]) + + matchers := payload["matchers"].([]map[string]any) + require.Len(t, matchers, 1) + assert.Equal(t, "alertname", matchers[0]["name"]) + assert.Equal(t, "HighLatency", matchers[0]["value"]) + assert.Equal(t, false, matchers[0]["isRegex"]) + assert.Equal(t, true, matchers[0]["isEqual"]) + + assert.NotEmpty(t, payload["startsAt"]) + assert.NotEmpty(t, payload["endsAt"]) + + startsAt, err := time.Parse(time.RFC3339, payload["startsAt"].(string)) + require.NoError(t, err) + endsAt, err := time.Parse(time.RFC3339, payload["endsAt"].(string)) + require.NoError(t, err) + assert.True(t, endsAt.After(startsAt)) + + metadata := metadataCtx.Metadata.(CreateSilenceNodeMetadata) + assert.Equal(t, "abc123", metadata.SilenceID) + }) + + t.Run("API error returns error", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"invalid matchers"}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": "alertname", "value": "test"}, + }, + "duration": "1h", + "createdBy": "SuperPlane", + "comment": "Test", + }, + HTTP: httpCtx, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{ + "baseURL": "https://prometheus.example.com", + "authType": AuthTypeNone, + }}, + Metadata: &contexts.MetadataContext{}, + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.ErrorContains(t, err, "failed to create silence") + }) + + t.Run("execute sanitizes configuration", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"silenceID":"abc123"}`)), + }, + }, + } + + executionCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "matchers": []any{ + map[string]any{"name": " alertname ", "value": " test "}, + }, + "duration": " 1h ", + "createdBy": " SuperPlane ", + "comment": " Test ", + }, + HTTP: httpCtx, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{ + "baseURL": "https://prometheus.example.com", + "authType": AuthTypeNone, + }}, + Metadata: &contexts.MetadataContext{}, + ExecutionState: executionCtx, + }) + + require.NoError(t, err) + assert.True(t, executionCtx.Passed) + require.Len(t, executionCtx.Payloads, 1) + wrappedPayload := executionCtx.Payloads[0].(map[string]any) + payload := wrappedPayload["data"].(map[string]any) + matchers := payload["matchers"].([]map[string]any) + assert.Equal(t, "alertname", matchers[0]["name"]) + assert.Equal(t, "test", matchers[0]["value"]) + assert.Equal(t, "SuperPlane", payload["createdBy"]) + assert.Equal(t, "Test", payload["comment"]) + }) +} diff --git a/pkg/integrations/prometheus/example.go b/pkg/integrations/prometheus/example.go index 6b27e2ce05..af8e0a4ea8 100644 --- a/pkg/integrations/prometheus/example.go +++ b/pkg/integrations/prometheus/example.go @@ -13,12 +13,24 @@ var exampleDataOnAlertBytes []byte //go:embed example_output_get_alert.json var exampleOutputGetAlertBytes []byte +//go:embed example_output_create_silence.json +var exampleOutputCreateSilenceBytes []byte + +//go:embed example_output_expire_silence.json +var exampleOutputExpireSilenceBytes []byte + var exampleDataOnAlertOnce sync.Once var exampleDataOnAlert map[string]any var exampleOutputGetAlertOnce sync.Once var exampleOutputGetAlert map[string]any +var exampleOutputCreateSilenceOnce sync.Once +var exampleOutputCreateSilence map[string]any + +var exampleOutputExpireSilenceOnce sync.Once +var exampleOutputExpireSilence map[string]any + func (t *OnAlert) ExampleData() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleDataOnAlertOnce, exampleDataOnAlertBytes, &exampleDataOnAlert) } @@ -26,3 +38,11 @@ func (t *OnAlert) ExampleData() map[string]any { func (c *GetAlert) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputGetAlertOnce, exampleOutputGetAlertBytes, &exampleOutputGetAlert) } + +func (c *CreateSilence) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateSilenceOnce, exampleOutputCreateSilenceBytes, &exampleOutputCreateSilence) +} + +func (c *ExpireSilence) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputExpireSilenceOnce, exampleOutputExpireSilenceBytes, &exampleOutputExpireSilence) +} diff --git a/pkg/integrations/prometheus/example_output_create_silence.json b/pkg/integrations/prometheus/example_output_create_silence.json new file mode 100644 index 0000000000..6c576b047a --- /dev/null +++ b/pkg/integrations/prometheus/example_output_create_silence.json @@ -0,0 +1,26 @@ +{ + "data": { + "silenceID": "a1b2c3d4-e5f6-4789-a012-3456789abcde", + "status": "active", + "matchers": [ + { + "name": "alertname", + "value": "HighLatency", + "isRegex": false, + "isEqual": true + }, + { + "name": "severity", + "value": "critical", + "isRegex": false, + "isEqual": true + } + ], + "startsAt": "2026-02-12T16:30:00Z", + "endsAt": "2026-02-12T17:30:00Z", + "createdBy": "SuperPlane", + "comment": "Scheduled maintenance window for database migration" + }, + "timestamp": "2026-02-12T16:30:05.123456789Z", + "type": "prometheus.silence" +} diff --git a/pkg/integrations/prometheus/example_output_expire_silence.json b/pkg/integrations/prometheus/example_output_expire_silence.json new file mode 100644 index 0000000000..c8c8d5c674 --- /dev/null +++ b/pkg/integrations/prometheus/example_output_expire_silence.json @@ -0,0 +1,8 @@ +{ + "data": { + "silenceID": "a1b2c3d4-e5f6-4789-a012-3456789abcde", + "status": "expired" + }, + "timestamp": "2026-02-12T17:45:10.987654321Z", + "type": "prometheus.silence.expired" +} diff --git a/pkg/integrations/prometheus/expire_silence.go b/pkg/integrations/prometheus/expire_silence.go new file mode 100644 index 0000000000..7c7f9b1b1a --- /dev/null +++ b/pkg/integrations/prometheus/expire_silence.go @@ -0,0 +1,148 @@ +package prometheus + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type ExpireSilence struct{} + +type ExpireSilenceConfiguration struct { + Silence string `json:"silence" mapstructure:"silence"` +} + +type ExpireSilenceNodeMetadata struct { + SilenceID string `json:"silenceID"` +} + +func (c *ExpireSilence) Name() string { + return "prometheus.expireSilence" +} + +func (c *ExpireSilence) Label() string { + return "Expire Silence" +} + +func (c *ExpireSilence) Description() string { + return "Expire an active silence in Alertmanager" +} + +func (c *ExpireSilence) Documentation() string { + return `The Expire Silence component expires an active silence in Alertmanager (` + "`DELETE /api/v2/silence/{silenceID}`" + `). + +## Configuration + +- **Silence**: Required silence to expire. Supports expressions so users can reference ` + "`$['Create Silence'].silenceID`" + `. + +## Output + +Emits one ` + "`prometheus.silence.expired`" + ` payload with silence ID and status.` +} + +func (c *ExpireSilence) Icon() string { + return "prometheus" +} + +func (c *ExpireSilence) Color() string { + return "gray" +} + +func (c *ExpireSilence) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *ExpireSilence) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "silence", + Label: "Silence", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Silence to expire", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: ResourceTypeSilence, + }, + }, + }, + } +} + +func (c *ExpireSilence) Setup(ctx core.SetupContext) error { + config := ExpireSilenceConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeExpireSilenceConfiguration(config) + + if config.Silence == "" { + return fmt.Errorf("silence is required") + } + + return nil +} + +func (c *ExpireSilence) Execute(ctx core.ExecutionContext) error { + config := ExpireSilenceConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeExpireSilenceConfiguration(config) + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create Prometheus client: %w", err) + } + + if err := client.ExpireSilence(config.Silence); err != nil { + return fmt.Errorf("failed to expire silence: %w", err) + } + + ctx.Metadata.Set(ExpireSilenceNodeMetadata{SilenceID: config.Silence}) + + payload := map[string]any{ + "silenceID": config.Silence, + "status": "expired", + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "prometheus.silence.expired", + []any{payload}, + ) +} + +func (c *ExpireSilence) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *ExpireSilence) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *ExpireSilence) Actions() []core.Action { + return []core.Action{} +} + +func (c *ExpireSilence) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *ExpireSilence) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *ExpireSilence) Cleanup(ctx core.SetupContext) error { + return nil +} + +func sanitizeExpireSilenceConfiguration(config ExpireSilenceConfiguration) ExpireSilenceConfiguration { + config.Silence = strings.TrimSpace(config.Silence) + return config +} diff --git a/pkg/integrations/prometheus/expire_silence_test.go b/pkg/integrations/prometheus/expire_silence_test.go new file mode 100644 index 0000000000..3c65098977 --- /dev/null +++ b/pkg/integrations/prometheus/expire_silence_test.go @@ -0,0 +1,138 @@ +package prometheus + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__ExpireSilence__Setup(t *testing.T) { + component := &ExpireSilence{} + + t.Run("configuration uses silence resource field", func(t *testing.T) { + fields := component.Configuration() + require.Len(t, fields, 1) + assert.Equal(t, "silence", fields[0].Name) + assert.Equal(t, "Silence", fields[0].Label) + assert.Equal(t, configuration.FieldTypeIntegrationResource, fields[0].Type) + require.NotNil(t, fields[0].TypeOptions) + require.NotNil(t, fields[0].TypeOptions.Resource) + assert.Equal(t, ResourceTypeSilence, fields[0].TypeOptions.Resource.Type) + }) + + t.Run("silence is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"silence": ""}, + }) + require.ErrorContains(t, err, "silence is required") + }) + + t.Run("valid setup", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"silence": "abc123"}, + }) + require.NoError(t, err) + }) +} + +func Test__ExpireSilence__Execute(t *testing.T) { + component := &ExpireSilence{} + + t.Run("silence is expired and emitted", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, + }, + } + + metadataCtx := &contexts.MetadataContext{} + executionCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"silence": "abc123"}, + HTTP: httpCtx, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{ + "baseURL": "https://prometheus.example.com", + "authType": AuthTypeNone, + }}, + Metadata: metadataCtx, + ExecutionState: executionCtx, + }) + + require.NoError(t, err) + assert.True(t, executionCtx.Finished) + assert.True(t, executionCtx.Passed) + assert.Equal(t, "prometheus.silence.expired", executionCtx.Type) + require.Len(t, executionCtx.Payloads, 1) + wrappedPayload := executionCtx.Payloads[0].(map[string]any) + payload := wrappedPayload["data"].(map[string]any) + assert.Equal(t, "abc123", payload["silenceID"]) + assert.Equal(t, "expired", payload["status"]) + + metadata := metadataCtx.Metadata.(ExpireSilenceNodeMetadata) + assert.Equal(t, "abc123", metadata.SilenceID) + }) + + t.Run("API error returns error", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"error":"silence not found"}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"silence": "nonexistent"}, + HTTP: httpCtx, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{ + "baseURL": "https://prometheus.example.com", + "authType": AuthTypeNone, + }}, + Metadata: &contexts.MetadataContext{}, + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.ErrorContains(t, err, "failed to expire silence") + }) + + t.Run("execute sanitizes silence", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, + }, + } + + executionCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"silence": " abc123 "}, + HTTP: httpCtx, + Integration: &contexts.IntegrationContext{Configuration: map[string]any{ + "baseURL": "https://prometheus.example.com", + "authType": AuthTypeNone, + }}, + Metadata: &contexts.MetadataContext{}, + ExecutionState: executionCtx, + }) + + require.NoError(t, err) + assert.True(t, executionCtx.Passed) + require.Len(t, executionCtx.Payloads, 1) + wrappedPayload := executionCtx.Payloads[0].(map[string]any) + payload := wrappedPayload["data"].(map[string]any) + assert.Equal(t, "abc123", payload["silenceID"]) + }) +} diff --git a/pkg/integrations/prometheus/prometheus.go b/pkg/integrations/prometheus/prometheus.go index 0d3c521d29..8d93d0740a 100644 --- a/pkg/integrations/prometheus/prometheus.go +++ b/pkg/integrations/prometheus/prometheus.go @@ -31,6 +31,7 @@ type Prometheus struct{} type Configuration struct { BaseURL string `json:"baseURL" mapstructure:"baseURL"` + AlertmanagerURL string `json:"alertmanagerURL,omitempty" mapstructure:"alertmanagerURL"` AuthType string `json:"authType" mapstructure:"authType"` Username string `json:"username,omitempty" mapstructure:"username"` Password string `json:"password,omitempty" mapstructure:"password"` @@ -61,7 +62,8 @@ func (p *Prometheus) Instructions() string { Configure this integration with: - **Prometheus Base URL**: URL of your Prometheus server (e.g., ` + "`https://prometheus.example.com`" + `) -- **API Auth**: ` + "`none`" + `, ` + "`basic`" + `, or ` + "`bearer`" + ` for Prometheus API requests +- **Alertmanager Base URL** (optional): URL of your Alertmanager instance (e.g., ` + "`https://alertmanager.example.com`" + `). Required for Silence components. If omitted, the Prometheus Base URL is used. +- **API Auth**: ` + "`none`" + `, ` + "`basic`" + `, or ` + "`bearer`" + ` for API requests - **Webhook Secret** (recommended): If set, Alertmanager must send ` + "`Authorization: Bearer `" + ` on webhook requests ### Alertmanager Setup (manual) @@ -82,6 +84,14 @@ func (p *Prometheus) Configuration() []configuration.Field { Placeholder: "https://prometheus.example.com", Description: "Base URL for Prometheus HTTP API", }, + { + Name: "alertmanagerURL", + Label: "Alertmanager Base URL", + Type: configuration.FieldTypeString, + Required: false, + Placeholder: "https://alertmanager.example.com", + Description: "Base URL for Alertmanager API (used by Silence components). Falls back to Prometheus Base URL if not set.", + }, { Name: "authType", Label: "API Auth Type", @@ -141,6 +151,8 @@ func (p *Prometheus) Configuration() []configuration.Field { func (p *Prometheus) Components() []core.Component { return []core.Component{ &GetAlert{}, + &CreateSilence{}, + &ExpireSilence{}, } } @@ -210,10 +222,6 @@ func (p *Prometheus) HandleRequest(ctx core.HTTPRequestContext) { // no-op } -func (p *Prometheus) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { - return []core.IntegrationResource{}, nil -} - func (p *Prometheus) Actions() []core.Action { return []core.Action{} } diff --git a/pkg/integrations/prometheus/resources.go b/pkg/integrations/prometheus/resources.go new file mode 100644 index 0000000000..9ddef4f549 --- /dev/null +++ b/pkg/integrations/prometheus/resources.go @@ -0,0 +1,63 @@ +package prometheus + +import ( + "fmt" + "strings" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ResourceTypeSilence = "silence" + +func (p *Prometheus) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + switch resourceType { + case ResourceTypeSilence: + return listSilenceResources(ctx, resourceType) + default: + return []core.IntegrationResource{}, nil + } +} + +func listSilenceResources(ctx core.ListResourcesContext, resourceType string) ([]core.IntegrationResource, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create Prometheus client: %w", err) + } + + silences, err := client.ListSilences() + if err != nil { + return nil, fmt.Errorf("failed to list silences: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(silences)) + for _, silence := range silences { + if strings.TrimSpace(silence.ID) == "" { + continue + } + + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: silenceResourceName(silence), + ID: silence.ID, + }) + } + + return resources, nil +} + +func silenceResourceName(silence AlertmanagerSilence) string { + state := strings.TrimSpace(silence.Status.State) + comment := strings.TrimSpace(silence.Comment) + if comment != "" { + if state == "" { + return comment + } + return fmt.Sprintf("%s (%s)", comment, state) + } + + if state == "" { + return silence.ID + } + + return fmt.Sprintf("%s (%s)", silence.ID, state) +} diff --git a/pkg/integrations/prometheus/resources_test.go b/pkg/integrations/prometheus/resources_test.go new file mode 100644 index 0000000000..222122b420 --- /dev/null +++ b/pkg/integrations/prometheus/resources_test.go @@ -0,0 +1,58 @@ +package prometheus + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Prometheus__ListResources(t *testing.T) { + integration := &Prometheus{} + + t.Run("unknown resource type returns empty list", func(t *testing.T) { + resources, err := integration.ListResources("unknown", core.ListResourcesContext{}) + require.NoError(t, err) + assert.Empty(t, resources) + }) + + t.Run("lists silence resources", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[ + {"id":"abc123","status":{"state":"active"},"comment":"Maintenance window"}, + {"id":"xyz789","status":{"state":"expired"},"comment":""}, + {"id":" ","status":{"state":"active"},"comment":"ignored"} + ]`)), + }, + }, + } + + resources, err := integration.ListResources(ResourceTypeSilence, core.ListResourcesContext{ + HTTP: httpCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://prometheus.example.com", + "authType": AuthTypeNone, + }, + }, + }) + require.NoError(t, err) + require.Len(t, resources, 2) + + assert.Equal(t, ResourceTypeSilence, resources[0].Type) + assert.Equal(t, "abc123", resources[0].ID) + assert.Equal(t, "Maintenance window (active)", resources[0].Name) + + assert.Equal(t, ResourceTypeSilence, resources[1].Type) + assert.Equal(t, "xyz789", resources[1].ID) + assert.Equal(t, "xyz789 (expired)", resources[1].Name) + }) +} diff --git a/web_src/src/pages/workflowv2/mappers/prometheus/create_silence.ts b/web_src/src/pages/workflowv2/mappers/prometheus/create_silence.ts new file mode 100644 index 0000000000..f220eec33c --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/prometheus/create_silence.ts @@ -0,0 +1,139 @@ +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { MetadataItem } from "@/ui/metadataList"; +import { getBackgroundColorClass, getColorClass } from "@/utils/colors"; +import { formatTimeAgo } from "@/utils/date"; +import prometheusIcon from "@/assets/icons/integrations/prometheus.svg"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { CreateSilenceConfiguration, CreateSilenceNodeMetadata, PrometheusSilencePayload } from "./types"; + +export const createSilenceMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return buildCreateSilenceProps(context.nodes, context.node, context.componentDefinition, context.lastExecutions); + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) { + return ""; + } + + return formatTimeAgo(new Date(context.execution.createdAt)); + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const details: Record = {}; + + if (context.execution.createdAt) { + details["Created At"] = new Date(context.execution.createdAt).toLocaleString(); + } + + if (!outputs || !outputs.default || outputs.default.length === 0) { + return details; + } + + const silence = outputs.default[0].data as PrometheusSilencePayload; + return { + ...details, + ...getDetailsForSilence(silence), + }; + }, +}; + +function buildCreateSilenceProps( + nodes: NodeInfo[], + node: NodeInfo, + componentDefinition: { name: string; label: string; color: string }, + lastExecutions: ExecutionInfo[], +): ComponentBaseProps { + const lastExecution = lastExecutions.length > 0 ? lastExecutions[0] : null; + const componentName = componentDefinition.name || node.componentName || "unknown"; + + return { + iconSrc: prometheusIcon, + iconColor: getColorClass(componentDefinition.color), + collapsedBackground: getBackgroundColorClass(componentDefinition.color), + collapsed: node.isCollapsed, + title: node.name || componentDefinition.label || "Unnamed component", + eventSections: lastExecution ? buildEventSections(nodes, lastExecution, componentName) : undefined, + metadata: getMetadata(node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; +} + +function getMetadata(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const nodeMetadata = node.metadata as CreateSilenceNodeMetadata | undefined; + const configuration = node.configuration as CreateSilenceConfiguration | undefined; + + if (nodeMetadata?.silenceID) { + metadata.push({ icon: "bell-off", label: nodeMetadata.silenceID }); + } + + if (configuration?.matchers && configuration.matchers.length > 0) { + metadata.push({ icon: "filter", label: `${configuration.matchers.length} matcher(s)` }); + } + + return metadata.slice(0, 3); +} + +function getDetailsForSilence(silence: PrometheusSilencePayload): Record { + const details: Record = {}; + + if (silence?.silenceID) { + details["Silence ID"] = silence.silenceID; + } + + if (silence?.matchers && silence.matchers.length > 0) { + details["Matchers"] = silence.matchers + .map((m) => { + const operator = m.isEqual === false ? "!=" : "="; + const suffix = m.isRegex ? " (regex)" : ""; + return `${m.name}${operator}"${m.value}"${suffix}`; + }) + .join(", "); + } + + if (silence?.startsAt) { + details["Starts At"] = new Date(silence.startsAt).toLocaleString(); + } + + if (silence?.endsAt && silence.endsAt !== "0001-01-01T00:00:00Z") { + details["Ends At"] = new Date(silence.endsAt).toLocaleString(); + } + + if (silence?.createdBy) { + details["Created By"] = silence.createdBy; + } + + if (silence?.comment) { + details["Comment"] = silence.comment; + } + + return details; +} + +function buildEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName!); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: execution.createdAt ? formatTimeAgo(new Date(execution.createdAt)) : "", + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/prometheus/expire_silence.ts b/web_src/src/pages/workflowv2/mappers/prometheus/expire_silence.ts new file mode 100644 index 0000000000..108bb9503a --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/prometheus/expire_silence.ts @@ -0,0 +1,110 @@ +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { MetadataItem } from "@/ui/metadataList"; +import { getBackgroundColorClass, getColorClass } from "@/utils/colors"; +import { formatTimeAgo } from "@/utils/date"; +import prometheusIcon from "@/assets/icons/integrations/prometheus.svg"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { ExpireSilenceConfiguration, ExpireSilenceNodeMetadata, PrometheusSilencePayload } from "./types"; + +export const expireSilenceMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return buildExpireSilenceProps(context.nodes, context.node, context.componentDefinition, context.lastExecutions); + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) { + return ""; + } + + return formatTimeAgo(new Date(context.execution.createdAt)); + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const details: Record = {}; + + if (context.execution.createdAt) { + details["Expired At"] = new Date(context.execution.createdAt).toLocaleString(); + } + + if (!outputs || !outputs.default || outputs.default.length === 0) { + return details; + } + + const silence = outputs.default[0].data as PrometheusSilencePayload; + + if (silence?.silenceID) { + details["Silence ID"] = silence.silenceID; + } + + if (silence?.status) { + details["Status"] = silence.status; + } + + return details; + }, +}; + +function buildExpireSilenceProps( + nodes: NodeInfo[], + node: NodeInfo, + componentDefinition: { name: string; label: string; color: string }, + lastExecutions: ExecutionInfo[], +): ComponentBaseProps { + const lastExecution = lastExecutions.length > 0 ? lastExecutions[0] : null; + const componentName = componentDefinition.name || node.componentName || "unknown"; + + return { + iconSrc: prometheusIcon, + iconColor: getColorClass(componentDefinition.color), + collapsedBackground: getBackgroundColorClass(componentDefinition.color), + collapsed: node.isCollapsed, + title: node.name || componentDefinition.label || "Unnamed component", + eventSections: lastExecution ? buildEventSections(nodes, lastExecution, componentName) : undefined, + metadata: getMetadata(node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; +} + +function getMetadata(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const nodeMetadata = node.metadata as ExpireSilenceNodeMetadata | undefined; + const configuration = node.configuration as ExpireSilenceConfiguration | undefined; + + const selectedSilence = configuration?.silence || configuration?.silenceID; + if (selectedSilence) { + metadata.push({ icon: "bell-off", label: selectedSilence }); + } + + if (nodeMetadata?.silenceID && nodeMetadata.silenceID !== selectedSilence) { + metadata.push({ icon: "bell-off", label: nodeMetadata.silenceID }); + } + + return metadata.slice(0, 3); +} + +function buildEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName!); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: execution.createdAt ? formatTimeAgo(new Date(execution.createdAt)) : "", + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/prometheus/index.ts b/web_src/src/pages/workflowv2/mappers/prometheus/index.ts index 418030ea1a..6c10ab3265 100644 --- a/web_src/src/pages/workflowv2/mappers/prometheus/index.ts +++ b/web_src/src/pages/workflowv2/mappers/prometheus/index.ts @@ -1,10 +1,14 @@ import { ComponentBaseMapper, CustomFieldRenderer, EventStateRegistry, TriggerRenderer } from "../types"; import { getAlertMapper } from "./get_alert"; +import { createSilenceMapper } from "./create_silence"; +import { expireSilenceMapper } from "./expire_silence"; import { onAlertCustomFieldRenderer, onAlertTriggerRenderer } from "./on_alert"; import { buildActionStateRegistry } from "../utils"; export const componentMappers: Record = { getAlert: getAlertMapper, + createSilence: createSilenceMapper, + expireSilence: expireSilenceMapper, }; export const triggerRenderers: Record = { @@ -17,4 +21,6 @@ export const customFieldRenderers: Record = { export const eventStateRegistry: Record = { getAlert: buildActionStateRegistry("retrieved"), + createSilence: buildActionStateRegistry("created"), + expireSilence: buildActionStateRegistry("expired"), }; diff --git a/web_src/src/pages/workflowv2/mappers/prometheus/types.ts b/web_src/src/pages/workflowv2/mappers/prometheus/types.ts index ba0f4e915d..8a7e928717 100644 --- a/web_src/src/pages/workflowv2/mappers/prometheus/types.ts +++ b/web_src/src/pages/workflowv2/mappers/prometheus/types.ts @@ -15,6 +15,23 @@ export interface PrometheusAlertPayload { externalURL?: string; } +export interface PrometheusSilencePayload { + silenceID?: string; + status?: string; + matchers?: PrometheusMatcher[]; + startsAt?: string; + endsAt?: string; + createdBy?: string; + comment?: string; +} + +export interface PrometheusMatcher { + name?: string; + value?: string; + isRegex?: boolean; + isEqual?: boolean; +} + export interface OnAlertConfiguration { statuses?: string[]; alertNames?: string[]; @@ -29,3 +46,23 @@ export interface GetAlertConfiguration { alertName?: string; state?: string; } + +export interface CreateSilenceConfiguration { + matchers?: PrometheusMatcher[]; + duration?: string; + createdBy?: string; + comment?: string; +} + +export interface CreateSilenceNodeMetadata { + silenceID?: string; +} + +export interface ExpireSilenceConfiguration { + silence?: string; + silenceID?: string; +} + +export interface ExpireSilenceNodeMetadata { + silenceID?: string; +}