diff --git a/apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx b/apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx new file mode 100644 index 00000000000..5381917ff1d --- /dev/null +++ b/apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx @@ -0,0 +1,122 @@ +import { useLocation, useMatches } from '@tanstack/react-router' +import { Icon, InputSelect, Tooltip } from '@qovery/shared/ui' +import { GIT_BRANCH, GIT_SHA } from '@qovery/shared/util-node-env' +import { useUseCases } from './use-case-context' + +export function UseCaseBottomBar() { + const location = useLocation() + const matches = useMatches() + const routeId = matches[matches.length - 1]?.routeId + const scopeLabel = resolveScopeLabel(routeId) + const pageName = resolvePageName(routeId, location.pathname) + const pageLabel = `${scopeLabel} - ${pageName}` + + const { activePageId, optionsByPageId, selectionsByPageId, setSelection } = useUseCases() + const useCaseOptions = activePageId ? optionsByPageId[activePageId] ?? [] : [] + const selectedFromState = activePageId ? selectionsByPageId[activePageId] : undefined + const resolvedSelection = + selectedFromState && useCaseOptions.some((option) => option.id === selectedFromState) + ? selectedFromState + : useCaseOptions[0]?.id + + if (useCaseOptions.length === 0) { + return null + } + + const branchLabel = GIT_BRANCH || 'unknown' + const commitLabel = GIT_SHA ? GIT_SHA.slice(0, 7) : undefined + + return ( +
+
+
+
+ + + + + + Branch + + {branchLabel} + {commitLabel ? ` (${commitLabel})` : ''} + +
+ +
+ Page + + {pageLabel} + +
+ +
+ Use case + ({ + label: option.label, + value: option.id, + }))} + value={resolvedSelection} + onChange={(next) => { + if (activePageId && typeof next === 'string') { + setSelection(activePageId, next) + } + }} + className="min-w-0 flex-1 [&_.input-select__control]:!h-10 [&_.input-select__value-container]:!top-0 [&_.input-select__value-container]:!mt-0 [&_.input-select__value-container]:!h-10 [&_.input-select__value-container]:!items-center" + inputClassName="input--inline !min-h-0 !h-10 !border-0 !bg-transparent !px-0 !py-0 !hover:bg-transparent !outline-none focus-within:!outline-none !shadow-none" + valueClassName="text-xs font-mono text-neutral" + iconClassName="right-0" + /> +
+
+
+
+ ) +} + +export default UseCaseBottomBar + +function resolveScopeLabel(routeId?: string) { + if (!routeId) { + return 'Org' + } + + if (routeId.includes('/service/$serviceId')) { + return 'Service' + } + + if (routeId.includes('/environment/$environmentId')) { + return 'Env' + } + + if (routeId.includes('/project/$projectId')) { + return 'Project' + } + + if (routeId.includes('/organization/$organizationId')) { + return 'Org' + } + + return 'Org' +} + +function resolvePageName(routeId: string | undefined, pathname: string) { + if (routeId) { + const segments = routeId.split('/').filter(Boolean) + let lastSegment = segments[segments.length - 1] ?? 'index' + + if (lastSegment.startsWith('$')) { + lastSegment = segments[segments.length - 2] ?? lastSegment + } + + if (lastSegment === '_index' || lastSegment === 'index') { + return 'index' + } + + return lastSegment + } + + const pathSegments = pathname.split('/').filter(Boolean) + return pathSegments[pathSegments.length - 1] ?? 'index' +} diff --git a/apps/console-v5/src/app/components/use-cases/use-case-context.tsx b/apps/console-v5/src/app/components/use-cases/use-case-context.tsx new file mode 100644 index 00000000000..c1c69937a8b --- /dev/null +++ b/apps/console-v5/src/app/components/use-cases/use-case-context.tsx @@ -0,0 +1,159 @@ +import { + type ReactNode, + type SetStateAction, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' + +export type UseCaseOption = { + id: string + label: string +} + +type UseCaseContextValue = { + activePageId: string | null + optionsByPageId: Record + selectionsByPageId: Record + registerUseCases: (pageId: string, options: UseCaseOption[]) => void + setActivePageId: (pageId: SetStateAction) => void + setSelection: (pageId: string, selectionId: string) => void +} + +type UseCaseProviderProps = { + children: ReactNode +} + +type UseCasePageConfig = { + pageId: string + options: UseCaseOption[] + defaultCaseId?: string +} + +const STORAGE_KEY = 'qovery:use-cases' + +const UseCaseContext = createContext(undefined) + +const areOptionsEqual = (next: UseCaseOption[], prev: UseCaseOption[]) => + next.length === prev.length && + next.every((option, index) => option.id === prev[index]?.id && option.label === prev[index]?.label) + +const readSelections = () => { + if (typeof window === 'undefined') { + return {} + } + + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +export function UseCaseProvider({ children }: UseCaseProviderProps) { + const [activePageId, setActivePageId] = useState(null) + const [optionsByPageId, setOptionsByPageId] = useState>({}) + const [selectionsByPageId, setSelectionsByPageId] = useState>(readSelections) + + const registerUseCases = useCallback((pageId: string, options: UseCaseOption[]) => { + setOptionsByPageId((prev) => { + const existing = prev[pageId] + if (existing && areOptionsEqual(options, existing)) { + return prev + } + + return { + ...prev, + [pageId]: options, + } + }) + }, []) + + const setSelection = useCallback((pageId: string, selectionId: string) => { + setSelectionsByPageId((prev) => ({ + ...prev, + [pageId]: selectionId, + })) + }, []) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(selectionsByPageId)) + } catch { + // Ignore localStorage failures (private mode, quota, etc.) + } + }, [selectionsByPageId]) + + const value = useMemo( + () => ({ + activePageId, + optionsByPageId, + selectionsByPageId, + registerUseCases, + setActivePageId, + setSelection, + }), + [activePageId, optionsByPageId, registerUseCases, selectionsByPageId, setSelection] + ) + + return {children} +} + +export function useUseCases() { + const context = useContext(UseCaseContext) + + if (!context) { + throw new Error('useUseCases must be used within a UseCaseProvider') + } + + return context +} + +export function useUseCasePage({ pageId, options, defaultCaseId }: UseCasePageConfig) { + const { registerUseCases, setActivePageId, selectionsByPageId, setSelection } = useUseCases() + + useEffect(() => { + registerUseCases(pageId, options) + setActivePageId(pageId) + + return () => { + setActivePageId((current) => (current === pageId ? null : current)) + } + }, [options, pageId, registerUseCases, setActivePageId]) + + const selectedCaseId = useMemo(() => { + const selected = selectionsByPageId[pageId] + if (selected && options.some((option) => option.id === selected)) { + return selected + } + + if (defaultCaseId && options.some((option) => option.id === defaultCaseId)) { + return defaultCaseId + } + + return options[0]?.id ?? '' + }, [defaultCaseId, options, pageId, selectionsByPageId]) + + useEffect(() => { + if (!selectedCaseId) { + return + } + + if (selectionsByPageId[pageId] !== selectedCaseId) { + setSelection(pageId, selectedCaseId) + } + }, [pageId, selectedCaseId, selectionsByPageId, setSelection]) + + return { + selectedCaseId, + setSelectedCaseId: (nextId: string) => setSelection(pageId, nextId), + } +} diff --git a/apps/console-v5/src/routeTree.gen.ts b/apps/console-v5/src/routeTree.gen.ts index 12740b23a1e..23094516386 100644 --- a/apps/console-v5/src/routeTree.gen.ts +++ b/apps/console-v5/src/routeTree.gen.ts @@ -68,6 +68,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugSummar import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/resources' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/general' import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/features' +import { Route as AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/create/$slug/addons' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/network' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/image-registry' @@ -76,6 +77,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSetting import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/danger-zone' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' +import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/index' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdVariablesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployments' @@ -573,6 +575,15 @@ const AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute = AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute = + AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRouteImport.update( + { + id: '/addons', + path: '/addons', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute = AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRouteImport.update( { @@ -645,6 +656,15 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSet AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute = + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRouteImport.update( + { + id: '/addons', + path: '/addons', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdIndexRouteImport.update( { @@ -1214,6 +1234,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/cluster/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/organization/$organizationId/settings/roles': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute '/organization/$organizationId/cluster/$clusterId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRoute @@ -1222,6 +1243,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/cluster/$clusterId/settings/image-registry': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute '/organization/$organizationId/cluster/$clusterId/settings/network': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute '/organization/$organizationId/cluster/$clusterId/settings/resources': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute + '/organization/$organizationId/cluster/create/$slug/addons': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute '/organization/$organizationId/cluster/create/$slug/features': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute '/organization/$organizationId/cluster/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute '/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute @@ -1335,6 +1357,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/cluster/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/organization/$organizationId/settings/roles': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute '/organization/$organizationId/cluster/$clusterId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRoute @@ -1343,6 +1366,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/cluster/$clusterId/settings/image-registry': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute '/organization/$organizationId/cluster/$clusterId/settings/network': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute '/organization/$organizationId/cluster/$clusterId/settings/resources': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute + '/organization/$organizationId/cluster/create/$slug/addons': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute '/organization/$organizationId/cluster/create/$slug/features': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute '/organization/$organizationId/cluster/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute '/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute @@ -1459,6 +1483,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/cluster/$clusterId/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute '/_authenticated/organization/$organizationId/settings/roles/': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute + '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/danger-zone': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRoute @@ -1467,6 +1492,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/image-registry': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsImageRegistryRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/network': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsNetworkRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsResourcesRoute + '/_authenticated/organization/$organizationId/cluster/create/$slug/addons': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/features': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/general': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute '/_authenticated/organization/$organizationId/cluster/create/$slug/resources': typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute @@ -1589,6 +1615,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId' | '/organization/$organizationId/project/$projectId' | '/organization/$organizationId/settings/roles' + | '/organization/$organizationId/cluster/$clusterId/settings/addons' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/organization/$organizationId/cluster/$clusterId/settings/credentials' | '/organization/$organizationId/cluster/$clusterId/settings/danger-zone' @@ -1597,6 +1624,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId/settings/image-registry' | '/organization/$organizationId/cluster/$clusterId/settings/network' | '/organization/$organizationId/cluster/$clusterId/settings/resources' + | '/organization/$organizationId/cluster/create/$slug/addons' | '/organization/$organizationId/cluster/create/$slug/features' | '/organization/$organizationId/cluster/create/$slug/general' | '/organization/$organizationId/cluster/create/$slug/resources' @@ -1710,6 +1738,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId' | '/organization/$organizationId/project/$projectId' | '/organization/$organizationId/settings/roles' + | '/organization/$organizationId/cluster/$clusterId/settings/addons' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/organization/$organizationId/cluster/$clusterId/settings/credentials' | '/organization/$organizationId/cluster/$clusterId/settings/danger-zone' @@ -1718,6 +1747,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId/settings/image-registry' | '/organization/$organizationId/cluster/$clusterId/settings/network' | '/organization/$organizationId/cluster/$clusterId/settings/resources' + | '/organization/$organizationId/cluster/create/$slug/addons' | '/organization/$organizationId/cluster/create/$slug/features' | '/organization/$organizationId/cluster/create/$slug/general' | '/organization/$organizationId/cluster/create/$slug/resources' @@ -1833,6 +1863,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/cluster/$clusterId/' | '/_authenticated/organization/$organizationId/project/$projectId/' | '/_authenticated/organization/$organizationId/settings/roles/' + | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/danger-zone' @@ -1841,6 +1872,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/image-registry' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/network' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources' + | '/_authenticated/organization/$organizationId/cluster/create/$slug/addons' | '/_authenticated/organization/$organizationId/cluster/create/$slug/features' | '/_authenticated/organization/$organizationId/cluster/create/$slug/general' | '/_authenticated/organization/$organizationId/cluster/create/$slug/resources' @@ -2337,6 +2369,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute } + '/_authenticated/organization/$organizationId/cluster/create/$slug/addons': { + id: '/_authenticated/organization/$organizationId/cluster/create/$slug/addons' + path: '/addons' + fullPath: '/organization/$organizationId/cluster/create/$slug/addons' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRoute + } '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources': { id: '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources' path: '/resources' @@ -2393,6 +2432,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons': { + id: '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons' + path: '/addons' + fullPath: '/organization/$organizationId/cluster/$clusterId/settings/addons' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/' path: '/project/$projectId/environment/$environmentId' @@ -2901,6 +2947,7 @@ const AuthenticatedOrganizationOrganizationIdSettingsRouteRouteWithChildren = ) interface AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteChildren { + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsDangerZoneRoute @@ -2914,6 +2961,8 @@ interface AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRo const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteChildren: AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteChildren = { + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute: + AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute, AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute: AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute, AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsCredentialsRoute: @@ -2940,6 +2989,7 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsRouteRouteW ) interface AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteChildren { + AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute: typeof AuthenticatedOrganizationOrganizationIdClusterCreateSlugResourcesRoute @@ -2949,6 +2999,8 @@ interface AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteChil const AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteChildren: AuthenticatedOrganizationOrganizationIdClusterCreateSlugRouteRouteChildren = { + AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute: + AuthenticatedOrganizationOrganizationIdClusterCreateSlugAddonsRoute, AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute: AuthenticatedOrganizationOrganizationIdClusterCreateSlugFeaturesRoute, AuthenticatedOrganizationOrganizationIdClusterCreateSlugGeneralRoute: diff --git a/apps/console-v5/src/routes/__root.tsx b/apps/console-v5/src/routes/__root.tsx index a03381a75a4..d2db9047262 100644 --- a/apps/console-v5/src/routes/__root.tsx +++ b/apps/console-v5/src/routes/__root.tsx @@ -1,6 +1,8 @@ import { type QueryClient } from '@tanstack/react-query' import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import { ModalProvider, ToastBehavior } from '@qovery/shared/ui' +import { UseCaseBottomBar } from '../app/components/use-cases/use-case-bottom-bar' +import { UseCaseProvider } from '../app/components/use-cases/use-case-context' import { type Auth0ContextType } from '../auth/auth0' interface RouterContext { @@ -10,10 +12,13 @@ interface RouterContext { const RootLayout = () => { return ( - - - - + + + + + + + ) } diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx new file mode 100644 index 00000000000..2319080c5c8 --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx @@ -0,0 +1,770 @@ +import { createFileRoute, useParams } from '@tanstack/react-router' +import { type CloudProvider, type ClusterRegion, ServiceTypeEnum } from 'qovery-typescript-axios' +import { useEffect, useMemo, useState } from 'react' +import { match } from 'ts-pattern' +import { useCloudProviders } from '@qovery/domains/cloud-providers/feature' +import { + type SecretManagerAssociatedProject, + SecretManagerAssociatedServicesModal, + SecretManagerIntegrationModal, + useCluster, + useEditCluster, +} from '@qovery/domains/clusters/feature' +import { SettingsHeading } from '@qovery/shared/console-shared' +import { useUserRole } from '@qovery/shared/iam/feature' +import { + Badge, + Button, + DropdownMenu, + Icon, + IconFlag, + Indicator, + InputSelect, + Section, + useModal, + useModalConfirmation, +} from '@qovery/shared/ui' +import { type UseCaseOption, useUseCasePage } from '../../../../../../../app/components/use-cases/use-case-context' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons')( + { + component: RouteComponent, + } +) + +type SecretManagerOption = { + value: 'aws-manager' | 'aws-parameter' | 'gcp-secret' + label: string + icon: 'AWS' | 'GCP' + typeLabel: string +} + +type SecretManagerItem = { + id: string + name: string + typeLabel: string + authentication: 'Automatic' | 'Manual' + provider: 'AWS' | 'GCP' + source: SecretManagerOption['value'] + authType?: 'sts' | 'static' + region?: string + roleArn?: string + accessKey?: string + secretAccessKey?: string + usedByServices?: number + associatedItems?: SecretManagerAssociatedProject[] +} + +const SECRET_MANAGER_USE_CASES: UseCaseOption[] = [ + { id: 'delete-no-secrets', label: 'Delete (no secrets)' }, + { id: 'delete-used-no-other', label: 'Delete used (no other)' }, + { id: 'delete-used-one-other', label: 'Delete used (one other)' }, + { id: 'delete-used-multiple-other', label: 'Delete used (multiple other)' }, +] + +const SECRET_MANAGER_OPTIONS: SecretManagerOption[] = [ + { value: 'aws-manager', label: 'AWS Secret manager', icon: 'AWS', typeLabel: 'AWS Secret manager' }, + { value: 'aws-parameter', label: 'AWS Parameter store', icon: 'AWS', typeLabel: 'AWS Parameter store' }, + { value: 'gcp-secret', label: 'GCP Secret manager', icon: 'GCP', typeLabel: 'GCP Secret manager' }, +] + +function createSecretManagerAssociatedItems(totalServices: number): SecretManagerAssociatedProject[] { + const projects = [ + { project_id: 'project-platform', project_name: 'platform' }, + { project_id: 'project-billing', project_name: 'billing' }, + { project_id: 'project-observability', project_name: 'observability' }, + ] + const environments = [ + { environment_id: 'environment-production', environment_name: 'production' }, + { environment_id: 'environment-staging', environment_name: 'staging' }, + { environment_id: 'environment-development', environment_name: 'development' }, + ] + const services = ['api', 'worker', 'scheduler', 'ingest', 'web', 'batch', 'cron', 'admin', 'sync', 'processor'] + const serviceTypes = [ + ServiceTypeEnum.APPLICATION, + ServiceTypeEnum.CONTAINER, + ServiceTypeEnum.DATABASE, + ServiceTypeEnum.HELM, + ServiceTypeEnum.JOB, + ServiceTypeEnum.TERRAFORM, + ] + + const flatItems = Array.from({ length: totalServices }, (_, index) => { + const project = projects[index % projects.length] + const environment = environments[Math.floor(index / projects.length) % environments.length] + const serviceName = services[index % services.length] + const serviceType = serviceTypes[index % serviceTypes.length] + + return { + project_id: project.project_id, + project_name: project.project_name, + environment_id: environment.environment_id, + environment_name: environment.environment_name, + service_id: `${project.project_id}-${environment.environment_id}-${serviceName}-${index + 1}`, + service_name: `${serviceName}-${index + 1}`, + service_type: serviceType, + } + }) + + return flatItems.reduce((projectsAcc, item) => { + let project = projectsAcc.find((projectEntry) => projectEntry.project_id === item.project_id) + + if (!project) { + project = { + project_id: item.project_id, + project_name: item.project_name, + environments: [], + } + projectsAcc.push(project) + } + + let environment = project.environments.find( + (environmentEntry) => environmentEntry.environment_id === item.environment_id + ) + + if (!environment) { + environment = { + environment_id: item.environment_id, + environment_name: item.environment_name, + services: [], + } + project.environments.push(environment) + } + + environment.services.push({ + service_id: item.service_id, + service_name: item.service_name, + service_type: item.service_type, + }) + + return projectsAcc + }, []) +} + +const BASE_SECRET_MANAGERS: SecretManagerItem[] = [ + { + id: 'secret-manager-prod', + name: 'Prod secret manager', + typeLabel: 'AWS Secret manager', + authentication: 'Automatic', + provider: 'AWS' as const, + source: 'aws-manager', + usedByServices: 32, + associatedItems: createSecretManagerAssociatedItems(32), + }, + { + id: 'secret-manager-gcp-staging', + name: 'GCP staging secret manager', + typeLabel: 'GCP secret manager', + authentication: 'Manual', + provider: 'GCP' as const, + source: 'gcp-secret', + authType: 'static', + usedByServices: 0, + }, + { + id: 'secret-manager-parameter', + name: 'AWS Parameter store', + typeLabel: 'AWS Parameter store', + authentication: 'Manual', + provider: 'AWS' as const, + source: 'aws-parameter', + authType: 'sts', + usedByServices: 0, + }, +] + +type DeletionAction = 'migrate' | 'detach' | 'convert' + +function SecretManagerDeletionHelperModal({ + integration, + otherManagers, + onClose, + onConfirm, +}: { + integration: SecretManagerItem + otherManagers: SecretManagerItem[] + onClose: () => void + onConfirm: (action: DeletionAction, targetId?: string) => void +}) { + const [selectedAction, setSelectedAction] = useState(null) + const [targetId, setTargetId] = useState('') + + const hasOtherManagers = otherManagers.length > 0 + const hasMultipleManagers = otherManagers.length > 1 + const hasSingleManager = otherManagers.length === 1 + + const handleSelect = (action: DeletionAction) => { + setSelectedAction(action) + if (action === 'migrate' && hasSingleManager) { + setTargetId(otherManagers[0]?.id ?? '') + } + if (action !== 'migrate') { + setTargetId('') + } + } + + const canFinalize = + Boolean(selectedAction) && (!hasMultipleManagers || selectedAction !== 'migrate' || Boolean(targetId)) + + const cardBase = + 'flex w-full items-center gap-3 rounded-lg bg-background p-3 text-left outline outline-1 focus:outline focus:outline-1 shadow-[0_0_4px_0_rgba(0,0,0,0.01),0_2px_3px_0_rgba(0,0,0,0.02)]' + const iconBase = 'flex h-10 w-10 items-center justify-center rounded-md' + + return ( +
+
+

Deletion helper

+

+ "{integration.name}" is currently used by {integration.usedByServices ?? 0} services. Choose what you want to + do with the linked external secrets before before deleting it. +

+
+
+ {hasOtherManagers && hasMultipleManagers ? ( +
+ + {selectedAction === 'migrate' && ( +
+ setTargetId(value as string)} + options={otherManagers.map((manager) => ({ label: manager.name, value: manager.id }))} + portal + /> +
+ )} +
+ ) : ( + hasOtherManagers && ( + + ) + )} + + + + +
+
+ + +
+
+ ) +} + +function RouteComponent() { + const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const { data: cluster } = useCluster({ organizationId, clusterId }) + const { mutateAsync: editCluster, isLoading: isEditClusterLoading } = useEditCluster() + const { isQoveryAdminUser } = useUserRole() + const { openModal, closeModal } = useModal() + const { openModalConfirmation } = useModalConfirmation() + const { data: cloudProviders = [] } = useCloudProviders() + const { selectedCaseId } = useUseCasePage({ + pageId: 'cluster-settings-addons-secret-manager', + options: SECRET_MANAGER_USE_CASES, + defaultCaseId: 'delete-no-secrets', + }) + const currentProvider = useMemo( + () => cloudProviders.find((cloud) => cloud.short_name === cluster?.cloud_provider), + [cloudProviders, cluster?.cloud_provider] + ) + const regionOptions = useMemo( + () => + (currentProvider as CloudProvider | undefined)?.regions?.map((region: ClusterRegion) => ({ + label: `${region.city} (${region.name})`, + value: region.name, + icon: , + })) || [], + [currentProvider] + ) + const [observabilityEnabled, setObservabilityEnabled] = useState(false) + const [kedaEnabled, setKedaEnabled] = useState(false) + const isGcpCluster = cluster?.cloud_provider === 'GCP' + const baseSecretManagers = useMemo( + () => + match(selectedCaseId) + .with('delete-no-secrets', () => + BASE_SECRET_MANAGERS.slice(0, 2).map((manager) => ({ ...manager, usedByServices: 0 })) + ) + .with('delete-used-no-other', () => [{ ...BASE_SECRET_MANAGERS[0], usedByServices: 32 }]) + .with('delete-used-one-other', () => [ + { ...BASE_SECRET_MANAGERS[0], usedByServices: 32 }, + { ...BASE_SECRET_MANAGERS[1], usedByServices: 0 }, + ]) + .with('delete-used-multiple-other', () => [ + { ...BASE_SECRET_MANAGERS[0], usedByServices: 32 }, + { ...BASE_SECRET_MANAGERS[1], usedByServices: 0 }, + { ...BASE_SECRET_MANAGERS[2], usedByServices: 0 }, + ]) + .otherwise(() => BASE_SECRET_MANAGERS.slice(0, 2)), + [selectedCaseId] + ) + const [secretManagers, setSecretManagers] = useState(() => baseSecretManagers) + const secretManagerDropdownOptions = useMemo(() => { + if (cluster?.cloud_provider !== 'GCP') { + return SECRET_MANAGER_OPTIONS + } + + const gcpOption = SECRET_MANAGER_OPTIONS.find((option) => option.value === 'gcp-secret') + const awsOptions = SECRET_MANAGER_OPTIONS.filter((option) => option.value !== 'gcp-secret') + return gcpOption ? [gcpOption, ...awsOptions] : SECRET_MANAGER_OPTIONS + }, [cluster?.cloud_provider]) + const hasAwsAutomaticIntegrationConfigured = secretManagers.some( + (secretManager) => secretManager.provider === 'AWS' && secretManager.authentication === 'Automatic' + ) + const hasAwsManualStsIntegrationConfigured = secretManagers.some( + (secretManager) => + secretManager.provider === 'AWS' && secretManager.authentication === 'Manual' && secretManager.authType === 'sts' + ) + useEffect(() => { + if (cluster) { + setObservabilityEnabled(Boolean(cluster.metrics_parameters?.enabled)) + setKedaEnabled(Boolean(cluster.keda?.enabled)) + } + }, [cluster]) + + useEffect(() => { + setSecretManagers(baseSecretManagers) + }, [baseSecretManagers]) + + const getSecretManagerOption = (source: SecretManagerOption['value']) => + SECRET_MANAGER_OPTIONS.find((option) => option.value === source) ?? SECRET_MANAGER_OPTIONS[0] + + const openSecretManagerModal = (option: SecretManagerOption, integration?: SecretManagerItem) => { + openModal({ + content: ( + { + setSecretManagers((prev) => { + if (integration) { + return prev.map((item) => + item.id === integration.id + ? { + ...payload, + usedByServices: integration.usedByServices ?? 0, + associatedItems: integration.associatedItems, + } + : item + ) + } + return [...prev, { ...payload, usedByServices: 0 }] + }) + }} + /> + ), + options: { + width: 676, + fakeModal: true, + }, + }) + } + + const openSecretManagerAssociatedServicesModal = (integration: SecretManagerItem) => { + openModal({ + content: ( + + ), + options: { + fakeModal: true, + }, + }) + } + + const handleSave = async () => { + if (!cluster) return + + const cloneCluster = { + ...cluster, + keda: { + enabled: isGcpCluster ? false : kedaEnabled, + }, + } + + if (isQoveryAdminUser) { + if (observabilityEnabled) { + cloneCluster.metrics_parameters = { + enabled: observabilityEnabled, + configuration: { + kind: 'MANAGED_BY_QOVERY', + resource_profile: cloneCluster.metrics_parameters?.configuration?.resource_profile, + cloud_watch_export_config: { + ...cloneCluster.metrics_parameters?.configuration?.cloud_watch_export_config, + enabled: cloneCluster.metrics_parameters?.configuration?.cloud_watch_export_config?.enabled ?? false, + }, + high_availability: cloneCluster.metrics_parameters?.configuration?.high_availability, + internal_network_monitoring: cloneCluster.metrics_parameters?.configuration?.internal_network_monitoring, + alerting: { + ...cloneCluster.metrics_parameters?.configuration?.alerting, + enabled: cloneCluster.metrics_parameters?.configuration?.alerting?.enabled ?? false, + }, + }, + } + } else { + cloneCluster.metrics_parameters = { + enabled: false, + } + } + } + + try { + await editCluster({ + organizationId, + clusterId: cluster.id, + clusterRequest: cloneCluster, + }) + } catch (error) { + console.error(error) + } + } + + if (!cluster) { + return null + } + + const openDeletionHelper = (integration: SecretManagerItem) => { + const otherManagers = secretManagers.filter((item) => item.id !== integration.id) + openModal({ + content: ( + { + setSecretManagers((prev) => prev.filter((item) => item.id !== integration.id)) + closeModal() + }} + /> + ), + options: { + width: 488, + fakeModal: true, + }, + }) + } + + const handleDeleteSecretManager = (integration: SecretManagerItem) => { + openModalConfirmation({ + title: 'Delete secret manager', + name: integration.name, + confirmationMethod: 'action', + confirmationAction: 'delete', + action: () => { + const hasSecrets = (integration.usedByServices ?? 0) > 0 + if (hasSecrets) { + openDeletionHelper(integration) + } else { + setSecretManagers((prev) => prev.filter((item) => item.id !== integration.id)) + } + }, + }) + } + + return ( +
+
+ +
+
+
+
+
+
+ Qovery Observe + + $199/month + +
+

+ Install Prometheus and Loki and your cluster to access Qovery monitoring page. Follow your services + usage, create alerts and troubleshoot when any bug occurs. +

+
+
+ + +
+
+
+ {!isGcpCluster && ( +
+
+
+
+ KEDA autoscaler + + Free + +
+

+ Qovery KEDA autoscaler allows you to add event-based autoscaling on all the services running on + this cluster. +

+
+
+ + +
+
+
+ )} +
+
+
+
+ Secret manager integration + + Free + +
+

+ Link any secret manager on your cluster to add external secrets variables to all the services + running on your cluster +

+
+
+ + + + + + {secretManagerDropdownOptions.map((option) => ( + } + onSelect={() => openSecretManagerModal(option)} + > + {option.label} + + ))} + + + {secretManagers.length > 0 && ( +
+ {secretManagers.map((manager, index) => ( +
+
+ +
+

{manager.name}

+
+ + Type: {manager.typeLabel} + + + Authentication: {manager.authentication} + +
+
+
+
+ + {manager.usedByServices ?? 0} + + } + > + + + {manager.authentication !== 'Automatic' && ( + + )} + +
+
+ ))} +
+ )} +
+
+
+
+
+ +
+
+
+
+ ) +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route.tsx index 256d1043b3d..1c39f7c791b 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/route.tsx @@ -51,6 +51,12 @@ function RouteComponent() { icon: 'plug' as const, } + const addonsLink = { + title: 'Add-ons', + to: `${pathSettings}/addons`, + icon: 'puzzle-piece' as const, + } + const advancedSettingsLink = { title: 'Advanced settings', to: `${pathSettings}/advanced-settings`, @@ -76,7 +82,7 @@ function RouteComponent() { credentialsLink, ...(eksAnywhereCluster ? [] : [resourcesLink]), imageRegistryLink, - ...(eksAnywhereCluster ? [] : [networkLink]), + ...(eksAnywhereCluster ? [] : [networkLink, addonsLink]), advancedSettingsLink, dangerZoneLink, ] @@ -96,6 +102,7 @@ function RouteComponent() { credentialsLink, imageRegistryLink, networkLink, + addonsLink, advancedSettingsLink, dangerZoneLink, ]) diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/addons.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/addons.tsx new file mode 100644 index 00000000000..c7f786da0eb --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/addons.tsx @@ -0,0 +1,42 @@ +import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' +import { useEffect } from 'react' +import { StepAddons, useMaybeClusterContainerCreateContext } from '@qovery/domains/clusters/feature' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/create/$slug/addons')({ + component: Addons, +}) + +function Addons() { + useDocumentTitle('Add-ons - Create Cluster') + const { organizationId = '', slug } = useParams({ strict: false }) + const navigate = useNavigate() + const createContext = useMaybeClusterContainerCreateContext() + const generalData = createContext?.generalData + + const creationFlowUrl = `/organization/${organizationId}/cluster/create/${slug}` + const isAllowed = + generalData?.installation_type === 'MANAGED' && + (generalData?.cloud_provider === 'AWS' || generalData?.cloud_provider === 'GCP') + const shouldRedirect = Boolean(generalData) && !isAllowed + + useEffect(() => { + if (shouldRedirect) { + navigate({ to: `${creationFlowUrl}/summary` }) + } + }, [creationFlowUrl, navigate, shouldRedirect]) + + if (shouldRedirect) { + return null + } + + if (!createContext) { + return null + } + + const handleSubmit = () => { + navigate({ to: `${creationFlowUrl}/summary` }) + } + + return +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/features.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/features.tsx index 29a9b3619cb..7f3bbc287e5 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/features.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/features.tsx @@ -1,5 +1,5 @@ import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' -import { StepFeatures } from '@qovery/domains/clusters/feature' +import { StepFeatures, useClusterContainerCreateContext } from '@qovery/domains/clusters/feature' import { useDocumentTitle } from '@qovery/shared/util-hooks' export const Route = createFileRoute('/_authenticated/organization/$organizationId/cluster/create/$slug/features')({ @@ -10,10 +10,15 @@ function Features() { useDocumentTitle('Features - Create Cluster') const { organizationId = '', slug } = useParams({ strict: false }) const navigate = useNavigate() + const { generalData } = useClusterContainerCreateContext() const creationFlowUrl = `/organization/${organizationId}/cluster/create/${slug}` const handleSubmit = () => { + if (generalData?.cloud_provider === 'AWS' || generalData?.cloud_provider === 'GCP') { + navigate({ to: `${creationFlowUrl}/addons` }) + return + } navigate({ to: `${creationFlowUrl}/summary` }) } diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx index 85aa5d266a4..a0901e70131 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables.tsx @@ -1,14 +1,20 @@ import { createFileRoute, useParams } from '@tanstack/react-router' -import { Suspense } from 'react' +import { Suspense, useState } from 'react' import { match } from 'ts-pattern' -import { useDeployService, useService } from '@qovery/domains/services/feature' import { - ImportEnvironmentVariableModalFeature, - VariableList, - VariablesActionToolbar, -} from '@qovery/domains/variables/feature' -import { Heading, LoaderSpinner, Section, toast, useModal } from '@qovery/shared/ui' + BuiltInTab, + CustomTab, + EXTERNAL_SECRETS_USE_CASES, + ExternalSecretsTab, + type ExternalSecretsUseCaseId, + useDeployService, + useService, +} from '@qovery/domains/services/feature' +import { Heading, Icon, LoaderSpinner, Navbar, Section } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { useUseCasePage } from '../../../../../../../../../../app/components/use-cases/use-case-context' + +type VariableTab = 'custom' | 'external-secrets' | 'built-in' export const Route = createFileRoute( '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables' @@ -20,6 +26,13 @@ function RouteComponent() { const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) useDocumentTitle('Service - Variables') + const [activeTab, setActiveTab] = useState('custom') + const { selectedCaseId } = useUseCasePage({ + pageId: 'service-variables-external-secrets', + options: EXTERNAL_SECRETS_USE_CASES, + defaultCaseId: 'filled', + }) + const { data: service } = useService({ environmentId, serviceId, @@ -39,7 +52,6 @@ function RouteComponent() { projectId, environmentId, }) - const { openModal, closeModal } = useModal() const toasterCallback = () => { if (!service?.serviceType) { @@ -59,95 +71,66 @@ function RouteComponent() { } > -
+
Service variables - {scope && ( - - openModal({ - content: ( - - ), - options: { - width: 750, - }, - }) - } - onCreateVariable={() => - toast( - 'SUCCESS', - 'Creation success', - 'You need to redeploy your service for your changes to be applied.', - toasterCallback, - undefined, - 'Redeploy' - ) - } - /> - )}

