Explorar o código

DB frontend v1 (#3733)

Co-authored-by: Justin Rhee <jusrhee@Justins-MacBook-Air-2.local>
Co-authored-by: jose-fully-ported <141160579+jose-fully-ported@users.noreply.github.com>
Co-authored-by: Jose Diaz-Gonzalez <jose@porter.run>
jusrhee %!s(int64=2) %!d(string=hai) anos
pai
achega
881a85eabe

+ 15 - 4
api/server/handlers/helmrepo/get_chart.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/porter-dev/porter/internal/templater/parser"
 )
 
@@ -30,8 +31,11 @@ func NewChartGetHandler(
 }
 
 func (t *ChartGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-	helmRepo, _ := r.Context().Value(types.HelmRepoScope).(*models.HelmRepo)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-chart")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	helmRepo, _ := ctx.Value(types.HelmRepoScope).(*models.HelmRepo)
 
 	name, _ := requestutils.GetURLParamString(r, types.URLParamTemplateName)
 	version, _ := requestutils.GetURLParamString(r, types.URLParamTemplateVersion)
@@ -41,14 +45,21 @@ func (t *ChartGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		version = ""
 	}
 
-	chart, err := release.LoadChart(t.Config(), &release.LoadAddonChartOpts{
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "helm-repo-url", Value: helmRepo.RepoURL},
+		telemetry.AttributeKV{Key: "template-name", Value: name},
+		telemetry.AttributeKV{Key: "template-version", Value: version},
+	)
+
+	chart, err := release.LoadChart(ctx, t.Config(), &release.LoadAddonChartOpts{
 		ProjectID:       proj.ID,
 		RepoURL:         helmRepo.RepoURL,
 		TemplateName:    name,
 		TemplateVersion: version,
 	})
 	if err != nil {
-		t.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err := telemetry.Error(ctx, span, nil, "error loading chart from helm")
+		t.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 

+ 146 - 14
api/server/handlers/release/create_addon.go

@@ -4,7 +4,10 @@ import (
 	"context"
 	"fmt"
 	"net/http"
+	"strings"
 
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -16,9 +19,17 @@ import (
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/stefanmcshane/helm/pkg/chart"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
+// Namespace_EnvironmentGroups is the base namespace for storing all environment groups.
+const Namespace_EnvironmentGroups = "porter-env-group"
+
+// Namespace_ACKSystem is the base namespace for interacting with ack chart controllers
+const Namespace_ACKSystem = "ack-system"
+
 type CreateAddonHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
@@ -36,10 +47,13 @@ func NewCreateAddonHandler(
 }
 
 func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-	namespace := r.Context().Value(types.NamespaceScope).(string)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-addon")
+	defer span.End()
+
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	namespace := ctx.Value(types.NamespaceScope).(string)
 	operationID := oauth.CreateRandomState()
 
 	c.Config().AnalyticsClient.Track(analytics.ApplicationLaunchStartTrack(
@@ -49,8 +63,9 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		},
 	))
 
-	helmAgent, err := c.GetHelmAgent(r.Context(), r, cluster, "")
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, "")
 	if err != nil {
+		err = telemetry.Error(ctx, span, nil, "error creating helm agent")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -58,6 +73,8 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	request := &types.CreateAddonRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
@@ -65,40 +82,63 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		request.TemplateVersion = ""
 	}
 
-	chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "repo-url", Value: request.RepoURL},
+		telemetry.AttributeKV{Key: "template-name", Value: request.TemplateName},
+		telemetry.AttributeKV{Key: "template-version", Value: request.TemplateVersion},
+	)
+
+	chart, err := LoadChart(ctx, c.Config(), &LoadAddonChartOpts{
 		ProjectID:       proj.ID,
 		RepoURL:         request.RepoURL,
 		TemplateName:    request.TemplateName,
 		TemplateVersion: request.TemplateVersion,
 	})
 	if err != nil {
+		err = telemetry.Error(ctx, span, nil, "error loading chart")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
 	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error retrieving project registry")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	vpcConfig, err := c.getVPCConfig(ctx, request, proj, cluster)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error retrieving vpc config")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	if err := c.performAddonPreinstall(ctx, r, request.TemplateName, cluster); err != nil {
+		err = telemetry.Error(ctx, span, err, "error performing addon preinstall")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	values := request.Values
+	values["vpcConfig"] = vpcConfig
+
 	conf := &helm.InstallChartConfig{
 		Chart:      chart,
 		Name:       request.Name,
 		Namespace:  namespace,
-		Values:     request.Values,
+		Values:     values,
 		Cluster:    cluster,
 		Repo:       c.Repo(),
 		Registries: registries,
 	}
 
-	helmRelease, err := helmAgent.InstallChart(context.Background(), conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+	helmRelease, err := helmAgent.InstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error installing a new chart: %s", err.Error()),
+			telemetry.Error(ctx, span, nil, fmt.Sprintf("error installing a new chart: %s", err.Error())),
 			http.StatusBadRequest,
 		))
-
 		return
 	}
 
@@ -122,10 +162,11 @@ type LoadAddonChartOpts struct {
 	RepoURL, TemplateName, TemplateVersion string
 }
 
