From 1b897d3a9f4573f9b33f065a23bfda9e9acd9a13 Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Fri, 19 Jan 2024 16:46:14 -0500 Subject: [PATCH] [POR-2202] allow users to link apps to their datastore from the dashboard (#4170) --- api/server/router/porter_app.go | 2 +- dashboard/src/assets/connect.svg | 3 + dashboard/src/lib/hooks/useDatabaseMethods.ts | 49 ++++- .../src/lib/hooks/useLatestAppRevisions.ts | 59 ++++++ dashboard/src/lib/revisions/types.ts | 1 + .../app-dashboard/apps/SelectableAppList.tsx | 122 +++++++++++++ .../v2/ConfigurableAppList.tsx | 49 +---- .../v2/setup-app/RequiredApps.tsx | 172 +++--------------- .../DatabaseContextProvider.tsx | 3 +- .../home/database-dashboard/DatabaseTabs.tsx | 15 +- .../forms/DatabaseFormAuroraPostgres.tsx | 2 +- .../forms/DatabaseFormElasticacheRedis.tsx | 2 +- .../forms/DatabaseFormRDSPostgres.tsx | 2 +- .../shared/ConnectAppsModal.tsx | 133 ++++++++++++++ .../{tabs => shared}/Resources.tsx | 0 .../tabs/ConnectedAppsTab.tsx | 132 ++++++++++++++ .../tabs/DatabaseEnvTab.tsx | 31 +--- .../tabs/DatabaseLinkedApp.tsx | 111 ----------- dashboard/src/shared/api.tsx | 13 ++ 19 files changed, 553 insertions(+), 348 deletions(-) create mode 100644 dashboard/src/assets/connect.svg create mode 100644 dashboard/src/lib/hooks/useLatestAppRevisions.ts create mode 100644 dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx create mode 100644 dashboard/src/main/home/database-dashboard/shared/ConnectAppsModal.tsx rename dashboard/src/main/home/database-dashboard/{tabs => shared}/Resources.tsx (100%) create mode 100644 dashboard/src/main/home/database-dashboard/tabs/ConnectedAppsTab.tsx delete mode 100644 dashboard/src/main/home/database-dashboard/tabs/DatabaseLinkedApp.tsx 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,