- {scope && ( -
- { - toast( - 'SUCCESS', - 'Creation success', - 'You need to redeploy your service for your changes to be applied.', - toasterCallback, - undefined, - 'Redeploy' - ) - }} - onEditVariable={() => { - toast( - 'SUCCESS', - 'Edition success', - 'You need to redeploy your service for your changes to be applied.', - toasterCallback, - undefined, - 'Redeploy' - ) - }} - onDeleteVariable={(variable) => { - let name = variable.key - if (name && name.length > 30) { - name = name.substring(0, 30) + '...' - } - toast( - 'SUCCESS', - 'Deletion success', - `${name} has been deleted. You need to redeploy your service for your changes to be applied.`, - toasterCallback, - undefined, - 'Redeploy' - ) - }} - /> + + {/* Tabs + content */} +
+ {/* Tab strip — grey background */} +
+
+ + setActiveTab('custom')}> + + Custom + + setActiveTab('external-secrets')}> + + External secrets + + setActiveTab('built-in')}> + + Built-in + + +
- )} + + {/* Tab content */} +
+
+ {activeTab === 'custom' && scope && ( + + )} + {activeTab === 'external-secrets' && ( + + )} + {activeTab === 'built-in' && scope && ( + + )} +
+
+
diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables.tsx index f467b92b86d..c636ad38967 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables.tsx @@ -1,11 +1,18 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useParams } from '@tanstack/react-router' -import { Suspense } from 'react' +import { createFileRoute, useParams } from '@tanstack/react-router' +import { Suspense, useState } from 'react' import { useDeployEnvironment } from '@qovery/domains/environments/feature' +import { + EXTERNAL_SECRETS_USE_CASES, + ExternalSecretsTab, + type ExternalSecretsUseCaseId, +} from '@qovery/domains/services/feature' import { VariableList, VariablesActionToolbar } from '@qovery/domains/variables/feature' import { ENVIRONMENT_LOGS_URL, ENVIRONMENT_STAGES_URL } from '@qovery/shared/routes' -import { Heading, LoaderSpinner, Section, toast } from '@qovery/shared/ui' +import { Heading, Icon, LoaderSpinner, Navbar, Section, toast } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { useUseCasePage } from '../../../../../../../../app/components/use-cases/use-case-context' + +type VariableTab = 'custom' | 'external-secrets' | 'built-in' export const Route = createFileRoute( '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/variables' @@ -15,6 +22,12 @@ export const Route = createFileRoute( function RouteComponent() { const { organizationId = '', projectId = '', environmentId = '' } = useParams({ strict: false }) + const [activeTab, setActiveTab] = useState('custom') + const { selectedCaseId } = useUseCasePage({ + pageId: 'environment-variables-external-secrets', + options: EXTERNAL_SECRETS_USE_CASES, + defaultCaseId: 'filled', + }) useDocumentTitle('Services - Variables') @@ -40,65 +53,146 @@ function RouteComponent() {
Environment variables - - toast( - 'SUCCESS', - 'Creation success', - 'You need to redeploy your environment for your changes to be applied.', - toasterCallback, - undefined, - 'Redeploy' - ) - } - />

-
- { - toast( - 'SUCCESS', - 'Creation success', - 'You need to redeploy your environment for your changes to be applied.', - toasterCallback, - undefined, - 'Redeploy' - ) - }} - onEditVariable={() => { - toast( - 'SUCCESS', - 'Edition success', - 'You need to redeploy your environment for your changes to be applied.', - toasterCallback, - undefined, - 'Redeploy' - ) - }} - onDeleteVariable={(variable) => { - let name = variable.key - if (name && name.length > 30) { - name = name.substring(0, 30) + '...' - } - toast( - 'SUCCESS', - 'Deletion success', - `${name} has been deleted. You need to redeploy your environment for your changes to be applied.`, - toasterCallback, - undefined, - 'Redeploy' - ) - }} - /> + +
+
+
+ + setActiveTab('custom')}> + + Custom + + setActiveTab('external-secrets')}> + + External secrets + + setActiveTab('built-in')}> + + Built-in + + +
+
+ +
+
+ {activeTab === 'custom' && ( + + toast( + 'SUCCESS', + 'Creation success', + 'You need to redeploy your environment for your changes to be applied.', + toasterCallback, + undefined, + 'Redeploy' + ) + } + /> + } + scope="ENVIRONMENT" + organizationId={organizationId} + projectId={projectId} + environmentId={environmentId} + onCreateVariable={() => { + toast( + 'SUCCESS', + 'Creation success', + 'You need to redeploy your environment for your changes to be applied.', + toasterCallback, + undefined, + 'Redeploy' + ) + }} + onEditVariable={() => { + toast( + 'SUCCESS', + 'Edition success', + 'You need to redeploy your environment for your changes to be applied.', + toasterCallback, + undefined, + 'Redeploy' + ) + }} + onDeleteVariable={(variable) => { + let name = variable.key + if (name && name.length > 30) { + name = name.substring(0, 30) + '...' + } + toast( + 'SUCCESS', + 'Deletion success', + `${name} has been deleted. You need to redeploy your environment for your changes to be applied.`, + toasterCallback, + undefined, + 'Redeploy' + ) + }} + /> + )} + + {activeTab === 'external-secrets' && ( + + )} + + {activeTab === 'built-in' && ( + } + scope="ENVIRONMENT" + organizationId={organizationId} + projectId={projectId} + environmentId={environmentId} + onCreateVariable={() => { + toast( + 'SUCCESS', + 'Creation success', + 'You need to redeploy your environment for your changes to be applied.', + toasterCallback, + undefined, + 'Redeploy' + ) + }} + onEditVariable={() => { + toast( + 'SUCCESS', + 'Edition success', + 'You need to redeploy your environment for your changes to be applied.', + toasterCallback, + undefined, + 'Redeploy' + ) + }} + onDeleteVariable={(variable) => { + let name = variable.key + if (name && name.length > 30) { + name = name.substring(0, 30) + '...' + } + toast( + 'SUCCESS', + 'Deletion success', + `${name} has been deleted. You need to redeploy your environment for your changes to be applied.`, + toasterCallback, + undefined, + 'Redeploy' + ) + }} + /> + )} +
+
diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx index f5de16de240..0a061af3c7f 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/overview.tsx @@ -1,10 +1,32 @@ import { createFileRoute } from '@tanstack/react-router' -import { EnvironmentsTable } from '@qovery/domains/environments/feature' +import { Suspense } from 'react' +import { CLONE_MIGRATION_USE_CASES, EnvironmentsTable } from '@qovery/domains/environments/feature' +import { LoaderSpinner } from '@qovery/shared/ui' +import { useUseCasePage } from '../../../../../../app/components/use-cases/use-case-context' export const Route = createFileRoute('/_authenticated/organization/$organizationId/project/$projectId/overview')({ component: RouteComponent, }) function RouteComponent() { - return + const { selectedCaseId } = useUseCasePage({ + pageId: 'environment-clone-migration', + options: CLONE_MIGRATION_USE_CASES, + defaultCaseId: 'default', + }) + + return ( + + +
+ } + > + + + ) } diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/variables.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/variables.tsx index bf9fe3ca39f..b7ebac0505b 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/variables.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/variables.tsx @@ -1,7 +1,9 @@ import { createFileRoute, useParams } from '@tanstack/react-router' -import { Suspense } from 'react' +import { Suspense, useState } from 'react' import { VariableList, VariablesActionToolbar } from '@qovery/domains/variables/feature' -import { Heading, LoaderSpinner, Section, toast } from '@qovery/shared/ui' +import { Heading, Icon, LoaderSpinner, Navbar, Section, toast } from '@qovery/shared/ui' + +type VariableTab = 'custom' | 'built-in' export const Route = createFileRoute('/_authenticated/organization/$organizationId/project/$projectId/variables')({ component: RouteComponent, @@ -9,6 +11,7 @@ export const Route = createFileRoute('/_authenticated/organization/$organization function RouteComponent() { const { projectId = '' } = useParams({ strict: false }) + const [activeTab, setActiveTab] = useState('custom') return (
Project variables - toast('SUCCESS', 'Creation success')} - />

-
- { - toast('SUCCESS', 'Creation success') - }} - onEditVariable={() => { - toast('SUCCESS', 'Edition success') - }} - onDeleteVariable={() => { - toast('SUCCESS', 'Deletion success') - }} - /> + +
+
+
+ + setActiveTab('custom')}> + + Custom + + setActiveTab('built-in')}> + + Built-in + + +
+
+ +
+
+ {activeTab === 'custom' && ( + toast('SUCCESS', 'Creation success')} + /> + } + scope="PROJECT" + projectId={projectId} + onCreateVariable={() => { + toast('SUCCESS', 'Creation success') + }} + onEditVariable={() => { + toast('SUCCESS', 'Edition success') + }} + onDeleteVariable={() => { + toast('SUCCESS', 'Deletion success') + }} + /> + )} + {activeTab === 'built-in' && ( + } + scope="PROJECT" + projectId={projectId} + onCreateVariable={() => { + toast('SUCCESS', 'Creation success') + }} + onEditVariable={() => { + toast('SUCCESS', 'Edition success') + }} + onDeleteVariable={() => { + toast('SUCCESS', 'Deletion success') + }} + /> + )} +
+
diff --git a/apps/console-v5/vite.config.ts b/apps/console-v5/vite.config.ts index d7685a6f7bb..74f478ce5e8 100644 --- a/apps/console-v5/vite.config.ts +++ b/apps/console-v5/vite.config.ts @@ -1,4 +1,5 @@ /// +import { execSync } from 'child_process' import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin' import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin' import { tanstackRouter } from '@tanstack/router-plugin/vite' @@ -7,8 +8,34 @@ import { join } from 'path' import { defineConfig, loadEnv } from 'vite' import { viteStaticCopy } from 'vite-plugin-static-copy' +const readGitValue = (command: string): string | undefined => { + try { + const value = execSync(command, { + cwd: process.cwd(), + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim() + + return value || undefined + } catch { + return undefined + } +} + export default defineConfig(({ mode }) => { const clientEnv = loadEnv(mode, process.cwd(), '') + const gitBranch = + clientEnv.NX_PUBLIC_GIT_BRANCH || clientEnv.NX_BRANCH || readGitValue('git rev-parse --abbrev-ref HEAD') + const gitSha = clientEnv.NX_PUBLIC_GIT_SHA || readGitValue('git rev-parse --short HEAD') + + if (gitBranch) { + clientEnv.NX_PUBLIC_GIT_BRANCH = gitBranch + } + + if (gitSha) { + clientEnv.NX_PUBLIC_GIT_SHA = gitSha + } return { root: __dirname, diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index a01a44343e0..e6ad8d5e60c 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -29,8 +29,11 @@ export * from './lib/cluster-creation-flow/cluster-new/cluster-new' export * from './lib/cluster-creation-flow/step-general/step-general' export * from './lib/cluster-creation-flow/step-resources/step-resources' export * from './lib/cluster-creation-flow/step-features/step-features' +export * from './lib/cluster-creation-flow/step-addons/step-addons' export * from './lib/cluster-creation-flow/step-summary/step-summary' export * from './lib/cluster-creation-flow/cluster-creation-flow' +export * from './lib/secret-manager-modals/secret-manager-associated-services-modal' +export * from './lib/secret-manager-modals/secret-manager-integration-modal' export * from './lib/cluster-general-settings/cluster-general-settings' export * from './lib/cluster-credentials-settings/cluster-credentials-settings' export * from './lib/hooks/use-cluster-cloud-provider-info/use-cluster-cloud-provider-info' diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.spec.tsx index a4006cf562d..95cc1a57f22 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.spec.tsx @@ -54,8 +54,8 @@ describe('steps', () => { const result = steps(data) - expect(result).toHaveLength(4) - expect(result.map((s) => s.key)).toEqual(['general', 'resources', 'features', 'summary']) + expect(result).toHaveLength(5) + expect(result.map((s) => s.key)).toEqual(['general', 'resources', 'features', 'addons', 'summary']) }) it('should return GCP managed steps', () => { @@ -66,8 +66,8 @@ describe('steps', () => { const result = steps(data) - expect(result).toHaveLength(3) - expect(result.map((s) => s.key)).toEqual(['general', 'features', 'summary']) + expect(result).toHaveLength(4) + expect(result.map((s) => s.key)).toEqual(['general', 'features', 'addons', 'summary']) }) it('should return self-managed steps', () => { diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.tsx index 43f00086a99..5ac20cb9b3b 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/cluster-creation-flow.tsx @@ -31,9 +31,32 @@ export interface ClusterContainerCreateContextInterface { setFeaturesData: Dispatch> kubeconfigData: ClusterKubeconfigData | undefined setKubeconfigData: Dispatch> + addonsData: ClusterAddonsData + setAddonsData: Dispatch> creationFlowUrl: string } +export type ClusterAddonsSecretManager = { + id: string + name: string + typeLabel: string + authentication: 'Automatic' | 'Manual' + provider: 'AWS' | 'GCP' + source: 'aws-manager' | 'aws-parameter' | 'gcp-secret' + authType?: 'sts' | 'static' + gcpProjectId?: string + region?: string + roleArn?: string + accessKey?: string + secretAccessKey?: string +} + +export type ClusterAddonsData = { + observabilityActivated: boolean + kedaActivated: boolean + secretManagers: ClusterAddonsSecretManager[] +} + export const ClusterContainerCreateContext = createContext( undefined ) @@ -46,6 +69,8 @@ export const useClusterContainerCreateContext = () => { return clusterContainerCreateContext } +export const useMaybeClusterContainerCreateContext = () => useContext(ClusterContainerCreateContext) + export const steps = (clusterGeneralData?: ClusterGeneralData) => { return match(clusterGeneralData) .with({ installation_type: 'PARTIALLY_MANAGED' }, () => [ @@ -68,6 +93,7 @@ export const steps = (clusterGeneralData?: ClusterGeneralData) => { .with({ installation_type: 'MANAGED', cloud_provider: 'GCP' }, () => [ { title: 'Create new cluster', key: 'general' }, { title: 'Set features', key: 'features' }, + { title: 'Add-ons', key: 'addons' }, { title: 'Ready to install', key: 'summary' }, ]) .with({ installation_type: 'MANAGED', cloud_provider: 'AZURE' }, () => [ @@ -79,6 +105,7 @@ export const steps = (clusterGeneralData?: ClusterGeneralData) => { { title: 'Create new cluster', key: 'general' }, { title: 'Resources', key: 'resources' }, { title: 'Network', key: 'features' }, + { title: 'Add-ons', key: 'addons' }, { title: 'Ready to install', key: 'summary' }, ]) .otherwise(() => []) @@ -127,6 +154,11 @@ export function ClusterCreationFlow({ children }: PropsWithChildren) { features: {}, }) const [kubeconfigData, setKubeconfigData] = useState() + const [addonsData, setAddonsData] = useState({ + observabilityActivated: false, + kedaActivated: false, + secretManagers: [], + }) const navigate = useNavigate() @@ -180,6 +212,8 @@ export function ClusterCreationFlow({ children }: PropsWithChildren) { setFeaturesData, kubeconfigData, setKubeconfigData, + addonsData, + setAddonsData, creationFlowUrl, }} > diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx new file mode 100644 index 00000000000..1da832d13e0 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx @@ -0,0 +1,359 @@ +import { type CloudProvider, type ClusterRegion } from 'qovery-typescript-axios' +import { type FormEventHandler, useEffect, useMemo, useState } from 'react' +import { useCloudProviders } from '@qovery/domains/cloud-providers/feature' +import { + Badge, + Button, + DropdownMenu, + FunnelFlowBody, + Heading, + Icon, + IconFlag, + Link, + Section, + useModal, +} from '@qovery/shared/ui' +import { + SecretManagerIntegrationModal, + type SecretManagerOption, +} from '../../secret-manager-modals/secret-manager-integration-modal' +import { type ClusterAddonsSecretManager, steps, useClusterContainerCreateContext } from '../cluster-creation-flow' + +export interface StepAddonsProps { + organizationId: string + onSubmit: () => void +} + +interface StepAddonsFormProps { + onSubmit: () => void + organizationId: string + backTo: '/organization/$organizationId/cluster/create/$slug/features' +} + +type AddonAction = + | { + type: 'toggle' + label: string + } + | { + type: 'secret-manager' + label: string + } + +const SECRET_MANAGER_OPTIONS: SecretManagerOption[] = [ + { + value: 'aws-manager', + label: 'AWS Secret manager', + icon: 'AWS', + typeLabel: 'AWS Secret manager', + }, + { + value: 'aws-parameter', + label: 'AWS Parameter store', + icon: 'AWS', + typeLabel: 'AWS Parameter store', + }, + { + value: 'gcp-secret', + label: 'GCP Secret manager', + icon: 'GCP', + typeLabel: 'GCP Secret manager', + }, +] + +const ADDONS: Array<{ + id: string + title: string + badge: { label: string; color: 'yellow' | 'green' } + description: string + primaryAction: AddonAction + secondaryAction?: string +}> = [ + { + id: 'qovery-observe', + title: 'Qovery Observe', + badge: { label: '$199/month', color: 'yellow' as const }, + description: + 'Install Prometheus and Loki and your cluster to access Qovery monitoring page. Follow your services usage, create alerts and troubleshoot when any bug occurs.', + primaryAction: { label: 'Activate', type: 'toggle' }, + secondaryAction: 'More details', + }, + { + id: 'keda-autoscaler', + title: 'KEDA autoscaler', + badge: { label: 'Free', color: 'green' as const }, + description: + 'Qovery KEDA autoscaler allows you to add event-based autoscaling on all your services running on this cluster.', + primaryAction: { label: 'Activate', type: 'toggle' }, + secondaryAction: 'More details', + }, + { + id: 'secret-manager', + title: 'Secret manager integration', + badge: { label: 'Free', color: 'green' as const }, + description: + 'Link any secret manager on your cluster to add external secrets variables to all the services running on your cluster.', + primaryAction: { label: 'Add secret manager', type: 'secret-manager' }, + }, +] + +function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProps) { + const { openModal, closeModal } = useModal() + const { generalData, addonsData, setAddonsData } = useClusterContainerCreateContext() + const { data: cloudProviders = [] } = useCloudProviders() + const currentProvider = useMemo( + () => cloudProviders.find((cloud) => cloud.short_name === generalData?.cloud_provider), + [cloudProviders, generalData?.cloud_provider] + ) + const regionOptions = useMemo( + () => + (currentProvider as CloudProvider | undefined)?.regions?.map((region: ClusterRegion) => ({ + label: `${region.city} (${region.name})`, + value: region.name, + icon: , + })) || [], + [currentProvider] + ) + const [activatedAddons, setActivatedAddons] = useState>(() => ({ + 'qovery-observe': addonsData.observabilityActivated, + 'keda-autoscaler': addonsData.kedaActivated, + })) + const [integrations, setIntegrations] = useState(() => addonsData.secretManagers) + const hasAwsAutomaticIntegrationConfigured = integrations.some( + (integration) => integration.provider === 'AWS' && integration.authentication === 'Automatic' + ) + const hasAwsManualStsIntegrationConfigured = integrations.some( + (integration) => + integration.provider === 'AWS' && integration.authentication === 'Manual' && integration.authType === 'sts' + ) + const visibleAddons = useMemo( + () => (generalData?.cloud_provider === 'GCP' ? ADDONS.filter((addon) => addon.id !== 'keda-autoscaler') : ADDONS), + [generalData?.cloud_provider] + ) + const secretManagerDropdownOptions = useMemo(() => { + if (generalData?.cloud_provider !== 'GCP') { + return SECRET_MANAGER_OPTIONS + } + + const gcpOption = SECRET_MANAGER_OPTIONS.find((option) => option.value === 'gcp-secret') + const awsOptions = SECRET_MANAGER_OPTIONS.filter((option) => option.value !== 'gcp-secret') + return gcpOption ? [gcpOption, ...awsOptions] : SECRET_MANAGER_OPTIONS + }, [generalData?.cloud_provider]) + const handleFormSubmit: FormEventHandler = (event) => { + event.preventDefault() + onSubmit() + } + + useEffect(() => { + setAddonsData({ + observabilityActivated: Boolean(activatedAddons['qovery-observe']), + kedaActivated: generalData?.cloud_provider === 'GCP' ? false : Boolean(activatedAddons['keda-autoscaler']), + secretManagers: integrations, + }) + }, [activatedAddons, generalData?.cloud_provider, integrations, setAddonsData]) + + const getSecretManagerOption = (source: SecretManagerOption['value']) => + SECRET_MANAGER_OPTIONS.find((option) => option.value === source) ?? SECRET_MANAGER_OPTIONS[0] + + const openSecretManagerModal = (option: SecretManagerOption, integration?: ClusterAddonsSecretManager) => { + openModal({ + content: ( + { + setIntegrations((prev) => { + if (integration) { + return prev.map((item) => (item.id === integration.id ? { ...payload } : item)) + } + return [...prev, payload] + }) + }} + /> + ), + options: { + width: 676, + fakeModal: true, + }, + }) + } + + return ( +
+
+ Add-ons +