-func LoadChart(config *config.Config, opts *LoadAddonChartOpts) (*chart.Chart, error) {
+// LoadChart fetches a chart from a remote repo
+func LoadChart(ctx context.Context, config *config.Config, opts *LoadAddonChartOpts) (*chart.Chart, error) {
 	// if the chart repo url is one of the specified application/addon charts, just load public
 	if opts.RepoURL == config.ServerConf.DefaultAddonHelmRepoURL || opts.RepoURL == config.ServerConf.DefaultApplicationHelmRepoURL {
-		return loader.LoadChartPublic(context.Background(), opts.RepoURL, opts.TemplateName, opts.TemplateVersion)
+		return loader.LoadChartPublic(ctx, opts.RepoURL, opts.TemplateName, opts.TemplateVersion)
 	} else {
 		// load the helm repos in the project
 		hrs, err := config.Repo.HelmRepo().ListHelmReposByProjectID(opts.ProjectID)
@@ -142,13 +183,13 @@ func LoadChart(config *config.Config, opts *LoadAddonChartOpts) (*chart.Chart, e
 						return nil, err
 					}
 
-					return loader.LoadChart(context.Background(),
+					return loader.LoadChart(ctx,
 						&loader.BasicAuthClient{
 							Username: string(basic.Username),
 							Password: string(basic.Password),
 						}, hr.RepoURL, opts.TemplateName, opts.TemplateVersion)
 				} else {
-					return loader.LoadChartPublic(context.Background(), hr.RepoURL, opts.TemplateName, opts.TemplateVersion)
+					return loader.LoadChartPublic(ctx, hr.RepoURL, opts.TemplateName, opts.TemplateVersion)
 				}
 			}
 		}
@@ -156,3 +197,94 @@ func LoadChart(config *config.Config, opts *LoadAddonChartOpts) (*chart.Chart, e
 
 	return nil, fmt.Errorf("chart repo not found")
 }
+
+func (c *CreateAddonHandler) performAddonPreinstall(ctx context.Context, r *http.Request, templateName string, cluster *models.Cluster) error {
+	awsTemplates := map[string][]string{
+		"rds-postgresql": {"ec2-chart", "rds-chart"},
+	}
+
+	if cluster.CloudProvider != "AWS" {
+		return nil
+	}
+
+	if _, ok := awsTemplates[templateName]; !ok {
+		return nil
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		return err
+	}
+
+	if _, err = agent.GetNamespace(Namespace_EnvironmentGroups); err != nil {
+		if _, err := agent.CreateNamespace(Namespace_EnvironmentGroups, map[string]string{}); err != nil {
+			return err
+		}
+	}
+
+	for _, chart := range awsTemplates[templateName] {
+		scale, err := agent.Clientset.AppsV1().Deployments(Namespace_ACKSystem).GetScale(context.TODO(), chart, metav1.GetOptions{})
+		if err != nil {
+			return err
+		}
+		if scale.Spec.Replicas > 0 {
+			continue
+		}
+
+		// scale the charts specific to the ack controller
+		scale.Spec.Replicas = 1
+		if _, err := agent.Clientset.AppsV1().Deployments(Namespace_ACKSystem).UpdateScale(ctx, chart, scale, metav1.UpdateOptions{}); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (c *CreateAddonHandler) getVPCConfig(ctx context.Context, request *types.CreateAddonRequest, project *models.Project, cluster *models.Cluster) (map[string]any, error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-vpc-config")
+	defer span.End()
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "cloud-provider", Value: cluster.CloudProvider},
+		telemetry.AttributeKV{Key: "template-name", Value: request.TemplateName},
+	)
+
+	vpcConfig := map[string]any{}
+	if cluster.CloudProvider != "AWS" {
+		return vpcConfig, nil
+	}
+
+	awsTemplates := map[string]bool{
+		"rds-postgresql": true,
+	}
+
+	if !awsTemplates[request.TemplateName] {
+		return vpcConfig, nil
+	}
+
+	req := connect.NewRequest(&porterv1.ClusterNetworkSettingsRequest{
+		ProjectId: int64(project.ID),
+		ClusterId: int64(cluster.ID),
+	})
+
+	resp, err := c.Config().ClusterControlPlaneClient.ClusterNetworkSettings(ctx, req)
+	if err != nil {
+		return vpcConfig, telemetry.Error(ctx, span, err, "error fetching cluster network settings from ccp")
+	}
+
+	vpcConfig["awsRegion"] = resp.Msg.Region
+	vpcConfig["subnetIDs"] = resp.Msg.SubnetIds
+	switch resp.Msg.CloudProvider {
+	case *porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS.Enum():
+		vpcConfig["vpcID"] = resp.Msg.GetEksCloudProviderNetwork().Id
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "vpc-id", Value: resp.Msg.GetEksCloudProviderNetwork().Id})
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "aws-region", Value: resp.Msg.Region},
+		telemetry.AttributeKV{Key: "subnet-ids", Value: strings.Join(resp.Msg.SubnetIds, ",")},
+	)
+
+	return vpcConfig, nil
+}

+ 1 - 1
api/server/handlers/release/upgrade.go

@@ -92,7 +92,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			}
 		}
 
-		chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+		chart, err := LoadChart(r.Context(), c.Config(), &LoadAddonChartOpts{
 			ProjectID:       cluster.ProjectID,
 			RepoURL:         chartRepoURL,
 			TemplateName:    helmRelease.Chart.Metadata.Name,

+ 1 - 1
api/server/handlers/v1/release/upgrade.go

@@ -92,7 +92,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			}
 		}
 
