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
+
+
+ - Save the canvas to generate the webhook URL.
+ - SuperPlane auto-provisions a Grafana webhook contact point in the background after save.
+ - If it is not created immediately, wait a moment and re-open the node.
+ - If provisioning still fails, create/update the contact point manually using the URL below.
+
+
+
+ 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;
}