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 @@ + + DigitalOcean + + + 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",