Przeglądaj źródła

Database view (#4065)

Co-authored-by: jose-fully-ported <141160579+jose-fully-ported@users.noreply.github.com>
Co-authored-by: Jose Diaz-Gonzalez <jose@porter.run>
Co-authored-by: Feroze Mohideen <feroze@porter.run>
sdess09 2 lat temu
rodzic
commit
e0b0a1aa62

+ 18 - 2
api/server/handlers/cloud_provider/list_aws.go

@@ -85,10 +85,26 @@ func (c *ListAwsAccountsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			return
 		}
 
-		res.Accounts = append(res.Accounts, AwsAccount{
+		account := AwsAccount{
 			CloudProviderID: targetArn.AccountID,
 			ProjectID:       uint(link.ProjectID),
-		})
+		}
+		if contains(res.Accounts, account) {
+			continue
+		}
+
+		res.Accounts = append(res.Accounts, account)
 	}
 	c.WriteResult(w, r, res)
 }
+
+// contains will check if the list of AwsAccounts contains the specified account
+// TODO: replace this with an upgrade to Go 1.21 in favor of slices.Contains()
+func contains(s []AwsAccount, e AwsAccount) bool {
+	for _, a := range s {
+		if a == e {
+			return true
+		}
+	}
+	return false
+}

+ 119 - 0
api/server/handlers/datastore/delete.go

@@ -0,0 +1,119 @@
+package datastore
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// DeleteRequest describes an inbound datastore deletion request
+type DeleteRequest struct {
+	Type string `json:"type" form:"required"`
+	Name string `json:"name" form:"required"`
+}
+
+// DeleteDatastoreHandler is a struct for handling datastore deletion requests
+type DeleteDatastoreHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewDeleteDatastoreHandler constructs a datastore DeleteDatastoreHandler
+func NewDeleteDatastoreHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteDatastoreHandler {
+	return &DeleteDatastoreHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (h *DeleteDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-datastore-delete")
+	defer span.End()
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &StatusRequest{}
+	if ok := h.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "datastore-name", Value: request.Name},
+		telemetry.AttributeKV{Key: "datastore-type", Value: request.Type},
+	)
+
+	cluster, err := h.getClusterForDatastore(ctx, r, project.ID, request.Name)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "unable to find datastore on any associated cluster")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID})
+
+	helmAgent, err := h.GetHelmAgent(ctx, r, cluster, "ack-system")
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to get helm client for cluster")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	_, err = helmAgent.GetRelease(ctx, request.Name, 0, false)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to get helm release")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	_, err = helmAgent.UninstallChart(ctx, request.Name)
+	if err != nil {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID})
+		err := telemetry.Error(ctx, span, err, "unable to uninstall chart")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	// if the release was deleted by helm without error, mark it as accepted
+	w.WriteHeader(http.StatusAccepted)
+}
+
+func (h *DeleteDatastoreHandler) getClusterForDatastore(ctx context.Context, r *http.Request, projectID uint, datastoreName string) (*models.Cluster, error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-cluster-for-datastore")
+
+	if r == nil {
+		return nil, telemetry.Error(ctx, span, nil, "missing http request object")
+	}
+
+	clusters, err := h.Repo().Cluster().ListClustersByProjectID(projectID)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "unable to get project clusters")
+	}
+
+	for _, cluster := range clusters {
+		helmAgent, err := h.GetHelmAgent(ctx, r, cluster, "ack-system")
+		if err != nil {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID})
+			return nil, telemetry.Error(ctx, span, err, "unable to get helm client for cluster")
+		}
+
+		_, err = helmAgent.GetRelease(ctx, datastoreName, 0, false)
+		if err == nil {
+			return cluster, nil
+		}
+	}
+
+	return nil, telemetry.Error(ctx, span, nil, "unable to find datastore on any associated cluster")
+}

+ 1 - 1
api/server/handlers/datastore/list.go

@@ -62,7 +62,7 @@ type ListDatastoresHandler struct {
 	authz.KubernetesAgentGetter
 }
 
-// NewListDatastoresHandler constructs a datastore ListHandler
+// NewListDatastoresHandler constructs a datastore ListDatastoresHandler
 func NewListDatastoresHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,

+ 30 - 2
api/server/router/datastore.go

@@ -58,7 +58,7 @@ func getDatastoreRoutes(
 	}
 	routes := make([]*router.Route, 0)
 
-	// GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewListHandler
+	// GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewListDatastoresHandler
 	listEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -86,7 +86,35 @@ func getDatastoreRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores/{datastore_type}/{datastore_name} -> cloud_provider.NewListHandler
+	// DELETE /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewDeleteDatastoreHandler
+	deleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/{%s}/datastores", relPath, types.URLParamCloudProviderType, types.URLParamCloudProviderID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	deleteHandler := datastore.NewDeleteDatastoreHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEndpoint,
+		Handler:  deleteHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores/{datastore_type}/{datastore_name} -> cloud_provider.NewListDatastoresHandler
 	getEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,

+ 47 - 39
dashboard/src/main/home/Home.tsx