+ Add-ons are activable options that will grant you access to specific Qovery feature. You can activate or + deactivate them when you want. +

+
+ +
+
+ {visibleAddons.map((addon, index) => ( +
+
+
+ {addon.title} + + {addon.badge.label} + +
+

{addon.description}

+
+ {addon.primaryAction.type === 'toggle' ? ( +
+ + {addon.secondaryAction && ( + + )} +
+ ) : ( +
+ + + + + + {secretManagerDropdownOptions.map((option) => ( + } + onSelect={() => openSecretManagerModal(option)} + > + {option.label} + + ))} + + + {integrations.length > 0 && ( +
+ {integrations.map((integration, integrationIndex) => ( +
+
+ +
+

{integration.name}

+
+ + Type: {integration.typeLabel} + + + Authentication: {integration.authentication} + +
+
+
+
+ {integration.authentication === 'Manual' && ( + + )} + +
+
+ ))} +
+ )} +
+ )} +
+ ))} +
+ +
+ + Back + + +
+
+
+ ) +} + +export function StepAddons({ organizationId, onSubmit }: StepAddonsProps) { + const { setCurrentStep, generalData } = useClusterContainerCreateContext() + + useEffect(() => { + const stepIndex = steps(generalData).findIndex((step) => step.key === 'addons') + 1 + setCurrentStep(stepIndex) + }, [setCurrentStep, generalData]) + + const backTo = '/organization/$organizationId/cluster/create/$slug/features' as const + + return ( + + + + ) +} + +export default StepAddons diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.spec.tsx index 947f0aca140..a6cc7550584 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-features/step-features.spec.tsx @@ -28,6 +28,8 @@ const mockContextValue: ClusterContainerCreateContextInterface = { setFeaturesData: jest.fn(), kubeconfigData: undefined, setKubeconfigData: jest.fn(), + addonsData: { observabilityActivated: false, kedaActivated: false, secretManagers: [] }, + setAddonsData: jest.fn(), creationFlowUrl: '/organization/org-123/cluster/create/aws', } diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-general/step-general.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-general/step-general.spec.tsx index b4679061dc5..9d6f25fa130 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-general/step-general.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-general/step-general.spec.tsx @@ -22,6 +22,8 @@ const mockContextValue: ClusterContainerCreateContextInterface = { setFeaturesData: jest.fn(), kubeconfigData: undefined, setKubeconfigData: jest.fn(), + addonsData: { observabilityActivated: false, kedaActivated: false, secretManagers: [] }, + setAddonsData: jest.fn(), creationFlowUrl: '/organization/org-123/cluster/create/aws', } diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-general/step-general.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-general/step-general.tsx index 65a61558949..bedd067e437 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-general/step-general.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-general/step-general.tsx @@ -20,7 +20,7 @@ import { useDocumentTitle } from '@qovery/shared/util-hooks' import { upperCaseFirstLetter } from '@qovery/shared/util-js' import { ClusterCredentialsSettings } from '../../cluster-credentials-settings/cluster-credentials-settings' import { ClusterGeneralSettings } from '../../cluster-general-settings/cluster-general-settings' -import { defaultResourcesData, useClusterContainerCreateContext } from '../cluster-creation-flow' +import { defaultResourcesData, steps, useClusterContainerCreateContext } from '../cluster-creation-flow' export interface StepGeneralProps extends PropsWithChildren { organizationId: string @@ -31,7 +31,7 @@ export interface StepGeneralProps extends PropsWithChildren { export function StepGeneral({ organizationId, onSubmit, labelsSetting }: StepGeneralProps) { useDocumentTitle('General - Create Cluster') - const { generalData, setGeneralData, setResourcesData } = useClusterContainerCreateContext() + const { generalData, setGeneralData, setResourcesData, setCurrentStep } = useClusterContainerCreateContext() const methods = useForm({ defaultValues: { installation_type: 'LOCAL_DEMO', production: false, ...generalData }, @@ -45,6 +45,13 @@ export function StepGeneral({ organizationId, onSubmit, labelsSetting }: StepGen } }, [generalData, methods]) + useEffect(() => { + const stepIndex = steps(generalData).findIndex((step) => step.key === 'general') + 1 + if (stepIndex > 0) { + setCurrentStep(stepIndex) + } + }, [setCurrentStep, generalData]) + const { control, formState, watch } = methods const { data: cloudProviders = [] } = useCloudProviders() diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-resources/step-resources.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-resources/step-resources.spec.tsx index aded6ebb5b4..08e22152005 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-resources/step-resources.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-resources/step-resources.spec.tsx @@ -27,6 +27,8 @@ const mockContextValue: ClusterContainerCreateContextInterface = { setFeaturesData: jest.fn(), kubeconfigData: undefined, setKubeconfigData: jest.fn(), + addonsData: { observabilityActivated: false, kedaActivated: false, secretManagers: [] }, + setAddonsData: jest.fn(), creationFlowUrl: '/organization/org-123/cluster/create/aws', } diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.spec.tsx index b4381b79465..af553c94f0d 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.spec.tsx @@ -10,6 +10,7 @@ const mockGoToResources = jest.fn() const mockGoToKubeconfig = jest.fn() const mockGoToEksConfig = jest.fn() const mockGoToGeneral = jest.fn() +const mockGoToAddons = jest.fn() const defaultProps: StepSummaryPresentationProps = { onSubmit: mockOnSubmit, @@ -19,6 +20,7 @@ const defaultProps: StepSummaryPresentationProps = { goToKubeconfig: mockGoToKubeconfig, goToEksConfig: mockGoToEksConfig, goToGeneral: mockGoToGeneral, + goToAddons: mockGoToAddons, isLoadingCreate: false, isLoadingCreateAndDeploy: false, generalData: { @@ -33,6 +35,7 @@ const defaultProps: StepSummaryPresentationProps = { instance_type: 't3.medium', }, featuresData: { vpc_mode: 'DEFAULT', features: {} }, + addonsData: { observabilityActivated: false, kedaActivated: false, secretManagers: [] }, kubeconfigData: undefined, detailInstanceType: { type: 't3.medium', diff --git a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx index b88ce0056bf..0a2f7bb0b4f 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-summary/step-summary-presentation.tsx @@ -13,6 +13,7 @@ import { type Subnets, } from '@qovery/shared/interfaces' import { Button, Callout, ExternalLink, Heading, Icon, Section } from '@qovery/shared/ui' +import { type ClusterAddonsData } from '../cluster-creation-flow' export interface StepSummaryPresentationProps { onSubmit: (withDeploy: boolean) => void @@ -21,11 +22,13 @@ export interface StepSummaryPresentationProps { kubeconfigData?: ClusterKubeconfigData resourcesData: ClusterResourcesData featuresData?: ClusterFeaturesData + addonsData: ClusterAddonsData goToFeatures: () => void goToResources: () => void goToKubeconfig: () => void goToEksConfig: () => void goToGeneral: () => void + goToAddons: () => void isLoadingCreate: boolean isLoadingCreateAndDeploy: boolean detailInstanceType?: ClusterInstanceTypeResponseListResultsInner @@ -83,6 +86,9 @@ export function StepSummaryPresentation(props: StepSummaryPresentationProps) { .with('AWS', 'GCP', () => checkIfFeaturesAvailable()) .with('SCW', () => checkIfScwNetworkFeaturesAvailable()) .otherwise(() => false) + const showAddonsSection = + props.generalData.installation_type === 'MANAGED' && + (props.generalData.cloud_provider === 'AWS' || props.generalData.cloud_provider === 'GCP') return (
@@ -557,6 +563,70 @@ export function StepSummaryPresentation(props: StepSummaryPresentationProps) {
)} + {showAddonsSection && ( +
+
+ Add-ons +
    +
  • + Observability: + {props.addonsData.observabilityActivated ? 'activated' : 'not activated'} +
  • +
  • + KEDA autoscaler: + {props.addonsData.kedaActivated ? 'activated' : 'not activated'} +
  • +
  • + Secret manager: + {props.addonsData.secretManagers.length > 0 ? 'activated' : 'not activated'} +
  • +
+ + {props.addonsData.secretManagers.length > 0 && ( +
+ {props.addonsData.secretManagers.map((manager, index) => { + const authenticationLabel = + manager.authentication === 'Manual' + ? manager.authType === 'static' + ? 'Static credentials' + : 'Assume role via STS' + : 'Automatic' + + return ( +
+
+ {manager.name} +
+
+ Type: + {manager.typeLabel} + +
+
+ Authentication: + {authenticationLabel} +
+
+
+ {index < props.addonsData.secretManagers.length - 1 && ( +
+ )} +
+ ) + })} +
+ )} +
+ + +
+ )} +
+ +
+ + + ) + } + + if (isManualOnlyAwsIntegration) { + return ( + +
+
+

{`${option.label} integration`}

+

+ {`Link your ${option.icon === 'GCP' ? 'GCP' : 'AWS'} secret manager to use external secrets on all service running on your cluster`} +

+
+
{renderAwsManualIntegrationSections()}
+
+ + +
+
+
+ ) + } + + return ( + +
+
+

{`${option.label} integration`}

+

+ {`Link your ${option.icon === 'GCP' ? 'GCP' : 'AWS'} secret manager to use external secrets on all service running on your cluster`} +

+
+ + {showAutomaticTabFirst ? ( + <> + setActiveTab('automatic')}> + + Automatic integration + + setActiveTab('manual')}> + + Manual integration + + + ) : ( + <> + setActiveTab('manual')}> + + Manual integration + + + event.preventDefault()} + > + + Automatic integration + + + + )} + +
+
+
+ {activeTab === 'automatic' && ( +
+
+

Automatic integration

+

+ Qovery will use the cluster’s credentials to configure access to your Secrets Manager automatically +

+
+
+ {option.icon === 'GCP' && ( + ( + + )} + /> + )} + ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> +
+ {option.icon === 'AWS' && ( + + + + + + Automatic integration requires the secret manager to be in the same AWS account as the cluster + + + )} + {option.icon === 'GCP' && ( + + + + + + Automatic integration requires the secret manager to be in the same GCP account as the cluster + + + )} +
+ )} + + {activeTab === 'manual' && + (isGcpManualTabOnGcpSecretManager ? ( +
{renderGcpManualIntegrationSections()}
+ ) : ( + renderAwsManualIntegrationSections() + ))} +
+
+ + +
+
+
+ ) +} diff --git a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx index fc0d9b52804..1f36fed2765 100644 --- a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx +++ b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx @@ -1,16 +1,17 @@ import { useNavigate } from '@tanstack/react-router' import { + type Cluster, type CreateEnvironmentModeEnum, type Environment, EnvironmentModeEnum, KubernetesEnum, } from 'qovery-typescript-axios' -import { type FormEvent } from 'react' +import { type FormEvent, useMemo, useState } from 'react' import { Controller, FormProvider, useForm } from 'react-hook-form' import { P, match } from 'ts-pattern' import { useClusters } from '@qovery/domains/clusters/feature' import { useProjects } from '@qovery/domains/projects/feature' -import { ExternalLink, Icon, InputSelect, InputText, ModalCrud, useModal } from '@qovery/shared/ui' +import { Button, Callout, ExternalLink, Icon, InputSelect, InputText, ModalCrud, useModal } from '@qovery/shared/ui' import { EnvironmentMode } from '../environment-mode/environment-mode' import { useCloneEnvironment } from '../hooks/use-clone-environment/use-clone-environment' import { useCreateEnvironment } from '../hooks/use-create-environment/use-create-environment' @@ -21,6 +22,360 @@ export interface CreateCloneEnvironmentModalProps { environmentToClone?: Environment onClose: () => void type?: EnvironmentModeEnum + cloneUseCaseId?: string +} + +type SecretManagerDescriptor = { + id: string + name: string + provider: 'AWS' | 'GCP' +} + +type CloneMigrationAction = 'migrate' | 'detach' | 'convert' +export type CloneMigrationUseCaseOption = { + id: string + label: string +} +type CloneUseCaseOverrides = { + forceDifferentCluster?: boolean + forceSelfManaged?: boolean + sourceManagers?: SecretManagerDescriptor[] + targetManagers?: SecretManagerDescriptor[] +} + +const SECRET_MANAGER_FIXTURES = { + awsProd: { id: 'secret-manager-aws-prod', name: 'Prod secret manager', provider: 'AWS' as const }, + awsParameter: { id: 'secret-manager-aws-parameter', name: 'AWS Parameter store', provider: 'AWS' as const }, + gcpStaging: { id: 'secret-manager-gcp-staging', name: 'GCP staging secret manager', provider: 'GCP' as const }, +} + +const SOURCE_MANAGERS_SINGLE: SecretManagerDescriptor[] = [SECRET_MANAGER_FIXTURES.gcpStaging] +const SOURCE_MANAGERS_MULTIPLE: SecretManagerDescriptor[] = [ + SECRET_MANAGER_FIXTURES.gcpStaging, + SECRET_MANAGER_FIXTURES.awsProd, +] +const TARGET_MANAGERS_SINGLE: SecretManagerDescriptor[] = [SECRET_MANAGER_FIXTURES.gcpStaging] +const TARGET_MANAGERS_MULTIPLE: SecretManagerDescriptor[] = [ + SECRET_MANAGER_FIXTURES.awsProd, + SECRET_MANAGER_FIXTURES.awsParameter, +] + +export const CLONE_MIGRATION_USE_CASES: CloneMigrationUseCaseOption[] = [ + { id: 'default', label: 'Default' }, + { id: 'self-managed', label: 'Self-managed target' }, + { id: 'single-target', label: 'Different cluster (1 manager each)' }, + { id: 'multi-target', label: 'Different cluster (multiple targets)' }, + { id: 'multi-source', label: 'Different cluster (multiple sources)' }, +] + +const getClusterSecretManagers = (cluster?: Cluster, scope: 'source' | 'target' = 'target') => { + if (!cluster || cluster.kubernetes === KubernetesEnum.SELF_MANAGED) return [] + if (cluster.cloud_provider === 'AWS') { + return scope === 'source' ? SOURCE_MANAGERS_MULTIPLE : TARGET_MANAGERS_MULTIPLE + } + return scope === 'source' ? SOURCE_MANAGERS_SINGLE : TARGET_MANAGERS_SINGLE +} + +function CloneMigrationHelperModal({ + targetManagers, + detectedTargetName, + onClose, + onConfirm, +}: { + targetManagers: SecretManagerDescriptor[] + detectedTargetName?: string + onClose: () => void + onConfirm: (action: CloneMigrationAction, targetId?: string) => void +}) { + const [selectedAction, setSelectedAction] = useState(null) + const [targetId, setTargetId] = useState('') + + const hasMultipleTargets = targetManagers.length > 1 + + const handleSelect = (action: CloneMigrationAction) => { + setSelectedAction(action) + if (action !== 'migrate') { + setTargetId('') + } + } + + const canFinalize = + Boolean(selectedAction) && (!hasMultipleTargets || selectedAction !== 'migrate' || Boolean(targetId)) + + const cardBase = + 'flex w-full items-center gap-3 rounded-lg bg-background p-3 text-left outline outline-1 focus:outline focus:outline-1 shadow-[0_0_4px_0_rgba(0,0,0,0.01),0_2px_3px_0_rgba(0,0,0,0.02)]' + const iconBase = 'flex h-10 w-10 items-center justify-center rounded-md' + + return ( +
+
+

Migration helper

+

+ You’re about to clone a environment that contains external secrets from a secret manager on a new cluster. + Please choose you’re preferred options. +

+
+
+ {hasMultipleTargets ? ( + selectedAction === 'migrate' ? ( +
+ +
+ setTargetId(value as string)} + options={targetManagers.map((manager) => ({ label: manager.name, value: manager.id }))} + portal + /> +
+
+ ) : ( + + ) + ) : ( + + )} + + + + +
+
+ + +
+
+ ) +} + +function CloneMigrationTableModal({ + sourceManagers, + targetManagers, + onClose, + onConfirm, +}: { + sourceManagers: SecretManagerDescriptor[] + targetManagers: SecretManagerDescriptor[] + onClose: () => void + onConfirm: (mapping: Record) => void +}) { + const [mapping, setMapping] = useState>({}) + const targetOptions = useMemo( + () => [ + ...targetManagers.map((item) => ({ + label: item.name, + value: item.id, + icon: , + })), + { + label: 'Detach all references', + value: '__detach_all__', + icon: , + }, + { + label: 'Convert to empty Qovery secrets', + value: '__convert_qovery__', + icon: , + }, + ], + [targetManagers] + ) + + const isComplete = sourceManagers.every((manager) => Boolean(mapping[manager.id])) + + return ( +
+
+

Migration helper

+

+ You’re about to clone a environment that contains external secrets from a secret manager on a new cluster. For + each detected secret manager please choose you’re preferred option. +

+
+
+
+
+
+ Initial source +
+
Migration target
+
+ {sourceManagers.map((manager, index) => ( +
+
+ null} + inputClassName="input--inline" + options={sourceManagers.map((item) => ({ + label: item.name, + value: item.id, + icon: , + }))} + portal + disabled + /> +
+
+ + setMapping((current) => ({ + ...current, + [manager.id]: value as string, + })) + } + inputClassName="input--inline" + options={targetOptions} + portal + /> +
+
+ ))} +
+
+
+ + +
+
+ ) } export function CreateCloneEnvironmentModal({ @@ -29,14 +384,22 @@ export function CreateCloneEnvironmentModal({ environmentToClone, onClose, type, + cloneUseCaseId, }: CreateCloneEnvironmentModalProps) { const navigate = useNavigate() const { enableAlertClickOutside } = useModal() const { data: clusters = [] } = useClusters({ organizationId }) const { data: projects = [] } = useProjects({ organizationId }) + const selectedCaseId = cloneUseCaseId ?? 'default' const { mutateAsync: createEnvironment, isLoading: isCreateEnvironmentLoading } = useCreateEnvironment() const { mutateAsync: cloneEnvironment, isLoading: isCloneEnvironmentLoading } = useCloneEnvironment() + const [migrationModal, setMigrationModal] = useState<{ + type: 'helper' | 'table' + payload: { name: string; cluster: string; mode: EnvironmentModeEnum; project_id: string } + sourceManagers: SecretManagerDescriptor[] + targetManagers: SecretManagerDescriptor[] + } | null>(null) const methods = useForm({ mode: 'onChange', @@ -48,22 +411,105 @@ export function CreateCloneEnvironmentModal({ }, }) + const selectedClusterId = methods.watch('cluster') + const selectedCluster = useMemo( + () => clusters.find((cluster) => cluster.id === selectedClusterId), + [clusters, selectedClusterId] + ) + const sourceCluster = useMemo( + () => clusters.find((cluster) => cluster.id === environmentToClone?.cluster_id), + [clusters, environmentToClone?.cluster_id] + ) + const isSelfManagedTarget = selectedCluster?.kubernetes === KubernetesEnum.SELF_MANAGED + const useCaseOverrides = useMemo(() => { + return match(selectedCaseId) + .with('self-managed', () => ({ + forceDifferentCluster: true, + forceSelfManaged: true, + sourceManagers: SOURCE_MANAGERS_SINGLE, + targetManagers: [], + })) + .with('single-target', () => ({ + forceDifferentCluster: true, + sourceManagers: SOURCE_MANAGERS_SINGLE, + targetManagers: TARGET_MANAGERS_SINGLE, + })) + .with('multi-target', () => ({ + forceDifferentCluster: true, + sourceManagers: SOURCE_MANAGERS_SINGLE, + targetManagers: TARGET_MANAGERS_MULTIPLE, + })) + .with('multi-source', () => ({ + forceDifferentCluster: true, + sourceManagers: SOURCE_MANAGERS_MULTIPLE, + targetManagers: TARGET_MANAGERS_MULTIPLE, + })) + .otherwise(() => ({})) + }, [selectedCaseId]) + + const resolvedIsSelfManaged = useCaseOverrides.forceSelfManaged ?? isSelfManagedTarget + const resolvedSourceManagers = useCaseOverrides.sourceManagers ?? getClusterSecretManagers(sourceCluster, 'source') + const resolvedTargetManagers = useCaseOverrides.targetManagers ?? getClusterSecretManagers(selectedCluster, 'target') + const resolvedIsDifferentCluster = + useCaseOverrides.forceDifferentCluster ?? + Boolean(sourceCluster && selectedCluster && sourceCluster.id !== selectedCluster.id) + methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) + + const executeClone = async (payload: { + name: string + cluster: string + mode: EnvironmentModeEnum + project_id: string + }) => { + const result = await cloneEnvironment({ + environmentId: environmentToClone?.id ?? '', + payload: { + name: payload.name, + mode: payload.mode, + cluster_id: payload.cluster, + project_id: payload.project_id, + }, + }) + + navigate({ + to: `/organization/${organizationId}/project/${payload.project_id}/environment/${result.id}/overview`, + }) + onClose() + } + const onSubmit = methods.handleSubmit(async ({ name, cluster, mode, project_id }) => { + if (!cluster) { + return + } + if (environmentToClone) { - const result = await cloneEnvironment({ - environmentId: environmentToClone.id, - payload: { - name, - mode: mode as EnvironmentModeEnum, - cluster_id: cluster, - project_id, - }, - }) + const payload = { + name, + cluster, + mode: mode as EnvironmentModeEnum, + project_id, + } - navigate({ - to: `/organization/${organizationId}/project/${project_id}/environment/${result.id}/overview`, - }) + const sourceManagers = resolvedSourceManagers + const targetManagers = resolvedTargetManagers + + if ( + resolvedIsDifferentCluster && + !resolvedIsSelfManaged && + sourceManagers.length > 0 && + targetManagers.length > 0 + ) { + setMigrationModal({ + type: sourceManagers.length > 1 ? 'table' : 'helper', + payload, + sourceManagers, + targetManagers, + }) + return + } + + await executeClone(payload) } else { const result = await createEnvironment({ projectId: project_id, @@ -76,8 +522,8 @@ export function CreateCloneEnvironmentModal({ navigate({ to: `/organization/${organizationId}/project/${project_id}/environment/${result.id}/overview`, }) + onClose() } - onClose() }) const environmentModes = [ @@ -100,178 +546,213 @@ export function CreateCloneEnvironmentModal({ return ( - -
- It creates a new environment having the same configuration of the source environment. All the - configurations will be copied within the new environment except for the custom domains defined on the - services. The environment will be cloned on the selected cluster and with the selected type. Once - cloned, you will be able to deploy it. -
- - Documentation - - - ) : ( - <> -
- Create a new environment to deploy your applications. You can create a new environment by defining: -
-
    -
  1. its name
  2. -
  3. - the cluster: you can select one of the existing clusters. Cluster can't be changed after the - environment creation. -
  4. -
  5. - the type: it defines the type of environment you are creating among Production, Staging, Development. -
  6. -
- - Documentation - - - ) - } - > - {environmentToClone && ( - - )} - setMigrationModal(null)} + onConfirm={() => { + const payload = migrationModal.payload + setMigrationModal(null) + executeClone(payload) + }} + /> + ) : migrationModal?.type === 'helper' ? ( + setMigrationModal(null)} + onConfirm={() => { + const payload = migrationModal.payload + setMigrationModal(null) + executeClone(payload) }} - render={({ field, fieldState: { error } }) => ( + /> + ) : ( + +
+ It creates a new environment having the same configuration of the source environment. All the + configurations will be copied within the new environment except for the custom domains defined on the + services. The environment will be cloned on the selected cluster and with the selected type. Once + cloned, you will be able to deploy it. +
+ + Documentation + + + ) : ( + <> +
+ Create a new environment to deploy your applications. You can create a new environment by defining: +
+
    +
  1. its name
  2. +
  3. + the cluster: you can select one of the existing clusters. Cluster can't be changed after the + environment creation. +
  4. +
  5. + the type: it defines the type of environment you are creating among Production, Staging, + Development. +
  6. +
+ + Documentation + + + ) + } + > + {environmentToClone && ( ) => { - field.onChange(event.currentTarget.value) + name="clone" + value={environmentToClone.name} + label="Environment to clone" + disabled={true} + /> + )} + ( + ) => { + field.onChange(event.currentTarget.value) + }} + value={field.value} + label={environmentToClone?.name ? 'New environment name' : 'Environment name'} + error={error?.message} + /> + )} + /> + {environmentToClone && ( + ( + ({ + value: p.id, + label: p.name, + }))} + portal + /> + )} /> )} - /> - {environmentToClone && ( ( ({ - value: p.id, - label: p.name, - }))} - portal + options={ + clusters?.map((c) => { + const clusterType = match([c.cloud_provider, c.kubernetes]) + .with(['AWS', KubernetesEnum.MANAGED], ['AWS', undefined], () => 'Managed (EKS)') + .with(['AWS', KubernetesEnum.SELF_MANAGED], ['AWS', undefined], () => 'Self-managed') + .with( + ['AWS', KubernetesEnum.PARTIALLY_MANAGED], + ['AWS', undefined], + () => 'Partially managed (EKS Anywhere)' + ) + .with(['SCW', P._], () => 'Managed (Kapsule)') + .with(['GCP', KubernetesEnum.SELF_MANAGED], () => 'Self-managed') + .with(['GCP', P._], () => 'GKE (Autopilot)') + .with(['ON_PREMISE', P._], () => 'On-premise') + .with(['AZURE', KubernetesEnum.SELF_MANAGED], () => 'Self-managed') + .with(['AZURE', P._], () => 'Azure') + .with(['DO', P._], () => 'DO') + .with(['OVH', P._], () => 'OVH') + .with(['CIVO', P._], () => 'CIVO') + .with(['HETZNER', P._], () => 'Hetzner') + .with(['ORACLE', P._], () => 'Oracle') + .with(['IBM', P._], () => 'IBM') + .exhaustive() + + return { + value: c.id, + label: `${c.name} - ${clusterType}`, + icon: , + } + }) ?? [] + } + portal={true} /> )} /> - )} - ( - { - const clusterType = match([c.cloud_provider, c.kubernetes]) - .with(['AWS', KubernetesEnum.MANAGED], ['AWS', undefined], () => 'Managed (EKS)') - .with(['AWS', KubernetesEnum.SELF_MANAGED], ['AWS', undefined], () => 'Self-managed') - .with( - ['AWS', KubernetesEnum.PARTIALLY_MANAGED], - ['AWS', undefined], - () => 'Partially managed (EKS Anywhere)' - ) - .with(['SCW', P._], () => 'Managed (Kapsule)') - .with(['GCP', KubernetesEnum.SELF_MANAGED], () => 'Self-managed') - .with(['GCP', P._], () => 'GKE (Autopilot)') - .with(['ON_PREMISE', P._], () => 'On-premise') - .with(['AZURE', KubernetesEnum.SELF_MANAGED], () => 'Self-managed') - .with(['AZURE', P._], () => 'Azure') - .with(['DO', P._], () => 'DO') - .with(['OVH', P._], () => 'OVH') - .with(['CIVO', P._], () => 'CIVO') - .with(['HETZNER', P._], () => 'Hetzner') - .with(['ORACLE', P._], () => 'Oracle') - .with(['IBM', P._], () => 'IBM') - .exhaustive() - - return { - value: c.id, - label: `${c.name} - ${clusterType}`, - icon: , - } - }) ?? [] - } - portal={true} - /> - )} - /> - ( - + {environmentToClone && resolvedIsSelfManaged && ( + + + + + + You are about to clone a environment containing external secrets to a self-manage cluster. All external + secrets will be converted to empty Qovery secrets upon cloning. + + )} - /> -
+ ( + + )} + /> +
+ )}
) } diff --git a/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx b/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx index 7bf5d272867..e0a8d369d23 100644 --- a/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx +++ b/libs/domains/environments/feature/src/lib/environment-action-toolbar/environment-action-toolbar.tsx @@ -246,10 +246,12 @@ export function MenuManageDeployment({ export function MenuOtherActions({ state, environment, + cloneUseCaseId, variant = 'default', }: { state: StateEnum environment: Environment + cloneUseCaseId?: string variant?: ActionToolbarVariant }) { const { openModal, closeModal } = useModal() @@ -274,6 +276,7 @@ export function MenuOtherActions({ } const openCloneModal = () => { + const modalWidth = cloneUseCaseId === 'multi-source' ? 676 : undefined openModal({ content: ( ), options: { fakeModal: true, + ...(modalWidth ? { width: modalWidth } : {}), }, }) } @@ -348,9 +353,14 @@ export function MenuOtherActions({ export interface EnvironmentActionToolbarProps { environment: Environment variant?: ActionToolbarVariant + cloneUseCaseId?: string } -export function EnvironmentActionToolbar({ environment, variant = 'default' }: EnvironmentActionToolbarProps) { +export function EnvironmentActionToolbar({ + environment, + variant = 'default', + cloneUseCaseId, +}: EnvironmentActionToolbarProps) { const { data: countServices, isFetched: isFetchedServices } = useServiceCount({ environmentId: environment.id }) const { data: deploymentStatus } = useDeploymentStatus({ environmentId: environment.id }) @@ -379,7 +389,7 @@ export function EnvironmentActionToolbar({ environment, variant = 'default' }: E */} - + )} diff --git a/libs/domains/environments/feature/src/lib/environment-list/environment-list.tsx b/libs/domains/environments/feature/src/lib/environment-list/environment-list.tsx index 866f4635ec8..bf18c2c2a4d 100644 --- a/libs/domains/environments/feature/src/lib/environment-list/environment-list.tsx +++ b/libs/domains/environments/feature/src/lib/environment-list/environment-list.tsx @@ -39,7 +39,7 @@ import { EnvironmentListSkeleton } from './environment-list-skeleton' const { Table } = TablePrimitives -function EnvironmentNameCell({ environment }: { environment: Environment }) { +function EnvironmentNameCell({ environment, cloneUseCaseId }: { environment: Environment; cloneUseCaseId?: string }) { return (
@@ -66,7 +66,7 @@ function EnvironmentNameCell({ environment }: { environment: Environment }) {
e.stopPropagation()}> - +
@@ -139,15 +139,16 @@ function EnvironmentStatusCell({ export interface EnvironmentListProps extends ComponentProps { project: Project clusterAvailable: boolean + cloneUseCaseId?: string } -export function EnvironmentList({ project, clusterAvailable, className, ...props }: EnvironmentListProps) { +export function EnvironmentList({ project, clusterAvailable, cloneUseCaseId, className, ...props }: EnvironmentListProps) { const { data: environments = [], isLoading: isEnvironmentsLoading } = useEnvironments({ projectId: project.id }) const [sorting, setSorting] = useState([]) const navigate = useNavigate() const { openModal, closeModal } = useModal() - const columnHelper = createColumnHelper<(typeof environments)[number]>() + const columnHelper = useMemo(() => createColumnHelper<(typeof environments)[number]>(), []) const columns = useMemo( () => [ columnHelper.accessor('mode', { @@ -167,7 +168,7 @@ export function EnvironmentList({ project, clusterAvailable, className, ...props }, }, cell: (info) => { - return + return }, }), columnHelper.accessor('runningStatus.stateLabel', { @@ -270,7 +271,7 @@ export function EnvironmentList({ project, clusterAvailable, className, ...props }, }), ], - [] + [cloneUseCaseId, columnHelper] ) const table = useReactTable({ @@ -318,10 +319,12 @@ export function EnvironmentList({ project, clusterAvailable, className, ...props onClose={closeModal} projectId={project.id} organizationId={project.organization.id} + cloneUseCaseId={cloneUseCaseId} /> ), options: { fakeModal: true, + ...(cloneUseCaseId === 'multi-source' ? { width: 676 } : {}), }, }) : navigate(CLUSTERS_URL(project.organization?.id) + CLUSTERS_NEW_URL) diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx index 5452de238a2..2f1af6fe3ec 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx @@ -17,9 +17,9 @@ const { Table } = TablePrimitives const gridLayoutClassName = 'grid w-full grid-cols-[minmax(280px,2fr)_minmax(220px,1.4fr)_minmax(240px,1.2fr)_minmax(140px,1fr)_96px]' -function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { - const navigate = useNavigate() +function EnvRow({ overview, cloneUseCaseId }: { overview: EnvironmentOverviewResponse; cloneUseCaseId?: string }) { const { organizationId = '', projectId = '' } = useParams({ strict: false }) + const navigate = useNavigate() const { data: environments = [] } = useEnvironments({ projectId, suspense: true }) const environment = environments.find((env) => env.id === overview.id) const cellClassName = 'h-auto border-l border-neutral py-2' @@ -108,8 +108,16 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { > {environment && overview.deployment_status && overview.service_count > 0 && ( <> - - + + )} @@ -122,10 +130,12 @@ export function EnvironmentSection({ type, items, onCreateEnvClicked, + cloneUseCaseId, }: { type: EnvironmentModeEnum items: EnvironmentOverviewResponse[] onCreateEnvClicked?: () => void + cloneUseCaseId?: string }) { const title = match(type) .with('PRODUCTION', () => 'Production') @@ -193,7 +203,7 @@ export function EnvironmentSection({ {items.map((environmentOverview) => ( - + ))} diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx index 2a280ccb68d..e213f27024b 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx @@ -100,7 +100,11 @@ function EnvironmentsTableSkeleton() { ) } -function EnvironmentsTableContent() { +export interface EnvironmentsTableProps { + cloneUseCaseId?: string +} + +function EnvironmentsTableContent({ cloneUseCaseId }: EnvironmentsTableProps) { const { openModal, closeModal } = useModal() const { organizationId = '', projectId = '' } = useParams({ strict: false }) const { data: project } = useProject({ organizationId, projectId, suspense: true }) @@ -122,14 +126,16 @@ function EnvironmentsTableContent() { projectId={projectId} organizationId={organizationId} type={type} + cloneUseCaseId={cloneUseCaseId} /> ), options: { fakeModal: true, + ...(cloneUseCaseId === 'multi-source' ? { width: 676 } : {}), }, }) }, - [projectId, organizationId, closeModal, openModal] + [projectId, organizationId, closeModal, openModal, cloneUseCaseId] ) const Sections = useCallback(() => { @@ -143,6 +149,7 @@ function EnvironmentsTableContent() { type={section} items={groupedEnvs?.get(section) || []} onCreateEnvClicked={() => onCreateEnvClicked(section)} + cloneUseCaseId={cloneUseCaseId} /> ))} @@ -177,12 +184,12 @@ function EnvironmentsTableContent() { ) } -export function EnvironmentsTable() { +export function EnvironmentsTable({ cloneUseCaseId }: EnvironmentsTableProps) { useDocumentTitle('Environments - Qovery') return ( }> - + ) } diff --git a/libs/domains/projects/feature/src/lib/deployment-rules/create-edit-deployment-rule/create-edit-deployment-rule.tsx b/libs/domains/projects/feature/src/lib/deployment-rules/create-edit-deployment-rule/create-edit-deployment-rule.tsx index 9d720f15edc..c5e73679ec0 100644 --- a/libs/domains/projects/feature/src/lib/deployment-rules/create-edit-deployment-rule/create-edit-deployment-rule.tsx +++ b/libs/domains/projects/feature/src/lib/deployment-rules/create-edit-deployment-rule/create-edit-deployment-rule.tsx @@ -8,6 +8,7 @@ import { type Value } from '@qovery/shared/interfaces' import { BlockContent, Button, + Callout, Heading, Icon, InputSelect, @@ -299,10 +300,19 @@ export function CreateEditDeploymentRule(props: CreateEditDeploymentRuleProps) { onChange={field.onChange} value={field.value} error={error?.message} - className="mb-5" + className="mb-3" /> )} /> + + + + + + If a service using external secrets is ran on another cluster Qovery will automatically detach those + secrets. + + diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index ec1595ba0ec..eba757a1844 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -97,3 +97,6 @@ export * from './lib/service-new/service-new' export * from './lib/service-general-settings/service-general-default-values' export * from './lib/service-general-settings/service-general-payload' export * from './lib/service-general-settings/service-general-settings-payloads' +export * from './lib/service-variables-tabs/service-variables-built-in-tab' +export * from './lib/service-variables-tabs/service-variables-custom-tab' +export * from './lib/service-variables-tabs/service-variables-external-secrets-tab' diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-creation-flow.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-creation-flow.tsx index 56c916fa0ea..8d031bffe36 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-creation-flow.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-creation-flow.tsx @@ -99,6 +99,7 @@ export function ApplicationContainerCreationFlow({ const variablesForm = useForm({ defaultValues: { variables: [], + externalSecrets: [], }, mode: 'onChange', }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx index 8ae7977404d..1055626b783 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx @@ -24,22 +24,43 @@ jest.mock('@tanstack/react-router', () => ({ })) jest.mock('@qovery/domains/variables/feature', () => ({ - FlowCreateVariable: ({ onAdd, onBack, onSubmit }: Record void>) => ( -
- - - -
+ CreateUpdateVariableModal: ({ + onSubmitLocal, + scope, + isFile, + }: { + onSubmitLocal?: (data: { + key: string + value: string + scope: 'APPLICATION' | 'CONTAINER' + isSecret: boolean + isFile: boolean + mountPath?: string + }) => void + scope: 'APPLICATION' | 'CONTAINER' + isFile?: boolean + }) => ( + ), })) function VariablesState() { const { variablesForm } = useApplicationContainerCreateContext() - return
{JSON.stringify(variablesForm.watch('variables'))}
+ return
{JSON.stringify(variablesForm.watch())}
} describe('ApplicationContainerStepVariables', () => { @@ -63,7 +84,8 @@ describe('ApplicationContainerStepVariables', () => { ) - await userEvent.click(screen.getByRole('button', { name: 'Add Variable' })) + await userEvent.click(screen.getByRole('button', { name: /^add variable$/i })) + await userEvent.click(screen.getByRole('button', { name: /confirm variable modal/i })) await waitFor(() => { expect(screen.getByTestId('variables-state')).toHaveTextContent('"scope":"APPLICATION"') @@ -80,7 +102,7 @@ describe('ApplicationContainerStepVariables', () => { ) - await userEvent.click(screen.getByRole('button', { name: 'Back' })) + await userEvent.click(screen.getByRole('button', { name: /^back$/i })) expect(onBack).toHaveBeenCalled() }) @@ -95,7 +117,7 @@ describe('ApplicationContainerStepVariables', () => { ) - await userEvent.click(screen.getByRole('button', { name: 'Continue' })) + await userEvent.click(screen.getByRole('button', { name: /^continue$/i })) expect(onSubmit).toHaveBeenCalled() }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.tsx index c4f1bfe158e..f8b004e173a 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.tsx @@ -1,10 +1,15 @@ -import { type APIVariableScopeEnum } from 'qovery-typescript-axios' -import { useEffect, useMemo } from 'react' -import { FormProvider, useFieldArray } from 'react-hook-form' -import { match } from 'ts-pattern' -import { FlowCreateVariable } from '@qovery/domains/variables/feature' -import { FunnelFlowBody } from '@qovery/shared/ui' -import { computeAvailableScope } from '@qovery/shared/util-js' +import { useParams } from '@tanstack/react-router' +import { APIVariableTypeEnum, type VariableResponse } from 'qovery-typescript-axios' +import { useEffect } from 'react' +import { useFieldArray } from 'react-hook-form' +import { CreateUpdateVariableModal, type CreateUpdateVariableModalSubmitData } from '@qovery/domains/variables/feature' +import { type ExternalSecretData, type VariableData } from '@qovery/shared/interfaces' +import { Button, EmptyState, FunnelFlowBody, Heading, Icon, Section, Tooltip, useModal } from '@qovery/shared/ui' +import { + AddSecretModal, + type ExternalSecret, + SECRET_SOURCES, +} from '../../../../service-variables-tabs/add-secret-modal/add-secret-modal' import { useApplicationContainerCreateContext } from '../../application-container-creation-flow' export interface ApplicationContainerStepVariablesProps { @@ -12,34 +17,230 @@ export interface ApplicationContainerStepVariablesProps { onSubmit: () => void | Promise } +function cardHeaderActions({ + onAddDefault, + onAddAsFile, + defaultLabel, + asFileLabel, +}: { + onAddDefault: () => void + onAddAsFile: () => void + defaultLabel: string + asFileLabel: string +}) { + return ( +
+ + +
+ ) +} + +function emptyState({ + title, + onAddDefault, + onAddAsFile, + defaultLabel, + asFileLabel, +}: { + title: string + onAddDefault: () => void + onAddAsFile: () => void + defaultLabel: string + asFileLabel: string +}) { + return ( + +
+ + +
+
+ ) +} + +function mapModalDataToVariable( + data: CreateUpdateVariableModalSubmitData, + current?: VariableData +): VariableData { + return { + variable: data.key, + value: data.value ?? '', + scope: data.scope, + isSecret: data.isSecret, + isReadOnly: current?.isReadOnly, + description: data.description?.trim() ? data.description : undefined, + file: data.isFile + ? { + path: data.mountPath ?? `/vault/secrets/${data.key.toLowerCase()}`, + enable_interpolation: data.enable_interpolation_in_file ?? false, + } + : undefined, + } +} + +function mapVariableToModalVariable( + variable: VariableData, + index: number, + serviceScope: 'APPLICATION' | 'CONTAINER' +): VariableResponse { + return { + id: `local-variable-${index}`, + key: variable.variable ?? '', + value: variable.value ?? '', + description: variable.description ?? '', + is_secret: variable.isSecret, + scope: variable.scope ?? serviceScope, + variable_type: variable.file ? APIVariableTypeEnum.FILE : APIVariableTypeEnum.VALUE, + mount_path: variable.file?.path ?? null, + enable_interpolation_in_file: variable.file?.enable_interpolation ?? false, + } as VariableResponse +} + +function mapSecretForForm(secret: Omit): ExternalSecretData { + const sourceIcon = secret.sourceIcon === 'aws' || secret.sourceIcon === 'gcp' ? secret.sourceIcon : undefined + return { + name: secret.name, + description: secret.description, + filePath: secret.filePath, + isFile: secret.isFile, + reference: secret.reference, + source: secret.source, + sourceIcon, + scope: secret.scope, + } +} + +function mapSecretForModal(secret: ExternalSecretData, index: number): ExternalSecret { + return { + id: `local-secret-${index}`, + name: secret.name, + description: secret.description, + filePath: secret.filePath, + isFile: secret.isFile, + reference: secret.reference, + source: secret.source ?? null, + sourceIcon: secret.sourceIcon, + scope: secret.scope ?? 'Application', + } +} + export function ApplicationContainerStepVariables({ onBack, onSubmit }: ApplicationContainerStepVariablesProps) { const { setCurrentStep, variablesForm, generalForm } = useApplicationContainerCreateContext() + const { projectId = '', environmentId = '' } = useParams({ strict: false }) + const { openModal, closeModal } = useModal() - const serviceType = generalForm.getValues('serviceType') === 'APPLICATION' ? 'APPLICATION' : 'CONTAINER' - const availableScopes = useMemo( - () => computeAvailableScope(undefined, false, serviceType), - [serviceType] - ) + const serviceScope = generalForm.getValues('serviceType') === 'APPLICATION' ? 'APPLICATION' : 'CONTAINER' - const { fields, append, remove } = useFieldArray({ + const { fields: variables, append: appendVariable, remove: removeVariable, update: updateVariable } = useFieldArray({ control: variablesForm.control, name: 'variables', }) + const { + fields: externalSecrets, + append: appendExternalSecret, + remove: removeExternalSecret, + update: updateExternalSecret, + } = useFieldArray({ + control: variablesForm.control, + name: 'externalSecrets', + }) + useEffect(() => { setCurrentStep(5) }, [setCurrentStep]) - const handleAddVariable = () => { - const scope = match(generalForm.getValues('serviceType')) - .with('APPLICATION', () => 'APPLICATION' as const) - .otherwise(() => 'CONTAINER' as const) + const openVariableModal = ({ isFile, index }: { isFile: boolean; index?: number }) => { + const currentVariable = typeof index === 'number' ? variables[index] : undefined + const mode = typeof index === 'number' ? 'UPDATE' : 'CREATE' + + openModal({ + content: ( + { + const mapped = mapModalDataToVariable(data, currentVariable) + if (typeof index === 'number') { + updateVariable(index, mapped) + } else { + appendVariable(mapped) + } + }} + scope={serviceScope} + projectId={projectId} + environmentId={environmentId} + serviceId="service-creation-flow" + /> + ), + options: { + fakeModal: true, + }, + }) + } + + const openSecretModal = ({ isFile, index }: { isFile: boolean; index?: number }) => { + const currentSecret = typeof index === 'number' ? mapSecretForModal(externalSecrets[index], index) : undefined + const defaultSource = currentSecret?.source + ? SECRET_SOURCES.find((source) => source.tableLabel === currentSecret.source) ?? SECRET_SOURCES[0] + : SECRET_SOURCES[0] - append({ - variable: '', - isSecret: false, - value: '', - scope, + openModal({ + content: ( + { + const mapped = mapSecretForForm(secret) + if (typeof index === 'number') { + updateExternalSecret(index, mapped) + } else { + appendExternalSecret(mapped) + } + }} + /> + ), + options: { + fakeModal: true, + width: 520, + }, }) } @@ -47,16 +248,234 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat return ( - - - +
+
+
+ Service variables +

Define here the variables required by your service.

+
+ +
+
+
+

Custom variables

+ {variables.length > 0 && + cardHeaderActions({ + onAddDefault: () => openVariableModal({ isFile: false }), + onAddAsFile: () => openVariableModal({ isFile: true }), + defaultLabel: 'Add variable', + asFileLabel: 'Add variable as file', + })} +
+
+ +
+
+ {variables.length === 0 ? ( + emptyState({ + title: 'No custom variables added yet', + onAddDefault: () => openVariableModal({ isFile: false }), + onAddAsFile: () => openVariableModal({ isFile: true }), + defaultLabel: 'Add variable', + asFileLabel: 'Add variable as file', + }) + ) : ( + <> +
+
Name
+
+ Value +
+
+ Actions +
+
+ + {variables.map((variable, index) => ( +
+
+
+ {variable.variable} + {variable.description && ( + + + + + + )} +
+ {variable.file?.path && ( +
+ + {variable.file.path} +
+ )} +
+ +
+ {variable.isSecret ? ( + + + *********************** + + ) : ( + {variable.value || '-'} + )} +
+ +
+ + +
+
+ ))} + + )} +
+
+
+ +
+
+
+

External secrets

+ {externalSecrets.length > 0 && + cardHeaderActions({ + onAddDefault: () => openSecretModal({ isFile: false }), + onAddAsFile: () => openSecretModal({ isFile: true }), + defaultLabel: 'Add secret', + asFileLabel: 'Add secret as file', + })} +
+
+ +
+
+ {externalSecrets.length === 0 ? ( + emptyState({ + title: 'No external secrets added yet', + onAddDefault: () => openSecretModal({ isFile: false }), + onAddAsFile: () => openSecretModal({ isFile: true }), + defaultLabel: 'Add secret', + asFileLabel: 'Add secret as file', + }) + ) : ( + <> +
+
Name
+
+ Reference +
+
+ Actions +
+
+ + {externalSecrets.map((secret, index) => ( +
+
+
+ {secret.name} + {secret.description && ( + + + + + + )} +
+ {secret.filePath && ( +
+ + {secret.filePath} +
+ )} +
+ +
+ {secret.reference || '-'} +
+ +
+ + +
+
+ ))} + + )} +
+
+
+ +
+ + +
+
+
) } diff --git a/libs/domains/services/feature/src/lib/service-variables-tabs/__snapshots__/service-variables-external-secrets-tab.spec.tsx.snap b/libs/domains/services/feature/src/lib/service-variables-tabs/__snapshots__/service-variables-external-secrets-tab.spec.tsx.snap new file mode 100644 index 00000000000..4ded649c593 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-variables-tabs/__snapshots__/service-variables-external-secrets-tab.spec.tsx.snap @@ -0,0 +1,1173 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`ExternalSecretsTab should render the updated external secrets table layout 1`] = ` +
+
+
+
+ + 7 external secrets + + +