diff --git a/docs/components/DigitalOcean.mdx b/docs/components/DigitalOcean.mdx
new file mode 100644
index 000000000..7edd2b953
--- /dev/null
+++ b/docs/components/DigitalOcean.mdx
@@ -0,0 +1,96 @@
+---
+title: "DigitalOcean"
+---
+
+Manage and monitor your DigitalOcean infrastructure
+
+import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+## Actions
+
+
+
+
+
+## Instructions
+
+## Create a DigitalOcean Personal Access Token
+
+1. Open the [DigitalOcean API Tokens page](https://cloud.digitalocean.com/account/api/tokens)
+2. Click **Generate New Token**
+3. Configure the token:
+ - **Token name**: SuperPlane Integration
+ - **Expiration**: No expiry (or choose an appropriate expiration)
+ - **Scopes**: Select **Full Access** (or customize as needed)
+4. Click **Generate Token**
+5. Copy the token and paste it below
+
+> **Note**: The token is only shown once. Store it securely if needed elsewhere.
+
+
+
+## Create Droplet
+
+The Create Droplet component creates a new droplet in DigitalOcean.
+
+### Use Cases
+
+- **Infrastructure provisioning**: Automatically provision droplets from workflow events
+- **Scaling**: Create new instances in response to load or alerts
+- **Environment setup**: Spin up droplets for testing or staging environments
+
+### Configuration
+
+- **Name**: The hostname for the droplet (required, supports expressions)
+- **Region**: Region slug where the droplet will be created (required)
+- **Size**: Size slug for the droplet (required)
+- **Image**: Image slug or ID for the droplet OS (required)
+- **SSH Keys**: SSH key fingerprints or IDs to add to the droplet (optional)
+- **Tags**: Tags to apply to the droplet (optional)
+- **User Data**: Cloud-init user data script (optional)
+
+### Output
+
+Returns the created droplet object including:
+- **id**: Droplet ID
+- **name**: Droplet hostname
+- **status**: Current droplet status
+- **region**: Region information
+- **networks**: Network information including IP addresses
+
+### Example Output
+
+```json
+{
+ "data": {
+ "disk": 25,
+ "id": 98765432,
+ "image": {
+ "id": 12345,
+ "name": "Ubuntu 24.04 (LTS) x64",
+ "slug": "ubuntu-24-04-x64"
+ },
+ "memory": 1024,
+ "name": "my-droplet",
+ "networks": {
+ "v4": [
+ {
+ "ip_address": "104.131.186.241",
+ "type": "public"
+ }
+ ]
+ },
+ "region": {
+ "name": "New York 3",
+ "slug": "nyc3"
+ },
+ "size_slug": "s-1vcpu-1gb",
+ "status": "new",
+ "tags": [
+ "web"
+ ],
+ "vcpus": 1
+ }
+}
+```
+
diff --git a/pkg/configuration/validation.go b/pkg/configuration/validation.go
index 64b906975..6bbbbb96d 100644
--- a/pkg/configuration/validation.go
+++ b/pkg/configuration/validation.go
@@ -319,7 +319,7 @@ func validateList(field Field, value any) error {
return fmt.Errorf("must contain at least one item")
}
- if field.TypeOptions.List == nil {
+ if field.TypeOptions == nil || field.TypeOptions.List == nil {
return nil
}
diff --git a/pkg/integrations/digitalocean/client.go b/pkg/integrations/digitalocean/client.go
new file mode 100644
index 000000000..8ff340b17
--- /dev/null
+++ b/pkg/integrations/digitalocean/client.go
@@ -0,0 +1,311 @@
+package digitalocean
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const baseURL = "https://api.digitalocean.com/v2"
+
+type Client struct {
+ Token string
+ http core.HTTPContext
+ BaseURL string
+}
+
+type DOAPIError struct {
+ StatusCode int
+ Body []byte
+}
+
+func (e *DOAPIError) Error() string {
+ return fmt.Sprintf("request got %d code: %s", e.StatusCode, string(e.Body))
+}
+
+func NewClient(http core.HTTPContext, ctx core.IntegrationContext) (*Client, error) {
+ apiToken, err := ctx.GetConfig("apiToken")
+ if err != nil {
+ return nil, fmt.Errorf("error finding API token: %v", err)
+ }
+
+ return &Client{
+ Token: string(apiToken),
+ http: http,
+ BaseURL: baseURL,
+ }, nil
+}
+
+func (c *Client) execRequest(method, url string, body io.Reader) ([]byte, error) {
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, fmt.Errorf("error building request: %v", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token))
+
+ res, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("error executing request: %v", err)
+ }
+ defer res.Body.Close()
+
+ responseBody, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading body: %v", err)
+ }
+
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ return nil, &DOAPIError{
+ StatusCode: res.StatusCode,
+ Body: responseBody,
+ }
+ }
+
+ return responseBody, nil
+}
+
+// Account represents a DigitalOcean account
+type Account struct {
+ Email string `json:"email"`
+ UUID string `json:"uuid"`
+ Status string `json:"status"`
+ DropletLimit int `json:"droplet_limit"`
+}
+
+// GetAccount validates the API token by fetching account info
+func (c *Client) GetAccount() (*Account, error) {
+ url := fmt.Sprintf("%s/account", c.BaseURL)
+ responseBody, err := c.execRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var response struct {
+ Account Account `json:"account"`
+ }
+
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return &response.Account, nil
+}
+
+// Region represents a DigitalOcean region
+type Region struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Available bool `json:"available"`
+}
+
+// ListRegions retrieves all available regions
+func (c *Client) ListRegions() ([]Region, error) {
+ url := fmt.Sprintf("%s/regions", c.BaseURL)
+ responseBody, err := c.execRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var response struct {
+ Regions []Region `json:"regions"`
+ }
+
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return response.Regions, nil
+}
+
+// Size represents a DigitalOcean droplet size
+type Size struct {
+ Slug string `json:"slug"`
+ Memory int `json:"memory"`
+ VCPUs int `json:"vcpus"`
+ Disk int `json:"disk"`
+ PriceMonthly float64 `json:"price_monthly"`
+ Available bool `json:"available"`
+}
+
+// ListSizes retrieves all available droplet sizes
+func (c *Client) ListSizes() ([]Size, error) {
+ url := fmt.Sprintf("%s/sizes?per_page=200", c.BaseURL)
+ responseBody, err := c.execRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var response struct {
+ Sizes []Size `json:"sizes"`
+ }
+
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return response.Sizes, nil
+}
+
+// Image represents a DigitalOcean image
+type Image struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Type string `json:"type"`
+ Distribution string `json:"distribution"`
+}
+
+// ListImages retrieves images of a given type (e.g., "distribution")
+func (c *Client) ListImages(imageType string) ([]Image, error) {
+ url := fmt.Sprintf("%s/images?type=%s&per_page=200", c.BaseURL, imageType)
+ responseBody, err := c.execRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var response struct {
+ Images []Image `json:"images"`
+ }
+
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return response.Images, nil
+}
+
+// CreateDropletRequest is the payload for creating a droplet
+type CreateDropletRequest struct {
+ Name string `json:"name"`
+ Region string `json:"region"`
+ Size string `json:"size"`
+ Image string `json:"image"`
+ SSHKeys []string `json:"ssh_keys,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ UserData string `json:"user_data,omitempty"`
+}
+
+// Droplet represents a DigitalOcean droplet
+type Droplet struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Memory int `json:"memory"`
+ VCPUs int `json:"vcpus"`
+ Disk int `json:"disk"`
+ Status string `json:"status"`
+ Region DropletRegion `json:"region"`
+ Image DropletImage `json:"image"`
+ SizeSlug string `json:"size_slug"`
+ Networks DropletNetworks `json:"networks"`
+ Tags []string `json:"tags"`
+}
+
+type DropletRegion struct {
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+}
+
+type DropletImage struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+}
+
+type DropletNetworks struct {
+ V4 []DropletNetworkV4 `json:"v4"`
+}
+
+type DropletNetworkV4 struct {
+ IPAddress string `json:"ip_address"`
+ Type string `json:"type"`
+}
+
+// CreateDroplet creates a new droplet
+func (c *Client) CreateDroplet(req CreateDropletRequest) (*Droplet, error) {
+ url := fmt.Sprintf("%s/droplets", c.BaseURL)
+
+ body, err := json.Marshal(req)
+ if err != nil {
+ return nil, fmt.Errorf("error marshaling request: %v", err)
+ }
+
+ responseBody, err := c.execRequest(http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+
+ var response struct {
+ Droplet Droplet `json:"droplet"`
+ }
+
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return &response.Droplet, nil
+}
+
+// GetDroplet retrieves a droplet by its ID
+func (c *Client) GetDroplet(dropletID int) (*Droplet, error) {
+ url := fmt.Sprintf("%s/droplets/%d", c.BaseURL, dropletID)
+ responseBody, err := c.execRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var response struct {
+ Droplet Droplet `json:"droplet"`
+ }
+
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return &response.Droplet, nil
+}
+
+// DOAction represents a DigitalOcean action
+type DOAction struct {
+ ID int `json:"id"`
+ Status string `json:"status"`
+ Type string `json:"type"`
+ StartedAt string `json:"started_at"`
+ CompletedAt string `json:"completed_at"`
+ ResourceID int `json:"resource_id"`
+ ResourceType string `json:"resource_type"`
+ RegionSlug string `json:"region_slug"`
+}
+
+// ListActions retrieves actions filtered by resource type.
+// The DigitalOcean /v2/actions API does not support resource_type as a query
+// parameter, so we fetch all recent actions and filter client-side.
+func (c *Client) ListActions(resourceType string) ([]DOAction, error) {
+ url := fmt.Sprintf("%s/actions?page=1&per_page=50", c.BaseURL)
+ responseBody, err := c.execRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var response struct {
+ Actions []DOAction `json:"actions"`
+ }
+
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ filtered := make([]DOAction, 0, len(response.Actions))
+ for _, a := range response.Actions {
+ if a.ResourceType == resourceType {
+ filtered = append(filtered, a)
+ }
+ }
+
+ return filtered, nil
+}
diff --git a/pkg/integrations/digitalocean/create_droplet.go b/pkg/integrations/digitalocean/create_droplet.go
new file mode 100644
index 000000000..b1927523d
--- /dev/null
+++ b/pkg/integrations/digitalocean/create_droplet.go
@@ -0,0 +1,310 @@
+package digitalocean
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "regexp"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const dropletPollInterval = 10 * time.Second
+
+var validHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.\-]*$`)
+
+type CreateDroplet struct{}
+
+type CreateDropletSpec struct {
+ Name string `json:"name"`
+ Region string `json:"region"`
+ Size string `json:"size"`
+ Image string `json:"image"`
+ SSHKeys []string `json:"sshKeys"`
+ Tags []string `json:"tags"`
+ UserData string `json:"userData"`
+}
+
+func (c *CreateDroplet) Name() string {
+ return "digitalocean.createDroplet"
+}
+
+func (c *CreateDroplet) Label() string {
+ return "Create Droplet"
+}
+
+func (c *CreateDroplet) Description() string {
+ return "Create a new DigitalOcean Droplet"
+}
+
+func (c *CreateDroplet) Documentation() string {
+ return `The Create Droplet component creates a new droplet in DigitalOcean.
+
+## Use Cases
+
+- **Infrastructure provisioning**: Automatically provision droplets from workflow events
+- **Scaling**: Create new instances in response to load or alerts
+- **Environment setup**: Spin up droplets for testing or staging environments
+
+## Configuration
+
+- **Name**: The hostname for the droplet (required, supports expressions)
+- **Region**: Region slug where the droplet will be created (required)
+- **Size**: Size slug for the droplet (required)
+- **Image**: Image slug or ID for the droplet OS (required)
+- **SSH Keys**: SSH key fingerprints or IDs to add to the droplet (optional)
+- **Tags**: Tags to apply to the droplet (optional)
+- **User Data**: Cloud-init user data script (optional)
+
+## Output
+
+Returns the created droplet object including:
+- **id**: Droplet ID
+- **name**: Droplet hostname
+- **status**: Current droplet status
+- **region**: Region information
+- **networks**: Network information including IP addresses`
+}
+
+func (c *CreateDroplet) Icon() string {
+ return "server"
+}
+
+func (c *CreateDroplet) Color() string {
+ return "gray"
+}
+
+func (c *CreateDroplet) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *CreateDroplet) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "name",
+ Label: "Droplet Name",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "The hostname for the droplet",
+ },
+ {
+ Name: "region",
+ Label: "Region",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The region where the droplet will be created",
+ Placeholder: "Select a region",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "region",
+ },
+ },
+ },
+ {
+ Name: "size",
+ Label: "Size",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The size (CPU/RAM) for the droplet",
+ Placeholder: "Select a size",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "size",
+ },
+ },
+ },
+ {
+ Name: "image",
+ Label: "Image",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The OS image for the droplet",
+ Placeholder: "Select an image",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "image",
+ },
+ },
+ },
+ {
+ Name: "sshKeys",
+ Label: "SSH Keys",
+ Type: configuration.FieldTypeList,
+ Required: false,
+ Togglable: true,
+ Description: "SSH key fingerprints or IDs to add to the droplet",
+ TypeOptions: &configuration.TypeOptions{
+ List: &configuration.ListTypeOptions{
+ ItemLabel: "SSH Key",
+ ItemDefinition: &configuration.ListItemDefinition{
+ Type: configuration.FieldTypeString,
+ },
+ },
+ },
+ },
+ {
+ Name: "tags",
+ Label: "Tags",
+ Type: configuration.FieldTypeList,
+ Required: false,
+ Togglable: true,
+ Description: "Tags to apply to the droplet",
+ TypeOptions: &configuration.TypeOptions{
+ List: &configuration.ListTypeOptions{
+ ItemLabel: "Tag",
+ ItemDefinition: &configuration.ListItemDefinition{
+ Type: configuration.FieldTypeString,
+ },
+ },
+ },
+ },
+ {
+ Name: "userData",
+ Label: "User Data",
+ Type: configuration.FieldTypeText,
+ Required: false,
+ Togglable: true,
+ Description: "Cloud-init user data script",
+ },
+ }
+}
+
+func (c *CreateDroplet) Setup(ctx core.SetupContext) error {
+ spec := CreateDropletSpec{}
+ err := mapstructure.Decode(ctx.Configuration, &spec)
+ if err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ if spec.Name == "" {
+ return errors.New("name is required")
+ }
+
+ if spec.Region == "" {
+ return errors.New("region is required")
+ }
+
+ if spec.Size == "" {
+ return errors.New("size is required")
+ }
+
+ if spec.Image == "" {
+ return errors.New("image is required")
+ }
+
+ return nil
+}
+
+func validateHostname(name string) error {
+ if !validHostnameRegex.MatchString(name) {
+ return fmt.Errorf("invalid droplet name %q: only letters (a-z, A-Z), numbers (0-9), hyphens (-) and dots (.) are allowed", name)
+ }
+ return nil
+}
+
+func (c *CreateDroplet) Execute(ctx core.ExecutionContext) error {
+ spec := CreateDropletSpec{}
+ err := mapstructure.Decode(ctx.Configuration, &spec)
+ if err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ if err := validateHostname(spec.Name); err != nil {
+ return err
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ droplet, err := client.CreateDroplet(CreateDropletRequest{
+ Name: spec.Name,
+ Region: spec.Region,
+ Size: spec.Size,
+ Image: spec.Image,
+ SSHKeys: spec.SSHKeys,
+ Tags: spec.Tags,
+ UserData: spec.UserData,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create droplet: %v", err)
+ }
+
+ err = ctx.Metadata.Set(map[string]any{"dropletID": droplet.ID})
+ if err != nil {
+ return fmt.Errorf("failed to store metadata: %v", err)
+ }
+
+ return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval)
+}
+
+func (c *CreateDroplet) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *CreateDroplet) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *CreateDroplet) Actions() []core.Action {
+ return []core.Action{
+ {
+ Name: "poll",
+ UserAccessible: false,
+ },
+ }
+}
+
+func (c *CreateDroplet) HandleAction(ctx core.ActionContext) error {
+ if ctx.Name != "poll" {
+ return fmt.Errorf("unknown action: %s", ctx.Name)
+ }
+
+ if ctx.ExecutionState.IsFinished() {
+ return nil
+ }
+
+ var metadata struct {
+ DropletID int `mapstructure:"dropletID"`
+ }
+
+ if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil {
+ return fmt.Errorf("failed to decode metadata: %v", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ droplet, err := client.GetDroplet(metadata.DropletID)
+ if err != nil {
+ return fmt.Errorf("failed to get droplet: %v", err)
+ }
+
+ switch droplet.Status {
+ case "active":
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "digitalocean.droplet.created",
+ []any{droplet},
+ )
+ case "new":
+ return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, dropletPollInterval)
+ default:
+ return fmt.Errorf("droplet reached unexpected status %q", droplet.Status)
+ }
+}
+
+func (c *CreateDroplet) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ return http.StatusOK, nil
+}
+
+func (c *CreateDroplet) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
diff --git a/pkg/integrations/digitalocean/create_droplet_test.go b/pkg/integrations/digitalocean/create_droplet_test.go
new file mode 100644
index 000000000..e12624c87
--- /dev/null
+++ b/pkg/integrations/digitalocean/create_droplet_test.go
@@ -0,0 +1,355 @@
+package digitalocean
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__CreateDroplet__Setup(t *testing.T) {
+ component := &CreateDroplet{}
+
+ t.Run("missing name returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "region": "nyc3",
+ "size": "s-1vcpu-1gb",
+ "image": "ubuntu-24-04-x64",
+ },
+ })
+
+ require.ErrorContains(t, err, "name is required")
+ })
+
+ t.Run("missing region returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "name": "my-droplet",
+ "size": "s-1vcpu-1gb",
+ "image": "ubuntu-24-04-x64",
+ },
+ })
+
+ require.ErrorContains(t, err, "region is required")
+ })
+
+ t.Run("missing size returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "name": "my-droplet",
+ "region": "nyc3",
+ "image": "ubuntu-24-04-x64",
+ },
+ })
+
+ require.ErrorContains(t, err, "size is required")
+ })
+
+ t.Run("missing image returns error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "name": "my-droplet",
+ "region": "nyc3",
+ "size": "s-1vcpu-1gb",
+ },
+ })
+
+ require.ErrorContains(t, err, "image is required")
+ })
+
+ t.Run("expression name is accepted at setup time", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "name": "{{ $.trigger.data.hostname }}",
+ "region": "nyc3",
+ "size": "s-1vcpu-1gb",
+ "image": "ubuntu-24-04-x64",
+ },
+ })
+
+ require.NoError(t, err)
+ })
+
+ t.Run("valid configuration -> no error", func(t *testing.T) {
+ err := component.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "name": "my-droplet",
+ "region": "nyc3",
+ "size": "s-1vcpu-1gb",
+ "image": "ubuntu-24-04-x64",
+ },
+ })
+
+ require.NoError(t, err)
+ })
+}
+
+func Test__CreateDroplet__Execute(t *testing.T) {
+ component := &CreateDroplet{}
+
+ t.Run("successful creation -> stores metadata and schedules poll", func(t *testing.T) {
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusCreated,
+ Body: io.NopCloser(strings.NewReader(`{
+ "droplet": {
+ "id": 98765432,
+ "name": "my-droplet",
+ "memory": 1024,
+ "vcpus": 1,
+ "disk": 25,
+ "status": "new",
+ "region": {"name": "New York 3", "slug": "nyc3"},
+ "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"},
+ "size_slug": "s-1vcpu-1gb",
+ "networks": {"v4": []},
+ "tags": ["web"]
+ }
+ }`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "test-token",
+ },
+ }
+
+ metadataCtx := &contexts.MetadataContext{}
+ requestCtx := &contexts.RequestContext{}
+ executionState := &contexts.ExecutionStateContext{
+ KVs: map[string]string{},
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "name": "my-droplet",
+ "region": "nyc3",
+ "size": "s-1vcpu-1gb",
+ "image": "ubuntu-24-04-x64",
+ "tags": []string{"web"},
+ },
+ HTTP: httpContext,
+ Integration: integrationCtx,
+ ExecutionState: executionState,
+ Metadata: metadataCtx,
+ Requests: requestCtx,
+ })
+
+ require.NoError(t, err)
+
+ // Should store droplet ID in metadata
+ metadata, ok := metadataCtx.Metadata.(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, 98765432, metadata["dropletID"])
+
+ // Should schedule a poll action
+ assert.Equal(t, "poll", requestCtx.Action)
+ assert.Equal(t, 10*time.Second, requestCtx.Duration)
+
+ // Should NOT emit yet (waiting for active status)
+ assert.False(t, executionState.Passed)
+ })
+
+ t.Run("API error -> returns error", func(t *testing.T) {
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusUnprocessableEntity,
+ Body: io.NopCloser(strings.NewReader(`{"id":"unprocessable_entity","message":"Name is already in use"}`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "test-token",
+ },
+ }
+
+ executionState := &contexts.ExecutionStateContext{
+ KVs: map[string]string{},
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Configuration: map[string]any{
+ "name": "my-droplet",
+ "region": "nyc3",
+ "size": "s-1vcpu-1gb",
+ "image": "ubuntu-24-04-x64",
+ },
+ HTTP: httpContext,
+ Integration: integrationCtx,
+ ExecutionState: executionState,
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to create droplet")
+ })
+}
+
+func Test__CreateDroplet__HandleAction(t *testing.T) {
+ component := &CreateDroplet{}
+
+ t.Run("droplet active -> emits with IP address", func(t *testing.T) {
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{
+ "droplet": {
+ "id": 98765432,
+ "name": "my-droplet",
+ "memory": 1024,
+ "vcpus": 1,
+ "disk": 25,
+ "status": "active",
+ "region": {"name": "New York 3", "slug": "nyc3"},
+ "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"},
+ "size_slug": "s-1vcpu-1gb",
+ "networks": {"v4": [{"ip_address": "104.131.186.241", "type": "public"}]},
+ "tags": ["web"]
+ }
+ }`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "test-token",
+ },
+ }
+
+ metadataCtx := &contexts.MetadataContext{
+ Metadata: map[string]any{"dropletID": 98765432},
+ }
+
+ executionState := &contexts.ExecutionStateContext{
+ KVs: map[string]string{},
+ }
+
+ err := component.HandleAction(core.ActionContext{
+ Name: "poll",
+ HTTP: httpContext,
+ Integration: integrationCtx,
+ Metadata: metadataCtx,
+ ExecutionState: executionState,
+ Requests: &contexts.RequestContext{},
+ })
+
+ require.NoError(t, err)
+ assert.True(t, executionState.Passed)
+ assert.Equal(t, "default", executionState.Channel)
+ assert.Equal(t, "digitalocean.droplet.created", executionState.Type)
+ })
+
+ t.Run("droplet still new -> schedules another poll", func(t *testing.T) {
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{
+ "droplet": {
+ "id": 98765432,
+ "name": "my-droplet",
+ "status": "new",
+ "region": {"name": "New York 3", "slug": "nyc3"},
+ "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"},
+ "size_slug": "s-1vcpu-1gb",
+ "networks": {"v4": []},
+ "tags": []
+ }
+ }`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "test-token",
+ },
+ }
+
+ metadataCtx := &contexts.MetadataContext{
+ Metadata: map[string]any{"dropletID": 98765432},
+ }
+
+ requestCtx := &contexts.RequestContext{}
+ executionState := &contexts.ExecutionStateContext{
+ KVs: map[string]string{},
+ }
+
+ err := component.HandleAction(core.ActionContext{
+ Name: "poll",
+ HTTP: httpContext,
+ Integration: integrationCtx,
+ Metadata: metadataCtx,
+ ExecutionState: executionState,
+ Requests: requestCtx,
+ })
+
+ require.NoError(t, err)
+ assert.False(t, executionState.Passed)
+ assert.Equal(t, "poll", requestCtx.Action)
+ assert.Equal(t, 10*time.Second, requestCtx.Duration)
+ })
+
+ t.Run("droplet terminal status -> returns error", func(t *testing.T) {
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{
+ "droplet": {
+ "id": 98765432,
+ "name": "my-droplet",
+ "status": "off",
+ "region": {"name": "New York 3", "slug": "nyc3"},
+ "image": {"id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64"},
+ "size_slug": "s-1vcpu-1gb",
+ "networks": {"v4": []},
+ "tags": []
+ }
+ }`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "test-token",
+ },
+ }
+
+ metadataCtx := &contexts.MetadataContext{
+ Metadata: map[string]any{"dropletID": 98765432},
+ }
+
+ executionState := &contexts.ExecutionStateContext{
+ KVs: map[string]string{},
+ }
+
+ err := component.HandleAction(core.ActionContext{
+ Name: "poll",
+ HTTP: httpContext,
+ Integration: integrationCtx,
+ Metadata: metadataCtx,
+ ExecutionState: executionState,
+ Requests: &contexts.RequestContext{},
+ })
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unexpected status")
+ assert.False(t, executionState.Passed)
+ })
+}
diff --git a/pkg/integrations/digitalocean/digitalocean.go b/pkg/integrations/digitalocean/digitalocean.go
new file mode 100644
index 000000000..36dcbb62d
--- /dev/null
+++ b/pkg/integrations/digitalocean/digitalocean.go
@@ -0,0 +1,202 @@
+package digitalocean
+
+import (
+ "fmt"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/registry"
+)
+
+func init() {
+ registry.RegisterIntegration("digitalocean", &DigitalOcean{})
+}
+
+type DigitalOcean struct{}
+
+type Configuration struct {
+ APIToken string `json:"apiToken"`
+}
+
+type Metadata struct {
+ AccountEmail string `json:"accountEmail"`
+ AccountUUID string `json:"accountUUID"`
+}
+
+func (d *DigitalOcean) Name() string {
+ return "digitalocean"
+}
+
+func (d *DigitalOcean) Label() string {
+ return "DigitalOcean"
+}
+
+func (d *DigitalOcean) Icon() string {
+ return "digitalocean"
+}
+
+func (d *DigitalOcean) Description() string {
+ return "Manage and monitor your DigitalOcean infrastructure"
+}
+
+func (d *DigitalOcean) Instructions() string {
+ return `## Create a DigitalOcean Personal Access Token
+
+1. Open the [DigitalOcean API Tokens page](https://cloud.digitalocean.com/account/api/tokens)
+2. Click **Generate New Token**
+3. Configure the token:
+ - **Token name**: SuperPlane Integration
+ - **Expiration**: No expiry (or choose an appropriate expiration)
+ - **Scopes**: Select **Full Access** (or customize as needed)
+4. Click **Generate Token**
+5. Copy the token and paste it below
+
+> **Note**: The token is only shown once. Store it securely if needed elsewhere.`
+}
+
+func (d *DigitalOcean) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "apiToken",
+ Label: "API Token",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Sensitive: true,
+ Description: "DigitalOcean Personal Access Token",
+ },
+ }
+}
+
+func (d *DigitalOcean) Components() []core.Component {
+ return []core.Component{
+ &CreateDroplet{},
+ }
+}
+
+func (d *DigitalOcean) Triggers() []core.Trigger {
+ return []core.Trigger{}
+}
+
+func (d *DigitalOcean) Sync(ctx core.SyncContext) error {
+ config := Configuration{}
+ err := mapstructure.Decode(ctx.Configuration, &config)
+ if err != nil {
+ return fmt.Errorf("failed to decode config: %v", err)
+ }
+
+ if config.APIToken == "" {
+ return fmt.Errorf("apiToken is required")
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ account, err := client.GetAccount()
+ if err != nil {
+ return fmt.Errorf("error fetching account: %v", err)
+ }
+
+ ctx.Integration.SetMetadata(Metadata{
+ AccountEmail: account.Email,
+ AccountUUID: account.UUID,
+ })
+ ctx.Integration.Ready()
+ return nil
+}
+
+func (d *DigitalOcean) Cleanup(ctx core.IntegrationCleanupContext) error {
+ return nil
+}
+
+func (d *DigitalOcean) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) {
+ switch resourceType {
+ case "region":
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create client: %w", err)
+ }
+
+ regions, err := client.ListRegions()
+ if err != nil {
+ return nil, fmt.Errorf("error listing regions: %w", err)
+ }
+
+ resources := make([]core.IntegrationResource, 0, len(regions))
+ for _, region := range regions {
+ if region.Available {
+ resources = append(resources, core.IntegrationResource{
+ Type: resourceType,
+ Name: region.Name,
+ ID: region.Slug,
+ })
+ }
+ }
+ return resources, nil
+
+ case "size":
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create client: %w", err)
+ }
+
+ sizes, err := client.ListSizes()
+ if err != nil {
+ return nil, fmt.Errorf("error listing sizes: %w", err)
+ }
+
+ resources := make([]core.IntegrationResource, 0, len(sizes))
+ for _, size := range sizes {
+ if size.Available {
+ resources = append(resources, core.IntegrationResource{
+ Type: resourceType,
+ Name: size.Slug,
+ ID: size.Slug,
+ })
+ }
+ }
+ return resources, nil
+
+ case "image":
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create client: %w", err)
+ }
+
+ images, err := client.ListImages("distribution")
+ if err != nil {
+ return nil, fmt.Errorf("error listing images: %w", err)
+ }
+
+ resources := make([]core.IntegrationResource, 0, len(images))
+ for _, image := range images {
+ name := image.Name
+ if image.Distribution != "" {
+ name = fmt.Sprintf("%s %s", image.Distribution, image.Name)
+ }
+ resources = append(resources, core.IntegrationResource{
+ Type: resourceType,
+ Name: name,
+ ID: image.Slug,
+ })
+ }
+ return resources, nil
+
+ default:
+ return []core.IntegrationResource{}, nil
+ }
+}
+
+func (d *DigitalOcean) HandleRequest(ctx core.HTTPRequestContext) {
+ // no-op
+}
+
+func (d *DigitalOcean) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (d *DigitalOcean) HandleAction(ctx core.IntegrationActionContext) error {
+ return nil
+}
diff --git a/pkg/integrations/digitalocean/digitalocean_test.go b/pkg/integrations/digitalocean/digitalocean_test.go
new file mode 100644
index 000000000..39ccb93c7
--- /dev/null
+++ b/pkg/integrations/digitalocean/digitalocean_test.go
@@ -0,0 +1,152 @@
+package digitalocean
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__DigitalOcean__Sync(t *testing.T) {
+ d := &DigitalOcean{}
+
+ t.Run("no apiToken -> error", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "",
+ },
+ }
+
+ err := d.Sync(core.SyncContext{
+ Configuration: integrationCtx.Configuration,
+ Integration: integrationCtx,
+ })
+
+ require.ErrorContains(t, err, "apiToken is required")
+ })
+
+ t.Run("valid token -> fetches account, sets ready and metadata", func(t *testing.T) {
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{
+ "account": {
+ "email": "user@example.com",
+ "uuid": "abc-123",
+ "status": "active",
+ "droplet_limit": 25
+ }
+ }`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "token123",
+ },
+ }
+
+ err := d.Sync(core.SyncContext{
+ Configuration: integrationCtx.Configuration,
+ HTTP: httpContext,
+ Integration: integrationCtx,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "ready", integrationCtx.State)
+ require.Len(t, httpContext.Requests, 1)
+ assert.Equal(t, "https://api.digitalocean.com/v2/account", httpContext.Requests[0].URL.String())
+
+ metadata := integrationCtx.Metadata.(Metadata)
+ assert.Equal(t, "user@example.com", metadata.AccountEmail)
+ assert.Equal(t, "abc-123", metadata.AccountUUID)
+ })
+
+ t.Run("invalid token (401) -> error", func(t *testing.T) {
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusUnauthorized,
+ Body: io.NopCloser(strings.NewReader(`{"id":"unauthorized","message":"Unable to authenticate you"}`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "invalid-token",
+ },
+ }
+
+ err := d.Sync(core.SyncContext{
+ Configuration: integrationCtx.Configuration,
+ HTTP: httpContext,
+ Integration: integrationCtx,
+ })
+
+ require.Error(t, err)
+ assert.NotEqual(t, "ready", integrationCtx.State)
+ require.Len(t, httpContext.Requests, 1)
+ assert.Equal(t, "https://api.digitalocean.com/v2/account", httpContext.Requests[0].URL.String())
+ assert.Nil(t, integrationCtx.Metadata)
+ })
+}
+
+func Test__DigitalOcean__ListResources(t *testing.T) {
+ d := &DigitalOcean{}
+
+ t.Run("list regions", func(t *testing.T) {
+ httpContext := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{
+ "regions": [
+ {"slug": "nyc3", "name": "New York 3", "available": true},
+ {"slug": "sfo3", "name": "San Francisco 3", "available": true},
+ {"slug": "old1", "name": "Old Region", "available": false}
+ ]
+ }`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "token123",
+ },
+ }
+
+ resources, err := d.ListResources("region", core.ListResourcesContext{
+ HTTP: httpContext,
+ Integration: integrationCtx,
+ })
+
+ require.NoError(t, err)
+ assert.Len(t, resources, 2)
+ assert.Equal(t, "region", resources[0].Type)
+ assert.Equal(t, "New York 3", resources[0].Name)
+ assert.Equal(t, "nyc3", resources[0].ID)
+ })
+
+ t.Run("unknown resource type returns empty list", func(t *testing.T) {
+ resources, err := d.ListResources("unknown", core.ListResourcesContext{
+ HTTP: &contexts.HTTPContext{},
+ Integration: &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "apiToken": "token123",
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ assert.Empty(t, resources)
+ })
+}
diff --git a/pkg/integrations/digitalocean/example.go b/pkg/integrations/digitalocean/example.go
new file mode 100644
index 000000000..b5441afac
--- /dev/null
+++ b/pkg/integrations/digitalocean/example.go
@@ -0,0 +1,18 @@
+package digitalocean
+
+import (
+ _ "embed"
+ "sync"
+
+ "github.com/superplanehq/superplane/pkg/utils"
+)
+
+//go:embed example_output_create_droplet.json
+var exampleOutputCreateDropletBytes []byte
+
+var exampleOutputCreateDropletOnce sync.Once
+var exampleOutputCreateDroplet map[string]any
+
+func (c *CreateDroplet) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateDropletOnce, exampleOutputCreateDropletBytes, &exampleOutputCreateDroplet)
+}
diff --git a/pkg/integrations/digitalocean/example_output_create_droplet.json b/pkg/integrations/digitalocean/example_output_create_droplet.json
new file mode 100644
index 000000000..2433eb69b
--- /dev/null
+++ b/pkg/integrations/digitalocean/example_output_create_droplet.json
@@ -0,0 +1,17 @@
+{
+ "data": {
+ "id": 98765432,
+ "name": "my-droplet",
+ "memory": 1024,
+ "vcpus": 1,
+ "disk": 25,
+ "status": "new",
+ "region": { "name": "New York 3", "slug": "nyc3" },
+ "image": { "id": 12345, "name": "Ubuntu 24.04 (LTS) x64", "slug": "ubuntu-24-04-x64" },
+ "size_slug": "s-1vcpu-1gb",
+ "networks": {
+ "v4": [{ "ip_address": "104.131.186.241", "type": "public" }]
+ },
+ "tags": ["web"]
+ }
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index b6c93e148..599a92db8 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -41,6 +41,7 @@ import (
_ "github.com/superplanehq/superplane/pkg/integrations/dash0"
_ "github.com/superplanehq/superplane/pkg/integrations/datadog"
_ "github.com/superplanehq/superplane/pkg/integrations/daytona"
+ _ "github.com/superplanehq/superplane/pkg/integrations/digitalocean"
_ "github.com/superplanehq/superplane/pkg/integrations/discord"
_ "github.com/superplanehq/superplane/pkg/integrations/dockerhub"
_ "github.com/superplanehq/superplane/pkg/integrations/github"
diff --git a/web_src/src/assets/icons/integrations/digitalocean.svg b/web_src/src/assets/icons/integrations/digitalocean.svg
new file mode 100644
index 000000000..1358524e7
--- /dev/null
+++ b/web_src/src/assets/icons/integrations/digitalocean.svg
@@ -0,0 +1,5 @@
+
diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/create_droplet.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/create_droplet.ts
new file mode 100644
index 000000000..5d9e56c19
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/digitalocean/create_droplet.ts
@@ -0,0 +1,95 @@
+import { ComponentBaseProps, EventSection } from "@/ui/componentBase";
+import { getBackgroundColorClass } from "@/utils/colors";
+import { getState, getStateMap, getTriggerRenderer } from "..";
+import {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ ExecutionDetailsContext,
+ ExecutionInfo,
+ NodeInfo,
+ OutputPayload,
+ SubtitleContext,
+} from "../types";
+import { MetadataItem } from "@/ui/metadataList";
+import doIcon from "@/assets/icons/integrations/digitalocean.svg";
+import { formatTimeAgo } from "@/utils/date";
+
+export const createDropletMapper: ComponentBaseMapper = {
+ props(context: ComponentBaseContext): ComponentBaseProps {
+ const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;
+ const componentName = context.componentDefinition.name ?? "digitalocean";
+
+ return {
+ iconSrc: doIcon,
+ collapsedBackground: getBackgroundColorClass(context.componentDefinition.color),
+ 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 details: Record = {};
+
+ if (context.execution.createdAt) {
+ details["Created At"] = new Date(context.execution.createdAt).toLocaleString();
+ }
+
+ const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined;
+ const droplet = outputs?.default?.[0]?.data as Record | undefined;
+ if (!droplet) return details;
+
+ const ip = droplet.networks?.v4?.find((n: any) => n.type === "public")?.ip_address;
+
+ details["Droplet ID"] = droplet.id?.toString() || "-";
+ details["Name"] = droplet.name || "-";
+ details["Region"] = droplet.region?.name || droplet.region?.slug || "-";
+ details["Size"] = droplet.size_slug || "-";
+ details["OS"] = droplet.image?.name || droplet.image?.slug || "-";
+
+ if (ip) {
+ details["IP Address"] = ip;
+ }
+
+ 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 any;
+
+ if (configuration?.region) {
+ metadata.push({ icon: "map-pin", label: `Region: ${configuration.region}` });
+ }
+
+ if (configuration?.size) {
+ metadata.push({ icon: "hard-drive", label: `Size: ${configuration.size}` });
+ }
+
+ 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! });
+
+ return [
+ {
+ receivedAt: new Date(execution.createdAt!),
+ eventTitle: title,
+ eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)),
+ eventState: getState(componentName)(execution),
+ eventId: execution.rootEvent!.id!,
+ },
+ ];
+}
diff --git a/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts b/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts
new file mode 100644
index 000000000..0e43cd3bf
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/digitalocean/index.ts
@@ -0,0 +1,13 @@
+import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types";
+import { createDropletMapper } from "./create_droplet";
+import { buildActionStateRegistry } from "../utils";
+
+export const componentMappers: Record = {
+ createDroplet: createDropletMapper,
+};
+
+export const triggerRenderers: Record = {};
+
+export const eventStateRegistry: Record = {
+ createDroplet: buildActionStateRegistry("created"),
+};
diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts
index 516017ead..543a1f5ef 100644
--- a/web_src/src/pages/workflowv2/mappers/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/index.ts
@@ -88,6 +88,11 @@ import {
import { triggerRenderers as bitbucketTriggerRenderers } from "./bitbucket/index";
import { componentMappers as hetznerComponentMappers } from "./hetzner/index";
import { timeGateMapper, TIME_GATE_STATE_REGISTRY } from "./timegate";
+import {
+ componentMappers as digitaloceanComponentMappers,
+ triggerRenderers as digitaloceanTriggerRenderers,
+ eventStateRegistry as digitaloceanEventStateRegistry,
+} from "./digitalocean/index";
import {
componentMappers as discordComponentMappers,
triggerRenderers as discordTriggerRenderers,
@@ -169,6 +174,7 @@ const componentBaseMappers: Record = {
const appMappers: Record> = {
cloudflare: cloudflareComponentMappers,
+ digitalocean: digitaloceanComponentMappers,
semaphore: semaphoreComponentMappers,
github: githubComponentMappers,
gitlab: gitlabComponentMappers,
@@ -196,6 +202,7 @@ const appMappers: Record> = {
const appTriggerRenderers: Record> = {
cloudflare: cloudflareTriggerRenderers,
+ digitalocean: digitaloceanTriggerRenderers,
semaphore: semaphoreTriggerRenderers,
github: githubTriggerRenderers,
gitlab: gitlabTriggerRenderers,
@@ -223,6 +230,7 @@ const appTriggerRenderers: Record> = {
const appEventStateRegistries: Record> = {
cloudflare: cloudflareEventStateRegistry,
+ digitalocean: digitaloceanEventStateRegistry,
semaphore: semaphoreEventStateRegistry,
github: githubEventStateRegistry,
pagerduty: pagerdutyEventStateRegistry,
diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
index 82689adcf..5268bc1c0 100644
--- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx
+++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
@@ -19,6 +19,7 @@ import bitbucketIcon from "@/assets/icons/integrations/bitbucket.svg";
import dash0Icon from "@/assets/icons/integrations/dash0.svg";
import daytonaIcon from "@/assets/icons/integrations/daytona.svg";
import datadogIcon from "@/assets/icons/integrations/datadog.svg";
+import digitaloceanIcon from "@/assets/icons/integrations/digitalocean.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";
@@ -411,6 +412,7 @@ function CategorySection({
dash0: dash0Icon,
datadog: datadogIcon,
daytona: daytonaIcon,
+ digitalocean: digitaloceanIcon,
discord: discordIcon,
github: githubIcon,
gitlab: gitlabIcon,
@@ -497,6 +499,7 @@ function CategorySection({
dash0: dash0Icon,
daytona: daytonaIcon,
datadog: datadogIcon,
+ digitalocean: digitaloceanIcon,
discord: discordIcon,
github: githubIcon,
gitlab: gitlabIcon,
diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx
index d960cff0b..0862bb6ca 100644
--- a/web_src/src/ui/componentSidebar/integrationIcons.tsx
+++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx
@@ -15,6 +15,7 @@ import cloudflareIcon from "@/assets/icons/integrations/cloudflare.svg";
import dash0Icon from "@/assets/icons/integrations/dash0.svg";
import datadogIcon from "@/assets/icons/integrations/datadog.svg";
import daytonaIcon from "@/assets/icons/integrations/daytona.svg";
+import digitaloceanIcon from "@/assets/icons/integrations/digitalocean.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";
@@ -44,6 +45,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = {
dash0: dash0Icon,
datadog: datadogIcon,
daytona: daytonaIcon,
+ digitalocean: digitaloceanIcon,
discord: discordIcon,
github: githubIcon,
gitlab: gitlabIcon,
@@ -74,6 +76,7 @@ export const APP_LOGO_MAP: Record> = {
dash0: dash0Icon,
datadog: datadogIcon,
daytona: daytonaIcon,
+ digitalocean: digitaloceanIcon,
discord: discordIcon,
github: githubIcon,
gitlab: gitlabIcon,
diff --git a/web_src/src/utils/integrationDisplayName.ts b/web_src/src/utils/integrationDisplayName.ts
index db5d4cc39..65e492d84 100644
--- a/web_src/src/utils/integrationDisplayName.ts
+++ b/web_src/src/utils/integrationDisplayName.ts
@@ -10,6 +10,7 @@ const INTEGRATION_TYPE_DISPLAY_NAMES: Record = {
cursor: "Cursor",
pagerduty: "PagerDuty",
slack: "Slack",
+ digitalocean: "DigitalOcean",
discord: "Discord",
datadog: "DataDog",
cloudflare: "Cloudflare",