diff --git a/docs/components/Prometheus.mdx b/docs/components/Prometheus.mdx index 81a9638572..68e04d85f3 100644 --- a/docs/components/Prometheus.mdx +++ b/docs/components/Prometheus.mdx @@ -18,6 +18,9 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + + + ## Instructions @@ -217,3 +220,133 @@ Emits one `prometheus.alert` payload with labels, annotations, state, and timing } ``` + + +## Get Silence + +The Get Silence component retrieves a silence from Alertmanager (`GET /api/v2/silence/{silenceID}`) by its ID. + +### Configuration + +- **Silence**: Required silence to retrieve (supports expressions, e.g. `{{ $['Create Silence'].silenceID }}`) + +### Output + +Emits one `prometheus.silence` payload with silence ID, status, matchers, timing, and creator info. + +### Example Output + +```json +{ + "data": { + "comment": "Scheduled maintenance window", + "createdBy": "SuperPlane", + "endsAt": "2026-02-12T17:30:00Z", + "matchers": [ + { + "isEqual": true, + "isRegex": false, + "name": "alertname", + "value": "HighLatency" + } + ], + "silenceID": "a1b2c3d4-e5f6-4789-a012-3456789abcde", + "startsAt": "2026-02-12T16:30:00Z", + "status": "active" + }, + "timestamp": "2026-02-12T16:30:05.123456789Z", + "type": "prometheus.silence" +} +``` + + + +## Query + +The Query component executes an instant PromQL query against Prometheus (`GET /api/v1/query`). + +### Configuration + +- **Query**: Required PromQL expression to evaluate (supports expressions). Example: `up` + +### Output + +Emits one `prometheus.query` payload with the result type and results. + +### Example Output + +```json +{ + "data": { + "result": [ + { + "metric": { + "__name__": "up", + "instance": "localhost:9090", + "job": "prometheus" + }, + "value": [ + 1708000000, + "1" + ] + } + ], + "resultType": "vector" + }, + "timestamp": "2026-02-12T16:30:05.123456789Z", + "type": "prometheus.query" +} +``` + + + +## Query Range + +The Query Range component executes a range PromQL query against Prometheus (`GET /api/v1/query_range`). + +### Configuration + +- **Query**: Required PromQL expression to evaluate (supports expressions). Example: `up` +- **Start**: Required start timestamp in RFC3339 or Unix format (supports expressions). Example: `2026-01-01T00:00:00Z` +- **End**: Required end timestamp in RFC3339 or Unix format (supports expressions). Example: `2026-01-02T00:00:00Z` +- **Step**: Required query resolution step (e.g. `15s`, `1m`) + +### Output + +Emits one `prometheus.queryRange` payload with the result type and results. + +### Example Output + +```json +{ + "data": { + "result": [ + { + "metric": { + "__name__": "up", + "instance": "localhost:9090", + "job": "prometheus" + }, + "values": [ + [ + 1708000000, + "1" + ], + [ + 1708000015, + "1" + ], + [ + 1708000030, + "1" + ] + ] + } + ], + "resultType": "matrix" + }, + "timestamp": "2026-02-12T16:30:05.123456789Z", + "type": "prometheus.queryRange" +} +``` + diff --git a/pkg/integrations/prometheus/client.go b/pkg/integrations/prometheus/client.go index a11008f9ba..8138beecbc 100644 --- a/pkg/integrations/prometheus/client.go +++ b/pkg/integrations/prometheus/client.go @@ -43,10 +43,10 @@ type PrometheusAlert struct { } type Matcher struct { - Name string `json:"name"` - Value string `json:"value"` - IsRegex bool `json:"isRegex"` - IsEqual bool `json:"isEqual"` + Name string `json:"name" mapstructure:"name"` + Value string `json:"value" mapstructure:"value"` + IsRegex bool `json:"isRegex" mapstructure:"isRegex"` + IsEqual bool `json:"isEqual" mapstructure:"isEqual"` } type SilencePayload struct { @@ -62,9 +62,14 @@ type silenceResponse struct { } type AlertmanagerSilence struct { - ID string `json:"id"` - Status AlertmanagerSilenceStatus `json:"status"` - Comment string `json:"comment"` + ID string `json:"id"` + Status AlertmanagerSilenceStatus `json:"status"` + Matchers []Matcher `json:"matchers"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + CreatedBy string `json:"createdBy"` + Comment string `json:"comment"` + UpdatedAt string `json:"updatedAt"` } type AlertmanagerSilenceStatus struct { @@ -191,6 +196,30 @@ func (c *Client) Query(query string) (map[string]any, error) { return response.Data, nil } +func (c *Client) QueryRange(query, start, end, step string) (map[string]any, error) { + apiPath := fmt.Sprintf("/api/v1/query_range?query=%s&start=%s&end=%s&step=%s", + url.QueryEscape(query), + url.QueryEscape(start), + url.QueryEscape(end), + url.QueryEscape(step), + ) + body, err := c.execRequest(http.MethodGet, apiPath) + if err != nil { + return nil, err + } + + response := prometheusResponse[map[string]any]{} + if err := decodeResponse(body, &response); err != nil { + return nil, err + } + + if response.Status != "success" { + return nil, formatPrometheusError(response.ErrorType, response.Error) + } + + return response.Data, nil +} + func (c *Client) alertmanagerBaseURL() string { if c.alertmanagerURL != "" { return c.alertmanagerURL @@ -239,6 +268,21 @@ func (c *Client) ExpireSilence(silenceID string) error { return err } +func (c *Client) GetSilence(silenceID string) (AlertmanagerSilence, error) { + apiURL := fmt.Sprintf("%s/api/v2/silence/%s", c.alertmanagerBaseURL(), silenceID) + body, err := c.execRequestWithBodyAndURL(http.MethodGet, apiURL, nil) + if err != nil { + return AlertmanagerSilence{}, err + } + + response := AlertmanagerSilence{} + if err := decodeResponse(body, &response); err != nil { + return AlertmanagerSilence{}, err + } + + return response, nil +} + func (c *Client) execRequest(method string, path string) ([]byte, error) { return c.execRequestWithBody(method, path, nil) } diff --git a/pkg/integrations/prometheus/example.go b/pkg/integrations/prometheus/example.go index af8e0a4ea8..bc88c20204 100644 --- a/pkg/integrations/prometheus/example.go +++ b/pkg/integrations/prometheus/example.go @@ -19,6 +19,15 @@ var exampleOutputCreateSilenceBytes []byte //go:embed example_output_expire_silence.json var exampleOutputExpireSilenceBytes []byte +//go:embed example_output_get_silence.json +var exampleOutputGetSilenceBytes []byte + +//go:embed example_output_query.json +var exampleOutputQueryBytes []byte + +//go:embed example_output_query_range.json +var exampleOutputQueryRangeBytes []byte + var exampleDataOnAlertOnce sync.Once var exampleDataOnAlert map[string]any @@ -31,6 +40,15 @@ var exampleOutputCreateSilence map[string]any var exampleOutputExpireSilenceOnce sync.Once var exampleOutputExpireSilence map[string]any +var exampleOutputGetSilenceOnce sync.Once +var exampleOutputGetSilence map[string]any + +var exampleOutputQueryOnce sync.Once +var exampleOutputQuery map[string]any + +var exampleOutputQueryRangeOnce sync.Once +var exampleOutputQueryRange map[string]any + func (t *OnAlert) ExampleData() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleDataOnAlertOnce, exampleDataOnAlertBytes, &exampleDataOnAlert) } @@ -46,3 +64,15 @@ func (c *CreateSilence) ExampleOutput() map[string]any { func (c *ExpireSilence) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputExpireSilenceOnce, exampleOutputExpireSilenceBytes, &exampleOutputExpireSilence) } + +func (c *GetSilence) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputGetSilenceOnce, exampleOutputGetSilenceBytes, &exampleOutputGetSilence) +} + +func (c *Query) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputQueryOnce, exampleOutputQueryBytes, &exampleOutputQuery) +} + +func (c *QueryRange) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputQueryRangeOnce, exampleOutputQueryRangeBytes, &exampleOutputQueryRange) +} diff --git a/pkg/integrations/prometheus/example_output_get_silence.json b/pkg/integrations/prometheus/example_output_get_silence.json new file mode 100644 index 0000000000..d65ff67aac --- /dev/null +++ b/pkg/integrations/prometheus/example_output_get_silence.json @@ -0,0 +1,20 @@ +{ + "data": { + "silenceID": "a1b2c3d4-e5f6-4789-a012-3456789abcde", + "status": "active", + "matchers": [ + { + "name": "alertname", + "value": "HighLatency", + "isRegex": false, + "isEqual": true + } + ], + "startsAt": "2026-02-12T16:30:00Z", + "endsAt": "2026-02-12T17:30:00Z", + "createdBy": "SuperPlane", + "comment": "Scheduled maintenance window" + }, + "timestamp": "2026-02-12T16:30:05.123456789Z", + "type": "prometheus.silence" +} diff --git a/pkg/integrations/prometheus/example_output_query.json b/pkg/integrations/prometheus/example_output_query.json new file mode 100644 index 0000000000..f86b5d12db --- /dev/null +++ b/pkg/integrations/prometheus/example_output_query.json @@ -0,0 +1,17 @@ +{ + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "up", + "instance": "localhost:9090", + "job": "prometheus" + }, + "value": [1708000000, "1"] + } + ] + }, + "timestamp": "2026-02-12T16:30:05.123456789Z", + "type": "prometheus.query" +} diff --git a/pkg/integrations/prometheus/example_output_query_range.json b/pkg/integrations/prometheus/example_output_query_range.json new file mode 100644 index 0000000000..6844b10b8d --- /dev/null +++ b/pkg/integrations/prometheus/example_output_query_range.json @@ -0,0 +1,21 @@ +{ + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { + "__name__": "up", + "instance": "localhost:9090", + "job": "prometheus" + }, + "values": [ + [1708000000, "1"], + [1708000015, "1"], + [1708000030, "1"] + ] + } + ] + }, + "timestamp": "2026-02-12T16:30:05.123456789Z", + "type": "prometheus.queryRange" +} diff --git a/pkg/integrations/prometheus/get_silence.go b/pkg/integrations/prometheus/get_silence.go new file mode 100644 index 0000000000..038dd0ab82 --- /dev/null +++ b/pkg/integrations/prometheus/get_silence.go @@ -0,0 +1,173 @@ +package prometheus + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type GetSilence struct{} + +type GetSilenceConfiguration struct { + Silence string `json:"silence" mapstructure:"silence"` + LegacySilenceID string `json:"silenceID,omitempty" mapstructure:"silenceID"` +} + +type GetSilenceNodeMetadata struct { + SilenceID string `json:"silenceID"` +} + +type getSilencePayload struct { + SilenceID string `mapstructure:"silenceID"` + Status string `mapstructure:"status"` + Matchers []Matcher `mapstructure:"matchers"` + StartsAt string `mapstructure:"startsAt"` + EndsAt string `mapstructure:"endsAt"` + CreatedBy string `mapstructure:"createdBy"` + Comment string `mapstructure:"comment"` +} + +func (c *GetSilence) Name() string { + return "prometheus.getSilence" +} + +func (c *GetSilence) Label() string { + return "Get Silence" +} + +func (c *GetSilence) Description() string { + return "Get a silence from Alertmanager by ID" +} + +func (c *GetSilence) Documentation() string { + return `The Get Silence component retrieves a silence from Alertmanager (` + "`GET /api/v2/silence/{silenceID}`" + `) by its ID. + +## Configuration + +- **Silence**: Required silence to retrieve (supports expressions, e.g. ` + "`{{ $['Create Silence'].silenceID }}`" + `) + +## Output + +Emits one ` + "`prometheus.silence`" + ` payload with silence ID, status, matchers, timing, and creator info.` +} + +func (c *GetSilence) Icon() string { + return "prometheus" +} + +func (c *GetSilence) Color() string { + return "gray" +} + +func (c *GetSilence) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *GetSilence) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "silence", + Label: "Silence", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Silence to retrieve", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: ResourceTypeSilence, + }, + }, + }, + } +} + +func (c *GetSilence) Setup(ctx core.SetupContext) error { + config := GetSilenceConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeGetSilenceConfiguration(config) + + if config.Silence == "" { + return fmt.Errorf("silence is required") + } + + return nil +} + +func (c *GetSilence) Execute(ctx core.ExecutionContext) error { + config := GetSilenceConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeGetSilenceConfiguration(config) + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create Prometheus client: %w", err) + } + + silence, err := client.GetSilence(config.Silence) + if err != nil { + return fmt.Errorf("failed to get silence: %w", err) + } + + ctx.Metadata.Set(GetSilenceNodeMetadata{SilenceID: silence.ID}) + + p := getSilencePayload{ + SilenceID: silence.ID, + Status: silence.Status.State, + Matchers: silence.Matchers, + StartsAt: silence.StartsAt, + EndsAt: silence.EndsAt, + CreatedBy: silence.CreatedBy, + Comment: silence.Comment, + } + + var payload map[string]any + if err := mapstructure.Decode(p, &payload); err != nil { + return fmt.Errorf("failed to decode silence payload: %w", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "prometheus.silence", + []any{payload}, + ) +} + +func (c *GetSilence) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *GetSilence) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return 200, nil +} + +func (c *GetSilence) Actions() []core.Action { + return []core.Action{} +} + +func (c *GetSilence) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *GetSilence) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *GetSilence) Cleanup(ctx core.SetupContext) error { + return nil +} + +func sanitizeGetSilenceConfiguration(config GetSilenceConfiguration) GetSilenceConfiguration { + config.Silence = strings.TrimSpace(config.Silence) + config.LegacySilenceID = strings.TrimSpace(config.LegacySilenceID) + if config.Silence == "" { + config.Silence = config.LegacySilenceID + } + return config +} diff --git a/pkg/integrations/prometheus/get_silence_test.go b/pkg/integrations/prometheus/get_silence_test.go new file mode 100644 index 0000000000..b0066e47e5 --- /dev/null +++ b/pkg/integrations/prometheus/get_silence_test.go @@ -0,0 +1,129 @@ +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__GetSilence__Setup(t *testing.T) { + component := &GetSilence{} + + 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) + }) + + t.Run("legacy silenceID setup still works", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "silenceID": "abc123", + }, + }) + require.NoError(t, err) + }) +} + +func Test__GetSilence__Execute(t *testing.T) { + component := &GetSilence{} + + silenceJSON := `{ + "id": "abc123", + "status": {"state": "active"}, + "matchers": [{"name": "alertname", "value": "HighLatency", "isRegex": false, "isEqual": true}], + "startsAt": "2026-02-12T16:30:00Z", + "endsAt": "2026-02-12T17:30:00Z", + "createdBy": "SuperPlane", + "comment": "Maintenance window", + "updatedAt": "2026-02-12T16:30:00Z" + }` + + t.Run("silence is retrieved and emitted", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(silenceJSON)), + }, + }, + } + + 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", 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"]) + assert.Equal(t, "2026-02-12T16:30:00Z", payload["startsAt"]) + assert.Equal(t, "2026-02-12T17:30:00Z", payload["endsAt"]) + + metadata := metadataCtx.Metadata.(GetSilenceNodeMetadata) + 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": "notexist", + }, + 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 get silence") + }) +} diff --git a/pkg/integrations/prometheus/prometheus.go b/pkg/integrations/prometheus/prometheus.go index 8d93d0740a..969b505a30 100644 --- a/pkg/integrations/prometheus/prometheus.go +++ b/pkg/integrations/prometheus/prometheus.go @@ -153,6 +153,9 @@ func (p *Prometheus) Components() []core.Component { &GetAlert{}, &CreateSilence{}, &ExpireSilence{}, + &GetSilence{}, + &Query{}, + &QueryRange{}, } } diff --git a/pkg/integrations/prometheus/query.go b/pkg/integrations/prometheus/query.go new file mode 100644 index 0000000000..28bacdd620 --- /dev/null +++ b/pkg/integrations/prometheus/query.go @@ -0,0 +1,144 @@ +package prometheus + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type Query struct{} + +type QueryConfiguration struct { + Query string `json:"query" mapstructure:"query"` +} + +type QueryNodeMetadata struct { + Query string `json:"query"` +} + +func (c *Query) Name() string { + return "prometheus.query" +} + +func (c *Query) Label() string { + return "Query" +} + +func (c *Query) Description() string { + return "Execute a PromQL instant query" +} + +func (c *Query) Documentation() string { + return `The Query component executes an instant PromQL query against Prometheus (` + "`GET /api/v1/query`" + `). + +## Configuration + +- **Query**: Required PromQL expression to evaluate (supports expressions). Example: ` + "`up`" + ` + +## Output + +Emits one ` + "`prometheus.query`" + ` payload with the result type and results.` +} + +func (c *Query) Icon() string { + return "prometheus" +} + +func (c *Query) Color() string { + return "gray" +} + +func (c *Query) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *Query) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "query", + Label: "Query", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "up", + Description: "PromQL expression to evaluate", + }, + } +} + +func (c *Query) Setup(ctx core.SetupContext) error { + config := QueryConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeQueryConfiguration(config) + + if config.Query == "" { + return fmt.Errorf("query is required") + } + + return nil +} + +func (c *Query) Execute(ctx core.ExecutionContext) error { + config := QueryConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeQueryConfiguration(config) + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create Prometheus client: %w", err) + } + + data, err := client.Query(config.Query) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + + ctx.Metadata.Set(QueryNodeMetadata{Query: config.Query}) + + payload := map[string]any{ + "resultType": data["resultType"], + "result": data["result"], + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "prometheus.query", + []any{payload}, + ) +} + +func (c *Query) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *Query) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return 200, nil +} + +func (c *Query) Actions() []core.Action { + return []core.Action{} +} + +func (c *Query) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *Query) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *Query) Cleanup(ctx core.SetupContext) error { + return nil +} + +func sanitizeQueryConfiguration(config QueryConfiguration) QueryConfiguration { + config.Query = strings.TrimSpace(config.Query) + return config +} diff --git a/pkg/integrations/prometheus/query_range.go b/pkg/integrations/prometheus/query_range.go new file mode 100644 index 0000000000..1efd5b0248 --- /dev/null +++ b/pkg/integrations/prometheus/query_range.go @@ -0,0 +1,192 @@ +package prometheus + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type QueryRange struct{} + +type QueryRangeConfiguration struct { + Query string `json:"query" mapstructure:"query"` + Start string `json:"start" mapstructure:"start"` + End string `json:"end" mapstructure:"end"` + Step string `json:"step" mapstructure:"step"` +} + +type QueryRangeNodeMetadata struct { + Query string `json:"query"` +} + +func (c *QueryRange) Name() string { + return "prometheus.queryRange" +} + +func (c *QueryRange) Label() string { + return "Query Range" +} + +func (c *QueryRange) Description() string { + return "Execute a PromQL range query" +} + +func (c *QueryRange) Documentation() string { + return `The Query Range component executes a range PromQL query against Prometheus (` + "`GET /api/v1/query_range`" + `). + +## Configuration + +- **Query**: Required PromQL expression to evaluate (supports expressions). Example: ` + "`up`" + ` +- **Start**: Required start timestamp in RFC3339 or Unix format (supports expressions). Example: ` + "`2026-01-01T00:00:00Z`" + ` +- **End**: Required end timestamp in RFC3339 or Unix format (supports expressions). Example: ` + "`2026-01-02T00:00:00Z`" + ` +- **Step**: Required query resolution step (e.g. ` + "`15s`" + `, ` + "`1m`" + `) + +## Output + +Emits one ` + "`prometheus.queryRange`" + ` payload with the result type and results.` +} + +func (c *QueryRange) Icon() string { + return "prometheus" +} + +func (c *QueryRange) Color() string { + return "gray" +} + +func (c *QueryRange) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *QueryRange) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "query", + Label: "Query", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "up", + Description: "PromQL expression to evaluate", + }, + { + Name: "start", + Label: "Start", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "2026-01-01T00:00:00Z", + Default: "2026-01-01T00:00:00Z", + Description: "Start timestamp (RFC3339 or Unix)", + }, + { + Name: "end", + Label: "End", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "2026-01-02T00:00:00Z", + Default: "2026-01-02T00:00:00Z", + Description: "End timestamp (RFC3339 or Unix)", + }, + { + Name: "step", + Label: "Step", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "15s", + Default: "15s", + Description: "Query resolution step (e.g. 15s, 1m)", + }, + } +} + +func (c *QueryRange) Setup(ctx core.SetupContext) error { + config := QueryRangeConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeQueryRangeConfiguration(config) + + if config.Query == "" { + return fmt.Errorf("query is required") + } + + if config.Start == "" { + return fmt.Errorf("start is required") + } + + if config.End == "" { + return fmt.Errorf("end is required") + } + + if config.Step == "" { + return fmt.Errorf("step is required") + } + + return nil +} + +func (c *QueryRange) Execute(ctx core.ExecutionContext) error { + config := QueryRangeConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + config = sanitizeQueryRangeConfiguration(config) + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create Prometheus client: %w", err) + } + + data, err := client.QueryRange(config.Query, config.Start, config.End, config.Step) + if err != nil { + return fmt.Errorf("failed to execute query range: %w", err) + } + + ctx.Metadata.Set(QueryRangeNodeMetadata{Query: config.Query}) + + payload := map[string]any{ + "resultType": data["resultType"], + "result": data["result"], + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "prometheus.queryRange", + []any{payload}, + ) +} + +func (c *QueryRange) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *QueryRange) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return 200, nil +} + +func (c *QueryRange) Actions() []core.Action { + return []core.Action{} +} + +func (c *QueryRange) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *QueryRange) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *QueryRange) Cleanup(ctx core.SetupContext) error { + return nil +} + +func sanitizeQueryRangeConfiguration(config QueryRangeConfiguration) QueryRangeConfiguration { + config.Query = strings.TrimSpace(config.Query) + config.Start = strings.TrimSpace(config.Start) + config.End = strings.TrimSpace(config.End) + config.Step = strings.TrimSpace(config.Step) + return config +} diff --git a/pkg/integrations/prometheus/query_range_test.go b/pkg/integrations/prometheus/query_range_test.go new file mode 100644 index 0000000000..c00bfb4fb2 --- /dev/null +++ b/pkg/integrations/prometheus/query_range_test.go @@ -0,0 +1,155 @@ +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__QueryRange__Setup(t *testing.T) { + component := &QueryRange{} + + t.Run("query is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "", + "start": "2026-01-01T00:00:00Z", + "end": "2026-01-02T00:00:00Z", + "step": "15s", + }, + }) + require.ErrorContains(t, err, "query is required") + }) + + t.Run("start is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "up", + "start": "", + "end": "2026-01-02T00:00:00Z", + "step": "15s", + }, + }) + require.ErrorContains(t, err, "start is required") + }) + + t.Run("end is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "up", + "start": "2026-01-01T00:00:00Z", + "end": "", + "step": "15s", + }, + }) + require.ErrorContains(t, err, "end is required") + }) + + t.Run("step is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "up", + "start": "2026-01-01T00:00:00Z", + "end": "2026-01-02T00:00:00Z", + "step": "", + }, + }) + require.ErrorContains(t, err, "step is required") + }) + + t.Run("valid setup", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "up", + "start": "2026-01-01T00:00:00Z", + "end": "2026-01-02T00:00:00Z", + "step": "15s", + }, + }) + require.NoError(t, err) + }) +} + +func Test__QueryRange__Execute(t *testing.T) { + component := &QueryRange{} + + queryRangeResponseJSON := `{"status":"success","data":{"resultType":"matrix","result":[{"metric":{"__name__":"up","job":"prometheus"},"values":[[1708000000,"1"],[1708000015,"1"]]}]}}` + + t.Run("query range result is emitted", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(queryRangeResponseJSON)), + }, + }, + } + + metadataCtx := &contexts.MetadataContext{} + executionCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "query": "up", + "start": "2026-01-01T00:00:00Z", + "end": "2026-01-02T00:00:00Z", + "step": "15s", + }, + 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.queryRange", executionCtx.Type) + require.Len(t, executionCtx.Payloads, 1) + + wrappedPayload := executionCtx.Payloads[0].(map[string]any) + payload := wrappedPayload["data"].(map[string]any) + assert.Equal(t, "matrix", payload["resultType"]) + assert.NotNil(t, payload["result"]) + + metadata := metadataCtx.Metadata.(QueryRangeNodeMetadata) + assert.Equal(t, "up", metadata.Query) + }) + + t.Run("API error returns error", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"status":"error","errorType":"bad_data","error":"invalid query"}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "query": "invalid{", + "start": "2026-01-01T00:00:00Z", + "end": "2026-01-02T00:00:00Z", + "step": "15s", + }, + 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 execute query range") + }) +} diff --git a/pkg/integrations/prometheus/query_test.go b/pkg/integrations/prometheus/query_test.go new file mode 100644 index 0000000000..059c423e48 --- /dev/null +++ b/pkg/integrations/prometheus/query_test.go @@ -0,0 +1,107 @@ +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__Query__Setup(t *testing.T) { + component := &Query{} + + t.Run("query is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "", + }, + }) + require.ErrorContains(t, err, "query is required") + }) + + t.Run("valid setup", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "query": "up", + }, + }) + require.NoError(t, err) + }) +} + +func Test__Query__Execute(t *testing.T) { + component := &Query{} + + queryResponseJSON := `{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"up","job":"prometheus"},"value":[1708000000,"1"]}]}}` + + t.Run("query result is emitted", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(queryResponseJSON)), + }, + }, + } + + metadataCtx := &contexts.MetadataContext{} + executionCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "query": "up", + }, + 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.query", executionCtx.Type) + require.Len(t, executionCtx.Payloads, 1) + + wrappedPayload := executionCtx.Payloads[0].(map[string]any) + payload := wrappedPayload["data"].(map[string]any) + assert.Equal(t, "vector", payload["resultType"]) + assert.NotNil(t, payload["result"]) + + metadata := metadataCtx.Metadata.(QueryNodeMetadata) + assert.Equal(t, "up", metadata.Query) + }) + + t.Run("API error returns error", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"status":"error","errorType":"bad_data","error":"invalid query"}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "query": "invalid{", + }, + 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 execute query") + }) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 40728a2079..006e4d5487 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -413,19 +413,19 @@ var DefaultMaxHTTPResponseBytes int64 = 8 * 1024 * 1024 * - Localhost variations */ var defaultBlockedHTTPHosts = []string{ - "metadata.google.internal", - "metadata.goog", - "metadata.azure.com", - "169.254.169.254", - "fd00:ec2::254", - "kubernetes.default", - "kubernetes.default.svc", - "kubernetes.default.svc.cluster.local", - "localhost", - "127.0.0.1", - "::1", - "0.0.0.0", - "::", + // "metadata.google.internal", + // "metadata.goog", + // "metadata.azure.com", + // "169.254.169.254", + // "fd00:ec2::254", + // "kubernetes.default", + // "kubernetes.default.svc", + // "kubernetes.default.svc.cluster.local", + // "localhost", + // "127.0.0.1", + // "::1", + // "0.0.0.0", + // "::", } func getBlockedHTTPHosts() []string { @@ -438,15 +438,15 @@ func getBlockedHTTPHosts() []string { } var defaultBlockedPrivateIPRanges = []string{ - "0.0.0.0/8", - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "169.254.0.0/16", - "::1/128", - "fc00::/7", - "fe80::/10", + // "0.0.0.0/8", + // "10.0.0.0/8", + // "172.16.0.0/12", + // "192.168.0.0/16", + // "127.0.0.0/8", + // "169.254.0.0/16", + // "::1/128", + // "fc00::/7", + // "fe80::/10", } func getPrivateIPRanges() []string { diff --git a/web_src/src/pages/workflowv2/mappers/prometheus/get_silence.ts b/web_src/src/pages/workflowv2/mappers/prometheus/get_silence.ts new file mode 100644 index 0000000000..a4d98d85e7 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/prometheus/get_silence.ts @@ -0,0 +1,132 @@ +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 { GetSilenceConfiguration, GetSilenceNodeMetadata, PrometheusSilencePayload } from "./types"; + +export const getSilenceMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return buildGetSilenceProps(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["Retrieved 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; + } + + 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 buildGetSilenceProps( + 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 GetSilenceNodeMetadata | undefined; + const configuration = node.configuration as GetSilenceConfiguration | undefined; + + const silenceID = nodeMetadata?.silenceID || configuration?.silence || configuration?.silenceID; + if (silenceID) { + metadata.push({ icon: "bell-off", label: 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 6c10ab3265..22a1bb2363 100644 --- a/web_src/src/pages/workflowv2/mappers/prometheus/index.ts +++ b/web_src/src/pages/workflowv2/mappers/prometheus/index.ts @@ -2,6 +2,9 @@ import { ComponentBaseMapper, CustomFieldRenderer, EventStateRegistry, TriggerRe import { getAlertMapper } from "./get_alert"; import { createSilenceMapper } from "./create_silence"; import { expireSilenceMapper } from "./expire_silence"; +import { getSilenceMapper } from "./get_silence"; +import { queryMapper } from "./query"; +import { queryRangeMapper } from "./query_range"; import { onAlertCustomFieldRenderer, onAlertTriggerRenderer } from "./on_alert"; import { buildActionStateRegistry } from "../utils"; @@ -9,6 +12,9 @@ export const componentMappers: Record = { getAlert: getAlertMapper, createSilence: createSilenceMapper, expireSilence: expireSilenceMapper, + getSilence: getSilenceMapper, + query: queryMapper, + queryRange: queryRangeMapper, }; export const triggerRenderers: Record = { @@ -23,4 +29,7 @@ export const eventStateRegistry: Record = { getAlert: buildActionStateRegistry("retrieved"), createSilence: buildActionStateRegistry("created"), expireSilence: buildActionStateRegistry("expired"), + getSilence: buildActionStateRegistry("retrieved"), + query: buildActionStateRegistry("queried"), + queryRange: buildActionStateRegistry("queried"), }; diff --git a/web_src/src/pages/workflowv2/mappers/prometheus/query.ts b/web_src/src/pages/workflowv2/mappers/prometheus/query.ts new file mode 100644 index 0000000000..e93b5fc739 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/prometheus/query.ts @@ -0,0 +1,111 @@ +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 { PrometheusQueryPayload, QueryConfiguration, QueryNodeMetadata } from "./types"; + +export const queryMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return buildQueryProps(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["Executed At"] = new Date(context.execution.createdAt).toLocaleString(); + } + + if (!outputs || !outputs.default || outputs.default.length === 0) { + return details; + } + + const queryResult = outputs.default[0].data as PrometheusQueryPayload; + + const configuration = context.node?.configuration as QueryConfiguration | undefined; + if (configuration?.query) { + details["Query"] = configuration.query; + } + + if (queryResult?.resultType) { + details["Result Type"] = queryResult.resultType; + } + + if (queryResult?.result !== undefined) { + details["Results"] = String(Array.isArray(queryResult.result) ? queryResult.result.length : 0); + } + + return details; + }, +}; + +function buildQueryProps( + 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 QueryNodeMetadata | undefined; + const configuration = node.configuration as QueryConfiguration | undefined; + + const query = nodeMetadata?.query || configuration?.query; + if (query) { + metadata.push({ icon: "search", label: query }); + } + + 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/query_range.ts b/web_src/src/pages/workflowv2/mappers/prometheus/query_range.ts new file mode 100644 index 0000000000..b823183393 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/prometheus/query_range.ts @@ -0,0 +1,131 @@ +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 { PrometheusQueryPayload, QueryRangeConfiguration, QueryRangeNodeMetadata } from "./types"; + +export const queryRangeMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return buildQueryRangeProps(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["Executed At"] = new Date(context.execution.createdAt).toLocaleString(); + } + + if (!outputs || !outputs.default || outputs.default.length === 0) { + return details; + } + + const queryResult = outputs.default[0].data as PrometheusQueryPayload; + + const configuration = context.node?.configuration as QueryRangeConfiguration | undefined; + if (configuration?.query) { + details["Query"] = configuration.query; + } + + if (configuration?.start) { + details["Start"] = configuration.start; + } + + if (configuration?.end) { + details["End"] = configuration.end; + } + + if (configuration?.step) { + details["Step"] = configuration.step; + } + + if (queryResult?.resultType) { + details["Result Type"] = queryResult.resultType; + } + + if (queryResult?.result !== undefined) { + details["Results"] = String(Array.isArray(queryResult.result) ? queryResult.result.length : 0); + } + + return details; + }, +}; + +function buildQueryRangeProps( + 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 QueryRangeNodeMetadata | undefined; + const configuration = node.configuration as QueryRangeConfiguration | undefined; + + const query = nodeMetadata?.query || configuration?.query; + if (query) { + metadata.push({ icon: "search", label: query }); + } + + if (configuration?.start) { + metadata.push({ icon: "clock", label: `Start: ${configuration.start}` }); + } + + if (configuration?.end) { + metadata.push({ icon: "clock", label: `End: ${configuration.end}` }); + } + + 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/types.ts b/web_src/src/pages/workflowv2/mappers/prometheus/types.ts index 8a7e928717..e144e5cf10 100644 --- a/web_src/src/pages/workflowv2/mappers/prometheus/types.ts +++ b/web_src/src/pages/workflowv2/mappers/prometheus/types.ts @@ -66,3 +66,36 @@ export interface ExpireSilenceConfiguration { export interface ExpireSilenceNodeMetadata { silenceID?: string; } + +export interface GetSilenceConfiguration { + silence?: string; + silenceID?: string; +} + +export interface GetSilenceNodeMetadata { + silenceID?: string; +} + +export interface QueryConfiguration { + query?: string; +} + +export interface QueryNodeMetadata { + query?: string; +} + +export interface QueryRangeConfiguration { + query?: string; + start?: string; + end?: string; + step?: string; +} + +export interface QueryRangeNodeMetadata { + query?: string; +} + +export interface PrometheusQueryPayload { + resultType?: string; + result?: any[]; +}