-		chart, err := baseReleaseHandler.LoadChart(c.Config(), &baseReleaseHandler.LoadAddonChartOpts{
+		chart, err := baseReleaseHandler.LoadChart(r.Context(), c.Config(), &baseReleaseHandler.LoadAddonChartOpts{
 			ProjectID:       cluster.ProjectID,
 			RepoURL:         chartRepoURL,
 			TemplateName:    helmRelease.Chart.Metadata.Name,

BIN=BIN
dashboard/src/assets/amazon-rds.png


BIN=BIN
dashboard/src/assets/aws-elasticache.png


+ 3 - 0
dashboard/src/assets/check.svg

@@ -0,0 +1,3 @@
+<svg width="12" height="10" viewBox="0 0 12 10" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.6 1L3.44048 8.2L1 5.74572" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
dashboard/src/assets/copy.svg

@@ -0,0 +1,3 @@
+<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.25 13.5H19.75C20.4404 13.5 21 12.9404 21 12.25L21 3C21 1.89543 20.1046 1 19 1L9.75 1C9.05965 1 8.5 1.55964 8.5 2.25V4.75M11 21L3.5 21C2.11929 21 1 19.8807 1 18.5L1 11C1 9.61929 2.11929 8.5 3.5 8.5L11 8.5C12.3807 8.5 13.5 9.61929 13.5 11L13.5 18.5C13.5 19.8807 12.3807 21 11 21Z" stroke="white" stroke-width="2" stroke-linecap="round"/>
+</svg>

BIN=BIN
dashboard/src/assets/placeholder.png


+ 17 - 26
dashboard/src/components/ClusterProvisioningPlaceholder.tsx

@@ -10,6 +10,9 @@ import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import Text from "./porter/Text";
 import Spacer from "./porter/Spacer";
+import DashboardPlaceholder from "./porter/DashboardPlaceholder";
+import PorterLink from "components/porter/Link";
+import Button from "./porter/Button";
 
 type Props = {};
 
@@ -17,42 +20,30 @@ const ClusterProvisioningPlaceholder: React.FC<RouteComponentProps> = (props) =>
   const { currentCluster } = useContext(Context);
 
   return (
-    <ClusterPlaceholder>
+    <DashboardPlaceholder>
       <Text size={16}>
         <Img src={loading} /> Your cluster is being created
       </Text>
-      <Spacer height="15px" />
+      <Spacer y={.5} />
       <Text color="helper">
-        You can view the status of your cluster creation
-        <Spacer inline width="5px" />
-        <Link onClick={() => {
-          pushFiltered(props, "/cluster-dashboard", ["project_id"], {
-            cluster: currentCluster.name,
-          });
-        }}>
-          here
-          <i className="material-icons">arrow_forward</i> 
-        </Link>
+        You can proceed as soon as your cluster is ready.
       </Text>
-    </ClusterPlaceholder>
+      <Spacer y={1} />
+      <PorterLink onClick={() => {
+        pushFiltered(props, "/cluster-dashboard", ["project_id"], {
+          cluster: currentCluster?.name,
+        });
+      }}>
+        <Button alt height="35px">
+          View status <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
+        </Button>
+      </PorterLink>
+    </DashboardPlaceholder>
   );
 };
 
 export default withRouter(ClusterProvisioningPlaceholder);
 
-const Link = styled.a`
-  text-decoration: underline;
-  position: relative;
-  cursor: pointer;
-  > i {
-    color: #aaaabb;
-    font-size: 15px;
-    position: absolute;
-    right: -17px;
-    top: 1px;
-  }
-`;
-
 const Img = styled.img`
   height: 15px;
   margin-right: 15px;

+ 125 - 0
dashboard/src/components/porter/ClickToCopy.tsx

@@ -0,0 +1,125 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import copy from "assets/copy.svg";
+import check from "assets/check.svg";
+import Text from "./Text";
+
+type Props = {
+  size?: number;
+  color?: string;
+  weight?: number;
+  children: any;
+  additionalStyles?: string;
+};
+
+const ClickToCopy: React.FC<Props> = ({
+  size,
+  weight,
+  color,
+  children,
+  additionalStyles,
+}) => {
+  const [showCopyPrompt, setShowCopyPrompt] = useState<boolean>(false);
+  const [copied, setCopied] = useState<boolean>(false);
+
+  const getColor = () => {
+    switch (color) {
+      case "helper":
+        return "#aaaabb";
+      case "warner":
+        return "#ff5a52";
+      default:
+        return color;
+    }
+  };
+
+  return (
+    <StyledClickToCopy
+      size={size}
+      color={getColor()}
+      weight={weight}
+      additionalStyles={additionalStyles}
+      onMouseEnter={() => {
+        setShowCopyPrompt(true);
+      }}
+      onMouseLeave={() => {
+        setShowCopyPrompt(false);
+        setCopied(false);
+      }}
+      onClick= {() => {
+        navigator.clipboard.writeText(children);
+        setCopied(true);
+      }}
+    >
+      {children}
+      {showCopyPrompt && (
+        <>
+          {copied ? (
+            <CopyPrompt width="80px">
+              <Img small={true} src={check} />
+              <Text>Copied</Text>
+            </CopyPrompt>
+          ) : (
+            <CopyPrompt width="120px">
+              <Img src={copy} />
+              <Text>Click to copy</Text>
+            </CopyPrompt>
+          )}
+        </>
+      )}
+    </StyledClickToCopy>
+  );
+};
+
+export default ClickToCopy;
+
+const Img = styled.img<{ small?: boolean }>`
+  height: ${props => props.small ? "10px" : "12px"};
+  margin-right: 5px;
+`;
+
+const CopyPrompt = styled.div<{ width: string }>`
+  position: absolute;
+  width: ${props => props.width};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  left: calc(100% + 10px);
+  top: -4px;
+  height: 28px;
+  background: #121212;
+  z-index: 999;
+  border: 1px solid #494B4F;
+  opacity: 0;
+  border-radius: 3px;
+  animation: fadeIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledClickToCopy = styled.div<{
+  size?: number;
+  color?: string;
+  weight?: number;
+  additionalStyles?: string;
+  truncate?: boolean;
+}>`
+  line-height: 1.5;
+  font-weight: ${props => props.weight || 400};
+  color: ${props => props.color || props.theme.text.primary};
+  font-size: ${props => props.size || 13}px;
+  display: inline;
+  align-items: center;
+  user-select: text;
+  ${props => props.additionalStyles ? props.additionalStyles : ""}
+  cursor: pointer;
+  position: relative;
+`;

+ 45 - 0
dashboard/src/components/porter/DashboardPlaceholder.tsx

@@ -0,0 +1,45 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import placeholder from "assets/placeholder.png";
+
+type Props = {
+  children: React.ReactNode;
+};
+
+const DashboardPlaceholder: React.FC<Props> = ({
+  children,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <StyledDashboardPlaceholder>
+      <Bg src={placeholder} />
+      <Fg>{children}</Fg>
+    </StyledDashboardPlaceholder>
+  );
+};
+
+export default DashboardPlaceholder;
+
+const Fg = styled.div`
+  position: relative;
+  z-index: 1;
+`;
+
+const Bg = styled.img`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  z-index: 0;
+`;
+
+const StyledDashboardPlaceholder = styled.div<{
+}>`
+  width: 100%;
+  padding: 25px 30px;
+  border-radius: 10px;
+  position: relative;
+  overflow: hidden;
+`;

+ 2 - 1
dashboard/src/components/porter/VerticalSteps.tsx

@@ -25,7 +25,7 @@ const VerticalSteps: React.FC<Props> = ({
       <Line />
       {steps.map((step, i) => {
         return (
-          <Relative>
+          <Relative key={i}>
             {i === steps.length - 1 && (
               <LineCover />
             )}
@@ -143,4 +143,5 @@ const StepWrapper = styled(AnimateHeight)<{
 const StyledVerticalSteps = styled.div<{
 }>`
   position: relative;
+  margin-left: 8px;
 `;

+ 9 - 0
dashboard/src/main/home/Home.tsx

@@ -22,6 +22,8 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import AppDashboard from "./app-dashboard/AppDashboard";
 import AddOnDashboard from "./add-on-dashboard/AddOnDashboard";
+import DatabaseDashboard from "./database-dashboard/DatabaseDashboard";
+import CreateDatabase from "./database-dashboard/CreateDatabase";
 
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
@@ -455,6 +457,13 @@ const Home: React.FC<Props> = (props) => {
                   )}
                 </Route>
 
+                <Route path="/databases/new">
+                  <CreateDatabase />
+                </Route>
+                <Route path="/databases">
+                  <DatabaseDashboard />
+                </Route>
+
                 <Route path="/addons/new">
                   <NewAddOnFlow />
                 </Route>

+ 16 - 22
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -9,7 +9,6 @@ import styled from "styled-components";
 import _ from "lodash";
 
 import addOn from "assets/add-ons.svg";
-import github from "assets/github.png";
 import time from "assets/time.png";
 import healthy from "assets/status-healthy.png";
 import grid from "assets/grid.png";
@@ -35,6 +34,7 @@ import { Link } from "react-router-dom";
 import Fieldset from "components/porter/Fieldset";
 import Select from "components/porter/Select";
 import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 
 type Props = {
 };
@@ -160,27 +160,21 @@ const AddOnDashboard: React.FC<Props> = ({
 
           isLoading ?
             (<Loading offset="-150px" />) : (
-              <Fieldset>
-
-                <CentralContainer>
-                  <Text size={16}>
-                    No add-ons have been deployed yet.
-                  </Text>
-                  <Spacer y={1} />
-
-                  <Text color={"helper"}>
-                    Deploy from our suite of curated add-ons.
-                  </Text>
-                  <Spacer y={.5} />
-                  <Link to="/addons/new">
-                    <Button onClick={() => { }} height="35px" >
-                      Deploy add-ons <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
-                    </Button>
-                  </Link>
-                </CentralContainer>
-
-
-              </Fieldset >
+              <DashboardPlaceholder>
+                <Text size={16}>
+                  No add-ons have been deployed yet
+                </Text>
+                <Spacer y={0.5} />
+                <Text color={"helper"}>
+                  Deploy from our suite of curated add-ons.
+                </Text>
+                <Spacer y={1} />
+                <Link to="/addons/new">
+                  <Button alt onClick={() => { }} height="35px" >
+                    Deploy add-ons <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
+                  </Button>
+                </Link>
+              </DashboardPlaceholder>
             )
         ) : (
           <>

+ 0 - 2
dashboard/src/main/home/add-on-dashboard/ConfigureTemplate.tsx

@@ -75,8 +75,6 @@ const ConfigureTemplate: React.FC<Props> = ({
     for (let key in wildcard) {
       _.set(values, key, wildcard[key]);
     }
-    console.log("values", values)
-    console.log("wildcard", wildcard)
     api
       .deployAddon(
         "<token>",

+ 1 - 2
dashboard/src/main/home/add-on-dashboard/NewAddOnFlow.tsx

@@ -4,7 +4,7 @@ import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 import semver from "semver";
 import _ from "lodash";
 
-import addOn from "assets/add-ons.png";
+import addOn from "assets/add-ons.svg";
 import notFound from "assets/not-found.png";
 
 import { Context } from "shared/Context";
@@ -112,7 +112,6 @@ const NewAddOnFlow: React.FC<Props> = ({
 
       sortedVersionData = sortedVersionData.map((template: any) => {
         let testTemplate: string[] = template?.tags || []
-        console.log(testTemplate)
         // Assign tags based on TAG_MAPPING
         for (let tag in TAG_MAPPING) {
           if (TAG_MAPPING[tag].includes(template.name)) {

+ 16 - 21
dashboard/src/main/home/app-dashboard/AppDashboard.tsx

@@ -31,6 +31,7 @@ import Loading from "components/Loading";
 import Fieldset from "components/porter/Fieldset";
 import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 import Icon from "components/porter/Icon";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 
 type Props = {};
 
@@ -218,27 +219,21 @@ const AppDashboard: React.FC<Props> = ({ }) => {
         apps.length === 0 ? (
           isLoading ?
             (<Loading offset="-150px" />) : (
-              <Fieldset>
-
-                <CentralContainer>
-                  <Text size={16}>
-                    No apps have been deployed yet.
-                  </Text>
-                  <Spacer y={1} />
-
-                  <Text color={"helper"}>
-                    Get started by deploying your app.
-                  </Text>
-                  <Spacer y={.5} />
-                  <PorterLink to="/apps/new/app">
-                    <Button onClick={async () => updateStackStartedStep()} height="35px">
-                      Deploy app <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
-                    </Button>
-                  </PorterLink>
-                </CentralContainer>
-
-
-              </Fieldset >
+              <DashboardPlaceholder>
+                <Text size={16}>
+                  No apps have been deployed yet
+                </Text>
+                <Spacer y={0.5} />
+                <Text color={"helper"}>
+                  Get started by deploying your app.
+                </Text>
+                <Spacer y={1} />
+                <PorterLink to="/apps/new/app">
+                  <Button alt onClick={async () => updateStackStartedStep()} height="35px">
+                    Deploy app <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
+                  </Button>
+                </PorterLink>
+              </DashboardPlaceholder>
             )
         ) : (
           <>

+ 243 - 0
dashboard/src/main/home/database-dashboard/CreateDatabase.tsx

@@ -0,0 +1,243 @@
+import React, { useEffect, useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import semver from "semver";
+import _ from "lodash";
+
+import database from "assets/database.svg";
+import notFound from "assets/not-found.png";
+import awsRDS from "assets/amazon-rds.png";
+import awsElastiCache from "assets/aws-elasticache.png";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { search } from "shared/search";
+
+import TemplateList from "../launch/TemplateList";
+import SearchBar from "components/porter/SearchBar";
+import Spacer from "components/porter/Spacer";
+import Loading from "components/Loading";
+import Back from "components/porter/Back";
+import Fieldset from "components/porter/Fieldset";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import RDSForm from "./RDSForm";
+
+type Props = {
+};
+
+const CreateDatabase: React.FC<Props> = ({
+}) => {
+  const { capabilities, currentProject, currentCluster, user } = useContext(Context);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [searchValue, setSearchValue] = useState("");
+  const [currentTemplate, setCurrentTemplate] = useState<any>(null);
+  const [databaseTemplates, setDatabaseTemplates] = useState<any[]>([
+    {
+      id: "rds-postgresql",
+      icon: awsRDS,
+      name: "RDS Postgres",
+      description: "Amazon Relational Database Service (RDS) is a web service that makes it easier to set up, operate, and scale a relational database in the cloud.",
+    },
+    {
+      id: "elasticache-redis",
+      icon: awsElastiCache,
+      name: "ElastiCache Redis",
+      description: "Contact support@porter.run.",
+      disabled: true,
+    },
+    {
+      id: "elasticache-memcached",
+      icon: awsElastiCache,
+      name: "ElastiCache Memcached",
+      description: "Contact support@porter.run",
+      disabled: true,
+    },
+  ]);
+  
+  const allFilteredTemplates = useMemo(() => {
+    const filteredBySearch = search(
+      databaseTemplates ?? [],
+      searchValue,
+      {
+        keys: ["name"],
+        isCaseSensitive: false,
+      }
+    );
+    return _.sortBy(filteredBySearch);
+  }, [databaseTemplates, searchValue]);
+
+  return (
+    <StyledTemplateComponent>
+      {
+        (currentTemplate) ? (
+          <RDSForm
+            currentTemplate={currentTemplate}
+            goBack={() => setCurrentTemplate(null)}
+            repoURL={capabilities?.default_addon_helm_repo_url}
+          />
+        ) : (
+          <>
+            <Back to="/databases" />
+            <DashboardHeader
+              image={database}
+              title="Create a new database"
+              capitalize={false}
+              disableLineBreak
+            />
+            {/*
+            <SearchBar
+              value={searchValue}
+              setValue={setSearchValue}
+              placeholder="Search available databases . . ."
+              width="100%"
+            />
+            <Spacer y={1} />
+            */}
+
+            {allFilteredTemplates.length === 0 && (
+              <Fieldset>
+                <Container row>
+                  <PlaceholderIcon src={notFound} />
+                  <Text color="helper">No matching add-ons were found.</Text>
+                </Container>
+              </Fieldset>
+            )}
+            <DarkMatter />
+
+            {databaseTemplates?.length > 0 &&
+              <>
+                <Spacer y={1.5} />
+                <Text size={15}>Production datastores</Text>
+                <Spacer y={.5} />
+                <Text color="helper">Fully-managed production-ready datastores.</Text>
+                <Spacer y={.5} />
+                <TemplateListWrapper>
+                  {databaseTemplates.map((template) => {
+                    let { id, name, icon, description, tags, disabled } = template;
+                    return (
+                      <TemplateBlock
+                        disabled={disabled}
+                        key={id}
+                        onClick={() => {
+                          !disabled && setCurrentTemplate(template);
+                        }}
+                      >
+                        <Icon src={icon} />
+                        <TemplateTitle>{name}</TemplateTitle>
+                        <TemplateDescription>{description}</TemplateDescription>
+                        <Spacer y={0.5} />
+                      </TemplateBlock>
+                    );
+                  })}
+                </TemplateListWrapper>
+              </>
+            }
+          </>
+        )
+      }
+    </StyledTemplateComponent >
+  );
+};
+
+export default CreateDatabase;
+
+const Icon = styled.img`
+  height: 25px;
+  margin-top: 30px;
+  margin-bottom: 5px;
+`;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -35px;
+`;
+
+const I = styled.i`
+  font-size: 16px;
+  padding: 4px;
+  cursor: pointer;
+  border-radius: 50%;
+  margin-right: 15px;
+  background: ${props => props.theme.fg};
+  color: ${props => props.theme.text.primary};
+  border: 1px solid ${props => props.theme.border};
+  :hover {
+    filter: brightness(150%);
+  }
+`;
+
+const StyledTemplateComponent = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const TemplateDescription = styled.div`
+  margin-bottom: 15px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  padding: 0px 25px;
+  line-height: 1.4;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const TemplateTitle = styled.div`
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  filter: ${({ disabled }) => (disabled ? "grayscale(1)" : "")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+  font-size: 13px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 180px;
+  color: #ffffff;
+  position: relative;
+  border-radius: 5px;
+  background: ${props => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: ${(props) => (props.disabled ? "" : "1px solid #7a7b80")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TemplateListWrapper = styled.div`
+  overflow: visible;
+  margin-top: 15px;
+  padding-bottom: 50px;
+  display: grid;
+  grid-column-gap: 30px;
+  grid-row-gap: 30px;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+`;

+ 415 - 0
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -0,0 +1,415 @@
+import React, {
+  useState,
+  useContext,
+  useEffect,
+  useMemo,
+  useCallback,
+} from "react";
+import styled from "styled-components";
+import _ from "lodash";
+
+import time from "assets/time.png";
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+import healthy from "assets/status-healthy.png";
+import letter from "assets/vector.svg";
+import calendar from "assets/calendar-number.svg";
+import database from "assets/database.svg";
+import notFound from "assets/not-found.png";
+
+import { Context } from "shared/Context";
+import { search } from "shared/search";
+import api from "shared/api";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import SearchBar from "components/porter/SearchBar";
+import Toggle from "components/porter/Toggle";
+import PorterLink from "components/porter/Link";
+import Loading from "components/Loading";
+import Fieldset from "components/porter/Fieldset";
+import { Link } from "react-router-dom";
+import { readableDate } from "shared/string_utils";
+
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+
+type Props = {};
+
+const templateWhitelist = [
+  "rds-postgresql",
+];
+
+const Apps: React.FC<Props> = ({ 
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [searchValue, setSearchValue] = useState("");
+  const [view, setView] = useState<"grid" | "list">("grid");
+  const [sort, setSort] = useState<"calendar" | "letter">("calendar");
+
+  // Placeholder (replace w useQuery)
+  const [databases, setDatabases] = useState([]);
+  const [status, setStatus] = useState("");
+
+  const filteredDatabases = useMemo(() => {
+    const filteredBySearch = search(
+      databases ?? [],
+      searchValue,
+      {
+        keys: ["name", "chart.metadata.name"],
+        isCaseSensitive: false,
+      }
+    );
+
+    return _.sortBy(filteredBySearch);
+  }, [databases, searchValue]);
+
+  const getExpandedChartLinkURL = useCallback((x: any) => {
+    const params = new Proxy(new URLSearchParams(window.location.search), {
+      get: (searchParams, prop: string) => searchParams.get(prop),
+    });
+    const cluster = currentCluster?.name;
+    const route = `/applications/${cluster}/${x.namespace}/${x.name}`;
+    const newParams = {
+      // @ts-ignore
+      project_id: params.project_id,
+      closeChartRedirectUrl: '/databases',
+    };
+    const newURLSearchParams = new URLSearchParams(
+      _.omitBy(newParams, _.isNil)
+    );
+    return `${route}?${newURLSearchParams.toString()}`;
+  }, [currentCluster]);
+
+  const getAddOns = async () => {
+    try {
+      setStatus("loading");
+      const res = await api.getCharts(
+        "<token>",
+        {
+          limit: 50,
+          skip: 0,
+          byDate: false,
+          statusFilter: [
+            "deployed",
+            "uninstalled",
+            "pending",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
+            "failed",
+          ],
+        },
+        {
+          id: currentProject?.id || -1,
+          cluster_id: currentCluster?.id || -1,
+          namespace: "ack-system",
+        }
+      );
+      setStatus("complete");
+      const charts = res.data || [];
+      const filtered = charts.filter((app: any) => {
+        return (
+          templateWhitelist.includes(app.chart.metadata.name)
+        );
+      });
+      setDatabases(filtered);
+    } catch (err) {
+      setStatus("error");
+    };
+  };
+
+  useEffect(() => {
+    // currentCluster sometimes returns as -1 and passes null check
+    if (currentProject?.id >= 0 && currentCluster?.id >= 0) {
+      getAddOns();
+    }
+  }, [currentCluster, currentProject]);
+
+  const renderContents = () => {
+    if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
+      return <ClusterProvisioningPlaceholder />;
+    }
+
+    if (status === "loading") {
+      return <Loading offset="-150px" />;
+    }
+
+    if (databases.length === 0) {
+      return (
+        <DashboardPlaceholder>
+          <Text size={16}>No databases have been created yet</Text>
+          <Spacer y={0.5} />
+
+          <Text color={"helper"}>Get started by creating a database.</Text>
+          <Spacer y={1} />
+          <PorterLink to="/databases/new/database">
+            <Button
+              onClick={async () =>
+                console.log() // TODO: add analytics
+              }
+              height="35px"
+              alt
+            >
+              Create database <Spacer inline x={1} />{" "}
+              <i className="material-icons" style={{ fontSize: "18px" }}>
+                east
+              </i>
+            </Button>
+          </PorterLink>
+        </DashboardPlaceholder>
+      );
+    }
+
+    return (
+      <>
+        <Container row spaced>
+          <SearchBar
+            value={searchValue}
+            setValue={(x) => {
+              setSearchValue(x);
+            }}
+            placeholder="Search databases . . ."
+            width="100%"
+          />
+          <Spacer inline x={2} />
+          <Toggle
+            items={[
+              { label: <ToggleIcon src={calendar} />, value: "calendar" },
+              { label: <ToggleIcon src={letter} />, value: "letter" },
+            ]}
+            active={sort}
+            setActive={(x) => {
+              if (x === "calendar") {
+                setSort("calendar");
+              } else {
+                setSort("letter");
+              }
+            }}
+          />
+          <Spacer inline x={1} />
+
+          <Toggle
+            items={[
+              { label: <ToggleIcon src={grid} />, value: "grid" },
+              { label: <ToggleIcon src={list} />, value: "list" },
+            ]}
+            active={view}
+            setActive={(x) => {
+              if (x === "grid") {
+                setView("grid");
+              } else {
+                setView("list");
+              }
+            }}
+          />
+
+          <Spacer inline x={2} />
+          <PorterLink to="/databases/new/database">
+            <Button
+              onClick={async () =>
+                console.log() // TODO: add analytics
+              }
+              height="30px"
+              width="140px"
+            >
+              <I className="material-icons">add</I> New database
+            </Button>
+          </PorterLink>
+        </Container>
+        <Spacer y={1} />
+
+        {filteredDatabases.length === 0 ? (
+          <Fieldset>
+            <Container row>
+              <PlaceholderIcon src={notFound} />
+              <Text color="helper">No matching databases were found.</Text>
+            </Container>
+          </Fieldset>
+        ) : (status === "loading" ? <Loading offset="-150px" /> : view === "grid" ? (
+          <GridList>
+            {(filteredDatabases ?? []).map((app: any, i: number) => {
+              return (
+                <Block to={getExpandedChartLinkURL(app)} key={i}>
+                  <Container row>
+                    <Icon
+                      src={
+                        hardcodedIcons[app.chart.metadata.name] ||
+                        app.chart.metadata.icon
+                      }
+                    />
+                    <Text size={14}>{app.name}</Text>
+                    <Spacer inline x={2} />
+                  </Container>
+                  <StatusIcon src={healthy} />
+                  <Container row>
+                    <SmallIcon opacity="0.4" src={time} />
+                    <Text size={13} color="#ffffff44">
+                      {readableDate(app.info.last_deployed)}
+                    </Text>
+                  </Container>
+                </Block>
+              );
+            })}
+          </GridList>
+        ) : (
+          <List>
+            {(filteredDatabases ?? []).map((app: any, i: number) => {
+              return (
+                <Row to={getExpandedChartLinkURL(app)} key={i}>
+                  <Container row>
+                    <MidIcon
+                      src={
+                        hardcodedIcons[app.chart.metadata.name] ||
+                        app.chart.metadata.icon
+                      }
+                    />
+                    <Text size={14}>{app.name}</Text>
+                    <Spacer inline x={1} />
+                    <MidIcon src={healthy} height="16px" />
+                  </Container>
+                  <Spacer height="15px" />
+                  <Container row>
+                    <SmallIcon opacity="0.4" src={time} />
+                    <Text size={13} color="#ffffff44">
+                      {readableDate(app.info.last_deployed)}
+                    </Text>
+                  </Container>
+                </Row>
+              );
+            })}
+          </List>
+        )
+        )}
+      </>
+    );
+  };
+
+  return (
+    <StyledAppDashboard>
+      <DashboardHeader
+        image={database}
+        title="Databases"
+        description="Storage, caches, and stateful workloads for this project."
+        disableLineBreak
+      />
+      {renderContents()}
+      <Spacer y={5} />
+    </StyledAppDashboard>
+  );
+};
+
+export default Apps;
+
+const MidIcon = styled.img<{ height?: string }>`
+  height: ${props => props.height || "18px"};
+  margin-right: 11px;
+`;
+
+const Row = styled(Link) <{ isAtBottom?: boolean }>`
+  cursor: pointer;
+  display: block;
+  padding: 15px;
+  border-bottom: ${props => props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${props => props.theme.clickable.bg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;
+
+const SmallIcon = styled.img<{ opacity?: string }>`
+  margin-left: 2px;
+  height: 14px;
+  opacity: ${props => props.opacity || 1};
+  margin-right: 10px;
+`;
+
+const StatusIcon = styled.img`
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  height: 18px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+  margin-right: 13px;
+`;
+
+const Block = styled(Link)`
+  height: 110px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${props => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${props => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const ToggleIcon = styled.img`
+  height: 12px;
+  margin: 0 5px;
+  min-width: 12px;
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const StyledAppDashboard = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const CentralContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: left;
+  align-items: left;
+`;

+ 403 - 0
dashboard/src/main/home/database-dashboard/RDSForm.tsx

@@ -0,0 +1,403 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+import _ from "lodash";
+import { v4 as uuidv4 } from 'uuid';
+
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { pushFiltered } from "shared/routing";
+
+import Back from "components/porter/Back";
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Input from "components/porter/Input";
+import VerticalSteps from "components/porter/VerticalSteps";
+import Button from "components/porter/Button";
+import { RouteComponentProps, withRouter } from "react-router";
+import Error from "components/porter/Error";
+import Fieldset from "components/porter/Fieldset";
+import Container from "components/porter/Container";
+import ClickToCopy from "components/porter/ClickToCopy";
+
+type Props = RouteComponentProps & {
+  currentTemplate: any;
+  goBack: () => void;
+  repoURL: string | undefined;
+};
+
+const RDSForm: React.FC<Props> = ({
+  currentTemplate,
+  goBack,
+  repoURL,
+  ...props
+}) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [name, setName] = useState<string>("");
+  const [buttonStatus, setButtonStatus] = useState<string>("");
+  const [credentialsSaved, setCredentialsSaved] = useState<boolean>(false);
+  const [dbName, setDbName] = useState<string>("postgres");
+  const [dbPassword, setDbPassword] = useState<string>(uuidv4());
+  const [dbUsername, setDbUsername] = useState<string>("postgres");
+  const [storage, setStorage] = useState<number>(0);
+  const [tier, setTier] = useState<string>("");
+  const [hidePassword, setHidePassword] = useState<boolean>(true);
+
+  useEffect(() => {
+    if (currentStep === 1) {
+      setCurrentStep(3);
+    }
+  }, [tier]);
+
+  const waitForHelmRelease = () => {
+    setTimeout(() => {
+      api.getChart(
+        "<token>",
+        {},
+        {
+          id: currentProject?.id || -1,
+          namespace: "ack-system",
+          cluster_id: currentCluster?.id || -1,
+          name,
+          revision: 0,
+        }
+      )
+        .then((res) => {
+          if (res?.data?.version) {
+            setButtonStatus("success");
+            pushFiltered(props, "/databases", ["project_id"], {
+              cluster: currentCluster?.name,
+            });
+          } else {
+            waitForHelmRelease();
+          }
+        })
+        .catch((err) => {
+          waitForHelmRelease();
+        });
+    }, 500);
+  };
+
+  const deploy = async (wildcard?: any) => {
+    setButtonStatus("loading");
+
+    let values = {
+      config: {
+        name,
+        masterUserPassword: dbPassword,
+        allocatedStorage: storage,
+        instanceClass: tier,
+      }
+    }
+
+    api
+      .deployAddon(
+        "<token>",
+        {
+          template_name: "rds-postgresql",
+          template_version: "latest",
+          values: values,
+          name,
+        },
+        {
+          id: currentProject?.id || -1,
+          cluster_id: currentCluster?.id || -1,
+          namespace: "ack-system",
+          repo_url: repoURL,
+        }
+      )
+      .then((_) => {
+        window.analytics?.track("Deployed RDS", {
+          name,
+          namespace: "ack-system",
+          values: values,
+        });
+        waitForHelmRelease();
+      })
+      .catch((err) => {
+        let parsedErr = err?.response?.data?.error;
+        err = parsedErr || err.message || JSON.stringify(err);
+        setButtonStatus(err);
+        window.analytics?.track("Failed to Deploy RDS", {
+          name,
+          namespace: "ack-system",
+          values: values,
+          error: err,
+        });
+        return;
+      });
+  };
+
+  const getStatus = () => {
+    if (!buttonStatus) {
+      return;
+    }
+    if (buttonStatus === "loading" || buttonStatus === "success") {
+      return buttonStatus;
+    } else {
+      return (
+        <Error message={buttonStatus} />
+      );
+    }
+  };
+
+  return (
+    <CenterWrapper>
+      <Div>
+        <StyledConfigureTemplate>
+          <Back onClick={goBack} />
+          <DashboardHeader
+            prefix={
+              <Icon 
+                src={hardcodedIcons[currentTemplate.name] || currentTemplate.icon}
+              />
+            }
+            title="Create an RDS Postgres instance"
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <VerticalSteps
+            currentStep={currentStep}
+            steps={[
+              <>
+                <Text size={16}>Database name</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Lowercase letters, numbers, and "-" only.
+                </Text>
+                <Spacer height="20px" />
+                <Input
+                  placeholder="ex: academic-sophon"
+                  value={name}
+                  width="300px"
+                  setValue={(e) => {
+                    if (e) {
+                      credentialsSaved ? setCurrentStep(2) : setCurrentStep(1);
+                    } else {
+                      setCurrentStep(0);
+                    }
+                    setName(e);
+                  }}
+                />
+              </>,
+              <>
+                <Text size={16}>Database resources</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Specify your database CPU, RAM, and storage.
+                </Text>
+                <Spacer y={.5} />
+                <Text>
+                  Select an instance tier:
+                </Text>
+                <Spacer height="20px" />
+                <ResourceOption
+                  selected={tier === "db.t4g.small"}
+                  onClick={() => {
+                    setStorage(30);
+                    setTier("db.t4g.small");
+                  }}
+                >
+                  <Container row>
+                    <Text>Small</Text>
+                    <Spacer inline width="5px" />
+                    <Text color="helper">- 2 CPU, 2 GB RAM</Text>
+                  </Container>
+                  <StorageTag>30 GB Storage</StorageTag>
+                </ResourceOption>
+                <Spacer height="15px" />
+                <ResourceOption
+                  selected={tier === "db.t4g.medium"}
+                  onClick={() => {
+                    setStorage(100);
+                    setTier("db.t4g.medium");
+                  }}
+                >
+                  <Container row>
+                    <Text>Medium</Text>
+                    <Spacer inline width="5px" />
+                    <Text color="helper">- 2 CPU, 4 GB RAM</Text>
+                  </Container>
+                  <StorageTag>100 GB Storage</StorageTag>
+                </ResourceOption>
+                <Spacer height="15px" />
+                <ResourceOption
+                  selected={tier === "db.t4g.large"}
+                  onClick={() => {
+                    setStorage(256);
+                    setTier("db.t4g.large");
+                  }}
+                >
+                  <Container row>
+                    <Text>Large</Text>
+                    <Spacer inline width="5px" />
+                    <Text color="helper">- 2 CPU, 8 GB RAM</Text>
+                  </Container>
+                  <StorageTag>256 GB Storage</StorageTag>
+                </ResourceOption>
+              </>,
+              <>
+                <Text size={16}>Database credentials</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  These credentials never leave your own cloud environment. You will be able to automatically import these credentials from any app.
+                </Text>
+                <Spacer height="20px" />
+                <Fieldset>
+                  <Text>Postgres DB name</Text>
+                  <Spacer y={0.5} />
+                  <Text
+                    additionalStyles="font-family: monospace;"
+                    color="helper"
+                  >
+                    {dbName}
+                  </Text>
+                  <Spacer y={1} />
+                  <Text>Postgres username</Text>
+                  <Spacer y={0.5} />
+                  <Text
+                    additionalStyles="font-family: monospace;"
+                    color="helper"
+                  >
+                    {dbUsername}
+                  </Text>
+                  <Spacer y={1} />
+                  <Text>Postgres password</Text>
+                  <Spacer y={0.5} />
+                  <Container row>
+                    {hidePassword ? (
+                      <>
+                        <Blur>{dbPassword}</Blur>
+                        <Spacer inline width="10px" />
+                        <RevealButton
+                          onClick={() => setHidePassword(false)}
+                        >
+                          Reveal
+                        </RevealButton>
+                      </>
+                    ) : (
+                      <>
+                        <ClickToCopy color="helper">
+                          {dbPassword}
+                        </ClickToCopy>
+                        <Spacer inline width="10px" />
+                        <RevealButton
+                          onClick={() => setHidePassword(true)}
+                        >
+                          Hide
+                        </RevealButton>
+                      </>
+                    )}
+                  </Container>
+                </Fieldset>
+              </>,
+              <>
+                <Text size={16}>Provision a database</Text>
+                <Spacer y={0.5} />
+                <Button
+                  onClick={deploy}
+                  disabled={buttonStatus === "loading"}
+                  status={getStatus()}
+                >
+                  Create database
+                </Button>
+              </>
+            ]}
+          />
+          <Spacer height="80px" />
+        </StyledConfigureTemplate>
+      </Div>
+    </CenterWrapper>
+  );
+};
+
+export default withRouter(RDSForm);
+
+const RevealButton = styled.div`
+  background: ${props => props.theme.fg};
+  padding: 5px 10px;
+  border-radius: 5px;
+  border: 1px solid #494b4f;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
+const Blur = styled.div`
+  filter: blur(5px);
+  -webkit-filter: blur(5px);
+  position: relative;
+  margin-left: -5px;
+  font-family: monospace;
+`;
+
+const StorageTag = styled.div`
+  background: #202227;
+  color: #aaaabb;
+  border-radius: 5px;
+  padding: 5px 10px;
+  font-size: 13px;
+  margin-left: 5px;
+`;
+
+const ResourceOption = styled.div<{ selected?: boolean }>`
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid ${props => props.selected ? "#ffffff" : props.theme.border};
+  width: 350px;
+  padding: 10px 15px;
+  border-radius: 5px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  cursor: pointer;
+  :hover {
+    border: 1px solid #ffffff;
+  }
+`;
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 30px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;

+ 0 - 1
dashboard/src/main/home/sidebar/ClusterList.tsx

@@ -192,7 +192,6 @@ const Option = styled.div<{ selected: boolean }>`
   opacity: 0.6;
   :hover {
     opacity: 1;
-
   }
 
   > i {

+ 2 - 0
dashboard/src/shared/hardcodedNameDict.tsx

@@ -1,4 +1,5 @@
 import lightning from "../assets/lightning.png";
+import awsRDS from "assets/amazon-rds.png";
 
 const hardcodedNames: { [key: string]: string } = {
   agones: "Agones System",
@@ -44,6 +45,7 @@ const hardcodedIcons: { [key: string]: string } = {
   mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
   postgresql:
     "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg",
+  "rds-postgresql": awsRDS,
   redis:
     "https://cdn4.iconfinder.com/data/icons/redis-2/1451/Untitled-2-512.png",
   ubuntu: "Ubuntu",

+ 1 - 1
go.mod

@@ -82,7 +82,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.18
+	github.com/porter-dev/api-contracts v0.2.22
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1516,8 +1516,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.18 h1:nqGQGOXAnqAaDSVG578YpkgmQBFTLK3dmLYjPEgDLS0=
-github.com/porter-dev/api-contracts v0.2.18/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.22 h1:eJt1RrntnyKQvFL2fDu2zQcuW3nU7ezLonCkSogBnuE=
+github.com/porter-dev/api-contracts v0.2.22/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 2 - 0
go.work.sum

@@ -826,6 +826,8 @@ github.com/porter-dev/api-contracts v0.1.7/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4d
 github.com/porter-dev/api-contracts v0.2.3 h1:JDdi2QT6RkI37XiYRaKM3L5wvFSp070pWmnlexDsd4c=
 github.com/porter-dev/api-contracts v0.2.3/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.11/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.22 h1:eJt1RrntnyKQvFL2fDu2zQcuW3nU7ezLonCkSogBnuE=
+github.com/porter-dev/api-contracts v0.2.22/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
 github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc=
 github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI=