diff --git a/docs/components/Grafana.mdx b/docs/components/Grafana.mdx new file mode 100644 index 000000000..aad1992b0 --- /dev/null +++ b/docs/components/Grafana.mdx @@ -0,0 +1,154 @@ +--- +title: "Grafana" +--- + +Connect Grafana alerts and data queries to SuperPlane workflows + +## Triggers + + + + + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Actions + + + + + +## Instructions + +To connect Grafana: +1. In Grafana, go to Administration > Users and access > Service accounts. +2. Create a Service Account and assign a role (Viewer/Editor/Admin as needed). +3. Open the Service Account and create a token. Copy it immediately. +4. (Legacy Grafana) If Service Accounts are unavailable, use an API key. +5. Set the Base URL to your Grafana instance (e.g. https://grafana.example.com). +6. Paste the token into SuperPlane and save. + +For the alert trigger: +1. SuperPlane will attempt to automatically create/update a Grafana Webhook contact point. +2. Route your alert rule to the contact point created by SuperPlane. +3. If auto-provisioning is not available (permissions/API limitations), create a Webhook contact point manually using the webhook URL from SuperPlane. + + + +## On Alert Firing + +The On Alert Firing trigger starts a workflow when Grafana Unified Alerting sends a firing alert webhook. + +### Setup + +1. SuperPlane attempts to automatically create/update a Grafana Webhook contact point for this trigger. +2. If you set a Shared Secret in this trigger, SuperPlane includes it as an Authorization bearer token in the provisioned contact point. +3. Route your alert rule to the contact point created by SuperPlane. +4. If auto-provisioning is unavailable (permissions/API limitations), create the contact point manually using the webhook URL generated by SuperPlane. +5. For manual setup with Shared Secret, add an HTTP header to the contact point: + - Authorization: Bearer + +### Event Data + +The trigger emits the full Grafana webhook payload, including: +- status (firing/resolved) +- alerts array with labels and annotations +- groupLabels, commonLabels, commonAnnotations +- externalURL and other alerting metadata + +### Example Data + +```json +{ + "data": { + "alerts": [ + { + "annotations": { + "summary": "Error rate above threshold" + }, + "labels": { + "alertname": "HighErrorRate", + "service": "api" + }, + "status": "firing" + } + ], + "commonLabels": { + "alertname": "HighErrorRate" + }, + "externalURL": "http://grafana.local", + "ruleUid": "alert_rule_uid", + "status": "firing", + "title": "High error rate" + }, + "timestamp": "2026-02-12T16:18:03.362582388Z", + "type": "grafana.alert.firing" +} +``` + + + +## Query Data Source + +The Query Data Source component executes a query against a Grafana data source using the Grafana Query API. + +### Use Cases + +- **Metrics investigation**: Run PromQL or other datasource queries from workflows +- **Alert validation**: Validate alert conditions before escalation +- **Incident context**: Pull current metrics into incident workflows + +### Configuration + +- **Data Source UID**: The Grafana datasource UID to query +- **Query**: The datasource query (PromQL, InfluxQL, etc.) +- **Time From / Time To**: Optional time range (relative like "now-5m" or absolute) +- **Format**: Optional query format (depends on the datasource) + +### Output + +Returns the Grafana query API response JSON. + +### Example Output + +```json +{ + "data": { + "results": { + "A": { + "frames": [ + { + "data": { + "values": [ + [ + "2026-02-07T08:00:00Z", + "2026-02-07T08:01:00Z" + ], + [ + 1, + 1 + ] + ] + }, + "schema": { + "fields": [ + { + "name": "time", + "type": "time" + }, + { + "name": "value", + "type": "number" + } + ] + } + } + ] + } + } + }, + "timestamp": "2026-02-12T16:18:03.362582388Z", + "type": "grafana.query.result" +} +``` + diff --git a/pkg/cli/commands/config/root.go b/pkg/cli/commands/config/root.go new file mode 100644 index 000000000..9fa7fa741 --- /dev/null +++ b/pkg/cli/commands/config/root.go @@ -0,0 +1,106 @@ +package config + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/superplanehq/superplane/pkg/cli/core" +) + +type getCommand struct{} + +func (c *getCommand) Execute(ctx core.CommandContext) error { + key := ctx.Args[0] + if !viper.IsSet(key) { + return fmt.Errorf("configuration key %q not found", key) + } + + value := viper.Get(key) + if ctx.Renderer.IsText() { + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, _ = fmt.Fprintln(stdout, value) + return nil + }) + } + + return ctx.Renderer.Render(map[string]any{ + key: value, + }) +} + +type setCommand struct{} + +func (c *setCommand) Execute(ctx core.CommandContext) error { + key := ctx.Args[0] + value := ctx.Args[1] + + viper.Set(key, value) + if err := viper.WriteConfig(); err != nil { + return fmt.Errorf("failed to write configuration: %w", err) + } + + return nil +} + +type viewCommand struct{} + +func (c *viewCommand) Execute(ctx core.CommandContext) error { + allSettings := viper.AllSettings() + if !ctx.Renderer.IsText() { + return ctx.Renderer.Render(allSettings) + } + + if len(allSettings) == 0 { + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, _ = fmt.Fprintln(stdout, "No configuration values set") + return nil + }) + } + + return ctx.Renderer.RenderText(func(stdout io.Writer) error { + _, _ = fmt.Fprintln(stdout, "Current configuration:") + for key, value := range allSettings { + _, _ = fmt.Fprintf(stdout, " %s: %v\n", key, value) + } + return nil + }) +} + +func NewCommand(options core.BindOptions) *cobra.Command { + root := &cobra.Command{ + Use: "config", + Short: "Get and set configuration options", + Long: "Get and set CLI configuration options like API URL and authentication token.", + } + + getCmd := &cobra.Command{ + Use: "get [KEY]", + Short: "Display a configuration value", + Long: "Display the value of a specific configuration key.", + Args: cobra.ExactArgs(1), + } + core.Bind(getCmd, &getCommand{}, options) + + setCmd := &cobra.Command{ + Use: "set [KEY] [VALUE]", + Short: "Set a configuration value", + Long: "Set the value of a specific configuration key.", + Args: cobra.ExactArgs(2), + } + core.Bind(setCmd, &setCommand{}, options) + + viewCmd := &cobra.Command{ + Use: "view", + Short: "View all configuration values", + Long: "Display all configuration values currently set.", + } + core.Bind(viewCmd, &viewCommand{}, options) + + root.AddCommand(getCmd) + root.AddCommand(setCmd) + root.AddCommand(viewCmd) + + return root +} diff --git a/pkg/integrations/grafana/client.go b/pkg/integrations/grafana/client.go new file mode 100644 index 000000000..0e609b21e --- /dev/null +++ b/pkg/integrations/grafana/client.go @@ -0,0 +1,345 @@ +package grafana + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + maxResponseSize = 2 * 1024 * 1024 // 2MB +) + +type Client struct { + BaseURL string + APIToken string + http core.HTTPContext +} + +type contactPoint struct { + UID string `json:"uid"` + Name string `json:"name"` +} + +type DataSource struct { + UID string `json:"uid"` + Name string `json:"name"` +} + +type apiStatusError struct { + Operation string + StatusCode int + ResponseBody string +} + +func (e *apiStatusError) Error() string { + return fmt.Sprintf("%s failed with status %d: %s", e.Operation, e.StatusCode, e.ResponseBody) +} + +func newAPIStatusError(operation string, status int, responseBody []byte) error { + return &apiStatusError{ + Operation: operation, + StatusCode: status, + ResponseBody: string(responseBody), + } +} + +func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext, requireToken bool) (*Client, error) { + baseURL, err := readBaseURL(ctx) + if err != nil { + return nil, err + } + + apiToken, err := readAPIToken(ctx) + if err != nil { + return nil, err + } + + if requireToken && apiToken == "" { + return nil, fmt.Errorf("apiToken is required") + } + + return &Client{ + BaseURL: baseURL, + APIToken: apiToken, + http: httpCtx, + }, nil +} + +func readBaseURL(ctx core.IntegrationContext) (string, error) { + baseURLConfig, err := ctx.GetConfig("baseURL") + if err != nil { + return "", fmt.Errorf("error reading baseURL: %v", err) + } + + if baseURLConfig == nil { + return "", fmt.Errorf("baseURL is required") + } + + baseURLRaw := strings.TrimSpace(string(baseURLConfig)) + if baseURLRaw == "" { + return "", fmt.Errorf("baseURL is required") + } + + parsed, err := url.Parse(baseURLRaw) + if err != nil { + return "", fmt.Errorf("invalid baseURL: %v", err) + } + + // url.Parse accepts relative URLs (e.g. "grafana.local"), which will fail later in http.NewRequest. + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("invalid baseURL: must include scheme and host (e.g. https://grafana.example.com)") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("invalid baseURL: unsupported scheme %q (expected http or https)", parsed.Scheme) + } + + return strings.TrimSuffix(baseURLRaw, "/"), nil +} + +func readAPIToken(ctx core.IntegrationContext) (string, error) { + type optionalConfigReader interface { + GetOptionalConfig(name string) ([]byte, error) + } + + var ( + apiTokenConfig []byte + err error + ) + + if optionalCtx, ok := ctx.(optionalConfigReader); ok { + apiTokenConfig, err = optionalCtx.GetOptionalConfig("apiToken") + } else { + apiTokenConfig, err = ctx.GetConfig("apiToken") + if err != nil && strings.Contains(err.Error(), "config apiToken not found") { + return "", nil + } + } + if err != nil { + return "", fmt.Errorf("error reading apiToken: %v", err) + } + + if apiTokenConfig == nil { + return "", nil + } + + return strings.TrimSpace(string(apiTokenConfig)), nil +} + +func (c *Client) buildURL(path string) string { + return fmt.Sprintf("%s/%s", strings.TrimSuffix(c.BaseURL, "/"), strings.TrimPrefix(path, "/")) +} + +func (c *Client) execRequest(method, path string, body io.Reader, contentType string) ([]byte, int, error) { + return c.execRequestWithHeaders(method, path, body, contentType, nil) +} + +func (c *Client) execRequestWithHeaders( + method, path string, + body io.Reader, + contentType string, + headers map[string]string, +) ([]byte, int, error) { + req, err := http.NewRequest(method, c.buildURL(path), body) + if err != nil { + return nil, 0, fmt.Errorf("error building request: %v", err) + } + + req.Header.Set("Accept", "application/json") + if c.APIToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.APIToken)) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + for key, value := range headers { + req.Header.Set(key, value) + } + + res, err := c.http.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("error executing request: %v", err) + } + defer res.Body.Close() + + // Read one byte beyond the max to detect overflow without rejecting an exact-limit response. + limitedReader := io.LimitReader(res.Body, int64(maxResponseSize)+1) + responseBody, err := io.ReadAll(limitedReader) + if err != nil { + return nil, res.StatusCode, fmt.Errorf("error reading body: %v", err) + } + + if len(responseBody) > maxResponseSize { + return nil, res.StatusCode, fmt.Errorf("response too large: exceeds maximum size of %d bytes", maxResponseSize) + } + + return responseBody, res.StatusCode, nil +} + +func (c *Client) listContactPoints() ([]contactPoint, error) { + responseBody, status, err := c.execRequest(http.MethodGet, "/api/v1/provisioning/contact-points", nil, "") + if err != nil { + return nil, fmt.Errorf("error listing contact points: %v", err) + } + + if status < 200 || status >= 300 { + return nil, newAPIStatusError("grafana contact point list", status, responseBody) + } + + var direct []contactPoint + if err := json.Unmarshal(responseBody, &direct); err == nil { + return direct, nil + } + + wrapped := struct { + Items json.RawMessage `json:"items"` + }{} + if err := json.Unmarshal(responseBody, &wrapped); err == nil { + if wrapped.Items == nil || bytes.Equal(bytes.TrimSpace(wrapped.Items), []byte("null")) { + return nil, fmt.Errorf("error parsing contact points response") + } + + var items []contactPoint + if err := json.Unmarshal(wrapped.Items, &items); err != nil { + return nil, fmt.Errorf("error parsing contact points response") + } + + return items, nil + } + + return nil, fmt.Errorf("error parsing contact points response") +} + +func (c *Client) UpsertWebhookContactPoint(name, webhookURL, bearerToken string) (string, error) { + points, err := c.listContactPoints() + if err != nil { + return "", err + } + + existingUID := "" + for _, point := range points { + if strings.TrimSpace(point.Name) == name { + existingUID = strings.TrimSpace(point.UID) + break + } + } + + payload := map[string]any{ + "name": name, + "type": "webhook", + "disableResolveMessage": false, + "settings": map[string]any{ + "url": webhookURL, + "httpMethod": "POST", + }, + } + + if bearerToken != "" { + settings := payload["settings"].(map[string]any) + settings["authorization_scheme"] = "Bearer" + settings["authorization_credentials"] = bearerToken + } + + body, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("error marshaling contact point payload: %v", err) + } + + if existingUID != "" { + responseBody, status, err := c.execRequestWithHeaders( + http.MethodPut, + fmt.Sprintf("/api/v1/provisioning/contact-points/%s", existingUID), + bytes.NewReader(body), + "application/json", + map[string]string{ + "X-Disable-Provenance": "true", + }, + ) + if err != nil { + return "", fmt.Errorf("error updating contact point: %v", err) + } + if status < 200 || status >= 300 { + return "", newAPIStatusError("grafana contact point update", status, responseBody) + } + return existingUID, nil + } + + responseBody, status, err := c.execRequestWithHeaders( + http.MethodPost, + "/api/v1/provisioning/contact-points", + bytes.NewReader(body), + "application/json", + map[string]string{ + "X-Disable-Provenance": "true", + }, + ) + if err != nil { + return "", fmt.Errorf("error creating contact point: %v", err) + } + if status < 200 || status >= 300 { + return "", newAPIStatusError("grafana contact point create", status, responseBody) + } + + created := contactPoint{} + if err := json.Unmarshal(responseBody, &created); err == nil && strings.TrimSpace(created.UID) != "" { + return strings.TrimSpace(created.UID), nil + } + + refreshedPoints, err := c.listContactPoints() + if err != nil { + return "", err + } + + for _, point := range refreshedPoints { + if strings.TrimSpace(point.Name) == name && strings.TrimSpace(point.UID) != "" { + return strings.TrimSpace(point.UID), nil + } + } + + return "", fmt.Errorf("contact point created but uid was not returned") +} + +func (c *Client) DeleteContactPoint(uid string) error { + if strings.TrimSpace(uid) == "" { + return nil + } + + responseBody, status, err := c.execRequest(http.MethodDelete, fmt.Sprintf("/api/v1/provisioning/contact-points/%s", uid), nil, "") + if err != nil { + return fmt.Errorf("error deleting contact point: %v", err) + } + + if status == http.StatusNotFound { + return nil + } + + if status < 200 || status >= 300 { + return newAPIStatusError("grafana contact point delete", status, responseBody) + } + + return nil +} + +func (c *Client) ListDataSources() ([]DataSource, error) { + responseBody, status, err := c.execRequest(http.MethodGet, "/api/datasources", nil, "") + if err != nil { + return nil, fmt.Errorf("error listing data sources: %v", err) + } + + if status < 200 || status >= 300 { + return nil, newAPIStatusError("grafana data source list", status, responseBody) + } + + var sources []DataSource + if err := json.Unmarshal(responseBody, &sources); err != nil { + return nil, fmt.Errorf("error parsing data sources response: %v", err) + } + + return sources, nil +} diff --git a/pkg/integrations/grafana/client_test.go b/pkg/integrations/grafana/client_test.go new file mode 100644 index 000000000..0dc896dcf --- /dev/null +++ b/pkg/integrations/grafana/client_test.go @@ -0,0 +1,285 @@ +package grafana + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__readBaseURL__RejectsRelativeURL(t *testing.T) { + _, err := readBaseURL(&contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "grafana.local", + }, + }) + require.ErrorContains(t, err, "must include scheme and host") +} + +func Test__readBaseURL__AcceptsAbsoluteHTTPURL(t *testing.T) { + baseURL, err := readBaseURL(&contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com/", + }, + }) + require.NoError(t, err) + require.Equal(t, "https://grafana.example.com", baseURL) +} + +func Test__Client__ExecRequest__AllowsExactMaxSize(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), maxResponseSize))), + }, + }, + } + + client := &Client{ + BaseURL: "https://grafana.example.com", + http: httpContext, + } + + body, status, err := client.execRequest(http.MethodGet, "/api/health", nil, "") + require.NoError(t, err) + require.Equal(t, http.StatusOK, status) + require.Len(t, body, maxResponseSize) +} + +func Test__Client__ExecRequest__RejectsOverMaxSize(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), maxResponseSize+1))), + }, + }, + } + + client := &Client{ + BaseURL: "https://grafana.example.com", + http: httpContext, + } + + _, status, err := client.execRequest(http.MethodGet, "/api/health", nil, "") + require.ErrorContains(t, err, "response too large") + require.Equal(t, http.StatusOK, status) +} + +func Test__Grafana__Sync__RejectsRelativeBaseURL(t *testing.T) { + err := (&Grafana{}).Sync(core.SyncContext{ + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "grafana.local", + }, + Metadata: map[string]any{}, + }, + }) + require.ErrorContains(t, err, "must include scheme and host") +} + +func Test__Client__UpsertWebhookContactPoint__UpdatesExisting(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[{"uid":"cp_1","name":"superplane-123"}]`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"uid":"cp_1"}`)), + }, + }, + } + + client := &Client{ + BaseURL: "https://grafana.example.com", + APIToken: "token", + http: httpContext, + } + + uid, err := client.UpsertWebhookContactPoint("superplane-123", "https://example.com/webhook", "secret") + require.NoError(t, err) + require.Equal(t, "cp_1", uid) + require.Len(t, httpContext.Requests, 2) + require.Equal(t, http.MethodPut, httpContext.Requests[1].Method) + require.Equal(t, "true", httpContext.Requests[1].Header.Get("X-Disable-Provenance")) +} + +func Test__Client__UpsertWebhookContactPoint__CreatesAndFindsUID(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[]`)), + }, + { + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[{"uid":"cp_2","name":"superplane-abc"}]`)), + }, + }, + } + + client := &Client{ + BaseURL: "https://grafana.example.com", + APIToken: "token", + http: httpContext, + } + + uid, err := client.UpsertWebhookContactPoint("superplane-abc", "https://example.com/webhook", "") + require.NoError(t, err) + require.Equal(t, "cp_2", uid) + require.Len(t, httpContext.Requests, 3) + require.Equal(t, "true", httpContext.Requests[1].Header.Get("X-Disable-Provenance")) + + body, err := io.ReadAll(httpContext.Requests[1].Body) + require.NoError(t, err) + payload := map[string]any{} + require.NoError(t, json.Unmarshal(body, &payload)) + settings := payload["settings"].(map[string]any) + _, hasAuthScheme := settings["authorization_scheme"] + require.False(t, hasAuthScheme) +} + +func Test__Client__DeleteContactPoint__NotFoundIsIgnored(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`not found`)), + }, + }, + } + + client := &Client{ + BaseURL: "https://grafana.example.com", + APIToken: "token", + http: httpContext, + } + + err := client.DeleteContactPoint("cp_missing") + require.NoError(t, err) +} + +func Test__Client__listContactPoints__AcceptsWrappedItemsFormat(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"items":[{"uid":"cp_1","name":"superplane-1"}]}`)), + }, + }, + } + + client := &Client{ + BaseURL: "https://grafana.example.com", + APIToken: "token", + http: httpContext, + } + + points, err := client.listContactPoints() + require.NoError(t, err) + require.Len(t, points, 1) + require.Equal(t, "cp_1", points[0].UID) +} + +func Test__Client__listContactPoints__ErrorsWhenWrappedItemsFieldMissing(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"contactPoints":[{"uid":"cp_1","name":"superplane-1"}]}`)), + }, + }, + } + + client := &Client{ + BaseURL: "https://grafana.example.com", + APIToken: "token", + http: httpContext, + } + + _, err := client.listContactPoints() + require.ErrorContains(t, err, "error parsing contact points response") +} + +func Test__Client__ListDataSources(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[{"uid":"prom","name":"Prometheus"},{"uid":"loki","name":"Loki"}]`)), + }, + }, + } + + client := &Client{ + BaseURL: "https://grafana.example.com", + APIToken: "token", + http: httpContext, + } + + dataSources, err := client.ListDataSources() + require.NoError(t, err) + require.Len(t, dataSources, 2) + require.Equal(t, "prom", dataSources[0].UID) + require.Equal(t, "Prometheus", dataSources[0].Name) +} + +func Test__Grafana__ListResources(t *testing.T) { + g := &Grafana{} + + t.Run("unknown resource type returns empty", func(t *testing.T) { + resources, err := g.ListResources("unknown", core.ListResourcesContext{ + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + "apiToken": "token", + }, + }, + }) + require.NoError(t, err) + require.Empty(t, resources) + }) + + t.Run("data-source returns grafana datasources", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[ + {"uid":"prom","name":"Prometheus"}, + {"uid":"loki","name":"Loki"}, + {"uid":"","name":"Missing UID"} + ]`)), + }, + }, + } + + resources, err := g.ListResources(resourceTypeDataSource, core.ListResourcesContext{ + HTTP: httpContext, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + "apiToken": "token", + }, + }, + }) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, "Prometheus", resources[0].Name) + require.Equal(t, "prom", resources[0].ID) + require.Equal(t, resourceTypeDataSource, resources[0].Type) + }) +} diff --git a/pkg/integrations/grafana/example.go b/pkg/integrations/grafana/example.go new file mode 100644 index 000000000..fdbe6c3cc --- /dev/null +++ b/pkg/integrations/grafana/example.go @@ -0,0 +1,28 @@ +package grafana + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output_query_data_source.json +var exampleOutputQueryDataSourceBytes []byte + +//go:embed example_data_on_alert_firing.json +var exampleDataOnAlertFiringBytes []byte + +var exampleOutputQueryDataSourceOnce sync.Once +var exampleOutputQueryDataSource map[string]any + +var exampleDataOnAlertFiringOnce sync.Once +var exampleDataOnAlertFiring map[string]any + +func (q *QueryDataSource) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputQueryDataSourceOnce, exampleOutputQueryDataSourceBytes, &exampleOutputQueryDataSource) +} + +func (t *OnAlertFiring) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnAlertFiringOnce, exampleDataOnAlertFiringBytes, &exampleDataOnAlertFiring) +} diff --git a/pkg/integrations/grafana/example_data_on_alert_firing.json b/pkg/integrations/grafana/example_data_on_alert_firing.json new file mode 100644 index 000000000..9befa78ce --- /dev/null +++ b/pkg/integrations/grafana/example_data_on_alert_firing.json @@ -0,0 +1,25 @@ +{ + "data": { + "status": "firing", + "title": "High error rate", + "ruleUid": "alert_rule_uid", + "alerts": [ + { + "status": "firing", + "labels": { + "alertname": "HighErrorRate", + "service": "api" + }, + "annotations": { + "summary": "Error rate above threshold" + } + } + ], + "commonLabels": { + "alertname": "HighErrorRate" + }, + "externalURL": "http://grafana.local" + }, + "timestamp": "2026-02-12T16:18:03.362582388Z", + "type": "grafana.alert.firing" +} diff --git a/pkg/integrations/grafana/example_output_query_data_source.json b/pkg/integrations/grafana/example_output_query_data_source.json new file mode 100644 index 000000000..1a39f4389 --- /dev/null +++ b/pkg/integrations/grafana/example_output_query_data_source.json @@ -0,0 +1,32 @@ +{ + "type": "grafana.query.result", + "timestamp": "2026-02-12T16:18:03.362582388Z", + "data": { + "results": { + "A": { + "frames": [ + { + "schema": { + "fields": [ + { + "name": "time", + "type": "time" + }, + { + "name": "value", + "type": "number" + } + ] + }, + "data": { + "values": [ + ["2026-02-07T08:00:00Z", "2026-02-07T08:01:00Z"], + [1, 1] + ] + } + } + ] + } + } + } +} diff --git a/pkg/integrations/grafana/grafana.go b/pkg/integrations/grafana/grafana.go new file mode 100644 index 000000000..a7be7b782 --- /dev/null +++ b/pkg/integrations/grafana/grafana.go @@ -0,0 +1,145 @@ +package grafana + +import ( + "fmt" + "strings" + + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +const resourceTypeDataSource = "data-source" + +func init() { + registry.RegisterIntegrationWithWebhookHandler("grafana", &Grafana{}, &GrafanaWebhookHandler{}) +} + +type Grafana struct{} + +func (g *Grafana) Name() string { + return "grafana" +} + +func (g *Grafana) Label() string { + return "Grafana" +} + +func (g *Grafana) Icon() string { + return "grafana" +} + +func (g *Grafana) Description() string { + return "Connect Grafana alerts and data queries to SuperPlane workflows" +} + +func (g *Grafana) Instructions() string { + return ` +To connect Grafana: +1. In Grafana, go to Administration > Users and access > Service accounts. +2. Create a Service Account and assign a role (Viewer/Editor/Admin as needed). +3. Open the Service Account and create a token. Copy it immediately. +4. (Legacy Grafana) If Service Accounts are unavailable, use an API key. +5. Set the Base URL to your Grafana instance (e.g. https://grafana.example.com). +6. Paste the token into SuperPlane and save. + +For the alert trigger: +1. SuperPlane will attempt to automatically create/update a Grafana Webhook contact point. +2. Route your alert rule to the contact point created by SuperPlane. +3. If auto-provisioning is not available (permissions/API limitations), create a Webhook contact point manually using the webhook URL from SuperPlane. +` +} + +func (g *Grafana) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "baseURL", + Label: "Base URL", + Type: configuration.FieldTypeString, + Description: "Your Grafana base URL (e.g. https://grafana.example.com)", + Required: true, + }, + { + Name: "apiToken", + Label: "API Token", + Type: configuration.FieldTypeString, + Description: "Grafana API key or service account token", + Sensitive: true, + Required: false, + }, + } +} + +func (g *Grafana) Actions() []core.Action { + return []core.Action{} +} + +func (g *Grafana) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} + +func (g *Grafana) Components() []core.Component { + return []core.Component{ + &QueryDataSource{}, + } +} + +func (g *Grafana) Triggers() []core.Trigger { + return []core.Trigger{ + &OnAlertFiring{}, + } +} + +func (g *Grafana) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (g *Grafana) Sync(ctx core.SyncContext) error { + if _, err := readBaseURL(ctx.Integration); err != nil { + return err + } + + ctx.Integration.Ready() + return nil +} + +func (g *Grafana) HandleRequest(ctx core.HTTPRequestContext) { + ctx.Response.WriteHeader(404) +} + +func (g *Grafana) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + if resourceType != resourceTypeDataSource { + return []core.IntegrationResource{}, nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration, true) + if err != nil { + return nil, fmt.Errorf("error creating client: %w", err) + } + + dataSources, err := client.ListDataSources() + if err != nil { + return nil, err + } + + resources := make([]core.IntegrationResource, 0, len(dataSources)) + for _, source := range dataSources { + id := strings.TrimSpace(source.UID) + if id == "" { + continue + } + + name := strings.TrimSpace(source.Name) + if name == "" { + name = id + } + + resources = append(resources, core.IntegrationResource{ + Type: resourceTypeDataSource, + Name: name, + ID: id, + }) + } + + return resources, nil +} diff --git a/pkg/integrations/grafana/on_alert_firing.go b/pkg/integrations/grafana/on_alert_firing.go new file mode 100644 index 000000000..d4ff718fa --- /dev/null +++ b/pkg/integrations/grafana/on_alert_firing.go @@ -0,0 +1,230 @@ +package grafana + +import ( + "crypto/subtle" + "encoding/json" + "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 OnAlertFiring struct{} + +type OnAlertFiringConfig struct { + SharedSecret string `json:"sharedSecret"` + WebhookBindingKey string `json:"webhookBindingKey,omitempty" mapstructure:"webhookBindingKey"` +} + +func (t *OnAlertFiring) Name() string { + return "grafana.onAlertFiring" +} + +func (t *OnAlertFiring) Label() string { + return "On Alert Firing" +} + +func (t *OnAlertFiring) Description() string { + return "Trigger when a Grafana alert rule is firing" +} + +func (t *OnAlertFiring) Documentation() string { + return `The On Alert Firing trigger starts a workflow when Grafana Unified Alerting sends a firing alert webhook. + +## Setup + +1. SuperPlane attempts to automatically create/update a Grafana Webhook contact point for this trigger. +2. If you set a Shared Secret in this trigger, SuperPlane includes it as an Authorization bearer token in the provisioned contact point. +3. Route your alert rule to the contact point created by SuperPlane. +4. If auto-provisioning is unavailable (permissions/API limitations), create the contact point manually using the webhook URL generated by SuperPlane. +5. For manual setup with Shared Secret, add an HTTP header to the contact point: + - Authorization: Bearer + +## Event Data + +The trigger emits the full Grafana webhook payload, including: +- status (firing/resolved) +- alerts array with labels and annotations +- groupLabels, commonLabels, commonAnnotations +- externalURL and other alerting metadata +` +} + +func (t *OnAlertFiring) Icon() string { + return "alert-triangle" +} + +func (t *OnAlertFiring) Color() string { + return "gray" +} + +func (t *OnAlertFiring) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "sharedSecret", + Label: "Shared Secret", + Type: configuration.FieldTypeString, + Sensitive: true, + Required: false, + Description: "Optional shared secret that must be sent as Authorization: Bearer header", + Placeholder: "your-secret", + }, + } +} + +func (t *OnAlertFiring) Setup(ctx core.TriggerContext) error { + if ctx.Integration == nil { + return fmt.Errorf("missing integration context") + } + + config := OnAlertFiringConfig{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + bindingKey := getWebhookBindingKey(ctx) + if bindingKey == "" { + bindingKey = uuid.NewString() + } + + requestConfig := OnAlertFiringConfig{ + SharedSecret: strings.TrimSpace(config.SharedSecret), + WebhookBindingKey: bindingKey, + } + + if ctx.Webhook == nil { + return fmt.Errorf("missing webhook context") + } + + if err := ctx.Integration.RequestWebhook(requestConfig); err != nil { + return err + } + + webhookURL, err := ctx.Webhook.Setup() + if err != nil { + return err + } + + if ctx.Metadata != nil { + if err := setWebhookMetadata(ctx, webhookURL, bindingKey); err != nil && ctx.Logger != nil { + ctx.Logger.Warnf("grafana onAlertFiring: failed to store webhook url metadata: %v", err) + } + } + + return nil +} + +func (t *OnAlertFiring) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnAlertFiring) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnAlertFiring) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + sharedSecret, err := resolveWebhookSharedSecret(ctx) + if err != nil { + return http.StatusInternalServerError, err + } + + if sharedSecret != "" { + authHeader := strings.TrimSpace(ctx.Headers.Get("Authorization")) + if authHeader == "" { + return http.StatusUnauthorized, fmt.Errorf("missing Authorization header") + } + if !strings.HasPrefix(authHeader, "Bearer ") { + return http.StatusUnauthorized, fmt.Errorf("invalid Authorization header") + } + + token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if subtle.ConstantTimeCompare([]byte(token), []byte(sharedSecret)) != 1 { + return http.StatusUnauthorized, fmt.Errorf("invalid Authorization token") + } + } + + if len(ctx.Body) == 0 { + return http.StatusBadRequest, fmt.Errorf("empty body") + } + + var payload map[string]any + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err) + } + + if !isFiringAlert(payload) { + return http.StatusOK, nil + } + + if err := ctx.Events.Emit("grafana.alert.firing", payload); err != nil { + return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err) + } + + return http.StatusOK, nil +} + +func (t *OnAlertFiring) Cleanup(ctx core.TriggerContext) error { + return nil +} + +func isFiringAlert(payload map[string]any) bool { + return strings.EqualFold(extractString(payload["status"]), "firing") +} + +func extractString(value any) string { + text, ok := value.(string) + if !ok { + return "" + } + return strings.TrimSpace(text) +} + +func resolveWebhookSharedSecret(ctx core.WebhookRequestContext) (string, error) { + if ctx.Webhook != nil { + secret, err := ctx.Webhook.GetSecret() + if err == nil { + return strings.TrimSpace(string(secret)), nil + } + } + + // Backward compatibility for older records where sharedSecret lived in configuration. + config := OnAlertFiringConfig{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return "", fmt.Errorf("error decoding configuration: %v", err) + } + + return strings.TrimSpace(config.SharedSecret), nil +} + +func getWebhookBindingKey(ctx core.TriggerContext) string { + if ctx.Metadata == nil { + return "" + } + + existing := ctx.Metadata.Get() + existingMap, ok := existing.(map[string]any) + if !ok { + return "" + } + + return strings.TrimSpace(extractString(existingMap["webhookBindingKey"])) +} + +func setWebhookMetadata(ctx core.TriggerContext, webhookURL, bindingKey string) error { + metadata := map[string]any{} + if existing := ctx.Metadata.Get(); existing != nil { + if existingMap, ok := existing.(map[string]any); ok { + for key, value := range existingMap { + metadata[key] = value + } + } + } + + metadata["webhookUrl"] = webhookURL + metadata["webhookBindingKey"] = bindingKey + return ctx.Metadata.Set(metadata) +} diff --git a/pkg/integrations/grafana/on_alert_firing_test.go b/pkg/integrations/grafana/on_alert_firing_test.go new file mode 100644 index 000000000..512d35bcc --- /dev/null +++ b/pkg/integrations/grafana/on_alert_firing_test.go @@ -0,0 +1,269 @@ +package grafana + +import ( + "net/http" + "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__OnAlertFiring__HandleWebhook(t *testing.T) { + trigger := &OnAlertFiring{} + + payload := []byte(`{"status":"firing","alerts":[{"status":"firing"}]}`) + + t.Run("missing auth header when secret configured -> 401", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: http.Header{}, + Configuration: map[string]any{"sharedSecret": "secret"}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorContains(t, err, "missing Authorization header") + }) + + t.Run("invalid auth header format -> 401", func(t *testing.T) { + headers := http.Header{} + headers.Set("Authorization", "Token secret") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: headers, + Configuration: map[string]any{"sharedSecret": "secret"}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorContains(t, err, "invalid Authorization header") + }) + + t.Run("invalid auth token -> 401", func(t *testing.T) { + headers := http.Header{} + headers.Set("Authorization", "Bearer wrong") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: headers, + Configuration: map[string]any{"sharedSecret": "secret"}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorContains(t, err, "invalid Authorization token") + }) + + t.Run("valid auth token -> event emitted", func(t *testing.T) { + headers := http.Header{} + headers.Set("Authorization", "Bearer secret") + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: headers, + Configuration: map[string]any{"sharedSecret": "secret"}, + Events: eventContext, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + + assert.Equal(t, "grafana.alert.firing", eventContext.Payloads[0].Type) + }) + + t.Run("no secret configured -> event emitted without auth header", func(t *testing.T) { + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: payload, + Headers: http.Header{}, + Configuration: map[string]any{}, + Events: eventContext, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + }) + + t.Run("resolved top-level status with firing sub-alerts -> no event emitted", func(t *testing.T) { + mixedPayload := []byte(`{"status":"resolved","alerts":[{"status":"firing"}]}`) + eventContext := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: mixedPayload, + Headers: http.Header{}, + Configuration: map[string]any{}, + Events: eventContext, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 0, eventContext.Count()) + }) + + t.Run("firing top-level status with resolved sub-alerts -> event emitted", func(t *testing.T) { + mixedPayload := []byte(`{"status":"firing","alerts":[{"status":"resolved"}]}`) + eventContext := &contexts.EventContext{} + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: mixedPayload, + Headers: http.Header{}, + Configuration: map[string]any{}, + Events: eventContext, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + assert.Equal(t, "grafana.alert.firing", eventContext.Payloads[0].Type) + }) +} + +func Test__OnAlertFiring__Setup(t *testing.T) { + trigger := &OnAlertFiring{} + + t.Run("requests webhook through integration and stores webhook url metadata", func(t *testing.T) { + integrationContext := &contexts.IntegrationContext{} + metadataContext := &contexts.MetadataContext{} + webhookContext := &contexts.WebhookContext{} + + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"sharedSecret": "secret"}, + Integration: integrationContext, + Metadata: metadataContext, + Webhook: webhookContext, + }) + + require.NoError(t, err) + require.Len(t, integrationContext.WebhookRequests, 1) + + requestConfig, ok := integrationContext.WebhookRequests[0].(OnAlertFiringConfig) + require.True(t, ok) + require.Equal(t, "secret", requestConfig.SharedSecret) + require.NotEmpty(t, requestConfig.WebhookBindingKey) + + metadata, ok := metadataContext.Metadata.(map[string]any) + require.True(t, ok) + require.NotEmpty(t, metadata["webhookUrl"]) + require.Equal(t, requestConfig.WebhookBindingKey, metadata["webhookBindingKey"]) + }) + + t.Run("reuses existing webhook binding key", func(t *testing.T) { + integrationContext := &contexts.IntegrationContext{} + metadataContext := &contexts.MetadataContext{ + Metadata: map[string]any{ + "webhookBindingKey": "node-1-key", + }, + } + webhookContext := &contexts.WebhookContext{} + + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"sharedSecret": "secret"}, + Integration: integrationContext, + Metadata: metadataContext, + Webhook: webhookContext, + }) + + require.NoError(t, err) + require.Len(t, integrationContext.WebhookRequests, 1) + + requestConfig, ok := integrationContext.WebhookRequests[0].(OnAlertFiringConfig) + require.True(t, ok) + require.Equal(t, "node-1-key", requestConfig.WebhookBindingKey) + }) + + t.Run("missing integration context returns error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"sharedSecret": "secret"}, + }) + + require.ErrorContains(t, err, "missing integration context") + }) + + t.Run("missing webhook context returns error", func(t *testing.T) { + integrationContext := &contexts.IntegrationContext{} + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"sharedSecret": "secret"}, + Integration: integrationContext, + }) + + require.ErrorContains(t, err, "missing webhook context") + require.Len(t, integrationContext.WebhookRequests, 0) + }) +} + +func Test__OnAlertFiring__Setup(t *testing.T) { + trigger := &OnAlertFiring{} + + t.Run("requests webhook through integration and stores webhook url metadata", func(t *testing.T) { + integrationContext := &contexts.IntegrationContext{} + metadataContext := &contexts.MetadataContext{} + webhookContext := &contexts.WebhookContext{} + + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"sharedSecret": "secret"}, + Integration: integrationContext, + Metadata: metadataContext, + Webhook: webhookContext, + }) + + require.NoError(t, err) + require.Len(t, integrationContext.WebhookRequests, 1) + + requestConfig, ok := integrationContext.WebhookRequests[0].(OnAlertFiringConfig) + require.True(t, ok) + require.Equal(t, "secret", requestConfig.SharedSecret) + require.NotEmpty(t, requestConfig.WebhookBindingKey) + + metadata, ok := metadataContext.Metadata.(map[string]any) + require.True(t, ok) + require.NotEmpty(t, metadata["webhookUrl"]) + require.Equal(t, requestConfig.WebhookBindingKey, metadata["webhookBindingKey"]) + }) + + t.Run("reuses existing webhook binding key", func(t *testing.T) { + integrationContext := &contexts.IntegrationContext{} + metadataContext := &contexts.MetadataContext{ + Metadata: map[string]any{ + "webhookBindingKey": "node-1-key", + }, + } + webhookContext := &contexts.WebhookContext{} + + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"sharedSecret": "secret"}, + Integration: integrationContext, + Metadata: metadataContext, + Webhook: webhookContext, + }) + + require.NoError(t, err) + require.Len(t, integrationContext.WebhookRequests, 1) + + requestConfig, ok := integrationContext.WebhookRequests[0].(OnAlertFiringConfig) + require.True(t, ok) + require.Equal(t, "node-1-key", requestConfig.WebhookBindingKey) + }) + + t.Run("missing integration context returns error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"sharedSecret": "secret"}, + }) + + require.ErrorContains(t, err, "missing integration context") + }) + + t.Run("missing webhook context returns error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Configuration: map[string]any{"sharedSecret": "secret"}, + Integration: &contexts.IntegrationContext{}, + }) + + require.ErrorContains(t, err, "missing webhook context") + }) +} diff --git a/pkg/integrations/grafana/query_data_source.go b/pkg/integrations/grafana/query_data_source.go new file mode 100644 index 000000000..2407657a5 --- /dev/null +++ b/pkg/integrations/grafana/query_data_source.go @@ -0,0 +1,266 @@ +package grafana + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type QueryDataSource struct{} + +type QueryDataSourceSpec struct { + DataSourceUID string `json:"dataSourceUid"` + Query string `json:"query"` + TimeFrom *string `json:"timeFrom,omitempty"` + TimeTo *string `json:"timeTo,omitempty"` + Format *string `json:"format,omitempty"` +} + +type grafanaQueryRequest struct { + Queries []grafanaQuery `json:"queries"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` +} + +type grafanaQuery struct { + RefID string `json:"refId"` + Datasource any `json:"datasource,omitempty"` + Expr string `json:"expr,omitempty"` + Query string `json:"query,omitempty"` + Format string `json:"format,omitempty"` +} + +func (q *QueryDataSource) Name() string { + return "grafana.queryDataSource" +} + +func (q *QueryDataSource) Label() string { + return "Query Data Source" +} + +func (q *QueryDataSource) Description() string { + return "Execute a query against a Grafana data source and return the result" +} + +func (q *QueryDataSource) Documentation() string { + return `The Query Data Source component executes a query against a Grafana data source using the Grafana Query API. + +## Use Cases + +- **Metrics investigation**: Run PromQL or other datasource queries from workflows +- **Alert validation**: Validate alert conditions before escalation +- **Incident context**: Pull current metrics into incident workflows + +## Configuration + +- **Data Source UID**: The Grafana datasource UID to query +- **Query**: The datasource query (PromQL, InfluxQL, etc.) +- **Time From / Time To**: Optional time range (relative like "now-5m" or absolute) +- **Format**: Optional query format (depends on the datasource) + +## Output + +Returns the Grafana query API response JSON. +` +} + +func (q *QueryDataSource) Icon() string { + return "database" +} + +func (q *QueryDataSource) Color() string { + return "blue" +} + +func (q *QueryDataSource) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (q *QueryDataSource) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "dataSourceUid", + Label: "Data Source UID", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The Grafana datasource UID to query", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: resourceTypeDataSource, + }, + }, + }, + { + Name: "query", + Label: "Query", + Type: configuration.FieldTypeText, + Required: true, + Description: "The datasource query (PromQL, InfluxQL, etc.)", + Placeholder: "sum(rate(http_requests_total[5m]))", + }, + { + Name: "timeFrom", + Label: "Time From", + Type: configuration.FieldTypeString, + Required: false, + Description: "Start time (e.g. now-5m or 2024-01-01T00:00:00Z)", + Placeholder: "now-5m", + }, + { + Name: "timeTo", + Label: "Time To", + Type: configuration.FieldTypeString, + Required: false, + Description: "End time (e.g. now or 2024-01-01T01:00:00Z)", + Placeholder: "now", + }, + { + Name: "format", + Label: "Format", + Type: configuration.FieldTypeString, + Required: false, + Description: "Optional format passed to the datasource query", + }, + } +} + +func (q *QueryDataSource) Setup(ctx core.SetupContext) error { + spec, err := decodeQueryDataSourceSpec(ctx.Configuration) + if err != nil { + return err + } + + return validateQueryDataSourceSpec(spec) +} + +func (q *QueryDataSource) Execute(ctx core.ExecutionContext) error { + spec, err := decodeQueryDataSourceSpec(ctx.Configuration) + if err != nil { + return err + } + if err := validateQueryDataSourceSpec(spec); err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration, true) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + request := grafanaQueryRequest{ + Queries: []grafanaQuery{ + { + RefID: "A", + Datasource: map[string]string{"uid": strings.TrimSpace(spec.DataSourceUID)}, + Expr: strings.TrimSpace(spec.Query), + Query: strings.TrimSpace(spec.Query), + }, + }, + } + + if spec.TimeFrom != nil && strings.TrimSpace(*spec.TimeFrom) != "" { + request.From = strings.TrimSpace(*spec.TimeFrom) + } + + if spec.TimeTo != nil && strings.TrimSpace(*spec.TimeTo) != "" { + request.To = strings.TrimSpace(*spec.TimeTo) + } + + if request.From == "" || request.To == "" { + from, to := defaultTimeRange() + if request.From == "" { + request.From = from + } + if request.To == "" { + request.To = to + } + } + + if spec.Format != nil && strings.TrimSpace(*spec.Format) != "" { + request.Queries[0].Format = strings.TrimSpace(*spec.Format) + } + + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("error marshaling request: %v", err) + } + + responseBody, status, err := client.execRequest(http.MethodPost, "/api/ds/query", bytes.NewReader(body), "application/json") + if err != nil { + return fmt.Errorf("error querying data source: %v", err) + } + + if status < 200 || status >= 300 { + return fmt.Errorf("grafana query failed with status %d: %s", status, string(responseBody)) + } + + var response map[string]any + if err := json.Unmarshal(responseBody, &response); err != nil { + return fmt.Errorf("error parsing response: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "grafana.query.result", + []any{response}, + ) +} + +func (q *QueryDataSource) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (q *QueryDataSource) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (q *QueryDataSource) Actions() []core.Action { + return []core.Action{} +} + +func (q *QueryDataSource) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (q *QueryDataSource) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (q *QueryDataSource) Cleanup(ctx core.SetupContext) error { + return nil +} + +func defaultTimeRange() (string, string) { + now := time.Now().UTC() + from := now.Add(-5 * time.Minute) + return fmt.Sprintf("%d", from.UnixMilli()), fmt.Sprintf("%d", now.UnixMilli()) +} + +func decodeQueryDataSourceSpec(configuration any) (QueryDataSourceSpec, error) { + spec := QueryDataSourceSpec{} + if err := mapstructure.Decode(configuration, &spec); err != nil { + return QueryDataSourceSpec{}, fmt.Errorf("error decoding configuration: %v", err) + } + + return spec, nil +} + +func validateQueryDataSourceSpec(spec QueryDataSourceSpec) error { + if strings.TrimSpace(spec.DataSourceUID) == "" { + return errors.New("dataSourceUid is required") + } + if strings.TrimSpace(spec.Query) == "" { + return errors.New("query is required") + } + + return nil +} diff --git a/pkg/integrations/grafana/query_data_source_test.go b/pkg/integrations/grafana/query_data_source_test.go new file mode 100644 index 000000000..fbacb7f6e --- /dev/null +++ b/pkg/integrations/grafana/query_data_source_test.go @@ -0,0 +1,254 @@ +package grafana + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "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__QueryDataSource__Setup(t *testing.T) { + component := QueryDataSource{} + + t.Run("data source uid is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "dataSourceUid": "", + "query": "up", + }, + }) + + require.ErrorContains(t, err, "dataSourceUid is required") + }) + + t.Run("query is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "dataSourceUid": "logs", + "query": "", + }, + }) + + require.ErrorContains(t, err, "query is required") + }) + + t.Run("valid configuration passes", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "dataSourceUid": "logs", + "query": "{}", + }, + }) + + require.NoError(t, err) + }) +} + +func Test__QueryDataSource__Configuration__UsesIntegrationResourceForDataSource(t *testing.T) { + component := QueryDataSource{} + fields := component.Configuration() + + var dataSourceField *configuration.Field + for i := range fields { + if fields[i].Name == "dataSourceUid" { + dataSourceField = &fields[i] + break + } + } + + require.NotNil(t, dataSourceField) + require.Equal(t, configuration.FieldTypeIntegrationResource, dataSourceField.Type) + require.NotNil(t, dataSourceField.TypeOptions) + require.NotNil(t, dataSourceField.TypeOptions.Resource) + require.Equal(t, resourceTypeDataSource, dataSourceField.TypeOptions.Resource.Type) +} + +func Test__QueryDataSource__Execute(t *testing.T) { + component := QueryDataSource{} + + t.Run("invalid configuration returns validation error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "dataSourceUid": "", + "query": "up", + }, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://grafana.example.com", + }, + }, + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.ErrorContains(t, err, "dataSourceUid is required") + }) + + t.Run("successful query emits result", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "results": { + "A": {"frames": []} + } + }`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "dataSourceUid": "logs", + "query": "{}", + "timeFrom": "now-5m", + "timeTo": "now", + }, + HTTP: httpContext, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://grafana.example.com", + }, + }, + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.True(t, execCtx.Finished) + assert.True(t, execCtx.Passed) + assert.Equal(t, "grafana.query.result", execCtx.Type) + require.Len(t, execCtx.Payloads, 1) + }) + + t.Run("request payload uses datasource uid", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"results": {}}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "dataSourceUid": "bfcwd2pm79hj4c", + "query": "up", + "timeFrom": "now-5m", + "timeTo": "now", + }, + HTTP: httpContext, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://grafana.example.com", + }, + }, + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 1) + + request := httpContext.Requests[0] + assert.Equal(t, http.MethodPost, request.Method) + assert.True(t, strings.HasSuffix(request.URL.String(), "/api/ds/query")) + + body := decodeJSONBody(t, request.Body) + queries := body["queries"].([]any) + query := queries[0].(map[string]any) + datasource := query["datasource"].(map[string]any) + + assert.Equal(t, "bfcwd2pm79hj4c", datasource["uid"]) + assert.Equal(t, "up", query["query"]) + assert.Equal(t, "up", query["expr"]) + }) + + t.Run("defaults time range when missing", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"results": {}}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "dataSourceUid": "logs", + "query": "{}", + }, + HTTP: httpContext, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://grafana.example.com", + }, + }, + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 1) + + body := decodeJSONBody(t, httpContext.Requests[0].Body) + require.NotEmpty(t, body["from"]) + require.NotEmpty(t, body["to"]) + + _, err = strconv.ParseInt(body["from"].(string), 10, 64) + require.NoError(t, err) + _, err = strconv.ParseInt(body["to"].(string), 10, 64) + require.NoError(t, err) + }) + + t.Run("non-2xx response returns error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader("bad request")), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "dataSourceUid": "logs", + "query": "{}", + }, + HTTP: httpContext, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://grafana.example.com", + }, + }, + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.ErrorContains(t, err, "grafana query failed with status 400") + }) +} + +func decodeJSONBody(t *testing.T, body io.ReadCloser) map[string]any { + t.Helper() + + raw, err := io.ReadAll(body) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(raw, &payload)) + return payload +} diff --git a/pkg/integrations/grafana/webhook_handler.go b/pkg/integrations/grafana/webhook_handler.go new file mode 100644 index 000000000..1139313fe --- /dev/null +++ b/pkg/integrations/grafana/webhook_handler.go @@ -0,0 +1,163 @@ +package grafana + +import ( + "crypto/sha256" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +type GrafanaWebhookHandler struct{} + +type GrafanaWebhookMetadata struct { + ContactPointUID string `json:"contactPointUid" mapstructure:"contactPointUid"` +} + +func (h *GrafanaWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + config := OnAlertFiringConfig{} + if err := mapstructure.Decode(ctx.Webhook.GetConfiguration(), &config); err != nil { + return nil, fmt.Errorf("error decoding webhook configuration: %v", err) + } + + sharedSecret := strings.TrimSpace(config.SharedSecret) + if err := ctx.Webhook.SetSecret([]byte(sharedSecret)); err != nil { + return nil, fmt.Errorf("failed to persist shared secret in webhook storage: %w", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration, true) + if err != nil { + if ctx.Logger != nil { + ctx.Logger.Warnf("grafana webhook setup: falling back to manual setup (client unavailable): %v", err) + } + return nil, nil + } + + name := buildContactPointName(ctx.Webhook.GetID()) + + uid, err := client.UpsertWebhookContactPoint(name, ctx.Webhook.GetURL(), sharedSecret) + if err != nil { + if !shouldFallbackToManualSetup(err) { + return nil, fmt.Errorf("grafana webhook setup: contact point provisioning will be retried: %w", err) + } + + if ctx.Logger != nil { + ctx.Logger.Warnf("grafana webhook setup: falling back to manual setup (contact point provisioning failed): %v", err) + } + return nil, nil + } + + return GrafanaWebhookMetadata{ + ContactPointUID: uid, + }, nil +} + +func (h *GrafanaWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + if ctx.Webhook.GetMetadata() == nil { + return nil + } + + metadata := GrafanaWebhookMetadata{} + if err := mapstructure.Decode(ctx.Webhook.GetMetadata(), &metadata); err != nil { + return err + } + + contactPointUID := strings.TrimSpace(metadata.ContactPointUID) + if contactPointUID == "" { + return nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration, true) + if err != nil { + return err + } + + return client.DeleteContactPoint(contactPointUID) +} + +func (h *GrafanaWebhookHandler) CompareConfig(a, b any) (bool, error) { + configA := OnAlertFiringConfig{} + if err := mapstructure.Decode(a, &configA); err != nil { + return false, err + } + + configB := OnAlertFiringConfig{} + if err := mapstructure.Decode(b, &configB); err != nil { + return false, err + } + + bindingKeyA := strings.TrimSpace(configA.WebhookBindingKey) + bindingKeyB := strings.TrimSpace(configB.WebhookBindingKey) + if bindingKeyA != "" || bindingKeyB != "" { + return bindingKeyA != "" && bindingKeyA == bindingKeyB, nil + } + + return strings.TrimSpace(configA.SharedSecret) == strings.TrimSpace(configB.SharedSecret), nil +} + +func (h *GrafanaWebhookHandler) Merge(current, requested any) (any, bool, error) { + currentConfig := OnAlertFiringConfig{} + if err := mapstructure.Decode(current, ¤tConfig); err != nil { + return nil, false, err + } + + requestedConfig := OnAlertFiringConfig{} + if err := mapstructure.Decode(requested, &requestedConfig); err != nil { + return nil, false, err + } + + sharedSecretProvided := false + webhookBindingKeyProvided := false + if requestedMap, ok := requested.(map[string]any); ok { + _, sharedSecretProvided = requestedMap["sharedSecret"] + _, webhookBindingKeyProvided = requestedMap["webhookBindingKey"] + } + + mergedSharedSecret := strings.TrimSpace(currentConfig.SharedSecret) + if sharedSecretProvided { + mergedSharedSecret = strings.TrimSpace(requestedConfig.SharedSecret) + } + + mergedWebhookBindingKey := strings.TrimSpace(currentConfig.WebhookBindingKey) + if webhookBindingKeyProvided && strings.TrimSpace(requestedConfig.WebhookBindingKey) != "" { + mergedWebhookBindingKey = strings.TrimSpace(requestedConfig.WebhookBindingKey) + } + + merged := OnAlertFiringConfig{ + SharedSecret: mergedSharedSecret, + WebhookBindingKey: mergedWebhookBindingKey, + } + + changed := strings.TrimSpace(currentConfig.SharedSecret) != merged.SharedSecret || + strings.TrimSpace(currentConfig.WebhookBindingKey) != merged.WebhookBindingKey + return merged, changed, nil +} + +func buildContactPointName(webhookID string) string { + hash := sha256.New() + hash.Write([]byte(webhookID)) + suffix := fmt.Sprintf("%x", hash.Sum(nil)) + return fmt.Sprintf("superplane-%s", suffix[:16]) +} + +func shouldFallbackToManualSetup(err error) bool { + var statusErr *apiStatusError + if !errors.As(err, &statusErr) { + return false + } + + switch statusErr.StatusCode { + case http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + http.StatusMethodNotAllowed, + http.StatusUnprocessableEntity: + return true + default: + return false + } +} diff --git a/pkg/integrations/grafana/webhook_handler_test.go b/pkg/integrations/grafana/webhook_handler_test.go new file mode 100644 index 000000000..9e24ba8a5 --- /dev/null +++ b/pkg/integrations/grafana/webhook_handler_test.go @@ -0,0 +1,367 @@ +package grafana + +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" +) + +type testWebhookContext struct { + id string + url string + configuration any + metadata any + secret []byte +} + +func (t *testWebhookContext) GetID() string { return t.id } +func (t *testWebhookContext) GetURL() string { return t.url } +func (t *testWebhookContext) GetSecret() ([]byte, error) { return t.secret, nil } +func (t *testWebhookContext) GetMetadata() any { return t.metadata } +func (t *testWebhookContext) GetConfiguration() any { return t.configuration } +func (t *testWebhookContext) SetSecret(secret []byte) error { t.secret = secret; return nil } + +func Test__GrafanaWebhookHandler__Setup__ProvisionContactPoint(t *testing.T) { + handler := &GrafanaWebhookHandler{} + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[]`)), + }, + { + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{"uid":"cp_123","name":"ignored"}`)), + }, + }, + } + + webhookCtx := &testWebhookContext{ + id: "wh_123", + url: "https://example.com/webhook", + configuration: map[string]any{"sharedSecret": "top-secret"}, + } + + metadata, err := handler.Setup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + "apiToken": "token", + }, + }, + }) + + require.NoError(t, err) + require.NotNil(t, metadata) + + result, ok := metadata.(GrafanaWebhookMetadata) + require.True(t, ok) + assert.Equal(t, "cp_123", result.ContactPointUID) + assert.Equal(t, []byte("top-secret"), webhookCtx.secret) + + require.Len(t, httpCtx.Requests, 2) + assert.Equal(t, http.MethodGet, httpCtx.Requests[0].Method) + assert.True(t, strings.HasSuffix(httpCtx.Requests[0].URL.String(), "/api/v1/provisioning/contact-points")) + assert.Equal(t, http.MethodPost, httpCtx.Requests[1].Method) + assert.True(t, strings.HasSuffix(httpCtx.Requests[1].URL.String(), "/api/v1/provisioning/contact-points")) + + body, err := io.ReadAll(httpCtx.Requests[1].Body) + require.NoError(t, err) + + payload := map[string]any{} + require.NoError(t, json.Unmarshal(body, &payload)) + settings, ok := payload["settings"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://example.com/webhook", settings["url"]) + assert.Equal(t, "Bearer", settings["authorization_scheme"]) + assert.Equal(t, "top-secret", settings["authorization_credentials"]) +} + +func Test__GrafanaWebhookHandler__Setup__ManualFallbackWhenClientUnavailable(t *testing.T) { + handler := &GrafanaWebhookHandler{} + webhookCtx := &testWebhookContext{ + id: "wh_123", + url: "https://example.com/webhook", + configuration: map[string]any{"sharedSecret": "top-secret"}, + } + + metadata, err := handler.Setup(core.WebhookHandlerContext{ + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + }, + }, + }) + + require.NoError(t, err) + assert.Nil(t, metadata) + assert.Equal(t, []byte("top-secret"), webhookCtx.secret) +} + +func Test__GrafanaWebhookHandler__Setup__ManualFallbackOnNonRetriableProvisioningError(t *testing.T) { + handler := &GrafanaWebhookHandler{} + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`forbidden`)), + }, + }, + } + webhookCtx := &testWebhookContext{ + id: "wh_123", + url: "https://example.com/webhook", + configuration: map[string]any{"sharedSecret": "top-secret"}, + } + + metadata, err := handler.Setup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + "apiToken": "token", + }, + }, + }) + + require.NoError(t, err) + assert.Nil(t, metadata) + require.Len(t, httpCtx.Requests, 1) + assert.Equal(t, http.MethodGet, httpCtx.Requests[0].Method) +} + +func Test__GrafanaWebhookHandler__Setup__RetriesOnRetriableProvisioningError(t *testing.T) { + handler := &GrafanaWebhookHandler{} + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`internal error`)), + }, + }, + } + webhookCtx := &testWebhookContext{ + id: "wh_123", + url: "https://example.com/webhook", + configuration: map[string]any{"sharedSecret": "top-secret"}, + } + + metadata, err := handler.Setup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + "apiToken": "token", + }, + }, + }) + + require.ErrorContains(t, err, "will be retried") + assert.Nil(t, metadata) + require.Len(t, httpCtx.Requests, 1) + assert.Equal(t, http.MethodGet, httpCtx.Requests[0].Method) +} + +func Test__GrafanaWebhookHandler__Setup__ManualFallbackOnNonRetriableProvisioningError(t *testing.T) { + handler := &GrafanaWebhookHandler{} + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`forbidden`)), + }, + }, + } + webhookCtx := &testWebhookContext{ + id: "wh_123", + url: "https://example.com/webhook", + configuration: map[string]any{"sharedSecret": "top-secret"}, + } + + metadata, err := handler.Setup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + "apiToken": "token", + }, + }, + }) + + require.NoError(t, err) + assert.Nil(t, metadata) + require.Len(t, httpCtx.Requests, 1) + assert.Equal(t, http.MethodGet, httpCtx.Requests[0].Method) +} + +func Test__GrafanaWebhookHandler__Setup__RetriesOnRetriableProvisioningError(t *testing.T) { + handler := &GrafanaWebhookHandler{} + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`internal error`)), + }, + }, + } + webhookCtx := &testWebhookContext{ + id: "wh_123", + url: "https://example.com/webhook", + configuration: map[string]any{"sharedSecret": "top-secret"}, + } + + metadata, err := handler.Setup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + "apiToken": "token", + }, + }, + }) + + require.ErrorContains(t, err, "will be retried") + assert.Nil(t, metadata) + require.Len(t, httpCtx.Requests, 1) + assert.Equal(t, http.MethodGet, httpCtx.Requests[0].Method) +} + +func Test__GrafanaWebhookHandler__Cleanup(t *testing.T) { + handler := &GrafanaWebhookHandler{} + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader(``)), + }, + }, + } + webhookCtx := &testWebhookContext{ + metadata: map[string]any{"contactPointUid": "cp_123"}, + } + + err := handler.Cleanup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + "apiToken": "token", + }, + }, + }) + + require.NoError(t, err) + require.Len(t, httpCtx.Requests, 1) + assert.Equal(t, http.MethodDelete, httpCtx.Requests[0].Method) + assert.True(t, strings.HasSuffix(httpCtx.Requests[0].URL.String(), "/api/v1/provisioning/contact-points/cp_123")) +} + +func Test__GrafanaWebhookHandler__Cleanup__NoContactPointUIDWithoutTokenIsNoOp(t *testing.T) { + handler := &GrafanaWebhookHandler{} + httpCtx := &contexts.HTTPContext{} + webhookCtx := &testWebhookContext{ + metadata: map[string]any{}, + } + + err := handler.Cleanup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + }, + }, + }) + + require.NoError(t, err) + require.Len(t, httpCtx.Requests, 0) +} + +func Test__GrafanaWebhookHandler__Cleanup__NilMetadataIsNoOp(t *testing.T) { + handler := &GrafanaWebhookHandler{} + httpCtx := &contexts.HTTPContext{} + webhookCtx := &testWebhookContext{ + metadata: nil, + } + + err := handler.Cleanup(core.WebhookHandlerContext{ + HTTP: httpCtx, + Webhook: webhookCtx, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseURL": "https://grafana.example.com", + }, + }, + }) + + require.NoError(t, err) + require.Len(t, httpCtx.Requests, 0) +} + +func Test__GrafanaWebhookHandler__CompareConfig(t *testing.T) { + handler := &GrafanaWebhookHandler{} + + equal, err := handler.CompareConfig( + map[string]any{"webhookBindingKey": "node-1"}, + map[string]any{"webhookBindingKey": " node-1 "}, + ) + require.NoError(t, err) + assert.True(t, equal) + + equal, err = handler.CompareConfig( + map[string]any{"webhookBindingKey": "node-1", "sharedSecret": "secret-a"}, + map[string]any{"webhookBindingKey": "node-2", "sharedSecret": "secret-a"}, + ) + require.NoError(t, err) + assert.False(t, equal) + + equal, err = handler.CompareConfig( + map[string]any{"sharedSecret": "secret"}, + map[string]any{"sharedSecret": " secret "}, + ) + require.NoError(t, err) + assert.True(t, equal) +} + +func Test__GrafanaWebhookHandler__Merge(t *testing.T) { + handler := &GrafanaWebhookHandler{} + + merged, changed, err := handler.Merge( + map[string]any{"sharedSecret": "old", "webhookBindingKey": "node-1"}, + map[string]any{"sharedSecret": " new ", "webhookBindingKey": "node-1"}, + ) + require.NoError(t, err) + require.True(t, changed) + assert.Equal(t, OnAlertFiringConfig{SharedSecret: "new", WebhookBindingKey: "node-1"}, merged) + + merged, changed, err = handler.Merge( + map[string]any{"sharedSecret": " same ", "webhookBindingKey": "node-1"}, + map[string]any{"sharedSecret": "same", "webhookBindingKey": "node-1"}, + ) + require.NoError(t, err) + require.False(t, changed) + assert.Equal(t, OnAlertFiringConfig{SharedSecret: "same", WebhookBindingKey: "node-1"}, merged) + + merged, changed, err = handler.Merge( + map[string]any{"sharedSecret": "keep-existing", "webhookBindingKey": "node-1"}, + map[string]any{}, + ) + require.NoError(t, err) + require.False(t, changed) + assert.Equal(t, OnAlertFiringConfig{SharedSecret: "keep-existing", WebhookBindingKey: "node-1"}, merged) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index fdc996af8..770815e4a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -45,6 +45,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/dockerhub" _ "github.com/superplanehq/superplane/pkg/integrations/github" _ "github.com/superplanehq/superplane/pkg/integrations/gitlab" + _ "github.com/superplanehq/superplane/pkg/integrations/grafana" _ "github.com/superplanehq/superplane/pkg/integrations/hetzner" _ "github.com/superplanehq/superplane/pkg/integrations/jira" _ "github.com/superplanehq/superplane/pkg/integrations/openai" diff --git a/pkg/workers/contexts/integration_context.go b/pkg/workers/contexts/integration_context.go index 6b195b6c2..a0600c2b7 100644 --- a/pkg/workers/contexts/integration_context.go +++ b/pkg/workers/contexts/integration_context.go @@ -233,6 +233,28 @@ func (c *IntegrationContext) GetConfig(name string) ([]byte, error) { return c.encryptor.Decrypt(context.Background(), []byte(decoded), []byte(c.integration.ID.String())) } +func (c *IntegrationContext) GetOptionalConfig(name string) ([]byte, error) { + config := c.integration.Configuration.Data() + _, ok := config[name] + if !ok { + impl, err := c.registry.GetIntegration(c.integration.AppName) + if err != nil { + return nil, fmt.Errorf("failed to get integration %s: %w", c.integration.AppName, err) + } + + configDef, err := findConfigDef(impl.Configuration(), name) + if err != nil { + return nil, fmt.Errorf("failed to find config %s: %w", name, err) + } + + if !configDef.Required { + return nil, nil + } + } + + return c.GetConfig(name) +} + func findConfigDef(configs []configuration.Field, name string) (configuration.Field, error) { for _, config := range configs { if config.Name == name { diff --git a/web_src/src/assets/icons/integrations/grafana.svg b/web_src/src/assets/icons/integrations/grafana.svg new file mode 100644 index 000000000..9457f19c8 --- /dev/null +++ b/web_src/src/assets/icons/integrations/grafana.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web_src/src/pages/workflowv2/mappers/grafana/index.ts b/web_src/src/pages/workflowv2/mappers/grafana/index.ts new file mode 100644 index 000000000..94bc76693 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/grafana/index.ts @@ -0,0 +1,20 @@ +import { ComponentBaseMapper, CustomFieldRenderer, TriggerRenderer, EventStateRegistry } from "../types"; +import { buildActionStateRegistry } from "../utils"; +import { onAlertFiringCustomFieldRenderer, onAlertFiringTriggerRenderer } from "./on_alert_firing"; +import { queryDataSourceMapper } from "./query_data_source"; + +export const componentMappers: Record = { + queryDataSource: queryDataSourceMapper, +}; + +export const triggerRenderers: Record = { + onAlertFiring: onAlertFiringTriggerRenderer, +}; + +export const customFieldRenderers: Record = { + onAlertFiring: onAlertFiringCustomFieldRenderer, +}; + +export const eventStateRegistry: Record = { + queryDataSource: buildActionStateRegistry("queried"), +}; diff --git a/web_src/src/pages/workflowv2/mappers/grafana/on_alert_firing.tsx b/web_src/src/pages/workflowv2/mappers/grafana/on_alert_firing.tsx new file mode 100644 index 000000000..f6532f383 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/grafana/on_alert_firing.tsx @@ -0,0 +1,179 @@ +import { useState, type FC } from "react"; +import { getBackgroundColorClass } from "@/utils/colors"; +import { formatTimeAgo } from "@/utils/date"; +import { CustomFieldRenderer, NodeInfo, TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import grafanaIcon from "@/assets/icons/integrations/grafana.svg"; +import { OnAlertFiringEventData } from "./types"; +import { stringOrDash } from "../utils"; +import { formatTimestamp } from "./utils"; +import { Icon } from "@/components/Icon"; +import { showErrorToast } from "@/utils/toast"; + +/** + * Renderer for the "grafana.onAlertFiring" trigger + */ +export const onAlertFiringTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnAlertFiringEventData | undefined; + const alertName = getAlertName(eventData); + const status = eventData?.status || "firing"; + const subtitle = buildSubtitle(status, context.event?.createdAt); + + return { + title: alertName || "Grafana alert firing", + subtitle, + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnAlertFiringEventData | undefined; + const createdAt = formatTimestamp(context.event?.createdAt); + + return { + "Triggered At": createdAt, + Status: stringOrDash(eventData?.status || "firing"), + "Alert Name": stringOrDash(getAlertName(eventData)), + "Rule UID": stringOrDash(eventData?.ruleUid), + "Rule ID": stringOrDash(eventData?.ruleId), + "Org ID": stringOrDash(eventData?.orgId), + "External URL": stringOrDash(eventData?.externalURL), + }; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const metadataItems = []; + + if (lastEvent?.data) { + const eventData = lastEvent.data as OnAlertFiringEventData; + const alertName = getAlertName(eventData); + if (alertName) { + metadataItems.push({ + icon: "bell", + label: alertName, + }); + } + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: grafanaIcon, + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnAlertFiringEventData | undefined; + const status = eventData?.status || "firing"; + const alertName = getAlertName(eventData); + const subtitle = buildSubtitle(status, lastEvent.createdAt); + + props.lastEventData = { + title: alertName || "Grafana alert firing", + subtitle, + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; + +interface OnAlertFiringMetadata { + webhookUrl?: string; + webhook_url?: string; + url?: string; +} + +const CopyWebhookUrlButton: FC<{ webhookUrl: string }> = ({ webhookUrl }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(webhookUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (_err) { + showErrorToast("Failed to copy webhook URL"); + } + }; + + return ( + + ); +}; + +export const onAlertFiringCustomFieldRenderer: CustomFieldRenderer = { + render: (node: NodeInfo) => { + const metadata = node.metadata as OnAlertFiringMetadata | undefined; + const webhookUrl = + metadata?.webhookUrl || metadata?.webhook_url || metadata?.url || "[URL GENERATED ONCE THE CANVAS IS SAVED]"; + + return ( +
+
+
+ Grafana Contact Point Setup +
+
    +
  1. Save the canvas to generate the webhook URL.
  2. +
  3. SuperPlane auto-provisions a Grafana webhook contact point in the background after save.
  4. +
  5. If it is not created immediately, wait a moment and re-open the node.
  6. +
  7. If provisioning still fails, create/update the contact point manually using the URL below.
  8. +
+
+
+ Webhook URL + +
+
+                  {webhookUrl}
+                
+
+
+
+
+
+ ); + }, +}; + +function getAlertName(eventData?: OnAlertFiringEventData): string | undefined { + if (!eventData) return undefined; + + if (eventData.title && eventData.title.trim() !== "") { + return eventData.title; + } + + const commonLabel = eventData.commonLabels?.alertname; + if (commonLabel && commonLabel.trim() !== "") { + return commonLabel; + } + + const firstAlert = eventData.alerts?.[0]; + const labelName = firstAlert?.labels?.alertname; + if (labelName && labelName.trim() !== "") { + return labelName; + } + + return undefined; +} + +function buildSubtitle(status: string, createdAt?: string): string { + const timeAgo = createdAt ? formatTimeAgo(new Date(createdAt)) : "-"; + if (status) { + return `${status} - ${timeAgo}`; + } + + return timeAgo; +} diff --git a/web_src/src/pages/workflowv2/mappers/grafana/query_data_source.ts b/web_src/src/pages/workflowv2/mappers/grafana/query_data_source.ts new file mode 100644 index 000000000..d8236bf09 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/grafana/query_data_source.ts @@ -0,0 +1,109 @@ +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { MetadataItem } from "@/ui/metadataList"; +import grafanaIcon from "@/assets/icons/integrations/grafana.svg"; +import { QueryDataSourceConfiguration } from "./types"; +import { formatTimeAgo } from "@/utils/date"; +import { formatTimestamp } from "./utils"; + +export const queryDataSourceMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + iconSrc: grafanaIcon, + collapsedBackground: "bg-white", + collapsed: context.node.isCollapsed, + title: context.node.name || context.componentDefinition.label || "Unnamed component", + 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; + const details: Record = { + "Queried At": formatTimestamp(context.execution.createdAt), + }; + + if (!outputs || !outputs.default || outputs.default.length === 0) { + details.Response = "No data returned"; + return details; + } + + const payload = outputs.default[0]; + const responseData = payload?.data as Record | undefined; + const payloadTimestamp = formatTimestamp(payload?.timestamp); + if (payloadTimestamp !== "-") { + details["Queried At"] = payloadTimestamp; + } + + if (!responseData) { + details.Response = "No data returned"; + return details; + } + + try { + details["Response Data"] = JSON.stringify(responseData, null, 2); + } catch (error) { + details["Response Data"] = String(responseData); + } + + return details; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) return "-"; + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as QueryDataSourceConfiguration; + + if (configuration?.dataSourceUid) { + metadata.push({ icon: "database", label: `Datasource: ${configuration.dataSourceUid}` }); + } + + if (configuration?.query) { + const preview = + configuration.query.length > 50 ? configuration.query.substring(0, 50) + "..." : configuration.query; + metadata.push({ icon: "code", label: preview }); + } + + if (configuration?.format) { + metadata.push({ icon: "funnel", label: `Format: ${configuration.format}` }); + } + + return metadata; +} + +function baseEventSections(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 }); + const eventTitle = title || "Trigger event"; + + return [ + { + receivedAt: execution.createdAt ? new Date(execution.createdAt) : undefined, + eventTitle: eventTitle, + 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/grafana/types.ts b/web_src/src/pages/workflowv2/mappers/grafana/types.ts new file mode 100644 index 000000000..9a3a69e2a --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/grafana/types.ts @@ -0,0 +1,26 @@ +export interface OnAlertFiringEventData { + status?: string; + title?: string; + ruleUid?: string; + ruleId?: number; + orgId?: number; + externalURL?: string; + alerts?: Array<{ + status?: string; + labels?: Record; + annotations?: Record; + startsAt?: string; + endsAt?: string; + }>; + groupLabels?: Record; + commonLabels?: Record; + commonAnnotations?: Record; +} + +export interface QueryDataSourceConfiguration { + dataSourceUid: string; + query: string; + timeFrom?: string; + timeTo?: string; + format?: string; +} diff --git a/web_src/src/pages/workflowv2/mappers/grafana/utils.ts b/web_src/src/pages/workflowv2/mappers/grafana/utils.ts new file mode 100644 index 000000000..56091685b --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/grafana/utils.ts @@ -0,0 +1,8 @@ +import { formatTimestampInUserTimezone } from "@/utils/timezone"; + +export function formatTimestamp(value?: string): string { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return formatTimestampInUserTimezone(date); +} diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index d996d130b..6e2c62514 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -85,6 +85,7 @@ import { triggerRenderers as awsTriggerRenderers, eventStateRegistry as awsEventStateRegistry, } from "./aws"; +import { triggerRenderers as bitbucketTriggerRenderers } from "./bitbucket/index"; import { componentMappers as hetznerComponentMappers } from "./hetzner/index"; import { timeGateMapper, TIME_GATE_STATE_REGISTRY } from "./timegate"; import { @@ -97,6 +98,12 @@ import { triggerRenderers as openaiTriggerRenderers, eventStateRegistry as openaiEventStateRegistry, } from "./openai/index"; +import { + componentMappers as grafanaComponentMappers, + customFieldRenderers as grafanaCustomFieldRenderers, + triggerRenderers as grafanaTriggerRenderers, + eventStateRegistry as grafanaEventStateRegistry, +} from "./grafana/index"; import { componentMappers as circleCIComponentMappers, triggerRenderers as circleCITriggerRenderers, @@ -107,7 +114,6 @@ import { triggerRenderers as claudeTriggerRenderers, eventStateRegistry as claudeEventStateRegistry, } from "./claude/index"; -import { triggerRenderers as bitbucketTriggerRenderers } from "./bitbucket/index"; import { componentMappers as prometheusComponentMappers, customFieldRenderers as prometheusCustomFieldRenderers, @@ -161,6 +167,7 @@ const appMappers: Record> = { semaphore: semaphoreComponentMappers, github: githubComponentMappers, gitlab: gitlabComponentMappers, + grafana: grafanaComponentMappers, pagerduty: pagerdutyComponentMappers, dash0: dash0ComponentMappers, daytona: daytonaComponentMappers, @@ -200,6 +207,7 @@ const appTriggerRenderers: Record> = { openai: openaiTriggerRenderers, circleci: circleCITriggerRenderers, claude: claudeTriggerRenderers, + grafana: grafanaTriggerRenderers, bitbucket: bitbucketTriggerRenderers, prometheus: prometheusTriggerRenderers, cursor: cursorTriggerRenderers, @@ -224,6 +232,7 @@ const appEventStateRegistries: Record circleci: circleCIEventStateRegistry, claude: claudeEventStateRegistry, aws: awsEventStateRegistry, + grafana: grafanaEventStateRegistry, prometheus: prometheusEventStateRegistry, cursor: cursorEventStateRegistry, gitlab: gitlabEventStateRegistry, @@ -253,6 +262,7 @@ const customFieldRenderers: Record = { const appCustomFieldRenderers: Record> = { github: githubCustomFieldRenderers, + grafana: grafanaCustomFieldRenderers, prometheus: prometheusCustomFieldRenderers, dockerhub: dockerhubCustomFieldRenderers, }; diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index fda6b78d9..c37570985 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -23,6 +23,7 @@ import discordIcon from "@/assets/icons/integrations/discord.svg"; import githubIcon from "@/assets/icons/integrations/github.svg"; import gitlabIcon from "@/assets/icons/integrations/gitlab.svg"; import jiraIcon from "@/assets/icons/integrations/jira.svg"; +import grafanaIcon from "@/assets/icons/integrations/grafana.svg"; import openAiIcon from "@/assets/icons/integrations/openai.svg"; import claudeIcon from "@/assets/icons/integrations/claude.svg"; import cursorIcon from "@/assets/icons/integrations/cursor.svg"; @@ -403,7 +404,6 @@ function CategorySection({ // Determine category icon const appLogoMap: Record> = { - bitbucket: bitbucketIcon, circleci: circleciIcon, cloudflare: cloudflareIcon, dash0: dash0Icon, @@ -413,6 +413,7 @@ function CategorySection({ github: githubIcon, gitlab: gitlabIcon, hetzner: hetznerIcon, + grafana: grafanaIcon, jira: jiraIcon, openai: openAiIcon, "open-ai": openAiIcon, @@ -497,6 +498,7 @@ function CategorySection({ github: githubIcon, gitlab: gitlabIcon, hetzner: hetznerIcon, + grafana: grafanaIcon, openai: openAiIcon, "open-ai": openAiIcon, claude: claudeIcon, diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index a89057385..bc81d7d62 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -2,13 +2,15 @@ import { resolveIcon } from "@/lib/utils"; import React from "react"; import awsIcon from "@/assets/icons/integrations/aws.svg"; import awsLambdaIcon from "@/assets/icons/integrations/aws.lambda.svg"; -import bitbucketIcon from "@/assets/icons/integrations/bitbucket.svg"; import awsEcsIcon from "@/assets/icons/integrations/aws.ecs.svg"; import circleciIcon from "@/assets/icons/integrations/circleci.svg"; import awsCloudwatchIcon from "@/assets/icons/integrations/aws.cloudwatch.svg"; -import awsRoute53Icon from "@/assets/icons/integrations/aws.route53.svg"; import awsSnsIcon from "@/assets/icons/integrations/aws.sns.svg"; +import bitbucketIcon from "@/assets/icons/integrations/bitbucket.svg"; +import awsRoute53Icon from "@/assets/icons/integrations/aws.route53.svg"; import awsEc2Icon from "@/assets/icons/integrations/aws.ec2.svg"; +import awsEcrIcon from "@/assets/icons/integrations/aws.ecr.svg"; +import awsCodeArtifactIcon from "@/assets/icons/integrations/aws.codeartifact.svg"; import cloudflareIcon from "@/assets/icons/integrations/cloudflare.svg"; import dash0Icon from "@/assets/icons/integrations/dash0.svg"; import datadogIcon from "@/assets/icons/integrations/datadog.svg"; @@ -16,6 +18,7 @@ import daytonaIcon from "@/assets/icons/integrations/daytona.svg"; import discordIcon from "@/assets/icons/integrations/discord.svg"; import githubIcon from "@/assets/icons/integrations/github.svg"; import gitlabIcon from "@/assets/icons/integrations/gitlab.svg"; +import grafanaIcon from "@/assets/icons/integrations/grafana.svg"; import jiraIcon from "@/assets/icons/integrations/jira.svg"; import openAiIcon from "@/assets/icons/integrations/openai.svg"; import claudeIcon from "@/assets/icons/integrations/claude.svg"; @@ -44,6 +47,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { github: githubIcon, gitlab: gitlabIcon, hetzner: hetznerIcon, + grafana: grafanaIcon, jira: jiraIcon, openai: openAiIcon, "open-ai": openAiIcon, @@ -72,6 +76,7 @@ export const APP_LOGO_MAP: Record> = { github: githubIcon, gitlab: gitlabIcon, hetzner: hetznerIcon, + grafana: grafanaIcon, jira: jiraIcon, openai: openAiIcon, "open-ai": openAiIcon, @@ -81,12 +86,15 @@ export const APP_LOGO_MAP: Record> = { rootly: rootlyIcon, semaphore: SemaphoreLogo, slack: slackIcon, + smtp: smtpIcon, sendgrid: sendgridIcon, prometheus: prometheusIcon, render: renderIcon, dockerhub: dockerIcon, aws: { cloudwatch: awsCloudwatchIcon, + codeArtifact: awsCodeArtifactIcon, + ecr: awsEcrIcon, lambda: awsLambdaIcon, ec2: awsEc2Icon, route53: awsRoute53Icon, @@ -120,7 +128,7 @@ export function getHeaderIconSrc(blockName: string | undefined): string | undefi const nested = appLogo[nameParts[1]]; if (nested) return nested; } - // AWS has a nested map (lambda only); use main AWS icon for other aws.* components + // Use main AWS icon for aws.* components without a dedicated sub-icon mapping. if (first === "aws") return getIntegrationIconSrc("aws"); return undefined; }