Преглед изворни кода

[POR-2202] allow users to link apps to their datastore from the dashboard (#4170)

Feroze Mohideen пре 2 година
родитељ
комит
1b897d3a9f

+ 1 - 1
api/server/router/porter_app.go

@@ -1065,7 +1065,7 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/revisions -> porter_app.NewCurrentAppRevisionHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/revisions -> porter_app.NewLatestAppRevisionsHandler
 	latestAppRevisionsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,

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

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.37851 10.1907L5.14505 12.4242C4.31092 13.2583 3.83124 14.3933 3.84001 15.5861C3.84877 16.7789 4.31796 17.9208 5.19167 18.7675C6.03836 19.6413 7.18048 20.1104 8.3731 20.1192C9.59293 20.1282 10.701 19.6755 11.5352 18.8414L13.7687 16.6079M16.6215 13.8097L18.8549 11.5762C19.6891 10.7421 20.1688 9.60711 20.16 8.4143C20.1512 7.22149 19.682 6.0796 18.8083 5.23287C17.9618 4.38638 16.8199 3.91717 15.6271 3.90841C14.4343 3.89964 13.2992 4.35209 12.465 5.18625L10.2315 7.4197M8.6131 15.3274L15.3135 8.62701" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

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

@@ -10,6 +10,15 @@ import { Context } from "shared/Context";
 type DatabaseHook = {
   create: (values: DbFormData) => Promise<void>;
   deleteDatastore: (name: string) => Promise<void>;
+  attachDatastoreToAppInstances: ({
+    name,
+    appInstanceIds,
+    clusterId,
+  }: {
+    name: string;
+    appInstanceIds: string[];
+    clusterId: number;
+  }) => Promise<void>;
 };
 type CreateDatastoreInput = {
   name: string;
@@ -76,7 +85,7 @@ const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => {
 };
 
 export const useDatabaseMethods = (): DatabaseHook => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, currentCluster } = useContext(Context);
 
   const queryClient = useQueryClient();
 
@@ -126,5 +135,41 @@ export const useDatabaseMethods = (): DatabaseHook => {
     [currentProject]
   );
 
-  return { create, deleteDatastore };
+  const attachDatastoreToAppInstances = useCallback(
+    async ({
+      name,
+      appInstanceIds,
+    }: {
+      name: string;
+      appInstanceIds: string[];
+    }): Promise<void> => {
+      if (
+        !currentProject?.id ||
+        currentProject.id === -1 ||
+        !currentCluster?.id ||
+        currentCluster.id === -1
+      ) {
+        return;
+      }
+
+      await api.attachEnvGroup(
+        "<token>",
+        {
+          app_instance_ids: appInstanceIds,
+          env_group_name: name,
+        },
+        {
+          project_id: currentProject.id,
+          // NB: this endpoint does not actually use the cluster id, because the app instance id is used
+          // to deploy in its correct deployment target.
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      await queryClient.invalidateQueries({ queryKey: ["getDatastore"] });
+    },
+    [currentProject, currentCluster]
+  );
+
+  return { create, deleteDatastore, attachDatastoreToAppInstances };
 };

+ 59 - 0
dashboard/src/lib/hooks/useLatestAppRevisions.ts

@@ -0,0 +1,59 @@
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import {
+  appRevisionWithSourceValidator,
+  type AppRevisionWithSource,
+} from "main/home/app-dashboard/apps/types";
+
+import api from "shared/api";
+
+// use this hook to get the latest revision of every app in the project/cluster
+export const useLatestAppRevisions = ({
+  projectId,
+  clusterId,
+}: {
+  projectId: number;
+  clusterId: number;
+}): {
+  revisions: AppRevisionWithSource[];
+} => {
+  const { data: apps = [] } = useQuery(
+    [
+      "getLatestAppRevisions",
+      {
+        cluster_id: clusterId,
+        project_id: projectId,
+      },
+    ],
+    async () => {
+      if (clusterId === -1 || projectId === -1) {
+        return;
+      }
+
+      const res = await api.getLatestAppRevisions(
+        "<token>",
+        {
+          deployment_target_id: undefined,
+          ignore_preview_apps: true,
+        },
+        { cluster_id: clusterId, project_id: projectId }
+      );
+
+      const apps = await z
+        .object({
+          app_revisions: z.array(appRevisionWithSourceValidator),
+        })
+        .parseAsync(res.data);
+
+      return apps.app_revisions;
+    },
+    {
+      refetchOnWindowFocus: false,
+      enabled: clusterId !== 0 && projectId !== 0,
+    }
+  );
+  return {
+    revisions: apps,
+  };
+};

+ 1 - 0
dashboard/src/lib/revisions/types.ts

@@ -36,6 +36,7 @@ export const appRevisionValidator = z.object({
   id: z.string(),
   created_at: z.string(),
   updated_at: z.string(),
+  app_instance_id: z.string(),
 });
 
 export type AppRevision = z.infer<typeof appRevisionValidator>;

+ 122 - 0
dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx

@@ -0,0 +1,122 @@
+import React, { useMemo } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+import styled, { css } from "styled-components";
+
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { AppIcon, AppSource } from "main/home/app-dashboard/apps/AppMeta";
+
+import healthy from "assets/status-healthy.png";
+
+import { type AppRevisionWithSource } from "./types";
+
+type SelectableAppRowProps = {
+  app: AppRevisionWithSource;
+  onSelect?: () => void;
+  onDeselect?: () => void;
+  selected?: boolean;
+};
+
+const SelectableAppRow: React.FC<SelectableAppRowProps> = ({
+  app,
+  selected,
+  onSelect,
+  onDeselect,
+}) => {
+  const proto = useMemo(() => {
+    return PorterApp.fromJsonString(atob(app.app_revision.b64_app_proto), {
+      ignoreUnknownFields: true,
+    });
+  }, [app.app_revision.b64_app_proto]);
+
+  return (
+    <ResourceOption
+      selected={selected}
+      onClick={() => {
+        if (selected) {
+          onDeselect?.();
+        } else {
+          onSelect?.();
+        }
+      }}
+      isHoverable={onSelect != null || onDeselect != null}
+    >
+      <div>
+        <Container row>
+          <Spacer inline width="1px" />
+          <AppIcon buildpacks={proto.build?.buildpacks ?? []} />
+          <Spacer inline width="12px" />
+          <Text size={14}>{proto.name}</Text>
+          <Spacer inline x={1} />
+        </Container>
+        <Spacer height="15px" />
+        <Container row>
+          <AppSource source={app.source} />
+          <Spacer inline x={1} />
+        </Container>
+      </div>
+      {selected && <Icon height="18px" src={healthy} />}
+    </ResourceOption>
+  );
+};
+
+type AppListProps = {
+  appListItems: Array<{
+    app: AppRevisionWithSource;
+    key: string;
+    onSelect?: () => void;
+    onDeselect?: () => void;
+    isSelected?: boolean;
+  }>;
+};
+
+const SelectableAppList: React.FC<AppListProps> = ({ appListItems }) => {
+  return (
+    <StyledSelectableAppList>
+      {appListItems.map((ali) => {
+        return (
+          <SelectableAppRow
+            key={ali.key}
+            app={ali.app}
+            selected={ali.isSelected}
+            onSelect={ali.onSelect}
+            onDeselect={ali.onDeselect}
+          />
+        );
+      })}
+    </StyledSelectableAppList>
+  );
+};
+
+export default SelectableAppList;
+
+const StyledSelectableAppList = styled.div`
+  display: flex;
+  row-gap: 10px;
+  flex-direction: column;
+  max-height: 400px;
+  overflow-y: scroll;
+`;
+
+const ResourceOption = styled.div<{ selected?: boolean; isHoverable: boolean }>`
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid
+    ${(props) => (props.selected ? "#ffffff" : props.theme.border)};
+  width: 100%;
+  padding: 10px 15px;
+  border-radius: 5px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  ${(props) => props.isHoverable && "cursor: pointer;"}
+  ${(props) =>
+    props.isHoverable &&
+    !props.selected &&
+    css`
+      &:hover {
+        border: 1px solid #7a7b80;
+      }
+    `}
+`;

+ 5 - 44
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx

@@ -1,17 +1,14 @@
 import React, { useContext } from "react";
-import { useQuery } from "@tanstack/react-query";
 import { useHistory } from "react-router";
 import styled from "styled-components";
-import { z } from "zod";
 
 import Loading from "components/Loading";
 import Button from "components/porter/Button";
 import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { appRevisionWithSourceValidator } from "main/home/app-dashboard/apps/types";
+import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
 
-import api from "shared/api";
 import { Context } from "shared/Context";
 
 import { ConfigurableAppRow } from "./ConfigurableAppRow";
@@ -22,46 +19,10 @@ export const ConfigurableAppList: React.FC = () => {
 
   const { currentProject, currentCluster } = useContext(Context);
 
-  const { data: apps = [], status } = useQuery(
-    [
-      "getLatestAppRevisions",
-      {
-        cluster_id: currentCluster?.id,
-        project_id: currentProject?.id,
-      },
-    ],
-    async () => {
-      if (
-        !currentCluster ||
-        !currentProject ||
-        currentCluster.id === -1 ||
-        currentProject.id === -1
-      ) {
-        return;
-      }
-
-      const res = await api.getLatestAppRevisions(
-        "<token>",
-        {
-          deployment_target_id: undefined,
-          ignore_preview_apps: true,
-        },
-        { cluster_id: currentCluster.id, project_id: currentProject.id }
-      );
-
-      const apps = await z
-        .object({
-          app_revisions: z.array(appRevisionWithSourceValidator),
-        })
-        .parseAsync(res.data);
-
-      return apps.app_revisions;
-    },
-    {
-      refetchOnWindowFocus: false,
-      enabled: !!currentCluster && !!currentProject,
-    }
-  );
+  const { revisions: apps } = useLatestAppRevisions({
+    projectId: currentProject?.id ?? 0,
+    clusterId: currentCluster?.id ?? 0,
+  });
 
   if (status === "loading") {
     return <Loading offset="-150px" />;

+ 24 - 148
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RequiredApps.tsx

@@ -1,82 +1,16 @@
 import React, { useContext, useMemo } from "react";
-import { PorterApp } from "@porter-dev/api-contracts";
-import { useQuery } from "@tanstack/react-query";
-import {
-  useFieldArray,
-  useFormContext,
-  type UseFieldArrayAppend,
-} from "react-hook-form";
-import styled from "styled-components";
-import { z } from "zod";
+import { useFieldArray, useFormContext } from "react-hook-form";
 
-import Container from "components/porter/Container";
-import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
 import AppSaveButton from "main/home/app-dashboard/app-view/AppSaveButton";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
-import { AppIcon, AppSource } from "main/home/app-dashboard/apps/AppMeta";
-import {
-  appRevisionWithSourceValidator,
-  type AppRevisionWithSource,
-} from "main/home/app-dashboard/apps/types";
+import SelectableAppList from "main/home/app-dashboard/apps/SelectableAppList";
+import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
 import { type PorterAppFormData } from "lib/porter-apps";
 
-import api from "shared/api";
 import { Context } from "shared/Context";
-import healthy from "assets/status-healthy.png";
-
-type RowProps = {
-  idx: number;
-  app: AppRevisionWithSource;
-  append: UseFieldArrayAppend<PorterAppFormData, "app.requiredApps">;
-  remove: (index: number) => void;
-  selected?: boolean;
-};
-
-const RequiredAppRow: React.FC<RowProps> = ({
-  idx,
-  app,
-  selected,
-  append,
-  remove,
-}) => {
-  const proto = useMemo(() => {
-    return PorterApp.fromJsonString(atob(app.app_revision.b64_app_proto), {
-      ignoreUnknownFields: true,
-    });
-  }, [app.app_revision.b64_app_proto]);
-
-  return (
-    <ResourceOption
-      selected={selected}
-      onClick={() => {
-        if (selected) {
-          remove(idx);
-        } else {
-          append({ name: app.source.name });
-        }
-      }}
-    >
-      <div>
-        <Container row>
-          <Spacer inline width="1px" />
-          <AppIcon buildpacks={proto.build?.buildpacks ?? []} />
-          <Spacer inline width="12px" />
-          <Text size={14}>{proto.name}</Text>
-          <Spacer inline x={1} />
-        </Container>
-        <Spacer height="15px" />
-        <Container row>
-          <AppSource source={app.source} />
-          <Spacer inline x={1} />
-        </Container>
-      </div>
-      {selected && <Icon height="18px" src={healthy} />}
-    </ResourceOption>
-  );
-};
 
 type Props = {
   buttonStatus: ButtonStatus;
@@ -96,46 +30,10 @@ export const RequiredApps: React.FC<Props> = ({ buttonStatus }) => {
 
   const { porterApp } = useLatestRevision();
 
-  const { data: apps = [] } = useQuery(
-    [
-      "getLatestAppRevisions",
-      {
-        cluster_id: currentCluster?.id,
-        project_id: currentProject?.id,
-      },
-    ],
-    async () => {
-      if (
-        !currentCluster ||
-        !currentProject ||
-        currentCluster.id === -1 ||
-        currentProject.id === -1
-      ) {
-        return;
-      }
-
-      const res = await api.getLatestAppRevisions(
-        "<token>",
-        {
-          deployment_target_id: undefined,
-          ignore_preview_apps: true,
-        },
-        { cluster_id: currentCluster.id, project_id: currentProject.id }
-      );
-
-      const apps = await z
-        .object({
-          app_revisions: z.array(appRevisionWithSourceValidator),
-        })
-        .parseAsync(res.data);
-
-      return apps.app_revisions;
-    },
-    {
-      refetchOnWindowFocus: false,
-      enabled: !!currentCluster && !!currentProject,
-    }
-  );
+  const { revisions: apps } = useLatestAppRevisions({
+    projectId: currentProject?.id ?? 0,
+    clusterId: currentCluster?.id ?? 0,
+  });
 
   const remainingApps = useMemo(() => {
     return apps.filter((a) => a.source.name !== porterApp.name);
@@ -151,28 +49,28 @@ export const RequiredApps: React.FC<Props> = ({ buttonStatus }) => {
         running on the cluster.
       </Text>
       <Spacer y={0.5} />
-      <RequiredAppList>
-        {remainingApps.map((ra) => {
+      <SelectableAppList
+        appListItems={remainingApps.map((ra) => {
           const selectedAppIdx = fields.findIndex(
             (f) => f.name === ra.source.name
           );
 
-          return (
-            <RequiredAppRow
-              idx={selectedAppIdx}
-              key={
-                selectedAppIdx !== -1
-                  ? fields[selectedAppIdx].id
-                  : ra.source.name
-              }
-              app={ra}
-              append={append}
-              remove={remove}
-              selected={selectedAppIdx !== -1}
-            />
-          );
+          return {
+            app: ra,
+            key:
+              selectedAppIdx !== -1
+                ? fields[selectedAppIdx].id
+                : ra.source.name,
+            onSelect: () => {
+              append({ name: ra.source.name });
+            },
+            onDeselect: () => {
+              remove(selectedAppIdx);
+            },
+            isSelected: selectedAppIdx !== -1,
+          };
         })}
-      </RequiredAppList>
+      />
       <Spacer y={0.75} />
       <AppSaveButton
         status={buttonStatus}
@@ -183,25 +81,3 @@ export const RequiredApps: React.FC<Props> = ({ buttonStatus }) => {
     </div>
   );
 };
-
-const RequiredAppList = styled.div`
-  display: flex;
-  row-gap: 10px;
-  flex-direction: column;
-`;
-
-const ResourceOption = styled.div<{ selected?: boolean }>`
-  background: ${(props) => props.theme.clickable.bg};
-  border: 1px solid
-    ${(props) => (props.selected ? "#ffffff" : props.theme.border)};
-  width: 100%;
-  padding: 10px 15px;
-  border-radius: 5px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #ffffff;
-  }
-`;

+ 1 - 2
dashboard/src/main/home/database-dashboard/DatabaseContextProvider.tsx

@@ -79,8 +79,7 @@ export const DatabaseContextProvider: React.FC<
     },
     {
       enabled: paramsExist,
-      refetchInterval: 5000,
-      refetchOnWindowFocus: false,
+      refetchOnWindowFocus: true,
     }
   );
   if (status === "loading" || !paramsExist) {

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

@@ -7,20 +7,19 @@ import TabSelector from "components/TabSelector";
 
 import { useDatabaseContext } from "./DatabaseContextProvider";
 import ConfigurationTab from "./tabs/ConfigurationTab";
+import ConnectedAppsTab from "./tabs/ConnectedAppsTab";
 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",
+  "credentials",
   "configuration",
   "settings",
+  "connected-apps",
 ] as const;
-const DEFAULT_TAB = "environment";
+const DEFAULT_TAB = "connected-apps";
 type ValidTab = (typeof validTabs)[number];
 
 type DbTabProps = {
@@ -44,7 +43,8 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
 
   const tabs = useMemo(() => {
     return [
-      { label: "Connection Info", value: "environment" },
+      { label: "Connected Apps", value: "connected-apps" },
+      { label: "Credentials", value: "credentials" },
       { label: "Configuration", value: "configuration" },
       { label: "Settings", value: "settings" },
     ];
@@ -62,10 +62,11 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
       />
       <Spacer y={1} />
       {match(currentTab)
-        .with("environment", () => <DatabaseEnvTab envData={datastore.env} />)
+        .with("credentials", () => <DatabaseEnvTab envData={datastore.env} />)
         .with("settings", () => <SettingsTab />)
         .with("metrics", () => <MetricsTab />)
         .with("configuration", () => <ConfigurationTab />)
+        .with("connected-apps", () => <ConnectedAppsTab />)
         .otherwise(() => null)}
       <Spacer y={2} />
     </>

+ 1 - 1
dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx

@@ -20,7 +20,7 @@ import {
 } from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import Resources from "../tabs/Resources";
+import Resources from "../shared/Resources";
 import DatabaseForm, {
   AppearingErrorContainer,
   Blur,

+ 1 - 1
dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx

@@ -20,7 +20,7 @@ import {
 } from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import Resources from "../tabs/Resources";
+import Resources from "../shared/Resources";
 import DatabaseForm, {
   AppearingErrorContainer,
   Blur,

+ 1 - 1
dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx

@@ -20,7 +20,7 @@ import {
 } from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import Resources from "../tabs/Resources";
+import Resources from "../shared/Resources";
 import DatabaseForm, {
   AppearingErrorContainer,
   Blur,

+ 133 - 0
dashboard/src/main/home/database-dashboard/shared/ConnectAppsModal.tsx

@@ -0,0 +1,133 @@
+import React, { useCallback, useMemo, useState } from "react";
+import axios from "axios";
+import pluralize from "pluralize";
+import { z } from "zod";
+
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+import Icon from "components/porter/Icon";
+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 { useIntercom } from "lib/hooks/useIntercom";
+
+import connect from "assets/connect.svg";
+
+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 [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]);
+      }
+    },
+    [selectedAppInstanceIds]
+  );
+  const remove = useCallback(
+    (appInstanceId: string): void => {
+      setSelectedAppInstanceIds(
+        selectedAppInstanceIds.filter((id) => id !== appInstanceId)
+      );
+    },
+    [selectedAppInstanceIds]
+  );
+  const isSelected = useCallback(
+    (appInstanceId: string): boolean => {
+      return selectedAppInstanceIds.includes(appInstanceId);
+    },
+    [selectedAppInstanceIds]
+  );
+  const submit = useCallback(async () => {
+    try {
+      setIsSubmitting(true);
+      await onSubmit(selectedAppInstanceIds);
+      closeModal();
+    } catch (err) {
+      let message = "Please contact support.";
+      if (axios.isAxiosError(err)) {
+        const parsed = z
+          .object({ error: z.string() })
+          .safeParse(err.response?.data);
+        if (parsed.success) {
+          message = `${parsed.data.error}`;
+        }
+      }
+      setSubmitErrorMessage(message);
+      showIntercomWithMessage({
+        message: "I am having trouble connecting apps to my database.",
+      });
+    } finally {
+      setIsSubmitting(false);
+    }
+  }, [onSubmit, selectedAppInstanceIds]);
+
+  const submitButtonStatus = useMemo(() => {
+    if (isSubmitting) {
+      return "loading";
+    }
+
+    if (submitErrorMessage) {
+      return <Error message={`Connection failed: ${submitErrorMessage}`} />;
+    }
+
+    return "";
+  }, [isSubmitting, submitErrorMessage]);
+
+  return (
+    <Modal closeModal={closeModal}>
+      <Text size={16}>Select apps</Text>
+      <Spacer y={0.5} />
+      <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.
+      </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>
+    </Modal>
+  );
+};
+
+export default ConnectAppsModal;

+ 0 - 0
dashboard/src/main/home/database-dashboard/tabs/Resources.tsx → dashboard/src/main/home/database-dashboard/shared/Resources.tsx


+ 132 - 0
dashboard/src/main/home/database-dashboard/tabs/ConnectedAppsTab.tsx

@@ -0,0 +1,132 @@
+import React, { useContext, useMemo, useState } from "react";
+import _ from "lodash";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import SelectableAppList from "main/home/app-dashboard/apps/SelectableAppList";
+import { useDatabaseMethods } from "lib/hooks/useDatabaseMethods";
+import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
+
+import { Context } from "shared/Context";
+
+import { useDatabaseContext } from "../DatabaseContextProvider";
+import ConnectAppsModal from "../shared/ConnectAppsModal";
+
+const ConnectedAppsTab: React.FC = () => {
+  const [showConnectAppsModal, setShowConnectAppsModal] = useState(false);
+  const { projectId, datastore } = useDatabaseContext();
+  // NB: the cluster id here is coming from the global context, but really it should be coming from
+  // the database context. However, we do not currently have a way to relate db to the cluster it lives in.
+  // This will be a bug for multi-cluster projects.
+  const { currentCluster: { id: clusterId = 0 } = {} } = useContext(Context);
+  const { revisions } = useLatestAppRevisions({
+    projectId,
+    clusterId,
+  });
+  const { attachDatastoreToAppInstances } = useDatabaseMethods();
+  const history = useHistory();
+
+  const { connectedApps, remainingApps } = useMemo(() => {
+    const [connected, remaining] = _.partition(
+      revisions,
+      (r) => datastore.env?.linked_applications.includes(r.source.name)
+    );
+    return {
+      connectedApps: connected.sort((a, b) =>
+        a.source.name.localeCompare(b.source.name)
+      ),
+      remainingApps: remaining.sort((a, b) =>
+        a.source.name.localeCompare(b.source.name)
+      ),
+    };
+  }, [revisions, datastore.env?.linked_applications]);
+
+  return (
+    <ConnectedAppsContainer>
+      <Container row>
+        <Text size={16}>Connected Apps</Text>
+      </Container>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Credentials for this datastore are injected as environment variables to
+        connected apps.
+      </Text>
+      <Spacer y={0.5} />
+      <SelectableAppList
+        appListItems={connectedApps.map((ra) => ({
+          app: ra,
+          key: ra.source.name,
+          onSelect: () => {
+            history.push(
+              `/apps/${ra.source.name}?target=${ra.app_revision.deployment_target.id}`
+            );
+          },
+        }))}
+      />
+      <Spacer y={0.5} />
+      <AddAddonButton
+        onClick={() => {
+          setShowConnectAppsModal(true);
+        }}
+      >
+        <I className="material-icons add-icon">add</I>
+        Connect apps to this datastore
+      </AddAddonButton>
+      {showConnectAppsModal && (
+        <ConnectAppsModal
+          closeModal={() => {
+            setShowConnectAppsModal(false);
+          }}
+          apps={remainingApps}
+          onSubmit={async (appInstanceIds: string[]) => {
+            await attachDatastoreToAppInstances({
+              name: datastore.name,
+              clusterId,
+              appInstanceIds,
+            });
+          }}
+        />
+      )}
+    </ConnectedAppsContainer>
+  );
+};
+
+export default ConnectedAppsTab;
+
+const ConnectedAppsContainer = styled.div`
+  width: 100%;
+`;
+
+const AddAddonButton = 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;
+`;

+ 1 - 30
dashboard/src/main/home/database-dashboard/tabs/DatabaseEnvTab.tsx

@@ -10,8 +10,6 @@ import { type DatastoreEnvWithSource } from "lib/databases/types";
 
 import copy from "assets/copy-left.svg";
 
-import DatabaseLinkedApp from "./DatabaseLinkedApp";
-
 type Props = {
   envData: DatastoreEnvWithSource;
   connectionString?: string;
@@ -42,31 +40,6 @@ const DatabaseEnvTab: React.FC<Props> = ({ envData, connectionString }) => {
     return keys;
   };
 
-  const renderLinkedApplications = (): JSX.Element => {
-    if (envData.linked_applications.length === 0) {
-      return (
-        <InnerWrapper>
-          <Text size={16}> Linked Applications</Text>
-          <Spacer y={0.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={0.5} />
-        {envData.linked_applications.map((appName, index) => (
-          <DatabaseLinkedApp appName={appName} key={index}></DatabaseLinkedApp>
-        ))}
-      </InnerWrapper>
-    );
-  };
-
   return (
     <StyledTemplateComponent>
       <InnerWrapper>
@@ -79,7 +52,7 @@ const DatabaseEnvTab: React.FC<Props> = ({ envData, connectionString }) => {
           values={setKeys()}
           setValues={(_) => {}}
           fileUpload={true}
-          secretOption={true}
+          secretOption={false}
           disabled={true}
         />
       </InnerWrapper>
@@ -101,8 +74,6 @@ const DatabaseEnvTab: React.FC<Props> = ({ envData, connectionString }) => {
           <Spacer y={1} />
         </InnerWrapper>
       )}
-
-      {renderLinkedApplications()}
     </StyledTemplateComponent>
   );
 };

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

@@ -1,111 +0,0 @@
-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;
-  }
-`;

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

@@ -893,6 +893,18 @@ const parsePorterYaml = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/parse`;
 });
 
+const attachEnvGroup = baseApi<
+  {
+    env_group_name: string;
+    app_instance_ids: string[];
+  },
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/apps/attach-env-group`
+);
+
 const getDefaultDeploymentTarget = baseApi<
   {},
   {
@@ -3537,6 +3549,7 @@ export default {
   getProcfileContents,
   getPorterYamlContents,
   parsePorterYaml,
+  attachEnvGroup,
   getDefaultDeploymentTarget,
   deleteDeploymentTarget,
   getBranchHead,