@@ -1,14 +1,14 @@
-import React, { useEffect, useState, useContext, useRef } from "react";
+import React, { useContext, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
 import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 import styled, { ThemeProvider } from "styled-components";
-import { createPortal } from "react-dom";
 
 import api from "shared/api";
-import midnight from "shared/themes/midnight";
-import standard from "shared/themes/standard";
 import { Context } from "shared/Context";
 import { PorterUrl, pushFiltered, pushQueryParams } from "shared/routing";
-import { ClusterType, ProjectType, ProjectListType } from "shared/types";
+import midnight from "shared/themes/midnight";
+import standard from "shared/themes/standard";
+import { ClusterType, ProjectListType, ProjectType } from "shared/types";
 
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
@@ -17,37 +17,39 @@ import Dashboard from "./dashboard/Dashboard";
 import Integrations from "./integrations/Integrations";
 import LaunchWrapper from "./launch/LaunchWrapper";
 
+import AddOnDashboard from "./add-on-dashboard/AddOnDashboard";
+import AppDashboard from "./app-dashboard/AppDashboard";
+import CreateDatabase from "./database-dashboard/CreateDatabase";
+import DatabaseDashboard from "./database-dashboard/DatabaseDashboard";
 import Navbar from "./navbar/Navbar";
 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";
-import discordLogo from "../../assets/discord.svg";
-import Onboarding from "./onboarding/Onboarding";
-import ModalHandler from "./ModalHandler";
-import { NewProjectFC } from "./new-project/NewProject";
-import InfrastructureRouter from "./infrastructure/InfrastructureRouter";
-import { overrideInfraTabEnabled } from "utils/infrastructure";
 import NoClusterPlaceHolder from "components/NoClusterPlaceHolder";
-import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow";
+import Button from "components/porter/Button";
 import Modal from "components/porter/Modal";
-import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
-import Button from "components/porter/Button";
-import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow";
-import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
-import CreateApp from "./app-dashboard/create-app/CreateApp";
+import Text from "components/porter/Text";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { fakeGuardedRoute } from "shared/auth/RouteGuard";
+import ClusterResourcesProvider from "shared/ClusterResourcesContext";
+import DeploymentTargetProvider from "shared/DeploymentTargetContext";
+import { overrideInfraTabEnabled } from "utils/infrastructure";
+import discordLogo from "../../assets/discord.svg";
+import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow";
 import AppView from "./app-dashboard/app-view/AppView";
 import Apps from "./app-dashboard/apps/Apps";
-import DeploymentTargetProvider from "shared/DeploymentTargetContext";
+import CreateApp from "./app-dashboard/create-app/CreateApp";
+import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
+import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow";
 import PreviewEnvs from "./cluster-dashboard/preview-environments/v2/PreviewEnvs";
 import SetupApp from "./cluster-dashboard/preview-environments/v2/setup-app/SetupApp";
-import ClusterResourcesProvider from "shared/ClusterResourcesContext";
+import DatabaseView from "./database-dashboard/DatabaseView";
+import InfrastructureRouter from "./infrastructure/InfrastructureRouter";
+import ModalHandler from "./ModalHandler";
+import { NewProjectFC } from "./new-project/NewProject";
+import Onboarding from "./onboarding/Onboarding";
+
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -198,7 +200,7 @@ const Home: React.FC<Props> = (props) => {
       } else {
         setHasFinishedOnboarding(true);
       }
-    } catch (error) {}
+    } catch (error) { }
   };
 
   useEffect(() => {
@@ -460,8 +462,14 @@ const Home: React.FC<Props> = (props) => {
                 <Route path="/databases/new">
                   <CreateDatabase />
                 </Route>
+                <Route path="/databases/:projectId/:cloudProviderName/:cloudProviderId/:datastoreName/:tab">
+                  <DatabaseView />
+                </Route>
+                <Route path="/databases/:projectId/:cloudProviderName/:cloudProviderId/:datastoreName">
+                  <DatabaseView />
+                </Route>
                 <Route path="/databases">
-                  <DatabaseDashboard />
+                  <DatabaseDashboard projectId={currentProject?.id} />
                 </Route>
 
                 <Route path="/addons/new">
@@ -486,17 +494,17 @@ const Home: React.FC<Props> = (props) => {
                   overrideInfraTabEnabled({
                     projectID: currentProject?.id,
                   })) && (
-                  <Route
-                    path="/infrastructure"
-                    render={() => {
-                      return (
-                        <DashboardWrapper>
-                          <InfrastructureRouter />
-                        </DashboardWrapper>
-                      );
-                    }}
-                  />
-                )}
+                    <Route
+                      path="/infrastructure"
+                      render={() => {
+                        return (
+                          <DashboardWrapper>
+                            <InfrastructureRouter />
+                          </DashboardWrapper>
+                        );
+                      }}
+                    />
+                  )}
                 <Route
                   path="/dashboard"
                   render={() => {
@@ -553,7 +561,7 @@ const Home: React.FC<Props> = (props) => {
                   render={() => <GuardedProjectSettings />}
                 />
                 {currentProject?.validate_apply_v2 &&
-                currentProject.preview_envs_enabled ? (
+                  currentProject.preview_envs_enabled ? (
                   <>
                     <Route exact path="/preview-environments/configure">
                       <SetupApp />

+ 3 - 4
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -1,10 +1,9 @@
+import EnvEditorModal from "main/home/modals/EnvEditorModal";
+import Modal from "main/home/modals/Modal";
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
-import Modal from "main/home/modals/Modal";
-import EnvEditorModal from "main/home/modals/EnvEditorModal";
 
 import upload from "assets/upload.svg";
-import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray";
 import { dotenv_parse } from "shared/string_utils";
 
 export type KeyValueType = {
@@ -348,7 +347,7 @@ const Label = styled.div`
 
 const StyledInputArray = styled.div`
   margin-bottom: 15px;
-  margin-top: 22px;
+  margin-top: 10px;
 `;
 
 export const MultiLineInputer = styled.textarea<InputProps>`

+ 258 - 315
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -1,220 +1,179 @@
+import React, { useContext, useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
 import _ from "lodash";
-import React, {
-  useCallback,
-  useContext,
-  useEffect,
-  useMemo,
-  useState,
-} from "react";
+import { Link } from "react-router-dom";
 import styled from "styled-components";
 
-import calendar from "assets/calendar-number.svg";
-import database from "assets/database.svg";
-import grid from "assets/grid.png";
-import list from "assets/list.png";
-import notFound from "assets/not-found.png";
-import healthy from "assets/status-healthy.png";
-import time from "assets/time.png";
-import letter from "assets/vector.svg";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { hardcodedIcons } from "shared/hardcodedNameDict";
-import { search } from "shared/search";
-
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import Loading from "components/Loading";
 import Button from "components/porter/Button";
 import Container from "components/porter/Container";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import Fieldset from "components/porter/Fieldset";
 import PorterLink from "components/porter/Link";
 import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
-import { Link } from "react-router-dom";
-import { readableDate } from "shared/string_utils";
-
-import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
-import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
-import loading from "assets/loading.gif";
 
-type Props = {};
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { search } from "shared/search";
+import database from "assets/database.svg";
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+import loading from "assets/loading.gif";
+import notFound from "assets/not-found.png";
+import healthy from "assets/status-healthy.png";
 
-const templateWhitelist = [
-  "elasticache-redis",
-  "rds-postgresql",
-  "rds-postgresql-aurora",
-];
+import { getDatastoreIcon } from "./icons";
+import {
+  cloudProviderListResponseValidator,
+  datastoreListResponseValidator,
+  type CloudProviderDatastore,
+  type CloudProviderWithSource,
+} from "./types";
+import { datastoreField } from "./utils";
+
+type Props = {
+  projectId: number;
+};
 
-const Apps: React.FC<Props> = ({
-}) => {
-  const { currentProject, currentCluster } = useContext(Context);
+const DatabaseDashboard: React.FC<Props> = ({ projectId }) => {
+  const { 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 [databaseStatuses, setDatabaseStatuses] = useState({});
-
-  const filteredDatabases = useMemo(() => {
-    const filteredBySearch = search(
-      databases ?? [],
-      searchValue,
-      {
-        keys: ["name", "chart.metadata.name"],
-        isCaseSensitive: false,
-      }
-    );
-
-    return _.sortBy(filteredBySearch);
-  }, [databases, searchValue]);
-
-  const updateDatabaseStatuses = async (): Promise<void> => {
-    const newStatuses = {};
-    for (const db of filteredDatabases) {
-      try {
-        if (databaseStatuses[db.name] !== "available") {
-          console.log(db)
-          const statusRes = await api.getDatabaseStatus("<token>", {
-            name: db.name,
-            type: db.chart.metadata.name
-          }, {
-            project_id: currentProject?.id ?? 0,
-            cluster_id: currentCluster?.id ?? 0,
-          });
-          if (statusRes.data.status === "available") {
-            newStatuses[db.name] = statusRes.data.status;
-          }
-          else {
-            newStatuses[db.name] = "updating";
-          }
-        }// Assuming status is returned in this field
-      } catch (err) {
-        console.error("Error fetching database status for", db.name, err);
-        newStatuses[db.name] = "error"; // Or some error state
-      }
-
-    }
-    setDatabaseStatuses(newStatuses);
-  };
-
-
-  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(
+  const { data: cloudProviderResponse } = useQuery(
+    ["cloudProviders", projectId],
+    async () => {
+      const response = await api.getAwsCloudProviders(
         "<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",
+          project_id: projectId,
         }
       );
-      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(() => {
-    // Call once when the component mounts
-    void updateDatabaseStatuses();
+      const results = await cloudProviderListResponseValidator.parseAsync(
+        response.data
+      );
+      return results;
+    },
+    {
+      enabled: !!projectId,
+    }
+  );
 
-    // Set up the interval for polling every 5 minutes
-    const intervalId = setInterval(() => {
-      void updateDatabaseStatuses();
-    }, 60000); // 60000 milliseconds = 5 minutes
+  const cloudProviders = cloudProviderResponse?.accounts;
 
-    // Clear interval on component unmount
-    return () => clearInterval(intervalId);
-  }, [filteredDatabases]);
+  const { data: datastores, isFetched: isLoaded } = useQuery(
+    [projectId],
+    async () => {
+      if (cloudProviders === undefined) {
+        return;
+      }
 
-  useEffect(() => {
-    // currentCluster sometimes returns as -1 and passes null check
+      const results = await Promise.all(
+        cloudProviders.map(
+          async (
+            cloudProvider: CloudProviderWithSource
+          ): Promise<CloudProviderDatastore[]> => {
+            const response = await api.getDatastores(
+              "<token>",
+              {},
+              {
+                project_id: cloudProvider.project_id,
+                cloud_provider_name: "aws",
+                cloud_provider_id: cloudProvider.cloud_provider_id,
+                include_metadata: true,
+              }
+            );
+
+            const results = await datastoreListResponseValidator.parseAsync(
+              response.data
+            );
+            return results.datastores.map(
+              (datastore): CloudProviderDatastore => {
+                return {
+                  cloud_provider_name: "aws",
+                  cloud_provider_id: cloudProvider.cloud_provider_id,
+                  datastore,
+                  project_id: cloudProvider.project_id,
+                };
+              }
+            );
+          }
+        )
+      );
 
-    if (currentProject?.id >= 0 && currentCluster?.id >= 0) {
-      getAddOns();
+      if (results.length === 0) {
+        return;
+      }
+
+      return results.flat(1);
+    },
+    {
+      enabled: !!cloudProviders,
+      refetchInterval: 10000,
+      refetchOnWindowFocus: false,
     }
-  }, [currentCluster, currentProject]);
+  );
+
+  const filteredDatabases = useMemo(() => {
+    const filteredBySearch = search(
+      datastores === undefined ? [] : datastores,
+      searchValue,
+      {
+        keys: ["name"],
+        isCaseSensitive: false,
+      }
+    );
+
+    return _.sortBy(filteredBySearch, ["name"]);
+  }, [datastores, searchValue]);
 
-  const renderStatusIcon = (dbName: string): JSX.Element => {
-    const status: string = databaseStatuses[dbName];
+  const renderStatusIcon = (status: string): JSX.Element => {
     switch (status) {
       case "available":
         return <StatusIcon src={healthy} />;
       case "":
         return <></>;
       case "error":
-        return <StatusText>
-          <StatusWrapper success={false}>
-            <Loading src={loading} />
-            {"Creating database"}
-          </StatusWrapper>
-        </StatusText>
+        return (
+          <StatusText>
+            <StatusWrapper success={false}>
+              <Status src={loading} />
+              {"Creating database"}
+            </StatusWrapper>
+          </StatusText>
+        );
       case "updating":
-        return <StatusText>
-          <StatusWrapper success={false}>
-            <Loading src={loading} />
-            {"Creating database"}
-          </StatusWrapper>
-        </StatusText>
+        return (
+          <StatusText>
+            <StatusWrapper success={false}>
+              <Status src={loading} />
+              {"Creating database"}
+            </StatusWrapper>
+          </StatusText>
+        );
       default:
         return <></>;
     }
   };
 
-
-  const renderContents = () => {
+  const renderContents = (): JSX.Element => {
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
       return <ClusterProvisioningPlaceholder />;
     }
 
-    if (status === "loading") {
+    if (datastores === undefined || !isLoaded) {
       return <Loading offset="-150px" />;
     }
 
-    if (databases.length === 0) {
+    if (datastores.length === 0) {
       return (
         <DashboardPlaceholder>
           <Text size={16}>No databases have been created yet</Text>
@@ -225,7 +184,8 @@ const Apps: React.FC<Props> = ({
           <PorterLink to="/databases/new/database">
             <Button
               onClick={async () =>
-                console.log() // TODO: add analytics
+                // TODO: add analytics
+                true
               }
               height="35px"
               alt
@@ -251,21 +211,6 @@ const Apps: React.FC<Props> = ({
             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
@@ -287,7 +232,8 @@ const Apps: React.FC<Props> = ({
           <PorterLink to="/databases/new/database">
             <Button
               onClick={async () =>
-                console.log() // TODO: add analytics
+                // TODO: add analytics
+                true
               }
               height="30px"
               width="140px"
@@ -305,60 +251,63 @@ const Apps: React.FC<Props> = ({
               <Text color="helper">No matching databases were found.</Text>
             </Container>
           </Fieldset>
-        ) : (status === "loading" ? <Loading offset="-150px" /> : view === "grid" ? (
+        ) : !isLoaded ? (
+          <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>
-                  {renderStatusIcon(app.name)}
-                  <Container row>
-                    <SmallIcon opacity="0.4" src={time} />
-                    <Text size={13} color="#ffffff44">
-                      {readableDate(app.info.last_deployed)}
-                    </Text>
-                  </Container>
-                </Block>
-              );
-            })}
+            {(filteredDatabases ?? []).map(
+              (entry: CloudProviderDatastore, i: number) => {
+                return (
+                  <Link
+                    to={`/databases/${entry.project_id}/${entry.cloud_provider_name}/${entry.cloud_provider_id}/${entry.datastore.name}/`}
+                    key={i}
+                  >
+                    <Block>
+                      <Container row>
+                        <Icon src={getDatastoreIcon(entry.datastore.type)} />
+                        <Text size={14}>{entry.datastore.name}</Text>
+                        <Spacer inline x={2} />
+                      </Container>
+                      {renderStatusIcon(
+                        datastoreField(entry.datastore, "status")
+                      )}
+                      <Container row>
+                        <Text size={13} color="#ffffff44">
+                          {datastoreField(entry.datastore, "engine")}
+                        </Text>
+                      </Container>
+                    </Block>
+                  </Link>
+                );
+              }
+            )}
           </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>
-              );
-            })}
+            {(filteredDatabases ?? []).map(
+              (entry: CloudProviderDatastore, i: number) => {
+                return (
+                  <Row
+                    to={`/databases/${entry.project_id}/${entry.cloud_provider_name}/${entry.cloud_provider_id}/${entry.datastore.name}/`}
+                    key={i}
+                  >
+                    <Container row>
+                      <MidIcon src={getDatastoreIcon(entry.datastore.type)} />
+                      <Text size={14}>{entry.datastore.name}</Text>
+                      <Spacer inline x={1} />
+                      <MidIcon src={healthy} height="16px" />
+                    </Container>
+                    <Spacer height="15px" />
+                    <Container row>
+                      <Text size={13} color="#ffffff44">
+                        {datastoreField(entry.datastore, "engine")}
+                      </Text>
+                    </Container>
+                  </Row>
+                );
+              }
+            )}
           </List>
-        )
         )}
       </>
     );
@@ -378,108 +327,102 @@ const Apps: React.FC<Props> = ({
   );
 };
 
-export default Apps;
+export default DatabaseDashboard;
 
 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;
-            `;
+  height: ${(props) => props.height || "18px"};
+  margin-right: 11px;
+`;
 
-const List = styled.div`
-            overflow: hidden;
-            `;
+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 SmallIcon = styled.img<{ opacity?: string }>`
-              margin-left: 2px;
-              height: 14px;
-              opacity: ${props => props.opacity || 1};
-              margin-right: 10px;
-              `;
+const List = styled.div`
+  overflow: hidden;
+`;
 
 const StatusIcon = styled.img`
-              position: absolute;
-              top: 20px;
-              right: 20px;
-              height: 18px;
-              `;
+  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;
+  height: 20px;
+  margin-right: 13px;
+`;
+
+const Block = styled.div`
+  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;
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
     }
-              to {
-                opacity: 1;
+    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));
-              `;
+  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;
-              `;
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
 
 const ToggleIcon = styled.img`
-              height: 12px;
-              margin: 0 5px;
-              min-width: 12px;
-              `;
+  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;
-              `;
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
 
 const StyledAppDashboard = styled.div`
-              width: 100%;
-              height: 100%;
-              `;
+  width: 100%;
+  height: 100%;
+`;
 
 const StatusText = styled.div`
   position: absolute;
@@ -509,9 +452,9 @@ const StatusWrapper = styled.div<{
     color: ${(props) => (props.success ? "#4797ff" : "#fcba03")};
   }
 `;
-const Loading = styled.img`
+const Status = styled.img`
   width: 15px;
   height: 15px;
   margin-right: 9px;
   margin-bottom: 0px;
-`;
+`;

+ 68 - 0
dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx

@@ -0,0 +1,68 @@
+import React from "react";
+import styled from "styled-components";
+
+import Banner from "components/porter/Banner";
+import Fieldset from "components/porter/Fieldset";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import TitleSection from "components/TitleSection";
+
+import DatabaseHeaderItem from "./DatabaseHeaderItem";
+import { getDatastoreIcon } from "./icons";
+import { type DatastoreWithSource } from "./types";
+import { datastoreField } from "./utils";
+
+type Props = {
+  datastore: DatastoreWithSource;
+};
+
+const DatabaseHeader: React.FC<Props> = ({ datastore }) => {
+  return (
+    <>
+      <TitleSection icon={getDatastoreIcon(datastore.type)} iconWidth="33px">
+        {datastore.name}
+      </TitleSection>
+      <Spacer y={1} />
+
+      {datastoreField(datastore, "status") !== "available" && (
+        <>
+          <Banner>
+            <BannerContents>
+              <b>Database is being created</b>
+            </BannerContents>
+            <Spacer inline width="5px" />
+          </Banner>
+          <Spacer y={1} />
+        </>
+      )}
+
+      <Fieldset>
+        <Text size={12}>Database details: </Text>
+        <Spacer y={0.5} />
+
+        {datastore.metadata !== undefined && datastore.metadata?.length > 0 && (
+          <GridList>
+            {datastore.metadata?.map((item, index) => (
+              <DatabaseHeaderItem item={item} key={index}></DatabaseHeaderItem>
+            ))}
+          </GridList>
+        )}
+      </Fieldset>
+    </>
+  );
+};
+
+export default DatabaseHeader;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const BannerContents = styled.div`
+  display: flex;
+  flex-direction: column;
+  row-gap: 0.5rem;
+`;

+ 57 - 0
dashboard/src/main/home/database-dashboard/DatabaseHeaderItem.tsx

@@ -0,0 +1,57 @@
+import React from "react";
+import styled from "styled-components";
+
+import CopyToClipboard from "components/CopyToClipboard";
+import Container from "components/porter/Container";
+import Text from "components/porter/Text";
+
+import copy from "assets/copy-left.svg";
+
+import { type DatastoreMetadataWithSource } from "./types";
+
+type DatabaseHeaderItemProps = {
+  item: DatastoreMetadataWithSource;
+};
+
+const DatabaseHeaderItem: React.FC<DatabaseHeaderItemProps> = ({ item }) => {
+  const truncateText = (text: string, length: number): string => {
+    if (text.length <= length) {
+      return text;
+    }
+    return `${text.substring(0, length)}...`;
+  };
+
+  const titleizeText = (text: string): string => {
+    return text
+      .split("_")
+      .map((word) => {
+        return word[0].toUpperCase() + word.substring(1);
+      })
+      .join(" ");
+  };
+
+  return (
+    <Container column>
+      <Text size={12}>{titleizeText(item.name)}</Text>
+
+      <Container row>
+        <Text title={item.value} color="helper" size={10}>
+          {truncateText(item.value, 42)}
+        </Text>
+        <CopyToClipboard text={item.value.toString()}>
+          <CopyIcon src={copy} alt="copy" />
+        </CopyToClipboard>
+      </Container>
+    </Container>
+  );
+};
+
+export default DatabaseHeaderItem;
+
+const CopyIcon = styled.img`
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 5px;
+  width: 10px;
+  height: 10px;
+`;

+ 81 - 0
dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx

@@ -0,0 +1,81 @@
+import React, {
+  useMemo
+} from "react";
+import { useHistory } from "react-router";
+import { match } from "ts-pattern";
+
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+
+import { CloudProviderDatastore } from "./types";
+
+import DatabaseEnvTab from "./tabs/DatabaseEnvTab";
+import MetricsTab from "./tabs/MetricsTab";
+import SettingsTab from "./tabs/SettingsTab";
+
+// commented out tabs are not yet implemented
+// will be included as support is available based on data from app revisions rather than helm releases
+const validTabs = [
+
+  "metrics",
+  // "debug",
+  "environment",
+  "settings",
+] as const;
+const DEFAULT_TAB = "environment";
+type ValidTab = (typeof validTabs)[number];
+
+type DbTabProps = {
+  tabParam?: string;
+  item: CloudProviderDatastore;
+};
+
+// todo(ianedwards): refactor button to use more predictable state
+export type ButtonStatus = "" | "loading" | JSX.Element | "success";
+
+const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam, item }) => {
+  const history = useHistory();
+
+  const currentTab = useMemo(() => {
+    if (tabParam && validTabs.includes(tabParam as ValidTab)) {
+      return tabParam as ValidTab;
+    }
+
+    return DEFAULT_TAB;
+  }, [tabParam]);
+
+  const tabs = useMemo(() => {
+    const base = [
+      { label: "Connection Info", value: "environment" },
+    ];
+    base.push({ label: "Settings", value: "settings" });
+    return base;
+  }, [
+
+  ]);
+
+  return (
+    <>
+      <TabSelector
+        noBuffer
+        options={tabs}
+        currentTab={currentTab}
+        setCurrentTab={(tab) => {
+          history.push(
+            `/databases/${item.project_id}/${item.cloud_provider_name}/${item.cloud_provider_id}/${item.datastore.name}/${tab}`
+          );
+        }} /><Spacer y={1} />
+      {match(currentTab)
+        .with("environment", () => (
+          <DatabaseEnvTab envData={item.datastore.env} />
+        ))
+        .with("settings", () => <SettingsTab item={item} />)
+        .with("metrics", () => <MetricsTab />)
+
+        .otherwise(() => null)}
+      <Spacer y={2} />
+    </>
+  );
+};
+
+export default DatabaseTabs;

+ 108 - 0
dashboard/src/main/home/database-dashboard/DatabaseView.tsx

@@ -0,0 +1,108 @@
+import { useQuery } from "@tanstack/react-query";
+import React, { useMemo } from "react";
+import { useParams, withRouter, type RouteComponentProps } from "react-router";
+import styled from "styled-components";
+import { z } from "zod";
+
+import api from "shared/api";
+
+import Loading from "components/Loading";
+import Back from "components/porter/Back";
+import Spacer from "components/porter/Spacer";
+
+import DatabaseHeader from "./DatabaseHeader";
+import DatabaseTabs from "./DatabaseTabs";
+import { CloudProviderDatastore, datastoreListResponseValidator } from "./types";
+
+type Props = RouteComponentProps;
+
+const DatabaseView: React.FC<Props> = ({ match }) => {
+  let { projectId, cloudProviderName, cloudProviderId, datastoreName } = useParams();
+
+  const params = useMemo(() => {
+    const { params } = match;
+    const validParams = z
+      .object({
+        tab: z.string().optional(),
+      })
+      .safeParse(params);
+
+    if (!validParams.success) {
+      return {
+        tab: undefined,
+      };
+    }
+
+    return validParams.data;
+  }, [match]);
+
+  const { data: item, status } = useQuery(
+    ["datastore", projectId, cloudProviderId, cloudProviderName, datastoreName],
+    async (): Promise<CloudProviderDatastore> => {
+      const response = await api.getDatastores(
+        "<token>",
+        {},
+        {
+          project_id: projectId,
+          cloud_provider_id: cloudProviderId,
+          cloud_provider_name: cloudProviderName,
+          datastore_name: datastoreName,
+          include_env_group: true,
+          include_metadata: true,
+        }
+      );
+
+      const results = await datastoreListResponseValidator.parseAsync(response.data);
+      if (results.datastores.length === 0) {
+        // TODO: fail the request
+        return {};
+      }
+
+      return results.datastores.map((datastore): CloudProviderDatastore => {
+        return {
+          cloud_provider_name: cloudProviderName,
+          cloud_provider_id: cloudProviderId,
+          datastore: datastore,
+          project_id: projectId,
+        }
+      })[0]
+    },
+    {
+      refetchInterval: 5000,
+      refetchOnWindowFocus: false,
+    }
+  );
+
+  return (
+    <>
+      {(status === "loading" || item == null) ?
+        <Loading />
+        :
+        <StyledExpandedDB>
+          <Back to="/databases" />
+          <DatabaseHeader datastore={item.datastore} />
+          <Spacer y={1} />
+          <DatabaseTabs tabParam={params.tab} item={item} />
+        </StyledExpandedDB>
+      }
+    </>
+  );
+};
+
+export default withRouter(DatabaseView);
+
+
+const StyledExpandedDB = styled.div`
+  width: 100%;
+  height: 100%;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 14 - 0
dashboard/src/main/home/database-dashboard/icons.tsx

@@ -0,0 +1,14 @@
+import awsRDS from "assets/amazon-rds.png";
+import awsElasticache from "assets/aws-elasticache.png";
+
+export const datastoreIcons: Record<string, string> = {
+  ENUM_DATASTORE_ELASTICACHE_REDIS: awsElasticache,
+  ENUM_DATASTORE_ELASTICACHE_MEMCACHED: awsElasticache,
+  ENUM_DATASTORE_RDS_POSTGRESQL: awsRDS,
+  ENUM_DATASTORE_RDS_MYSQL: awsRDS,
+  ENUM_DATASTORE_RDS_AURORA_POSTGRESQL: awsRDS,
+};
+
+export const getDatastoreIcon = (datastoreType: string): string => {
+  return datastoreIcons[datastoreType] ?? awsRDS;
+};

+ 175 - 0
dashboard/src/main/home/database-dashboard/tabs/DatabaseEnvTab.tsx

@@ -0,0 +1,175 @@
+import React from "react";
+import styled from "styled-components";
+
+import CopyToClipboard from "components/CopyToClipboard";
+import Helper from "components/form-components/Helper";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import EnvGroupArray from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import { DatastoreEnvWithSource } from "../types";
+import DatabaseLinkedApp from "./DatabaseLinkedApp";
+
+import copy from "assets/copy-left.svg";
+
+type Props = {
+  envData: DatastoreEnvWithSource;
+  connectionString?: string;
+};
+
+export type KeyValueType = {
+  key: string;
+  value: string;
+  hidden: boolean;
+  locked: boolean;
+  deleted: boolean;
+};
+
+const DatabaseEnvTab: React.FC<Props> = ({ envData, connectionString
+}) => {
+
+  const setKeys = (): KeyValueType[] => {
+    const keys: KeyValueType[] = [];
+    if (envData != null) {
+      Object.entries(envData?.variables).forEach(([key, value]) => {
+        keys.push({ key, value, hidden: false, locked: false, deleted: false });
+      });
+
+      // Adding secret variables with locked set to true
+      Object.entries(envData?.secret_variables).forEach(([key, value]) => {
+        keys.push({ key, value, hidden: false, locked: true, deleted: false });
+      });
+    }
+
+    return (keys)
+  }
+
+  const renderLinkedApplications = (): JSX.Element => {
+    if (envData.linked_applications.length === 0) {
+      return <InnerWrapper>
+        <Text size={16}> Linked Applications</Text><Spacer y={.5} />
+        <Helper>
+          No applications are linked to the &quot;{envData.name}&quot; env group.
+        </Helper>
+      </InnerWrapper>;
+    }
+
+    return <InnerWrapper>
+      <Text size={16}> Linked Applications</Text><Spacer y={.5} />
+      {envData.linked_applications.map((appName, index) => <DatabaseLinkedApp appName={appName} key={index}></DatabaseLinkedApp>)}
+    </InnerWrapper>;
+  }
+
+  return (
+    <StyledTemplateComponent>
+      <InnerWrapper>
+        <Text size={16}>Environment Variables</Text>
+        <Helper>
+          These environment variables are available to your applications once the &quot;{envData.name}&quot; env group is linked to your app.
+        </Helper>
+        <EnvGroupArray
+            values={setKeys()}
+            setValues={(_: any) => {}}
+            fileUpload={true}
+            secretOption={true}
+            disabled={
+                true
+            }
+        />
+      </InnerWrapper>
+      {
+        connectionString &&
+          <InnerWrapper>
+            <Text size={16}>Connection String</Text>
+            <Spacer y={.5} />
+            <IdContainer>
+              <ConnectionContainer>
+                <IconWithName>Connection String: </IconWithName>
+                <CopyContainer>
+                  <IdText> {connectionString}</IdText>
+                  <CopyToClipboard text={connectionString.toString()}>
+                    <CopyIcon src={copy} alt="copy" />
+                  </CopyToClipboard>
+                </CopyContainer>
+              </ConnectionContainer>
+            </IdContainer>
+            <Spacer y={1} />
+          </InnerWrapper>
+      }
+
+      {renderLinkedApplications()}
+    </StyledTemplateComponent>
+  );
+};
+
+export default DatabaseEnvTab;
+
+const StyledTemplateComponent = styled.div`
+width: 100%;
+animation: fadeIn 0.3s 0s;
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+`;
+
+const IdContainer = styled.div`
+    color: #aaaabb;
+    border-radius: 5px;
+    padding: 5px;
+    padding-left: 10px;
+    display: block;
+    width: 100%;
+    border-radius: 5px;
+    background: ${(props) => props.theme.fg};
+    border: 1px solid ${({ theme }) => theme.border};
+    margin-bottom: 10px;
+`;
+
+const ConnectionContainer = styled.div`
+  padding: 5px;
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+`;
+
+const IconWithName = styled.span`
+  font-size: 0.8em;
+  margin-left: 10px;
+`;
+
+const CopyContainer = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: auto;
+`;
+
+const IdText = styled.span`
+  font-size: 0.8em;
+  margin-right: 5px;
+`;
+
+const CopyIcon = styled.img`
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 5px;
+  width: 10px;
+  height: 10px;
+`;
+
+const InnerWrapper = styled.div<{ full?: boolean }>`
+  width: 100%;
+  height: ${(props) => (props.full ? "100%" : "calc(100% - 65px)")};
+  padding: 30px;
+  padding-bottom: 15px;
+  position: relative;
+  overflow: auto;
+  margin-bottom: 30px;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+`;

+ 111 - 0
dashboard/src/main/home/database-dashboard/tabs/DatabaseLinkedApp.tsx

@@ -0,0 +1,111 @@
+import React from "react";
+import styled, { keyframes } from "styled-components";
+
+import DynamicLink from "components/DynamicLink";
+
+type DatabaseLinkedAppProps = {
+  appName: String;
+};
+
+const DatabaseLinkedApp: React.FC<DatabaseLinkedAppProps> = ({ appName }) => {
+  return (
+    <StyledCard>
+      <Flex>
+        <ContentContainer>
+          <EventInformation>
+            <EventName>{appName}</EventName>
+          </EventInformation>
+        </ContentContainer>
+        <ActionContainer>
+          <ActionButton
+            to={`/apps/${appName}`}
+            target="_blank"
+            >
+            <span className="material-icons-outlined">open_in_new</span>
+          </ActionButton>
+        </ActionContainer>
+      </Flex>
+    </StyledCard>
+  );
+};
+
+export default DatabaseLinkedApp;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  border-radius: 8px;
+  padding: 10px 18px;
+  overflow: hidden;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+
+  background: #2b2e3699;
+  margin-bottom: 15px;
+  overflow: hidden;
+  border: 1px solid #ffffff0a;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled(DynamicLink)`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  border: 1px solid #ffffff00;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;

+ 34 - 0
dashboard/src/main/home/database-dashboard/tabs/MetricsTab.tsx

@@ -0,0 +1,34 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+};
+
+const MetricsTab: React.FC<Props> = ({
+}) => {
+    const [isExpanded, setIsExpanded] = useState(false);
+
+    useEffect(() => {
+        // Do something
+    }, []);
+
+    return (
+        <StyledTemplateComponent>
+        </StyledTemplateComponent>
+    );
+};
+
+export default MetricsTab;
+
+const StyledTemplateComponent = styled.div`
+width: 100%;
+animation: fadeIn 0.3s 0s;
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+`;

+ 113 - 0
dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx

@@ -0,0 +1,113 @@
+import React, { useContext } from "react";
+import { useHistory, useLocation } from "react-router";
+import styled from "styled-components";
+
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
+
+import { CloudProviderDatastore } from "../types";
+
+type Props = {
+  item: CloudProviderDatastore
+};
+
+const SettingsTab: React.FC<Props> = ({ item }) => {
+  const {
+    setCurrentError,
+    setCurrentOverlay,
+  } = useContext(Context);
+  const history = useHistory();
+  const location = useLocation();
+  const handleDeletionSubmit = async (): Promise<void> => {
+    if (setCurrentOverlay === undefined || setCurrentError === undefined) {
+      return;
+    }
+
+    setCurrentOverlay(null);
+    try {
+      await api.deleteDatastore(
+        "<token>",
+        {
+          name: item.datastore.name,
+          type: item.datastore.type,
+        },
+        {
+          project_id: item.project_id,
+          cloud_provider_name: item.cloud_provider_name,
+          cloud_provider_id: item.cloud_provider_id,
+        }
+      );
+    } catch (error) {
+      setCurrentError("Couldn't uninstall database, please try again");
+    }
+    pushFiltered(
+      {
+        history,
+        location,
+      },
+      `/databases`,
+      []
+    );
+  };
+
+  const handleDeletionClick = async (): Promise<void> => {
+    if (setCurrentOverlay === undefined) {
+      return;
+    }
+
+    setCurrentOverlay({
+      message: `Are you sure you want to delete ${item.datastore.name}?`,
+      onYes: handleDeletionSubmit,
+      onNo: () => setCurrentOverlay(null),
+    });
+  }
+
+  return (
+    <StyledTemplateComponent>
+      <InnerWrapper>
+        <Text size={16}>Delete &quot;{item.datastore.name}&quot;</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Delete this database and all of its resources.
+        </Text>
+        <Spacer y={0.5} />
+        <Button color="#b91133" onClick={handleDeletionClick}>
+          Delete {item.datastore.name}
+        </Button>
+      </InnerWrapper>
+    </StyledTemplateComponent>
+  );
+};
+
+export default SettingsTab;
+
+const StyledTemplateComponent = styled.div`
+width: 100%;
+animation: fadeIn 0.3s 0s;
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+`;
+
+const InnerWrapper = styled.div<{ full?: boolean }>`
+  width: 100%;
+  height: ${(props) => (props.full ? "100%" : "calc(100% - 65px)")};
+  padding: 30px;
+  padding-bottom: 15px;
+  position: relative;
+  overflow: auto;
+  margin-bottom: 30px;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+`;

+ 57 - 0
dashboard/src/main/home/database-dashboard/types.ts

@@ -0,0 +1,57 @@
+import { z } from "zod";
+
+export const datastoreEnvValidator = z.object({
+  name: z.string(),
+  linked_applications: z.string().array().default([]),
+  secret_variables: z.record(z.string()).default({}),
+  variables: z.record(z.string()).default({}),
+  version: z.number(),
+});
+
+export type DatastoreEnvWithSource = z.infer<typeof datastoreEnvValidator>;
+
+export const datastoreMetadataValidator = z.object({
+  name: z.string(),
+  value: z.string().default(""),
+});
+
+export type DatastoreMetadataWithSource = z.infer<
+  typeof datastoreMetadataValidator
+>;
+
+export const datastoreValidator = z.object({
+  name: z.string(),
+  type: z.string(),
+  status: z.string().default(""),
+  metadata: datastoreMetadataValidator.array().default([]),
+  env: datastoreEnvValidator.optional(),
+  connection_string: z.string().default(""),
+});
+
+export type DatastoreWithSource = z.infer<typeof datastoreValidator>;
+
+export const datastoreListResponseValidator = z.object({
+  datastores: datastoreValidator.array(),
+});
+
+export const cloudProviderValidator = z.object({
+  cloud_provider_id: z.string(),
+  project_id: z.number(),
+});
+
+export type CloudProviderWithSource = z.infer<typeof cloudProviderValidator>;
+
+export const cloudProviderListResponseValidator = z.object({
+  accounts: cloudProviderValidator.array(),
+});
+
+export const cloudProviderDatastoreSchema = z.object({
+  project_id: z.number(),
+  cloud_provider_name: z.string(),
+  cloud_provider_id: z.string(),
+  datastore: datastoreValidator,
+});
+
+export type CloudProviderDatastore = z.infer<
+  typeof cloudProviderDatastoreSchema
+>;

+ 23 - 0
dashboard/src/main/home/database-dashboard/utils.tsx

@@ -0,0 +1,23 @@
+import { type DatastoreWithSource } from "./types";
+
+export const datastoreField = (
+  datastore: DatastoreWithSource,
+  field: string
+): string => {
+  if (datastore.metadata?.length === 0) {
+    return "";
+  }
+
+  const properties = datastore.metadata?.filter(
+    (metadata) => metadata.name === field
+  );
+  if (properties === undefined || properties.length === 0) {
+    return "";
+  }
+
+  if (properties.length === 0) {
+    return "";
+  }
+
+  return properties[0].value;
+};

+ 68 - 0
dashboard/src/shared/api.tsx

@@ -2674,6 +2674,18 @@ const provisionDatabase = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/provision/rds`
 );
 
+const getAwsCloudProviders = baseApi<
+  {},
+  {
+    project_id: number;
+  }
+>(
+  "GET",
+  ({ project_id }) => {
+    return `/api/projects/${project_id}/cloud-providers/aws`;
+  }
+);
+
 const getDatabases = baseApi<
   {},
   {
@@ -2685,6 +2697,59 @@ const getDatabases = baseApi<
   ({ project_id, cluster_id }) =>
     `/api/projects/${project_id}/clusters/${cluster_id}/databases`
 );
+
+const getDatastores = baseApi<
+  {},
+  {
+    project_id: number;
+    cloud_provider_name: string;
+    cloud_provider_id: string;
+    datastore_name?: string;
+    datastore_type?: string;
+    include_env_group?: boolean;
+    include_metadata?: boolean;
+  }
+>(
+  "GET",
+  ({ project_id, cloud_provider_name, cloud_provider_id, datastore_name, datastore_type, include_env_group, include_metadata }) => {
+    const queryParams = new URLSearchParams();
+
+    if (datastore_name) {
+      queryParams.set("name", datastore_name);
+    }
+
+    if (datastore_type) {
+      queryParams.set("type", datastore_type);
+    }
+
+    if (include_env_group) {
+      queryParams.set("include_env_group", "true");
+    }
+
+    if (include_metadata) {
+      queryParams.set("include_metadata", "true");
+    }
+
+    return `/api/projects/${project_id}/cloud-providers/${cloud_provider_name}/${cloud_provider_id}/datastores?${queryParams.toString()}`;
+  }
+);
+
+const deleteDatastore = baseApi<
+  {
+    name: string;
+    type: string;
+  },
+  {
+    project_id: number;
+    cloud_provider_name: string;
+    cloud_provider_id: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cloud_provider_name, cloud_provider_id }) =>
+    `/api/projects/${project_id}/cloud-providers/${cloud_provider_name}/${cloud_provider_id}/datastores`
+);
+
 const getPreviousLogsForContainer = baseApi<
   {
     container_name: string;
@@ -3537,7 +3602,10 @@ export default {
   provisionDatabase,
   preflightCheck,
   requestQuotaIncrease,
+  getAwsCloudProviders,
   getDatabases,
+  getDatastores,
+  deleteDatastore,
   getPreviousLogsForContainer,
   upgradePorterAgent,
   deletePRDeployment,