Parcourir la source

New datastore tabs (#4419)

Feroze Mohideen il y a 2 ans
Parent
commit
137bca4b78

+ 9 - 0
api/server/handlers/datastore/get.go

@@ -142,6 +142,13 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
 		return
 	}
+	connectedClusterIds := make([]uint, 0)
+	if matchingDatastore.ConnectedClusters != nil {
+		for _, cc := range matchingDatastore.ConnectedClusters.ConnectedClusterIds {
+			connectedClusterIds = append(connectedClusterIds, uint(cc))
+		}
+	}
+
 	encoded, err := helpers.MarshalContractObject(ctx, matchingDatastore)
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error marshaling datastore")
@@ -159,6 +166,8 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Status:                            string(datastoreRecord.Status),
 		CloudProvider:                     SupportedDatastoreCloudProvider_AWS,
 		CloudProviderCredentialIdentifier: datastoreRecord.CloudProviderCredentialIdentifier,
+		ConnectedClusterIds:               connectedClusterIds,
+		OnManagementCluster:               true,
 		B64Proto:                          b64,
 	}
 

+ 2 - 13
api/server/handlers/datastore/list.go

@@ -10,7 +10,6 @@ import (
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers/cloud_provider"
-	"github.com/porter-dev/porter/api/server/handlers/environment_groups"
 	"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"
@@ -140,8 +139,6 @@ func Datastores(ctx context.Context, inp DatastoresInput) ([]datastore.Datastore
 		CloudProvider:          inp.CloudProvider.Type,
 		CloudProviderAccountId: inp.CloudProvider.AccountID,
 		Name:                   inp.Name,
-		IncludeEnvGroup:        inp.IncludeEnvGroup,
-		IncludeMetadata:        inp.IncludeMetadata,
 	}
 	if inp.Type != porterv1.EnumDatastore_ENUM_DATASTORE_UNSPECIFIED {
 		message.Type = &inp.Type
@@ -168,18 +165,10 @@ func Datastores(ctx context.Context, inp DatastoresInput) ([]datastore.Datastore
 			Engine:                            datastoreRecord.Engine,
 			CreatedAtUTC:                      datastoreRecord.CreatedAt,
 			Status:                            string(datastoreRecord.Status),
-			Metadata:                          ds.Metadata,
 			CloudProvider:                     datastoreRecord.CloudProvider,
 			CloudProviderCredentialIdentifier: datastoreRecord.CloudProviderCredentialIdentifier,
-		}
-		if inp.IncludeEnvGroup && ds.Env != nil {
-			encodedDatastore.Env = environment_groups.EnvironmentGroupListItem{
-				Name:               ds.Env.Name,
-				LatestVersion:      int(ds.Env.Version),
-				Variables:          ds.Env.Variables,
-				SecretVariables:    ds.Env.SecretVariables,
-				LinkedApplications: ds.Env.LinkedApplications,
-			}
+			ConnectedClusterIds:               []uint{uint(ds.ConnectedClusterId)},
+			OnManagementCluster:               false,
 		}
 		datastores = append(datastores, encodedDatastore)
 	}

+ 3 - 2
api/server/handlers/environment_groups/list.go

@@ -193,6 +193,7 @@ func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.
 }
 
 var translateProtoTypeToEnvGroupType = map[porterv1.EnumEnvGroupProviderType]string{
-	porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_DOPPLER: "doppler",
-	porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_PORTER:  "porter",
+	porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_DATASTORE: "datastore",
+	porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_DOPPLER:   "doppler",
+	porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_PORTER:    "porter",
 }

+ 2 - 0
dashboard/src/lib/databases/types.ts

@@ -55,6 +55,8 @@ export const datastoreValidator = z.object({
   cloud_provider: z.string().pipe(cloudProviderValidator.catch("UNKNOWN")),
   cloud_provider_credential_identifier: z.string(),
   credential: datastoreCredentialValidator,
+  connected_cluster_ids: z.number().array().optional().default([]),
+  on_management_cluster: z.boolean().default(false),
 });
 
 export type SerializedDatastore = z.infer<typeof datastoreValidator>;

+ 16 - 2
dashboard/src/lib/env-groups/types.ts

@@ -18,7 +18,21 @@ export const envGroupFormValidator = z.object({
         locked: z.boolean(),
       })
     )
-    .min(1, { message: "At least one environment variable is required" })
+    .min(1, { message: "At least one environment variable is required" }),
 });
 
-export type EnvGroupFormData = z.infer<typeof envGroupFormValidator>;
+export type EnvGroupFormData = z.infer<typeof envGroupFormValidator>;
+
+export const envGroupValidator = z.object({
+  name: z.string(),
+  variables: z.record(z.string()).optional().default({}),
+  secret_variables: z.record(z.string()).optional().default({}),
+  created_at: z.string(),
+  type: z
+    .string()
+    .pipe(
+      z.enum(["UNKNOWN", "datastore", "doppler", "porter"]).catch("UNKNOWN")
+    ),
+});
+
+export type ClientEnvGroup = z.infer<typeof envGroupValidator>;

+ 0 - 2
dashboard/src/lib/hooks/useDatabaseMethods.ts

