diff --git a/api/server/router/porter_app.go b/api/server/router/porter_app.go
index 791a58edaa..191c3eed42 100644
--- a/api/server/router/porter_app.go
+++ b/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,
diff --git a/dashboard/src/assets/connect.svg b/dashboard/src/assets/connect.svg
new file mode 100644
index 0000000000..7510668f98
--- /dev/null
+++ b/dashboard/src/assets/connect.svg
@@ -0,0 +1,3 @@
+
diff --git a/dashboard/src/lib/hooks/useDatabaseMethods.ts b/dashboard/src/lib/hooks/useDatabaseMethods.ts
index f6f3630b1f..1383741ec4 100644
--- a/dashboard/src/lib/hooks/useDatabaseMethods.ts
+++ b/dashboard/src/lib/hooks/useDatabaseMethods.ts
@@ -10,6 +10,15 @@ import { Context } from "shared/Context";
type DatabaseHook = {
create: (values: DbFormData) => Promise;
deleteDatastore: (name: string) => Promise;
+ attachDatastoreToAppInstances: ({
+ name,
+ appInstanceIds,
+ clusterId,
+ }: {
+ name: string;
+ appInstanceIds: string[];
+ clusterId: number;
+ }) => Promise;
};
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 => {
+ if (
+ !currentProject?.id ||
+ currentProject.id === -1 ||
+ !currentCluster?.id ||
+ currentCluster.id === -1
+ ) {
+ return;
+ }
+
+ await api.attachEnvGroup(
+ "",
+ {
+ 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 };
};
diff --git a/dashboard/src/lib/hooks/useLatestAppRevisions.ts b/dashboard/src/lib/hooks/useLatestAppRevisions.ts
new file mode 100644
index 0000000000..3e04892637
--- /dev/null
+++ b/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(
+ "",
+ {
+ 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,
+ };
+};
diff --git a/dashboard/src/lib/revisions/types.ts b/dashboard/src/lib/revisions/types.ts
index bb19940599..b7b0e204bd 100644
--- a/dashboard/src/lib/revisions/types.ts
+++ b/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;
diff --git a/dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx b/dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx
new file mode 100644
index 0000000000..bc2825c5fb
--- /dev/null
+++ b/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 = ({
+ 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 (
+ {
+ if (selected) {
+ onDeselect?.();
+ } else {
+ onSelect?.();
+ }
+ }}
+ isHoverable={onSelect != null || onDeselect != null}
+ >
+
+
+
+
+
+ {proto.name}
+
+
+
+
+
+
+
+
+ {selected && }
+
+ );
+};
+
+type AppListProps = {
+ appListItems: Array<{
+ app: AppRevisionWithSource;
+ key: string;
+ onSelect?: () => void;
+ onDeselect?: () => void;
+ isSelected?: boolean;
+ }>;
+};
+
+const SelectableAppList: React.FC = ({ appListItems }) => {
+ return (
+
+ {appListItems.map((ali) => {
+ return (
+
+ );
+ })}
+
+ );
+};
+
+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;
+ }
+ `}
+`;
diff --git a/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx b/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx
index ebbb339a43..93e5946925 100644
--- a/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx
+++ b/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(
- "",
- {
- 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 ;
diff --git a/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RequiredApps.tsx b/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RequiredApps.tsx
index b8a037512d..2da472fb0f 100644
--- a/dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RequiredApps.tsx
+++ b/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;
- remove: (index: number) => void;
- selected?: boolean;
-};
-
-const RequiredAppRow: React.FC = ({
- 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 (
- {
- if (selected) {
- remove(idx);
- } else {
- append({ name: app.source.name });
- }
- }}
- >
-
-
-
-
-
- {proto.name}
-
-
-
-
-
-
-
-
- {selected && }
-
- );
-};
type Props = {
buttonStatus: ButtonStatus;
@@ -96,46 +30,10 @@ export const RequiredApps: React.FC = ({ 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(
- "",
- {
- 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 = ({ buttonStatus }) => {
running on the cluster.
-
- {remainingApps.map((ra) => {
+ {
const selectedAppIdx = fields.findIndex(
(f) => f.name === ra.source.name
);
- return (
-
- );
+ return {
+ app: ra,
+ key:
+ selectedAppIdx !== -1
+ ? fields[selectedAppIdx].id
+ : ra.source.name,
+ onSelect: () => {
+ append({ name: ra.source.name });
+ },
+ onDeselect: () => {
+ remove(selectedAppIdx);
+ },
+ isSelected: selectedAppIdx !== -1,
+ };
})}
-
+ />
= ({ buttonStatus }) => {
);
};
-
-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;
- }
-`;
diff --git a/dashboard/src/main/home/database-dashboard/DatabaseContextProvider.tsx b/dashboard/src/main/home/database-dashboard/DatabaseContextProvider.tsx
index 3c2e86fba1..7cc8d694e3 100644
--- a/dashboard/src/main/home/database-dashboard/DatabaseContextProvider.tsx
+++ b/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) {
diff --git a/dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx b/dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx
index 014158f77b..97535a6425 100644
--- a/dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx
+++ b/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 = ({ 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 = ({ tabParam }) => {
/>
{match(currentTab)
- .with("environment", () => )
+ .with("credentials", () => )
.with("settings", () => )
.with("metrics", () => )
.with("configuration", () => )
+ .with("connected-apps", () => )
.otherwise(() => null)}
>
diff --git a/dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx b/dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx
index 97551d5030..f9c43115aa 100644
--- a/dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx
+++ b/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,
diff --git a/dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx b/dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx
index 33b3e05b2b..76c0a1877a 100644
--- a/dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx
+++ b/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,
diff --git a/dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx b/dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx
index 4fb5f88480..522bee06f6 100644
--- a/dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx
+++ b/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,
diff --git a/dashboard/src/main/home/database-dashboard/shared/ConnectAppsModal.tsx b/dashboard/src/main/home/database-dashboard/shared/ConnectAppsModal.tsx
new file mode 100644
index 0000000000..f6cb572a88
--- /dev/null
+++ b/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;
+};
+
+const ConnectAppsModal: React.FC = ({ closeModal, apps, onSubmit }) => {
+ const [selectedAppInstanceIds, setSelectedAppInstanceIds] = useState<
+ string[]
+ >([]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitErrorMessage, setSubmitErrorMessage] = useState("");
+ 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 ;
+ }
+
+ return "";
+ }, [isSubmitting, submitErrorMessage]);
+
+ return (
+
+ Select apps
+
+ ({
+ 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),
+ }))}
+ />
+
+
+ Click the button below to confirm the above selections. Newly connected
+ apps may take a few seconds to appear on the dashboard.
+
+
+
+
+ );
+};
+
+export default ConnectAppsModal;
diff --git a/dashboard/src/main/home/database-dashboard/tabs/Resources.tsx b/dashboard/src/main/home/database-dashboard/shared/Resources.tsx
similarity index 100%
rename from dashboard/src/main/home/database-dashboard/tabs/Resources.tsx
rename to dashboard/src/main/home/database-dashboard/shared/Resources.tsx
diff --git a/dashboard/src/main/home/database-dashboard/tabs/ConnectedAppsTab.tsx b/dashboard/src/main/home/database-dashboard/tabs/ConnectedAppsTab.tsx
new file mode 100644
index 0000000000..b6fdd9a47a
--- /dev/null
+++ b/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 (
+
+
+ Connected Apps
+
+
+
+ Credentials for this datastore are injected as environment variables to
+ connected apps.
+
+
+ ({
+ app: ra,
+ key: ra.source.name,
+ onSelect: () => {
+ history.push(
+ `/apps/${ra.source.name}?target=${ra.app_revision.deployment_target.id}`
+ );
+ },
+ }))}
+ />
+
+ {
+ setShowConnectAppsModal(true);
+ }}
+ >
+ add
+ Connect apps to this datastore
+
+ {showConnectAppsModal && (
+ {
+ setShowConnectAppsModal(false);
+ }}
+ apps={remainingApps}
+ onSubmit={async (appInstanceIds: string[]) => {
+ await attachDatastoreToAppInstances({
+ name: datastore.name,
+ clusterId,
+ appInstanceIds,
+ });
+ }}
+ />
+ )}
+
+ );
+};
+
+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;
+`;
diff --git a/dashboard/src/main/home/database-dashboard/tabs/DatabaseEnvTab.tsx b/dashboard/src/main/home/database-dashboard/tabs/DatabaseEnvTab.tsx
index 22c34f4b19..531596e636 100644
--- a/dashboard/src/main/home/database-dashboard/tabs/DatabaseEnvTab.tsx
+++ b/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 = ({ envData, connectionString }) => {
return keys;
};
- const renderLinkedApplications = (): JSX.Element => {
- if (envData.linked_applications.length === 0) {
- return (
-
- Linked Applications
-
-
- No applications are linked to the "{envData.name}" env
- group.
-
-
- );
- }
-
- return (
-
- Linked Applications
-
- {envData.linked_applications.map((appName, index) => (
-
- ))}
-
- );
- };
-
return (
@@ -79,7 +52,7 @@ const DatabaseEnvTab: React.FC = ({ envData, connectionString }) => {
values={setKeys()}
setValues={(_) => {}}
fileUpload={true}
- secretOption={true}
+ secretOption={false}
disabled={true}
/>
@@ -101,8 +74,6 @@ const DatabaseEnvTab: React.FC = ({ envData, connectionString }) => {
)}
-
- {renderLinkedApplications()}
);
};
diff --git a/dashboard/src/main/home/database-dashboard/tabs/DatabaseLinkedApp.tsx b/dashboard/src/main/home/database-dashboard/tabs/DatabaseLinkedApp.tsx
deleted file mode 100644
index 2100b6c700..0000000000
--- a/dashboard/src/main/home/database-dashboard/tabs/DatabaseLinkedApp.tsx
+++ /dev/null
@@ -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 = ({ appName }) => {
- return (
-
-
-
-
- {appName}
-
-
-
-
- open_in_new
-
-
-
-
- );
-};
-
-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;
- }
-`;
diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx
index 7c76ffeec3..675d30953c 100644
--- a/dashboard/src/shared/api.tsx
+++ b/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,