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