@@ -13,11 +13,9 @@ type DatastoreHook = {
   attachDatastoreToAppInstances: ({
     name,
     appInstanceIds,
-    clusterId,
   }: {
     name: string;
     appInstanceIds: string[];
-    clusterId: number;
   }) => Promise<void>;
 };
 type CreateDatastoreInput = {

+ 50 - 0
dashboard/src/lib/hooks/useEnvGroups.ts

@@ -0,0 +1,50 @@
+import { useContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import { envGroupValidator, type ClientEnvGroup } from "lib/env-groups/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type TUseEnvGroupList = {
+  envGroups: ClientEnvGroup[];
+  isLoading: boolean;
+};
+export const useEnvGroupList = ({
+  clusterId,
+}: {
+  clusterId?: number;
+}): TUseEnvGroupList => {
+  const { currentProject } = useContext(Context);
+
+  const envGroupReq = useQuery(
+    ["getEnvGroups", currentProject?.id],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1 || !clusterId) {
+        return;
+      }
+
+      const res = await api.getAllEnvGroups(
+        "<token>",
+        {},
+        {
+          id: currentProject?.id,
+          cluster_id: clusterId,
+        }
+      );
+      const parsed = await z
+        .object({ environment_groups: z.array(envGroupValidator) })
+        .parseAsync(res.data);
+      return parsed.environment_groups;
+    },
+    {
+      enabled: !!currentProject && currentProject.id !== -1,
+    }
+  );
+
+  return {
+    envGroups: envGroupReq.data ?? [],
+    isLoading: envGroupReq.isLoading,
+  };
+};

+ 79 - 49
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroupRow.tsx

@@ -1,10 +1,6 @@
-import React, {useContext, useMemo} from "react";
-import styled from "styled-components";
-
+import React, { useContext, useMemo } from "react";
 import { useHistory } from "react-router";
-
-import doppler from "assets/doppler.png";
-import key from "assets/key.svg";
+import styled from "styled-components";
 
 import Container from "components/porter/Container";
 import Expandable from "components/porter/Expandable";
@@ -12,83 +8,117 @@ import Image from "components/porter/Image";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import EnvGroupArray from "main/home/env-dashboard/EnvGroupArray";
-import {envGroupPath} from "shared/util";
-import {Context} from "shared/Context";
+
+import { Context } from "shared/Context";
+import { envGroupPath } from "shared/util";
+import database from "assets/database.svg";
+import doppler from "assets/doppler.png";
+import key from "assets/key.svg";
 
 type Props = {
   onRemove: (name: string) => void;
   envGroup: {
     name: string;
-    id: number;
     type: string;
-    isActive: boolean;
     variables: Record<string, string>;
     secret_variables: Record<string, string>;
   };
+  canDelete?: boolean;
 };
 
 // TODO: support footer for consolidation w/ app services
-const EnvGroupRow: React.FC<Props> = ({ envGroup, onRemove }) => {
+const EnvGroupRow: React.FC<Props> = ({
+  envGroup,
+  onRemove,
+  canDelete = true,
+}) => {
   const { currentProject } = useContext(Context);
   const history = useHistory();
 
   const variables = useMemo(() => {
-    const normalVariables = Object.entries(
-      envGroup.variables || {}
-    ).map(([key, value]) => ({
-      key,
-      value,
-      hidden: value.includes("PORTERSECRET"),
-      locked: value.includes("PORTERSECRET"),
-      deleted: false,
-    }));
-  
-    const secretVariables = Object.entries(
-      envGroup.secret_variables || {}
-    ).map(([key, value]) => ({
-      key,
-      value,
-      hidden: true,
-      locked: true,
-      deleted: false,
-    }));
-  
+    const normalVariables = Object.entries(envGroup.variables || {}).map(
+      ([key, value]) => ({
+        key,
+        value,
+        hidden: value.includes("PORTERSECRET"),
+        locked: value.includes("PORTERSECRET"),
+        deleted: false,
+      })
+    );
+
+    const secretVariables = Object.entries(envGroup.secret_variables || {}).map(
+      ([key, value]) => ({
+        key,
+        value,
+        hidden: true,
+        locked: true,
+        deleted: false,
+      })
+    );
+
     return [...normalVariables, ...secretVariables];
   }, [envGroup]);
 
   return (
     <Expandable
-      header={(
+      header={
         <Container row spaced>
           <Container row>
             <Image
               size={20}
-              src={envGroup.type === "doppler" ? doppler : key}
+              src={
+                envGroup.type === "doppler"
+                  ? doppler
+                  : envGroup.type === "datastore"
+                  ? database
+                  : key
+              }
             />
             <Spacer inline x={1} />
             <Text size={14}>{envGroup.name}</Text>
           </Container>
           <Container row>
-            <Svg 
-              onClick={() => { 
-                history.push(envGroupPath(currentProject, `/${envGroup.name}/synced-apps`))
+            <Svg
+              onClick={() => {
+                history.push(
+                  envGroupPath(currentProject, `/${envGroup.name}/synced-apps`)
+                );
               }}
-              data-testid="geist-icon" fill="none" height="27px" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" strokeLinejoin="round" stroke-width="2" viewBox="0 0 24 24" width="27px" data-darkreader-inline-stroke="" data-darkreader-inline-color=""><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></Svg>
-            <Spacer inline x={.5} />
-            <I 
-              className="material-icons"
-              onClick={() => { onRemove(envGroup.name) }}
+              data-testid="geist-icon"
+              fill="none"
+              height="27px"
+              shape-rendering="geometricPrecision"
+              stroke="currentColor"
+              stroke-linecap="round"
+              strokeLinejoin="round"
+              stroke-width="2"
+              viewBox="0 0 24 24"
+              width="27px"
+              data-darkreader-inline-stroke=""
+              data-darkreader-inline-color=""
             >
-              delete
-            </I>
+              <path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path>
+              <path d="M15 3h6v6"></path>
+              <path d="M10 14L21 3"></path>
+            </Svg>
+            {canDelete && (
+              <>
+                <Spacer inline x={0.5} />
+                <I
+                  className="material-icons"
+                  onClick={() => {
+                    onRemove(envGroup.name);
+                  }}
+                >
+                  delete
+                </I>
+              </>
+            )}
           </Container>
         </Container>
-      )}
+      }
     >
-      <EnvGroupArray
-        values={variables}
-        disabled={true}
-      />
+      <EnvGroupArray values={variables} disabled={true} />
     </Expandable>
   );
 };
@@ -113,4 +143,4 @@ const Svg = styled.svg`
   :hover {
     stroke: white;
   }
-`;
+`;

+ 6 - 7
dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx

@@ -7,7 +7,7 @@ import TabSelector from "components/TabSelector";
 
 import { useDatastoreContext } from "./DatabaseContextProvider";
 import DatastoreProvisioningIndicator from "./DatastoreProvisioningIndicator";
-import ConnectedAppsTab from "./tabs/ConnectedAppsTab";
+import ConfigurationTab from "./tabs/ConfigurationTab";
 import ConnectTab from "./tabs/ConnectTab";
 import MetricsTab from "./tabs/MetricsTab";
 import SettingsTab from "./tabs/SettingsTab";
@@ -43,9 +43,9 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
 
   const tabs = useMemo(() => {
     return [
-      { label: "Connect", value: "connect" },
-      { label: "Connected Apps", value: "connected-apps" },
-      // { label: "Configuration", value: "configuration" },
+      { label: "Connectivity", value: "connect" },
+      // { label: "Connected Apps", value: "connected-apps" },
+      { label: "Configuration", value: "configuration" },
       { label: "Settings", value: "settings" },
     ];
   }, []);
@@ -69,10 +69,9 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
         .with("connect", () => <ConnectTab />)
         .with("settings", () => <SettingsTab />)
         .with("metrics", () => <MetricsTab />)
-        // .with("configuration", () => <ConfigurationTab />)
-        .with("connected-apps", () => <ConnectedAppsTab />)
+        .with("configuration", () => <ConfigurationTab />)
+        // .with("connected-apps", () => <ConnectedAppsTab />)
         .otherwise(() => null)}
-      <Spacer y={2} />
     </div>
   );
 };

+ 183 - 66
dashboard/src/main/home/database-dashboard/shared/ConnectAppsModal.tsx

@@ -1,8 +1,10 @@
-import React, { useCallback, useMemo, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
 import axios from "axios";
 import pluralize from "pluralize";
+import styled from "styled-components";
 import { z } from "zod";
 
+import Loading from "components/Loading";
 import Button from "components/porter/Button";
 import Error from "components/porter/Error";
 import Icon from "components/porter/Icon";
@@ -10,51 +12,111 @@ import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import SelectableAppList from "main/home/app-dashboard/apps/SelectableAppList";
-import { type AppRevisionWithSource } from "main/home/app-dashboard/apps/types";
+import {
+  appRevisionWithSourceValidator,
+  type AppRevisionWithSource,
+} from "main/home/app-dashboard/apps/types";
+import EnvGroupRow from "main/home/app-dashboard/validate-apply/app-settings/EnvGroupRow";
+import { useDatastoreMethods } from "lib/hooks/useDatabaseMethods";
+import { useEnvGroupList } from "lib/hooks/useEnvGroups";
 import { useIntercom } from "lib/hooks/useIntercom";
 
+import api from "shared/api";
 import connect from "assets/connect.svg";
 
+import { useDatastoreContext } from "../DatabaseContextProvider";
+
 type Props = {
   closeModal: () => void;
-  apps: AppRevisionWithSource[];
-  onSubmit: (appInstanceIds: string[]) => Promise<void>;
 };
 
-const ConnectAppsModal: React.FC<Props> = ({ closeModal, apps, onSubmit }) => {
-  const [selectedAppInstanceIds, setSelectedAppInstanceIds] = useState<
-    string[]
+const ConnectAppsModal: React.FC<Props> = ({ closeModal }) => {
+  const { datastore, projectId } = useDatastoreContext();
+  const { attachDatastoreToAppInstances } = useDatastoreMethods();
+  const { envGroups, isLoading } = useEnvGroupList({
+    clusterId: datastore.connected_cluster_ids.length
+      ? datastore.connected_cluster_ids[0]
+      : undefined,
+  });
+  const matchingEnvGroup = useMemo(() => {
+    return envGroups.find((eg) => eg.name === datastore.name);
+  }, [envGroups, datastore]);
+  const [clusterConnectedApps, setClusterConnectedApps] = useState<
+    AppRevisionWithSource[]
+  >([]);
+
+  useEffect(() => {
+    const fetchClusterConnectedApps = async (): Promise<void> => {
+      try {
+        const res = await Promise.all(
+          datastore.connected_cluster_ids.map(async (clusterId) => {
+            return await api.getLatestAppRevisions(
+              "<token>",
+              {
+                deployment_target_id: undefined,
+                ignore_preview_apps: true,
+              },
+              { cluster_id: clusterId, project_id: projectId }
+            );
+          })
+        );
+        const apps = await Promise.all(
+          res.map(async (r) => {
+            const parsed = await z
+              .object({
+                app_revisions: z.array(appRevisionWithSourceValidator),
+              })
+              .parseAsync(r.data);
+            return parsed.app_revisions;
+          })
+        );
+        setClusterConnectedApps(apps.flat());
+      } catch (err) {
+        // TODO: handle error
+      }
+    };
+    void fetchClusterConnectedApps();
+  }, [datastore.connected_cluster_ids, projectId]);
+
+  const [selectedAppInstances, setSelectedAppInstances] = useState<
+    AppRevisionWithSource[]
   >([]);
   const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
   const [submitErrorMessage, setSubmitErrorMessage] = useState<string>("");
   const { showIntercomWithMessage } = useIntercom();
 
   const append = useCallback(
-    (appInstanceId: string): void => {
-      if (!selectedAppInstanceIds.includes(appInstanceId)) {
-        setSelectedAppInstanceIds([...selectedAppInstanceIds, appInstanceId]);
+    (appInstance: AppRevisionWithSource): void => {
+      if (
+        !selectedAppInstances
+          .map((s) => s.app_revision.app_instance_id)
+          .includes(appInstance.app_revision.app_instance_id)
+      ) {
+        setSelectedAppInstances([...selectedAppInstances, appInstance]);
       }
     },
-    [selectedAppInstanceIds]
+    [selectedAppInstances]
   );
   const remove = useCallback(
-    (appInstanceId: string): void => {
-      setSelectedAppInstanceIds(
-        selectedAppInstanceIds.filter((id) => id !== appInstanceId)
+    (appInstance: AppRevisionWithSource): void => {
+      setSelectedAppInstances(
+        selectedAppInstances.filter(
+          (a) => a.app_revision.app_instance_id !== appInstance.app_revision.id
+        )
       );
     },
-    [selectedAppInstanceIds]
-  );
-  const isSelected = useCallback(
-    (appInstanceId: string): boolean => {
-      return selectedAppInstanceIds.includes(appInstanceId);
-    },
-    [selectedAppInstanceIds]
+    [selectedAppInstances]
   );
+
   const submit = useCallback(async () => {
     try {
       setIsSubmitting(true);
-      await onSubmit(selectedAppInstanceIds);
+      await attachDatastoreToAppInstances({
+        name: datastore.name,
+        appInstanceIds: selectedAppInstances.map(
+          (a) => a.app_revision.app_instance_id
+        ),
+      });
       closeModal();
     } catch (err) {
       let message = "Please contact support.";
@@ -73,7 +135,7 @@ const ConnectAppsModal: React.FC<Props> = ({ closeModal, apps, onSubmit }) => {
     } finally {
       setIsSubmitting(false);
     }
-  }, [onSubmit, selectedAppInstanceIds]);
+  }, [selectedAppInstances]);
 
   const submitButtonStatus = useMemo(() => {
     if (isSubmitting) {
@@ -87,56 +149,111 @@ const ConnectAppsModal: React.FC<Props> = ({ closeModal, apps, onSubmit }) => {
     return "";
   }, [isSubmitting, submitErrorMessage]);
 
+  if (isLoading) {
+    return (
+      <Modal closeModal={closeModal}>
+        <Text size={16}>Inject credentials into apps</Text>
+        <Spacer y={0.5} />
+        <Loading />
+      </Modal>
+    );
+  }
+  if (datastore.connected_cluster_ids.length === 0) {
+    return (
+      <Modal closeModal={closeModal}>
+        <Text size={16}>Inject credentials into apps</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          No clusters are connected to this datastore. Please connect a cluster
+          first.
+        </Text>
+      </Modal>
+    );
+  }
+
+  if (!matchingEnvGroup) {
+    return (
+      <Modal closeModal={closeModal}>
+        <Text size={16}>Inject credentials into apps</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          The env group for this datastore has not yet been created. Please add
+          credentials to your application environment variables manually.
+        </Text>
+      </Modal>
+    );
+  }
+
   return (
     <Modal closeModal={closeModal}>
-      <Text size={16}>Select apps</Text>
-      <Spacer y={0.5} />
-      {apps.length === 0 && (
+      <InnerModalContents>
+        <Text size={16}>Inject credentials into apps</Text>
+        <Spacer y={0.5} />
         <Text color="helper">
-          No apps are available to connect. Please create an app first.
+          The following env group contains credentials for your datastore:
         </Text>
-      )}
-      {apps.length !== 0 && (
-        <>
-          <SelectableAppList
-            appListItems={apps.map((a) => ({
-              app: a,
-              key: a.source.name,
-              onSelect: () => {
-                append(a.app_revision.app_instance_id);
-              },
-              onDeselect: () => {
-                remove(a.app_revision.app_instance_id);
-              },
-              isSelected: isSelected(a.app_revision.app_instance_id),
-            }))}
-          />
-          <Spacer y={1} />
-          <Text color="helper">
-            Click the button below to confirm the above selections. Newly
-            connected apps may take a few seconds to appear on the dashboard.
+        <Spacer y={0.5} />
+        <EnvGroupRow
+          onRemove={() => ({})}
+          envGroup={matchingEnvGroup}
+          canDelete={false}
+        />
+        <Spacer y={1} />
+        <Text size={16}>Select apps</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Select the apps you want to link this env group to.
+        </Text>
+        <Spacer y={0.5} />
+        {clusterConnectedApps.length === 0 && (
+          <Text color="warner">
+            No apps are available. Please create an app first.
           </Text>
-        </>
-      )}
-      <Spacer y={0.5} />
-      <Button
-        disabled={selectedAppInstanceIds.length === 0 || isSubmitting}
-        onClick={submit}
-        status={submitButtonStatus}
-      >
-        <Icon src={connect} height={"13px"} />
-        <Spacer inline x={0.5} />
-        {`Connect ${
-          selectedAppInstanceIds.length
-            ? `${selectedAppInstanceIds.length} ${pluralize(
-                "app",
-                selectedAppInstanceIds.length
-              )}`
-            : ""
-        }`}
-      </Button>
+        )}
+        {clusterConnectedApps.length !== 0 && (
+          <>
+            <SelectableAppList
+              appListItems={clusterConnectedApps.map((a) => ({
+                app: a,
+                key: a.source.name,
+                onSelect: () => {
+                  append(a);
+                },
+                onDeselect: () => {
+                  remove(a);
+                },
+                isSelected: selectedAppInstances
+                  .map((s) => s.app_revision.app_instance_id)
+                  .includes(a.app_revision.app_instance_id),
+              }))}
+            />
+            <Spacer y={1} />
+            <Button
+              disabled={selectedAppInstances.length === 0 || isSubmitting}
+              onClick={submit}
+              status={submitButtonStatus}
+            >
+              <Icon src={connect} height={"13px"} />
+              <Spacer inline x={0.5} />
+              {`Inject credentials ${
+                selectedAppInstances.length
+                  ? `into ${selectedAppInstances.length} ${pluralize(
+                      "app",
+                      selectedAppInstances.length
+                    )}`
+                  : ""
+              }`}
+            </Button>
+          </>
+        )}
+      </InnerModalContents>
     </Modal>
   );
 };
 
 export default ConnectAppsModal;
+
+const InnerModalContents = styled.div`
+  overflow-y: auto;
+  max-height: 80vh;
+`;

+ 115 - 87
dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx

@@ -22,93 +22,121 @@ const ConnectionInfo: React.FC<Props> = ({ connectionInfo, type }) => {
 
   return (
     <Fieldset>
-      <Text>Host</Text>
-      <Spacer y={0.2} />
-      <ClickToCopy color="helper">{connectionInfo.host}</ClickToCopy>
-      <Spacer y={0.5} />
-      <Text>Port</Text>
-      <Spacer y={0.2} />
-      <ClickToCopy color="helper">{connectionInfo.port.toString()}</ClickToCopy>
-      <Spacer y={0.5} />
-      {type === DATASTORE_TYPE_ELASTICACHE ? (
-        <>
-          <Text>Auth token</Text>
-          <Spacer y={0.2} />
-          <Container row>
-            {isPasswordHidden ? (
-              <>
-                <Blur>{connectionInfo.password}</Blur>
-                <Spacer inline width="10px" />
-                <RevealButton
-                  onClick={() => {
-                    setIsPasswordHidden(false);
-                  }}
-                >
-                  Reveal
-                </RevealButton>
-              </>
-            ) : (
-              <>
-                <ClickToCopy color="helper">
-                  {connectionInfo.password}
-                </ClickToCopy>
-                <Spacer inline width="10px" />
-                <RevealButton
-                  onClick={() => {
-                    setIsPasswordHidden(true);
-                  }}
-                >
-                  Hide
-                </RevealButton>
-              </>
-            )}
-          </Container>
-        </>
-      ) : (
-        <>
-          <Text>Database name</Text>
-          <Spacer y={0.2} />
-          <ClickToCopy color="helper">
-            {connectionInfo.database_name}
-          </ClickToCopy>
-          <Spacer y={0.5} />
-          <Text>Username</Text>
-          <Spacer y={0.2} />
-          <ClickToCopy color="helper">{connectionInfo.username}</ClickToCopy>
-          <Spacer y={0.5} />
-          <Text>Password</Text>
-          <Spacer y={0.2} />
-          <Container row>
-            {isPasswordHidden ? (
-              <>
-                <Blur>{connectionInfo.password}</Blur>
-                <Spacer inline width="10px" />
-                <RevealButton
-                  onClick={() => {
-                    setIsPasswordHidden(false);
-                  }}
-                >
-                  Reveal
-                </RevealButton>
-              </>
-            ) : (
-              <>
-                <ClickToCopy color="helper">
-                  {connectionInfo.password}
-                </ClickToCopy>
-                <Spacer inline width="10px" />
-                <RevealButton
-                  onClick={() => {
-                    setIsPasswordHidden(true);
-                  }}
-                >
-                  Hide
-                </RevealButton>
-              </>
-            )}
-          </Container>
-        </>
-      )}
+      <table style={{ borderSpacing: "5px" }}>
+        <tbody>
+          <tr>
+            <td>
+              <Text>Host</Text>
+            </td>
+            <td>
+              <ClickToCopy color="helper">{connectionInfo.host}</ClickToCopy>
+            </td>
+          </tr>
+          <tr>
+            <td>
+              <Text>Port</Text>
+            </td>
+            <td>
+              <ClickToCopy color="helper">
+                {connectionInfo.port.toString()}
+              </ClickToCopy>
+            </td>
+          </tr>
+          {type === DATASTORE_TYPE_ELASTICACHE ? (
+            <tr>
+              <td>
+                <Text>Auth token</Text>
+              </td>
+              <td>
+                {isPasswordHidden ? (
+                  <Container row>
+                    <Blur>{connectionInfo.password}</Blur>
+                    <Spacer inline width="10px" />
+                    <RevealButton
+                      onClick={() => {
+                        setIsPasswordHidden(false);
+                      }}
+                    >
+                      Reveal
+                    </RevealButton>
+                  </Container>
+                ) : (
+                  <Container row>
+                    <ClickToCopy color="helper">
+                      {connectionInfo.password}
+                    </ClickToCopy>
+                    <Spacer inline width="10px" />
+                    <RevealButton
+                      onClick={() => {
+                        setIsPasswordHidden(true);
+                      }}
+                    >
+                      Hide
+                    </RevealButton>
+                  </Container>
+                )}
+              </td>
+            </tr>
+          ) : (
+            <>
+              <tr>
+                <td>
+                  <Text>Database name</Text>
+                </td>
+                <td>
+                  <ClickToCopy color="helper">
+                    {connectionInfo.database_name}
+                  </ClickToCopy>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  <Text>Username</Text>
+                </td>
+                <td>
+                  <ClickToCopy color="helper">
+                    {connectionInfo.username}
+                  </ClickToCopy>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  <Text>Password</Text>
+                </td>
+                <td>
+                  {isPasswordHidden ? (
+                    <Container row>
+                      <Blur>{connectionInfo.password}</Blur>
+                      <Spacer inline width="10px" />
+                      <RevealButton
+                        onClick={() => {
+                          setIsPasswordHidden(false);
+                        }}
+                      >
+                        Reveal
+                      </RevealButton>
+                    </Container>
+                  ) : (
+                    <Container row>
+                      <ClickToCopy color="helper">
+                        {connectionInfo.password}
+                      </ClickToCopy>
+                      <Spacer inline width="10px" />
+                      <RevealButton
+                        onClick={() => {
+                          setIsPasswordHidden(true);
+                        }}
+                      >
+                        Hide
+                      </RevealButton>
+                    </Container>
+                  )}
+                </td>
+              </tr>
+            </>
+          )}
+        </tbody>
+      </table>
     </Fieldset>
   );
 };

+ 19 - 22
dashboard/src/main/home/database-dashboard/tabs/ConfigurationTab.tsx

@@ -1,36 +1,33 @@
-import React from "react";
-import styled from "styled-components";
+import React, { useMemo } from "react";
 
-import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import { ClusterList } from "main/home/infrastructure-dashboard/ClusterDashboard";
+import { useClusterList } from "lib/hooks/useCluster";
 
 import { useDatastoreContext } from "../DatabaseContextProvider";
-import DatabaseHeaderItem from "../DatabaseHeaderItem";
 
 const ConfigurationTab: React.FC = () => {
   const { datastore } = useDatastoreContext();
+  const { clusters } = useClusterList();
+
+  const connectedClusters = useMemo(() => {
+    return clusters.filter((cluster) => {
+      return datastore.connected_cluster_ids.includes(cluster.id);
+    });
+  }, [clusters, datastore.connected_cluster_ids]);
   return (
-    <Fieldset>
-      <Text size={12}>Datastore details: </Text>
+    <div>
+      <Text size={16}>Connected clusters</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>
+      <Text color="helper">
+        Porter automatically manages connectivity between connected clusters and
+        this datastore.
+      </Text>
+      <Spacer y={0.5} />
+      <ClusterList clusters={connectedClusters} />
+    </div>
   );
 };
 
 export default ConfigurationTab;
-
-const GridList = styled.div`
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
-`;

+ 136 - 58
dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx

@@ -1,87 +1,134 @@
-import React from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 
 import CopyToClipboard from "components/CopyToClipboard";
+import Banner from "components/porter/Banner";
 import Container from "components/porter/Container";
 import Link from "components/porter/Link";
+import ShowIntercomButton from "components/porter/ShowIntercomButton";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 
 import copy from "assets/copy-left.svg";
 
 import { useDatastoreContext } from "../DatabaseContextProvider";
+import ConnectAppsModal from "../shared/ConnectAppsModal";
 import ConnectionInfo from "../shared/ConnectionInfo";
 
 const ConnectTab: React.FC = () => {
   const { datastore } = useDatastoreContext();
+  const [showConnectAppsModal, setShowConnectAppsModal] = useState(false);
 
+  if (datastore.credential.host === "") {
+    return (
+      <Banner
+        type="error"
+        suffix={
+          <>
+            <ShowIntercomButton
+              message={"I need help retrieving credentials for my datastore."}
+            >
+              Talk to support
+            </ShowIntercomButton>
+          </>
+        }
+      >
+        Error reaching your datastore for credentials. Please contact support.
+        <Spacer inline width="5px" />
+      </Banner>
+    );
+  }
   return (
-    <CredentialsTabContainer>
-      <Container row>
-        <Text size={16}>Application connection</Text>
-      </Container>
-      {datastore.credential.host !== "" && (
-        <>
-          <Spacer y={0.5} />
-          <Text color="helper">
-            All apps deployed in your cluster can access this datastore using
-            the following credentials:
-          </Text>
-          <Spacer y={0.5} />
-          <ConnectionInfo
-            connectionInfo={datastore.credential}
-            type={datastore.template.type}
-          />
-          <Spacer y={0.5} />
-          <Text color="warner">
-            For security, access to the datastore is restricted - connection
-            attempts from outside the cluster will not succeed.
-          </Text>
-          <Spacer y={0.5} />
-          <Text color="helper">
-            The datastore client of your application should use these
-            credentials to create a connection.
-          </Text>
+    <ConnectTabContainer>
+      <div
+        style={{
+          width: "100%",
+          height: "100%",
+          paddingRight: "10px",
+          borderRight: "1px #aaaabb55 solid",
+        }}
+      >
+        <Container row>
+          <Text size={16}>Application connection</Text>
+        </Container>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          An application deployed in one of this datastore&apos;s connected
+          clusters can use the following credentials to access the datastore:
+        </Text>
+        <Spacer y={0.5} />
+        <ConnectionInfo
+          connectionInfo={datastore.credential}
+          type={datastore.template.type}
+        />
+        <Spacer y={0.5} />
+        <Text color="warner">
+          For security, access to the datastore is restricted - connection
+          attempts from outside a connected cluster will not succeed.
+        </Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          The datastore client of your application should use these credentials
+          to create a connection.{" "}
           {datastore.template.type.name === "ELASTICACHE" && (
-            <>
-              <Spacer y={0.5} />
-              <Text color="warner">
-                Your datastore client must connect via SSL.
-              </Text>
-            </>
+            <Text color="warner">
+              The datastore client must connect via SSL.
+            </Text>
           )}
-        </>
-      )}
-      <Spacer y={1} />
-      <Text size={16}>Local connection</Text>
-      <Spacer y={0.5} />
-      <Text color="helper">
-        For local connection, you can create a temporary, secure tunnel to this
-        datastore using the{" "}
-        <Link
-          to="https://docs.porter.run/standard/cli/command-reference/porter-datastore-connect"
-          target="_blank"
+        </Text>
+        <Spacer y={1} />
+        <ConnectAppButton
+          onClick={() => {
+            setShowConnectAppsModal(true);
+          }}
         >
-          <Text>Porter CLI</Text>
-        </Link>
-      </Text>
-      <Spacer y={0.5} />
-      <IdContainer>
-        <Code>{`$ porter datastore connect ${datastore.name}`}</Code>
-        <CopyContainer>
-          <CopyToClipboard text={`porter datastore connect ${datastore.name}`}>
-            <CopyIcon src={copy} alt="copy" />
-          </CopyToClipboard>
-        </CopyContainer>
-      </IdContainer>
-    </CredentialsTabContainer>
+          <I className="material-icons add-icon">add</I>
+          Inject these credentials into an app
+        </ConnectAppButton>
+        {showConnectAppsModal && (
+          <ConnectAppsModal
+            closeModal={() => {
+              setShowConnectAppsModal(false);
+            }}
+          />
+        )}
+      </div>
+      <div style={{ width: "100%", height: "100%", paddingLeft: "10px" }}>
+        <Text size={16}>Local connection</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          For local connection, you can create a temporary, secure tunnel to
+          this datastore using the{" "}
+          <Link
+            to="https://docs.porter.run/standard/cli/command-reference/porter-datastore-connect"
+            target="_blank"
+          >
+            <Text>Porter CLI</Text>
+          </Link>
+        </Text>
+        <Spacer y={0.5} />
+        <IdContainer>
+          <Code>{`$ porter datastore connect ${datastore.name}`}</Code>
+          <CopyContainer>
+            <CopyToClipboard
+              text={`porter datastore connect ${datastore.name}`}
+            >
+              <CopyIcon src={copy} alt="copy" />
+            </CopyToClipboard>
+          </CopyContainer>
+        </IdContainer>
+      </div>
+    </ConnectTabContainer>
   );
 };
 
 export default ConnectTab;
 
-const CredentialsTabContainer = styled.div`
+const ConnectTabContainer = styled.div`
   width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: row;
 `;
 
 const IdContainer = styled.div`
@@ -115,3 +162,34 @@ const CopyIcon = styled.img`
 const Code = styled.span`
   font-family: monospace;
 `;
+
+const ConnectAppButton = styled.div`
+  color: #aaaabb;
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+  display: flex;
+  align-items: center;
+  border-radius: 5px;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  .add-icon {
+    width: 30px;
+    font-size: 20px;
+  }
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 7px;
+  justify-content: center;
+`;

+ 11 - 26
dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx

@@ -45,19 +45,17 @@ const SettingsTab: React.FC = () => {
 
   return (
     <StyledTemplateComponent>
-      <InnerWrapper>
-        <Text size={16}>Delete &quot;{datastore.name}&quot;</Text>
-        <Spacer y={0.5} />
-        <Text color="helper">
-          Delete this datastore and all of its resources.
-        </Text>
-        <Spacer y={0.5} />
-        <Button color="#b91133" onClick={handleDeletionClick}>
-          <Icon src={trash} height={"15px"} />
-          <Spacer inline x={0.5} />
-          Delete {datastore.name}
-        </Button>
-      </InnerWrapper>
+      <Text size={16}>Delete &quot;{datastore.name}&quot;</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Delete this datastore and all of its resources.
+      </Text>
+      <Spacer y={0.5} />
+      <Button color="#b91133" onClick={handleDeletionClick}>
+        <Icon src={trash} height={"15px"} />
+        <Spacer inline x={0.5} />
+        Delete {datastore.name}
+      </Button>
     </StyledTemplateComponent>
   );
 };
@@ -76,16 +74,3 @@ const StyledTemplateComponent = styled.div`
     }
   }
 `;
-
-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;
-`;

+ 3 - 2
dashboard/src/main/home/env-dashboard/EnvDashboard.tsx

@@ -30,6 +30,7 @@ import key from "assets/key.svg";
 import list from "assets/list.png";
 import notFound from "assets/not-found.png";
 import time from "assets/time.png";
+import database from "assets/database.svg";
 
 import { envGroupPath } from "../../../shared/util";
 
@@ -201,7 +202,7 @@ const EnvDashboard: React.FC<Props> = (props) => {
                 >
                   <Container row>
                     <Image
-                      src={envGroup.type === "doppler" ? doppler : key}
+                      src={envGroup.type === "doppler" ? doppler : envGroup.type === "datastore" ? database : key}
                       size={20}
                     />
                     <Spacer inline x={0.7} />
@@ -227,7 +228,7 @@ const EnvDashboard: React.FC<Props> = (props) => {
                   key={i}
                 >
                   <Container row>
-                    <Image src={envGroup.type === "doppler" ? doppler : key} />
+                    <Image src={envGroup.type === "doppler" ? doppler : envGroup.type === "datastore" ? database : key} />
                     <Spacer inline x={0.7} />
                     <Text size={14}>{envGroup.name}</Text>
                   </Container>

+ 2 - 1
dashboard/src/main/home/env-dashboard/ExpandedEnv.tsx

@@ -15,6 +15,7 @@ import TabSelector from "components/TabSelector";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import doppler from "assets/doppler.png";
+import database from "assets/database.svg";
 import key from "assets/key.svg";
 import notFound from "assets/not-found.png";
 import time from "assets/time.png";
@@ -108,7 +109,7 @@ const ExpandedEnv: React.FC = () => {
 
           <Container row>
             <Image
-              src={envGroup.type === "doppler" ? doppler : key}
+              src={envGroup.type === "doppler" ? doppler : envGroup.type === "datastore" ? database : key}
               size={28}
             />
             <Spacer inline x={1} />

+ 1 - 1
dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx

@@ -198,7 +198,7 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
             secretOption={true}
             disabled={envGroup.type === "doppler"}
           />
-          {envGroup.type !== "doppler" && (
+          {envGroup.type !== "doppler" && envGroup.type !== "datastore" && (
             <>
               <Spacer y={1} />
               <Button

+ 52 - 42
dashboard/src/main/home/infrastructure-dashboard/ClusterDashboard.tsx

@@ -237,48 +237,7 @@ const ClusterDashboard: React.FC = () => {
           })}
         </GridList>
       ) : (
-        <List>
-          {filteredClusters.map((cluster: ClientCluster, i: number) => {
-            return (
-              <Row to={`/infrastructure/${cluster.id}`} key={i}>
-                <Container row spaced>
-                  <Container row>
-                    <MidIcon src={cluster.cloud_provider.icon} />
-                    <Text size={14}>{cluster.vanity_name}</Text>
-                  </Container>
-                  <Container row>
-                    <StatusDot
-                      status={
-                        cluster.status === "READY" ? "available" : "pending"
-                      }
-                      heightPixels={8}
-                    />
-                    <Spacer inline x={0.5} />
-                    <Text color="helper">
-                      {cluster.status === "READY" ? "Running" : "Updating"}
-                    </Text>
-                  </Container>
-                </Container>
-                <Spacer y={0.5} />
-                {cluster.contract != null && (
-                  <Container row>
-                    <Container row>
-                      <SmallIcon opacity="0.3" src={globe} />
-                      <Text size={13} color="#ffffff44">
-                        {cluster.contract.config.cluster.config.region}
-                      </Text>
-                      <Spacer inline x={1} />
-                      <SmallIcon opacity="0.3" src={time} />
-                      <Text size={13} color="#ffffff44">
-                        {readableDate(cluster.contract.updated_at)}
-                      </Text>
-                    </Container>
-                  </Container>
-                )}
-              </Row>
-            );
-          })}
-        </List>
+        <ClusterList clusters={filteredClusters} />
       )}
 
       <Spacer y={5} />
@@ -286,6 +245,54 @@ const ClusterDashboard: React.FC = () => {
   );
 };
 
+type ClusterListProps = {
+  clusters: ClientCluster[];
+};
+export const ClusterList: React.FC<ClusterListProps> = ({ clusters }) => {
+  return (
+    <List>
+      {clusters.map((cluster: ClientCluster, i: number) => {
+        return (
+          <Row to={`/infrastructure/${cluster.id}`} key={i}>
+            <Container row spaced>
+              <Container row>
+                <MidIcon src={cluster.cloud_provider.icon} />
+                <Text size={14}>{cluster.vanity_name}</Text>
+              </Container>
+              <Container row>
+                <StatusDot
+                  status={cluster.status === "READY" ? "available" : "pending"}
+                  heightPixels={8}
+                />
+                <Spacer inline x={0.5} />
+                <Text color="helper">
+                  {cluster.status === "READY" ? "Running" : "Updating"}
+                </Text>
+              </Container>
+            </Container>
+            <Spacer y={0.5} />
+            {cluster.contract != null && (
+              <Container row>
+                <Container row>
+                  <SmallIcon opacity="0.3" src={globe} />
+                  <Text size={13} color="#ffffff44">
+                    {cluster.contract.config.cluster.config.region}
+                  </Text>
+                  <Spacer inline x={1} />
+                  <SmallIcon opacity="0.3" src={time} />
+                  <Text size={13} color="#ffffff44">
+                    {readableDate(cluster.contract.updated_at)}
+                  </Text>
+                </Container>
+              </Container>
+            )}
+          </Row>
+        );
+      })}
+    </List>
+  );
+};
+
 export default ClusterDashboard;
 
 const MidIcon = styled.img<{ height?: string }>`
@@ -305,6 +312,9 @@ const Row = styled(Link)<{ isAtBottom?: boolean }>`
   border-radius: 5px;
   margin-bottom: 15px;
   animation: fadeIn 0.3s 0s;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
 `;
 
 const List = styled.div`

+ 1 - 1
go.mod

@@ -84,7 +84,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.125
+	github.com/porter-dev/api-contracts v0.2.127
 	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 - 4
go.sum

@@ -1525,8 +1525,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.125 h1:IBZkLyOiqD6WE6SGCcUGbIcqcfgkizuTuotmkRfF2ak=
-github.com/porter-dev/api-contracts v0.2.125/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.127 h1:pF2vV9sohSzBIauBunII1n7f2puqMXdEGza4GdSkQBQ=
+github.com/porter-dev/api-contracts v0.2.127/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=
@@ -2802,7 +2802,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kF
 sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
 sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
 sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
-sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
-sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
 sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
 sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

+ 4 - 7
internal/datastore/datastore.go

@@ -2,9 +2,6 @@ package datastore
 
 import (
 	"time"
-
-	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
-	"github.com/porter-dev/porter/api/server/handlers/environment_groups"
 )
 
 // Datastore describes an outbound datastores response entry
@@ -15,10 +12,6 @@ type Datastore struct {
 	Type string `json:"type"`
 	// Engine is the engine of the datastore
 	Engine string `json:"engine,omitempty"`
-	// Env is the env group for the datastore
-	Env environment_groups.EnvironmentGroupListItem `json:"env,omitempty"`
-	// Metadata is a list of metadata objects for the datastore - TODO: remove this field, it is unnecessary
-	Metadata []*porterv1.DatastoreMetadata `json:"metadata,omitempty"`
 	// Status is the status of the datastore
 	Status string `json:"status"`
 	// CreatedAtUTC is the time the datastore was created in UTC
@@ -29,6 +22,10 @@ type Datastore struct {
 	CloudProviderCredentialIdentifier string `json:"cloud_provider_credential_identifier"`
 	// Credential is the credential used for connecting to the datastore
 	Credential Credential `json:"credential"`
+	// ConnectedClusterIds is a list of connected cluster ids
+	ConnectedClusterIds []uint `json:"connected_cluster_ids,omitempty"`
+	// OnManagementCluster is a flag indicating whether the datastore is on the management cluster
+	OnManagementCluster bool `json:"on_management_cluster"`
 	// B64Proto is the base64 encoded datastore proto. Note that this is only populated for datastores created with the new cloud contract flow
 	B64Proto string `json:"b64_proto"`
 }