diff --git a/docs/components/Dash0.mdx b/docs/components/Dash0.mdx index faacc53df3..75e7bf8f54 100644 --- a/docs/components/Dash0.mdx +++ b/docs/components/Dash0.mdx @@ -11,6 +11,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + @@ -150,3 +151,48 @@ Returns the Prometheus query response including: } ``` + + +## Update Check Rule + +The Update Check Rule component updates an existing Dash0 check rule. + +### Use Cases + +- **Threshold tuning**: Adjust alert sensitivity as service behavior changes +- **Rule maintenance**: Update labels, annotations, and notification routing +- **Operational automation**: Enable or disable rules from workflows + +### Configuration + +- **Check Rule**: Existing check rule origin/ID +- **Name**: Human-readable rule name +- **Expression**: Prometheus expression used by the rule +- **For (Optional)**: How long expression must remain true before firing +- **Interval (Optional)**: Evaluation interval override +- **Keep Firing For (Optional)**: Additional duration to keep firing after recovery +- **Labels (Optional)**: Label key/value pairs +- **Annotations (Optional)**: Annotation key/value pairs + +### Output + +Emits: +- **originOrId**: Check rule identifier used for the API request +- **response**: Raw Dash0 API response + +### Example Output + +```json +{ + "data": { + "originOrId": "checkout-errors", + "response": { + "id": "checkout-errors", + "status": "updated" + } + }, + "timestamp": "2026-02-09T12:00:00Z", + "type": "dash0.check.rule.updated" +} +``` + diff --git a/pkg/integrations/dash0/check_rule.go b/pkg/integrations/dash0/check_rule.go new file mode 100644 index 0000000000..ad3720edc9 --- /dev/null +++ b/pkg/integrations/dash0/check_rule.go @@ -0,0 +1,107 @@ +package dash0 + +import ( + "fmt" + "strings" +) + +// CheckRuleKeyValue represents a single key/value entry for labels or annotations. +type CheckRuleKeyValue struct { + Key string `json:"key" mapstructure:"key"` + Value string `json:"value" mapstructure:"value"` +} + +// UpsertCheckRuleConfiguration contains user input for the check rule upsert actions. +type UpsertCheckRuleConfiguration struct { + OriginOrID string `json:"originOrId" mapstructure:"originOrId"` + Name string `json:"name" mapstructure:"name"` + Expression string `json:"expression" mapstructure:"expression"` + For string `json:"for" mapstructure:"for"` + Interval string `json:"interval" mapstructure:"interval"` + KeepFiringFor string `json:"keepFiringFor" mapstructure:"keepFiringFor"` + Labels []CheckRuleKeyValue `json:"labels" mapstructure:"labels"` + Annotations []CheckRuleKeyValue `json:"annotations" mapstructure:"annotations"` +} + +// buildCheckRuleSpecification validates and normalizes the check rule payload. +func buildCheckRuleSpecification(config UpsertCheckRuleConfiguration, scope string) (map[string]any, error) { + ruleName, err := requireNonEmptyValue(config.Name, "name", scope) + if err != nil { + return nil, err + } + + expression, err := requireNonEmptyValue(config.Expression, "expression", scope) + if err != nil { + return nil, err + } + + specification := map[string]any{ + "name": ruleName, + "expression": expression, + } + + addOptionalStringField(specification, "for", config.For) + addOptionalStringField(specification, "interval", config.Interval) + addOptionalStringField(specification, "keepFiringFor", config.KeepFiringFor) + + labels, err := normalizeKeyValuePairs(config.Labels, "labels", scope) + if err != nil { + return nil, err + } + if len(labels) > 0 { + specification["labels"] = labels + } + + annotations, err := normalizeKeyValuePairs(config.Annotations, "annotations", scope) + if err != nil { + return nil, err + } + if len(annotations) > 0 { + specification["annotations"] = annotations + } + + return specification, nil +} + +// addOptionalStringField adds a field when the provided value is non-empty. +func addOptionalStringField(target map[string]any, fieldName, value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + + target[fieldName] = trimmed +} + +// normalizeKeyValuePairs validates and normalizes list-based key/value entries. +func normalizeKeyValuePairs(pairs []CheckRuleKeyValue, fieldName, scope string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + + normalized := make(map[string]string, len(pairs)) + for index, pair := range pairs { + key := strings.TrimSpace(pair.Key) + if key == "" { + return nil, fmt.Errorf("%s: %s[%d].key is required", scope, fieldName, index) + } + + if _, exists := normalized[key]; exists { + return nil, fmt.Errorf("%s: %s[%d].key %q is duplicated", scope, fieldName, index, key) + } + + normalized[key] = strings.TrimSpace(pair.Value) + } + + return normalized, nil +} + +// requireNonEmptyValue trims and validates required string values. +func requireNonEmptyValue(value, fieldName, scope string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("%s: %s is required", scope, fieldName) + } + + return trimmed, nil +} diff --git a/pkg/integrations/dash0/client.go b/pkg/integrations/dash0/client.go index 7a16eb1baa..5566e6b2ff 100644 --- a/pkg/integrations/dash0/client.go +++ b/pkg/integrations/dash0/client.go @@ -1,6 +1,7 @@ package dash0 import ( + "bytes" "encoding/json" "fmt" "io" @@ -86,6 +87,20 @@ func (c *Client) execRequest(method, url string, body io.Reader, contentType str return responseBody, nil } +// withDefaultDatasetQuery appends dataset=default to Dash0 configuration API requests. +func withDefaultDatasetQuery(requestURL string) (string, error) { + parsedURL, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("error parsing request URL: %v", err) + } + + query := parsedURL.Query() + query.Set("dataset", "default") + parsedURL.RawQuery = query.Encode() + + return parsedURL.String(), nil +} + type PrometheusResponse struct { Status string `json:"status"` Data PrometheusResponseData `json:"data"` @@ -168,8 +183,12 @@ type CheckRule struct { func (c *Client) ListCheckRules() ([]CheckRule, error) { apiURL := fmt.Sprintf("%s/api/alerting/check-rules", c.BaseURL) + requestURL, err := withDefaultDatasetQuery(apiURL) + if err != nil { + return nil, fmt.Errorf("error setting check rules dataset query: %v", err) + } - responseBody, err := c.execRequest(http.MethodGet, apiURL, nil, "") + responseBody, err := c.execRequest(http.MethodGet, requestURL, nil, "") if err != nil { return nil, err } @@ -207,3 +226,42 @@ func (c *Client) ListCheckRules() ([]CheckRule, error) { return checkRules, nil } + +// UpsertCheckRule creates or updates a Dash0 check rule by origin/ID. +func (c *Client) UpsertCheckRule(originOrID string, specification map[string]any) (map[string]any, error) { + trimmedOriginOrID := strings.TrimSpace(originOrID) + if trimmedOriginOrID == "" { + return nil, fmt.Errorf("check rule originOrId is required") + } + + if len(specification) == 0 { + return nil, fmt.Errorf("check rule specification is required") + } + + requestBody, err := json.Marshal(specification) + if err != nil { + return nil, fmt.Errorf("error serializing check rule specification: %v", err) + } + + apiURL := fmt.Sprintf("%s/api/alerting/check-rules/%s", c.BaseURL, url.PathEscape(trimmedOriginOrID)) + requestURL, err := withDefaultDatasetQuery(apiURL) + if err != nil { + return nil, fmt.Errorf("error setting check rule dataset query: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPut, requestURL, bytes.NewReader(requestBody), "application/json") + if err != nil { + return nil, err + } + + if len(responseBody) == 0 { + return map[string]any{}, nil + } + + var response map[string]any + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing check rule upsert response: %v", err) + } + + return response, nil +} diff --git a/pkg/integrations/dash0/client_test.go b/pkg/integrations/dash0/client_test.go index fd33e0c43a..f19908c3ec 100644 --- a/pkg/integrations/dash0/client_test.go +++ b/pkg/integrations/dash0/client_test.go @@ -210,3 +210,53 @@ func Test__Client__ExecutePrometheusRangeQuery(t *testing.T) { assert.Contains(t, httpContext.Requests[0].URL.String(), "/api/prometheus/api/v1/query_range") }) } + +func Test__Client__UpsertCheckRule(t *testing.T) { + t.Run("successful upsert", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"status":"updated"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://api.us-west-2.aws.dash0.com", + }, + } + + client, err := NewClient(httpContext, integrationCtx) + require.NoError(t, err) + + response, err := client.UpsertCheckRule("checkout-errors", map[string]any{ + "name": "CheckoutErrors", + "expression": "sum(rate(http_requests_total[5m])) > 1", + }) + require.NoError(t, err) + assert.Equal(t, "updated", response["status"]) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodPut, httpContext.Requests[0].Method) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/api/alerting/check-rules/checkout-errors") + assert.Equal(t, "default", httpContext.Requests[0].URL.Query().Get("dataset")) + }) + + t.Run("missing origin id", func(t *testing.T) { + httpContext := &contexts.HTTPContext{} + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://api.us-west-2.aws.dash0.com", + }, + } + + client, err := NewClient(httpContext, integrationCtx) + require.NoError(t, err) + + _, err = client.UpsertCheckRule(" ", map[string]any{"name": "Rule", "expression": "up > 0"}) + require.ErrorContains(t, err, "originOrId is required") + }) +} diff --git a/pkg/integrations/dash0/dash0.go b/pkg/integrations/dash0/dash0.go index a5f96699e3..e3d578fe40 100644 --- a/pkg/integrations/dash0/dash0.go +++ b/pkg/integrations/dash0/dash0.go @@ -69,6 +69,7 @@ func (d *Dash0) Components() []core.Component { return []core.Component{ &QueryPrometheus{}, &ListIssues{}, + &UpdateCheckRule{}, } } diff --git a/pkg/integrations/dash0/example.go b/pkg/integrations/dash0/example.go index a2737e8e53..e69e7c35ba 100644 --- a/pkg/integrations/dash0/example.go +++ b/pkg/integrations/dash0/example.go @@ -19,6 +19,12 @@ var exampleOutputListIssuesBytes []byte var exampleOutputListIssuesOnce sync.Once var exampleOutputListIssues map[string]any +//go:embed example_output_update_check_rule.json +var exampleOutputUpdateCheckRuleBytes []byte + +var exampleOutputUpdateCheckRuleOnce sync.Once +var exampleOutputUpdateCheckRule map[string]any + func (c *QueryPrometheus) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputQueryPrometheusOnce, exampleOutputQueryPrometheusBytes, &exampleOutputQueryPrometheus) } @@ -26,3 +32,8 @@ func (c *QueryPrometheus) ExampleOutput() map[string]any { func (c *ListIssues) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputListIssuesOnce, exampleOutputListIssuesBytes, &exampleOutputListIssues) } + +// ExampleOutput returns sample output data for Update Check Rule. +func (c *UpdateCheckRule) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputUpdateCheckRuleOnce, exampleOutputUpdateCheckRuleBytes, &exampleOutputUpdateCheckRule) +} diff --git a/pkg/integrations/dash0/example_output_update_check_rule.json b/pkg/integrations/dash0/example_output_update_check_rule.json new file mode 100644 index 0000000000..8daf797b6d --- /dev/null +++ b/pkg/integrations/dash0/example_output_update_check_rule.json @@ -0,0 +1,11 @@ +{ + "data": { + "originOrId": "checkout-errors", + "response": { + "id": "checkout-errors", + "status": "updated" + } + }, + "timestamp": "2026-02-09T12:00:00Z", + "type": "dash0.check.rule.updated" +} diff --git a/pkg/integrations/dash0/update_check_rule.go b/pkg/integrations/dash0/update_check_rule.go new file mode 100644 index 0000000000..0405b5b59a --- /dev/null +++ b/pkg/integrations/dash0/update_check_rule.go @@ -0,0 +1,283 @@ +package dash0 + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const UpdateCheckRulePayloadType = "dash0.check.rule.updated" + +// UpdateCheckRule updates existing Dash0 check rules via configuration API. +type UpdateCheckRule struct{} + +// Name returns the stable component identifier. +func (c *UpdateCheckRule) Name() string { + return "dash0.updateCheckRule" +} + +// Label returns the display name used in the workflow builder. +func (c *UpdateCheckRule) Label() string { + return "Update Check Rule" +} + +// Description returns a short summary of component behavior. +func (c *UpdateCheckRule) Description() string { + return "Update an existing check rule in Dash0 configuration API" +} + +// Documentation returns markdown help shown in the component docs panel. +func (c *UpdateCheckRule) Documentation() string { + return `The Update Check Rule component updates an existing Dash0 check rule. + +## Use Cases + +- **Threshold tuning**: Adjust alert sensitivity as service behavior changes +- **Rule maintenance**: Update labels, annotations, and notification routing +- **Operational automation**: Enable or disable rules from workflows + +## Configuration + +- **Check Rule**: Existing check rule origin/ID +- **Name**: Human-readable rule name +- **Expression**: Prometheus expression used by the rule +- **For (Optional)**: How long expression must remain true before firing +- **Interval (Optional)**: Evaluation interval override +- **Keep Firing For (Optional)**: Additional duration to keep firing after recovery +- **Labels (Optional)**: Label key/value pairs +- **Annotations (Optional)**: Annotation key/value pairs + +## Output + +Emits: +- **originOrId**: Check rule identifier used for the API request +- **response**: Raw Dash0 API response` +} + +// Icon returns the Lucide icon name for this component. +func (c *UpdateCheckRule) Icon() string { + return "refresh-cw" +} + +// Color returns the node color used in the UI. +func (c *UpdateCheckRule) Color() string { + return "blue" +} + +// OutputChannels declares the channel emitted by this action. +func (c *UpdateCheckRule) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +// Configuration defines fields required to update check rules. +func (c *UpdateCheckRule) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "originOrId", + Label: "Check Rule", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Togglable: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "check-rule", + }, + }, + Description: "Check rule origin/ID to update", + }, + { + Name: "name", + Label: "Rule Name", + Type: configuration.FieldTypeString, + Required: true, + Description: "Name of the check rule", + Placeholder: "Checkout errors", + }, + { + Name: "expression", + Label: "Expression", + Type: configuration.FieldTypeText, + Required: true, + Description: "Prometheus expression evaluated by the rule", + Placeholder: "sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1", + }, + { + Name: "for", + Label: "For", + Type: configuration.FieldTypeString, + Required: false, + Togglable: true, + Description: "Optional firing delay duration (for example: 5m)", + Placeholder: "5m", + }, + { + Name: "interval", + Label: "Interval", + Type: configuration.FieldTypeString, + Required: false, + Togglable: true, + Description: "Optional evaluation interval override (for example: 1m)", + Placeholder: "1m", + }, + { + Name: "keepFiringFor", + Label: "Keep Firing For", + Type: configuration.FieldTypeString, + Required: false, + Togglable: true, + Description: "Optional extra duration to keep alert firing after recovery", + Placeholder: "10m", + }, + { + Name: "labels", + Label: "Labels", + Type: configuration.FieldTypeList, + Required: false, + Togglable: true, + Description: "Optional label key/value pairs", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Label", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "key", + Label: "Key", + Type: configuration.FieldTypeString, + Required: true, + DisallowExpression: true, + }, + { + Name: "value", + Label: "Value", + Type: configuration.FieldTypeString, + Required: true, + }, + }, + }, + }, + }, + }, + { + Name: "annotations", + Label: "Annotations", + Type: configuration.FieldTypeList, + Required: false, + Togglable: true, + Description: "Optional annotation key/value pairs", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Annotation", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "key", + Label: "Key", + Type: configuration.FieldTypeString, + Required: true, + DisallowExpression: true, + }, + { + Name: "value", + Label: "Value", + Type: configuration.FieldTypeString, + Required: true, + }, + }, + }, + }, + }, + }, + } +} + +// Setup validates component configuration during save/setup. +func (c *UpdateCheckRule) Setup(ctx core.SetupContext) error { + scope := "dash0.updateCheckRule setup" + config := UpsertCheckRuleConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("%s: decode configuration: %w", scope, err) + } + + if _, err := requireNonEmptyValue(config.OriginOrID, "originOrId", scope); err != nil { + return err + } + + if _, err := buildCheckRuleSpecification(config, scope); err != nil { + return err + } + + return nil +} + +// ProcessQueueItem delegates queue processing to default behavior. +func (c *UpdateCheckRule) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +// Execute updates a check rule and emits API response payload. +func (c *UpdateCheckRule) Execute(ctx core.ExecutionContext) error { + scope := "dash0.updateCheckRule execute" + config := UpsertCheckRuleConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("%s: decode configuration: %w", scope, err) + } + + originOrID, err := requireNonEmptyValue(config.OriginOrID, "originOrId", scope) + if err != nil { + return err + } + + specification, err := buildCheckRuleSpecification(config, scope) + if err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("%s: create client: %w", scope, err) + } + + response, err := client.UpsertCheckRule(originOrID, specification) + if err != nil { + return fmt.Errorf("%s: update check rule %q: %w", scope, originOrID, err) + } + + payload := map[string]any{ + "originOrId": originOrID, + "response": response, + } + + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, UpdateCheckRulePayloadType, []any{payload}) +} + +// Actions returns no manual actions for this component. +func (c *UpdateCheckRule) Actions() []core.Action { + return []core.Action{} +} + +// HandleAction is unused because this component has no actions. +func (c *UpdateCheckRule) HandleAction(ctx core.ActionContext) error { + return nil +} + +// HandleWebhook is unused because this component does not receive webhooks. +func (c *UpdateCheckRule) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +// Cancel is a no-op because execution is synchronous and short-lived. +func (c *UpdateCheckRule) Cancel(ctx core.ExecutionContext) error { + return nil +} + +// Cleanup is a no-op because no external resources are provisioned. +func (c *UpdateCheckRule) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/dash0/update_check_rule_test.go b/pkg/integrations/dash0/update_check_rule_test.go new file mode 100644 index 0000000000..a6d43904d9 --- /dev/null +++ b/pkg/integrations/dash0/update_check_rule_test.go @@ -0,0 +1,114 @@ +package dash0 + +import ( + "encoding/json" + "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__UpdateCheckRule__Setup(t *testing.T) { + component := UpdateCheckRule{} + + t.Run("origin is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "originOrId": "", + "name": "Checkout errors", + "expression": `sum(rate(http_requests_total{service="checkout",status=~"5.."}[5m])) > 1`, + }, + }) + + require.ErrorContains(t, err, "originOrId is required") + }) + + t.Run("name is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "originOrId": "checkout-errors", + "expression": `sum(rate(http_requests_total{service="checkout",status=~"5.."}[5m])) > 1`, + }, + }) + + require.ErrorContains(t, err, "name is required") + }) + + t.Run("duplicate label keys are rejected", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "originOrId": "checkout-errors", + "name": "Checkout errors", + "expression": `sum(rate(http_requests_total{service="checkout",status=~"5.."}[5m])) > 1`, + "labels": []map[string]any{ + {"key": "severity", "value": "critical"}, + {"key": "severity", "value": "warning"}, + }, + }, + }) + + require.ErrorContains(t, err, `labels[1].key "severity" is duplicated`) + }) +} + +func Test__UpdateCheckRule__Execute(t *testing.T) { + component := UpdateCheckRule{} + + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"status":"updated"}`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "originOrId": "checkout-errors", + "name": "CheckoutErrors", + "expression": `sum(rate(http_requests_total{service="checkout",status=~"5.."}[5m])) > 1`, + "for": "10m", + "interval": "1m", + "labels": []map[string]any{ + {"key": "severity", "value": "critical"}, + }, + "annotations": []map[string]any{ + {"key": "summary", "value": "Checkout errors are critical"}, + }, + }, + HTTP: httpContext, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://api.us-west-2.aws.dash0.com", + }, + }, + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.Equal(t, UpdateCheckRulePayloadType, execCtx.Type) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodPut, httpContext.Requests[0].Method) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/api/alerting/check-rules/checkout-errors") + assert.Equal(t, "default", httpContext.Requests[0].URL.Query().Get("dataset")) + + requestBody, readErr := io.ReadAll(httpContext.Requests[0].Body) + require.NoError(t, readErr) + + var payload map[string]any + require.NoError(t, json.Unmarshal(requestBody, &payload)) + assert.Equal(t, "CheckoutErrors", payload["name"]) + assert.Equal(t, "sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1", payload["expression"]) + assert.Equal(t, "10m", payload["for"]) + assert.Equal(t, "1m", payload["interval"]) + assert.Equal(t, map[string]any{"severity": "critical"}, payload["labels"]) + assert.Equal(t, map[string]any{"summary": "Checkout errors are critical"}, payload["annotations"]) +} diff --git a/web_src/src/pages/workflowv2/mappers/dash0/index.ts b/web_src/src/pages/workflowv2/mappers/dash0/index.ts index fd9865aafd..79e9ae0e2d 100644 --- a/web_src/src/pages/workflowv2/mappers/dash0/index.ts +++ b/web_src/src/pages/workflowv2/mappers/dash0/index.ts @@ -4,10 +4,12 @@ import { withOrganizationHeader } from "@/utils/withOrganizationHeader"; import { queryPrometheusMapper } from "./query_prometheus"; import { listIssuesMapper, LIST_ISSUES_STATE_REGISTRY } from "./list_issues"; import { buildActionStateRegistry } from "../utils"; +import { updateCheckRuleMapper } from "./update_check_rule"; export const componentMappers: Record = { queryPrometheus: queryPrometheusMapper, listIssues: listIssuesMapper, + updateCheckRule: updateCheckRuleMapper, }; export const triggerRenderers: Record = {}; @@ -15,6 +17,7 @@ export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { listIssues: LIST_ISSUES_STATE_REGISTRY, queryPrometheus: buildActionStateRegistry("queried"), + updateCheckRule: buildActionStateRegistry("updated"), }; export async function resolveExecutionErrors(canvasId: string, executionIds: string[]) { diff --git a/web_src/src/pages/workflowv2/mappers/dash0/types.ts b/web_src/src/pages/workflowv2/mappers/dash0/types.ts index f26ca938e8..ebf6ec4ede 100644 --- a/web_src/src/pages/workflowv2/mappers/dash0/types.ts +++ b/web_src/src/pages/workflowv2/mappers/dash0/types.ts @@ -15,6 +15,23 @@ export interface ListIssuesConfiguration { checkRules?: string[]; } +export interface CheckRuleKeyValueConfiguration { + key: string; + value: string; +} + +export interface UpsertCheckRuleConfiguration { + originOrId?: string; + name?: string; + expression?: string; + for?: string; + interval?: string; + keepFiringFor?: string; + labels?: CheckRuleKeyValueConfiguration[]; + annotations?: CheckRuleKeyValueConfiguration[]; + spec?: string; +} + export interface PrometheusResponse { status: string; data: { diff --git a/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts b/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts new file mode 100644 index 0000000000..c5a4cf2296 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts @@ -0,0 +1,93 @@ +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import dash0Icon from "@/assets/icons/integrations/dash0.svg"; +import { MetadataItem } from "@/ui/metadataList"; +import { formatTimeAgo } from "@/utils/date"; +import { UpsertCheckRuleConfiguration } from "./types"; + +export const updateCheckRuleMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + iconSrc: dash0Icon, + collapsedBackground: "bg-white", + collapsed: context.node.isCollapsed, + title: context.node.name || context.componentDefinition.label || "Update Check Rule", + eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: metadataList(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + if (!outputs || !outputs.default || outputs.default.length === 0) { + return { Response: "No data returned" }; + } + + const payload = outputs.default[0]; + const responseData = payload?.data as Record | undefined; + if (!responseData) { + return { Response: "No data returned" }; + } + + const details: Record = {}; + if (payload?.timestamp) { + details["Updated At"] = new Date(payload.timestamp).toLocaleString(); + } + + try { + details["Update Response"] = JSON.stringify(responseData, null, 2); + } catch { + details["Update Response"] = String(responseData); + } + + return details; + }, + + subtitle(context: SubtitleContext): string { + return formatTimeAgo(new Date(context.execution.createdAt!)); + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as UpsertCheckRuleConfiguration; + + if (configuration?.originOrId) { + metadata.push({ + icon: "hash", + label: configuration.originOrId, + }); + } + + return metadata; +} + +function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((node) => node.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: formatTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +}