From 0a5afc6deb7b20d387684a0d9981fc7b9b08d495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 23 Mar 2026 11:55:04 +0100 Subject: [PATCH 01/18] feat(use-cases): add reusable bottom bar baseline --- .../use-cases/use-case-bottom-bar.tsx | 124 ++++++++++++++ .../components/use-cases/use-case-context.tsx | 159 ++++++++++++++++++ apps/console-v5/src/routes/__root.tsx | 14 +- .../src/lib/shared-util-node-env.ts | 2 + 4 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx create mode 100644 apps/console-v5/src/app/components/use-cases/use-case-context.tsx 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..a4c2a7df671 --- /dev/null +++ b/apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx @@ -0,0 +1,124 @@ +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 + + const branchLabel = GIT_BRANCH || 'unknown' + const commitLabel = GIT_SHA ? GIT_SHA.slice(0, 7) : undefined + + return ( +
+
+
+
+ + + + + + Branch + + {branchLabel} + {commitLabel ? ` (${commitLabel})` : ''} + +
+ +
+ Page + + {pageLabel} + +
+ +
+ {useCaseOptions.length > 0 && resolvedSelection ? ( + <> + 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__single-value]:!font-mono [&_.input-select__single-value]:!text-xs [&_.input-select__single-value]:!text-neutral [&_.react-select__dropdown-indicator]:!right-0" + /> + + ) : ( + <> + Use case + No use case detected + + )} +
+
+
+
+ ) +} + +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/routes/__root.tsx b/apps/console-v5/src/routes/__root.tsx index a03381a75a4..e82b6f6fdce 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,14 @@ interface RouterContext { const RootLayout = () => { return ( - - - - + + + + + + + + ) } diff --git a/libs/shared/util-node-env/src/lib/shared-util-node-env.ts b/libs/shared/util-node-env/src/lib/shared-util-node-env.ts index cfd494455e7..558f68e06ae 100644 --- a/libs/shared/util-node-env/src/lib/shared-util-node-env.ts +++ b/libs/shared/util-node-env/src/lib/shared-util-node-env.ts @@ -4,6 +4,7 @@ declare global { interface ProcessEnv { NODE_ENV: string NX_PUBLIC_GIT_SHA: string + NX_PUBLIC_GIT_BRANCH: string NX_PUBLIC_QOVERY_API: string NX_PUBLIC_QOVERY_WS: string NX_PUBLIC_OAUTH_DOMAIN: string @@ -25,6 +26,7 @@ declare global { export const NODE_ENV = process.env.NODE_ENV, GIT_SHA = process.env.NX_PUBLIC_GIT_SHA, + GIT_BRANCH = process.env.NX_PUBLIC_GIT_BRANCH, QOVERY_API = process.env.NX_PUBLIC_QOVERY_API, QOVERY_WS = process.env.NX_PUBLIC_QOVERY_WS, OAUTH_DOMAIN = process.env.NX_PUBLIC_OAUTH_DOMAIN, From 91c3c4de7acd22fc709285741f3f46dc55132037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 23 Mar 2026 14:38:54 +0100 Subject: [PATCH 02/18] refactor(root-layout): remove TanStackRouterDevtools for cleaner structure --- apps/console-v5/src/routes/__root.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/console-v5/src/routes/__root.tsx b/apps/console-v5/src/routes/__root.tsx index e82b6f6fdce..d2db9047262 100644 --- a/apps/console-v5/src/routes/__root.tsx +++ b/apps/console-v5/src/routes/__root.tsx @@ -18,7 +18,6 @@ const RootLayout = () => { - ) } From 160849da2d60efa72c6e6c58e955b45414e2c463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 23 Mar 2026 14:46:43 +0100 Subject: [PATCH 03/18] fix(use-cases): resolve branch metadata from git in vite --- apps/console-v5/vite.config.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/console-v5/vite.config.ts b/apps/console-v5/vite.config.ts index d7685a6f7bb..7411db3243e 100644 --- a/apps/console-v5/vite.config.ts +++ b/apps/console-v5/vite.config.ts @@ -3,12 +3,39 @@ 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' import react from '@vitejs/plugin-react' +import { execSync } from 'child_process' 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, From 99cac87de00c0fcb176c5dcc6e23fbe69c5bd929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 23 Mar 2026 17:59:40 +0100 Subject: [PATCH 04/18] fix(use-cases): port select styles for bottom bar use-case input --- .../input-select-small/input-select-small.tsx | 7 +++- .../inputs/input-select/input-select.tsx | 15 ++++++-- .../ui/src/lib/styles/components/select.scss | 34 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx b/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx index 3927c5514d0..6e0428a5581 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-select-small/input-select-small.tsx @@ -13,6 +13,7 @@ export interface InputSelectSmallProps { onChange?: (item: string | undefined) => void defaultValue?: string inputClassName?: string + iconClassName?: string disabled?: boolean } @@ -27,6 +28,7 @@ export function InputSelectSmall(props: InputSelectSmallProps) { getValue, dataTestId, inputClassName = '', + iconClassName = '', disabled = false, } = props @@ -70,7 +72,10 @@ export function InputSelectSmall(props: InputSelectSmallProps) { ) diff --git a/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx b/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx index 41efbcde0e6..811675f777c 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx @@ -22,6 +22,9 @@ import { LoaderSpinner } from '../../loader-spinner/loader-spinner' export interface InputSelectProps { className?: string + inputClassName?: string + valueClassName?: string + iconClassName?: string label?: string value?: string | string[] options: Value[] @@ -55,6 +58,9 @@ export interface InputSelectProps { export function InputSelect({ className = '', + inputClassName = '', + valueClassName = '', + iconClassName = '', label, value, options, @@ -201,7 +207,8 @@ export function InputSelect({ clsx('mr-1 text-sm', { 'text-neutral-subtle': disabled, 'text-neutral': !disabled, - }) + }), + valueClassName )} > {props.data.label} @@ -334,7 +341,8 @@ export function InputSelect({ 'input--has-icon': hasIcon, '!border-neutral !bg-surface-neutral-subtle': disabled, 'input--filter': isFilter, - }) + }), + inputClassName )} data-testid={dataTestId || 'select'} > @@ -377,7 +385,8 @@ export function InputSelect({ clsx('text-sm', { 'text-neutral-disabled': disabled, 'text-neutral-subtle': !disabled, - }) + }), + iconClassName )} /> diff --git a/libs/shared/ui/src/lib/styles/components/select.scss b/libs/shared/ui/src/lib/styles/components/select.scss index baa966db5f5..6d0ca4c4214 100644 --- a/libs/shared/ui/src/lib/styles/components/select.scss +++ b/libs/shared/ui/src/lib/styles/components/select.scss @@ -21,6 +21,40 @@ @apply top-1 mt-2; } +.input--inline { + min-height: 40px !important; + height: 40px !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + + .input-select__control { + height: 40px !important; + min-height: 40px !important; + align-items: center !important; + } + + .input-select__value-container { + margin-top: 0 !important; + top: 0 !important; + height: 100% !important; + align-items: center !important; + padding: 0 !important; + } + + .input-select__single-value { + line-height: 1 !important; + } + + .input-select__input-container { + margin: 0 !important; + } + + .input-select__placeholder { + display: block !important; + @apply text-neutral-subtle; + } +} + .input--has-icon .input-select__control { padding-left: theme('spacing.8'); transition-property: transform; From 638e8ab2fcc5a4d3a5bacf69f1cf356a3f76c7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 24 Mar 2026 16:20:56 +0100 Subject: [PATCH 05/18] fix(use-cases): handle empty use case options in bottom bar --- .../use-cases/use-case-bottom-bar.tsx | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) 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 index a4c2a7df671..e65cbf63a22 100644 --- 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 @@ -19,6 +19,10 @@ export function UseCaseBottomBar() { ? 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 @@ -47,29 +51,20 @@ export function UseCaseBottomBar() {
- {useCaseOptions.length > 0 && resolvedSelection ? ( - <> - 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__single-value]:!font-mono [&_.input-select__single-value]:!text-xs [&_.input-select__single-value]:!text-neutral [&_.react-select__dropdown-indicator]:!right-0" - /> - - ) : ( - <> - Use case - No use case detected - - )} + 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__single-value]:!font-mono [&_.input-select__single-value]:!text-xs [&_.input-select__single-value]:!text-neutral [&_.react-select__dropdown-indicator]:!right-0" + />
From 57cc5c79ec8809d74876a9db4150f4cc57f8c6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 24 Mar 2026 17:16:23 +0100 Subject: [PATCH 06/18] fix(use-cases): update styles for bottom bar input components --- .../src/app/components/use-cases/use-case-bottom-bar.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index e65cbf63a22..5381917ff1d 100644 --- 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 @@ -63,7 +63,10 @@ export function UseCaseBottomBar() { setSelection(activePageId, next) } }} - className="min-w-0 flex-1 [&_.input-select__control]:!h-10 [&_.input-select__single-value]:!font-mono [&_.input-select__single-value]:!text-xs [&_.input-select__single-value]:!text-neutral [&_.react-select__dropdown-indicator]:!right-0" + 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" /> From c5fc832a331ad539cee2ef6c92d3ab0ea21f8e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 9 Mar 2026 10:37:49 +0100 Subject: [PATCH 07/18] feat(use-cases): implement UseCaseBottomBar and context for managing use case selections --- apps/console-v5/src/routeTree.gen.ts | 52 + .../cluster/$clusterId/settings/addons.tsx | 1043 ++++++++++++++++ .../cluster/$clusterId/settings/route.tsx | 9 +- .../cluster/create/$slug/addons.tsx | 37 + .../cluster/create/$slug/features.tsx | 7 +- .../$serviceId/variables-built-in-tab.tsx | 61 + .../$serviceId/variables-custom-tab.tsx | 70 ++ .../variables-external-secrets-tab.tsx | 1078 +++++++++++++++++ .../service/$serviceId/variables.tsx | 147 +-- .../project/$projectId/overview.tsx | 26 +- libs/domains/clusters/feature/src/index.ts | 1 + .../cluster-creation-flow.spec.tsx | 8 +- .../cluster-creation-flow.tsx | 31 + .../step-addons/step-addons.tsx | 775 ++++++++++++ .../step-features/step-features.spec.tsx | 2 + .../step-general/step-general.spec.tsx | 2 + .../step-general/step-general.tsx | 11 +- .../step-resources/step-resources.spec.tsx | 2 + .../step-summary-presentation.spec.tsx | 3 + .../step-summary-presentation.tsx | 70 ++ .../step-summary/step-summary.spec.tsx | 2 + .../step-summary/step-summary.tsx | 18 +- .../create-clone-environment-modal.tsx | 819 ++++++++++--- .../environment-action-toolbar.tsx | 16 +- .../lib/environment-list/environment-list.tsx | 15 +- .../environment-section.tsx | 19 +- .../environments-table/environments-table.tsx | 10 +- .../src/lib/variable-list/variable-list.tsx | 359 ++++-- .../variables-action-toolbar.tsx | 95 +- .../page-settings-feature.tsx | 4 +- .../src/lib/ui/page-general/page-general.tsx | 4 +- libs/shared/ui/src/index.ts | 1 + .../inputs/input-select/input-select.tsx | 5 + .../sticky-action-form-toaster.tsx | 2 +- .../sync-status-badge/sync-status-badge.tsx | 68 ++ .../table-filter-search.tsx | 2 +- 36 files changed, 4452 insertions(+), 422 deletions(-) create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/addons.tsx create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-built-in-tab.tsx create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-custom-tab.tsx create mode 100644 apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx create mode 100644 libs/shared/ui/src/lib/components/sync-status-badge/sync-status-badge.tsx 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/_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..40895b40e46 --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons.tsx @@ -0,0 +1,1043 @@ +import { createFileRoute, useParams } from '@tanstack/react-router' +import { type CloudProvider, type ClusterRegion } from 'qovery-typescript-axios' +import { type FormEventHandler, useEffect, useMemo, useState } from 'react' +import { useDropzone } from 'react-dropzone' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { match } from 'ts-pattern' +import { useCloudProviders } from '@qovery/domains/cloud-providers/feature' +import { useCluster, useEditCluster } from '@qovery/domains/clusters/feature' +import { SettingsHeading } from '@qovery/shared/console-shared' +import { useUserRole } from '@qovery/shared/iam/feature' +import { + Badge, + Button, + Callout, + CopyButton, + DropdownMenu, + Dropzone, + ExternalLink, + Icon, + IconFlag, + InputSelect, + InputText, + Navbar, + 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 IntegrationTab = 'automatic' | 'manual' + +type SecretManagerIntegrationFormValues = { + authenticationType: string + region: string + roleArn: string + accessKey: string + secretAccessKey: string + secretManagerName: 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 +} + +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 Manager type', icon: 'AWS' }, + { value: 'aws-parameter', label: 'AWS Parameter store', icon: 'AWS' }, + { value: 'gcp-secret', label: 'GCP Secret manager', icon: 'GCP' }, +].map((option) => ({ ...option, typeLabel: option.label })) + +const BASE_SECRET_MANAGERS: SecretManagerItem[] = [ + { + id: 'secret-manager-prod', + name: 'Prod secret manager', + typeLabel: 'AWS Manager type', + authentication: 'Automatic', + provider: 'AWS' as const, + source: 'aws-manager', + usedByServices: 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 SecretManagerIntegrationModal({ + option, + regionOptions, + clusterProvider, + mode = 'create', + initialValues, + onClose, + onSubmit, +}: { + option: SecretManagerOption + regionOptions: Array<{ label: string; value: string; icon?: JSX.Element }> + clusterProvider?: string + mode?: 'create' | 'edit' + initialValues?: SecretManagerItem + onClose: () => void + onSubmit: (payload: SecretManagerItem) => void +}) { + const [activeTab, setActiveTab] = useState( + initialValues?.authentication === 'Manual' ? 'manual' : 'automatic' + ) + const methods = useForm({ + mode: 'onChange', + defaultValues: { + authenticationType: initialValues?.authType ?? '', + region: initialValues?.region ?? '', + roleArn: initialValues?.roleArn ?? '', + accessKey: initialValues?.accessKey ?? '', + secretAccessKey: initialValues?.secretAccessKey ?? '', + secretManagerName: initialValues?.name ?? '', + }, + }) + + const authenticationOptions = useMemo( + () => [ + { label: 'Assume role via STS', value: 'sts' }, + { label: 'Static credentials', value: 'static' }, + ], + [] + ) + + const authenticationType = methods.watch('authenticationType') + const isStaticCredentials = authenticationType === 'static' + const isAwsCluster = clusterProvider === 'AWS' + const isGcpSecretManagerOnAws = option.value === 'gcp-secret' && isAwsCluster + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + multiple: false, + accept: { 'application/json': ['.json'] }, + }) + + useEffect(() => { + if (activeTab === 'manual' && !authenticationType) { + methods.setValue('authenticationType', 'sts', { shouldDirty: false }) + } + }, [activeTab, authenticationType, methods]) + + const handleSubmit = methods.handleSubmit((data) => { + onSubmit({ + id: initialValues?.id ?? `secret-manager-${Date.now()}`, + name: data.secretManagerName.trim() || 'Secret manager', + typeLabel: option.typeLabel, + authentication: activeTab === 'manual' ? 'Manual' : 'Automatic', + provider: option.icon, + source: option.value, + authType: + activeTab === 'manual' && data.authenticationType ? (data.authenticationType as 'sts' | 'static') : undefined, + region: data.region || undefined, + roleArn: data.roleArn || undefined, + accessKey: data.accessKey || undefined, + secretAccessKey: data.secretAccessKey || undefined, + }) + onClose() + }) + + const handleGcpAwsSubmit = methods.handleSubmit((data) => { + onSubmit({ + id: initialValues?.id ?? `secret-manager-${Date.now()}`, + name: data.secretManagerName.trim() || 'Secret manager', + typeLabel: option.typeLabel, + authentication: 'Manual', + provider: option.icon, + source: option.value, + authType: 'static', + }) + onClose() + }) + + useEffect(() => { + methods.trigger().then() + }, [methods.trigger]) + + if (isGcpSecretManagerOnAws) { + return ( + +
+
+

{`${option.label} integration`}

+

+ Link your AWS secret manager to use external secrets on all service running on your cluster +

+
+
+
+

+ 1. Connect to your GCP Console and create/open a project +

+

Make sure you are connected to the right GCP account

+ + https://console.cloud.google.com/ + +
+
+

+ 2. Open the embedded Google shell and run the following command +

+
+
+ $ + curl https://setup.qovery.com/create_credentials_gcp.sh | \ bash -s -- $GOOGLE_CLOUD_PROJECT + qovery_role qovery-service-account{' '} +
+ +
+
+
+

+ 3. Download the key.json generated and drag and drop it here +

+
+ + +
+ ( + + )} + /> +
+
+
+ + +
+
+
+ ) + } + + return ( + +
+
+

{`${option.label} integration`}

+

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

+
+ + setActiveTab('automatic')}> + + Automatic integration + + setActiveTab('manual')}> + + Manual integration + + +
+
+
+ {activeTab === 'automatic' && ( +
+
+

Automatic integration

+

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

+
+
+ ( + 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 + + + )} +
+ )} + + {activeTab === 'manual' && ( +
+ ( + field.onChange(value as string)} + options={authenticationOptions} + portal + /> + )} + /> + + {!authenticationType && ( +

+ Select an authentication type to see the required information. +

+ )} + {authenticationType && isStaticCredentials ? ( + <> +
+

1. Create a user for Qovery

+

Follow the instructions available on this page

+ + How to create new credentials + +
+
+

2. Fill in these information

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + ) : authenticationType ? ( +
+
+

1. Connect to your AWS Console

+

Make sure you are connected to the right AWS account

+ + https://aws.amazon.com/fr/console/ + +
+
+

+ 2. Create a role for Qovery and grant assume role permissions +

+

+ Execute the following Cloudformation stack and retrieve the role ARN from the “Output” section. +

+ + Cloudformation stack + +
+
+

3. Provide your credentials info

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> +
+
+ ) : null} +
+ )} +
+
+ + +
+
+
+ ) +} + +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 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) + + 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 } : item + ) + } + return [...prev, { ...payload, usedByServices: 0 }] + }) + }} + /> + ), + options: { + width: 676, + fakeModal: true, + }, + }) + } + + const handleSave = async () => { + if (!cluster) return + + const cloneCluster = { + ...cluster, + keda: { + enabled: 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. +

+
+
+ + +
+
+
+
+
+
+
+ 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 +

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

{manager.name}

+
+ + Type: {manager.typeLabel} + + + Authentication: {manager.authentication} + +
+
+
+
+ {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..b51d68cb73a --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/cluster/create/$slug/addons.tsx @@ -0,0 +1,37 @@ +import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' +import { useEffect } from 'react' +import { StepAddons, useClusterContainerCreateContext } 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 { generalData } = useClusterContainerCreateContext() + + 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 + } + + 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-built-in-tab.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-built-in-tab.tsx new file mode 100644 index 00000000000..3fd8757c838 --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-built-in-tab.tsx @@ -0,0 +1,61 @@ +import { VariableList } from '@qovery/domains/variables/feature' +import { toast } from '@qovery/shared/ui' + +interface BuiltInTabProps { + scope: 'APPLICATION' | 'CONTAINER' | 'JOB' | 'HELM' | 'TERRAFORM' + organizationId: string + projectId: string + environmentId: string + serviceId: string + toasterCallback: () => void +} + +export function BuiltInTab({ scope, organizationId, projectId, environmentId, serviceId, toasterCallback }: BuiltInTabProps) { + const onCreateVariableToast = () => + toast( + 'SUCCESS', + 'Creation success', + 'You need to redeploy your service for your changes to be applied.', + toasterCallback, + undefined, + 'Redeploy' + ) + + return ( + } + scope={scope} + serviceId={serviceId} + organizationId={organizationId} + projectId={projectId} + environmentId={environmentId} + onCreateVariable={onCreateVariableToast} + 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' + ) + }} + /> + ) +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-custom-tab.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-custom-tab.tsx new file mode 100644 index 00000000000..7446d156a08 --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-custom-tab.tsx @@ -0,0 +1,70 @@ +import { VariableList, VariablesActionToolbar } from '@qovery/domains/variables/feature' +import { toast } from '@qovery/shared/ui' + +interface CustomTabProps { + scope: 'APPLICATION' | 'CONTAINER' | 'JOB' | 'HELM' | 'TERRAFORM' + organizationId: string + projectId: string + environmentId: string + serviceId: string + toasterCallback: () => void +} + +export function CustomTab({ scope, organizationId, projectId, environmentId, serviceId, toasterCallback }: CustomTabProps) { + const onCreateVariableToast = () => + toast( + 'SUCCESS', + 'Creation success', + 'You need to redeploy your service for your changes to be applied.', + toasterCallback, + undefined, + 'Redeploy' + ) + + return ( + + } + scope={scope} + serviceId={serviceId} + organizationId={organizationId} + projectId={projectId} + environmentId={environmentId} + onCreateVariable={onCreateVariableToast} + 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' + ) + }} + /> + ) +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx new file mode 100644 index 00000000000..9c466825a5d --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx @@ -0,0 +1,1078 @@ +import { + type RowSelectionState, + type SortingState, + createColumnHelper, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { match } from 'ts-pattern' +import { + Button, + Callout, + Checkbox, + DropdownMenu, + EmptyState, + Icon, + InputSelect, + InputText, + InputTextArea, + ModalCrud, + type SyncStatus, + SyncStatusBadge, + TableFilterSearch, + TablePrimitives, + Tooltip, + useModal, + useModalConfirmation, +} from '@qovery/shared/ui' +import { pluralize, twMerge } from '@qovery/shared/util-js' +import { + type UseCaseOption, + useUseCasePage, +} from '../../../../../../../../../../app/components/use-cases/use-case-context' + +const { Table } = TablePrimitives + +interface ExternalSecret { + id: string + name: string + description?: string + filePath?: string + isFile?: boolean + reference: string + status: SyncStatus + source: string | null + sourceIcon?: string + lastSync: string +} + +const FAKE_SECRETS: ExternalSecret[] = [ + { + id: '1', + name: 'DB_PASSWORD', + reference: 'my-app/prod/db-password', + status: 'synced', + source: 'Prod secret manager', + sourceIcon: 'aws', + lastSync: '32 min ago', + }, + { + id: '2', + name: 'API_KEY', + filePath: '/vault/secrets/api-key', + isFile: true, + reference: 'my-app/prod/api-key', + status: 'synced', + source: 'Prod secret manager', + sourceIcon: 'aws', + lastSync: '32 min ago', + }, + { + id: '3', + name: 'API_SECRET_KEY', + reference: 'my-app/prod/api-keys', + status: 'synced', + source: 'Prod secret manager', + sourceIcon: 'aws', + lastSync: '32 min ago', + }, + { + id: '4', + name: 'APP_SECRET_KEY', + reference: 'prod/app/backend/payment/datab...', + status: 'broken', + source: 'GCP secret manager', + sourceIcon: 'gcp', + lastSync: '32 min ago', + }, + { + id: '5', + name: 'SMTP_PASSWORD', + reference: 'my-app/prod/smtp-credentials', + status: 'broken', + source: 'Prod secret manager', + sourceIcon: 'aws', + lastSync: '32 min ago', + }, + { + id: '6', + name: 'REDIS_PASSWORD', + reference: 'my-app/prod/redis-auth', + status: 'detached', + source: null, + lastSync: '32 min ago', + }, + { + id: '7', + name: 'AWS_SECRET_KEY', + reference: 'my-app/prod/oauth-secrets', + status: 'detached', + source: null, + lastSync: '32 min ago', + }, + { + id: '8', + name: 'GCP_API_KEY', + reference: 'my-app/prod/billing-keys', + status: 'detached', + source: null, + lastSync: '32 min ago', + }, +] + +const EMPTY_SECRETS: ExternalSecret[] = [] + +const EXTERNAL_SECRETS_USE_CASES: UseCaseOption[] = [ + { id: 'filled', label: 'Filled' }, + { id: 'secret-manager-addon-not-detected', label: 'Secret manager add-on not detected' }, + { id: 'secret-manager-addon-no-manager', label: 'Secret manager add-on with no manager' }, + { id: 'secret-manager-addon-not-redeployed', label: 'Secret manager add-on not redeployed' }, + { id: 'empty', label: 'No secrets yet' }, +] + +type SecretSourceOption = { + value: 'aws-manager' | 'aws-parameter' | 'gcp-secret' + label: string + tableLabel: string + icon: 'aws' | 'gcp' +} + +const SECRET_SOURCES: SecretSourceOption[] = [ + { + value: 'aws-manager', + label: 'AWS Manager type', + tableLabel: 'Prod secret manager', + icon: 'aws', + }, + { + value: 'aws-parameter', + label: 'AWS Parameter store', + tableLabel: 'AWS Parameter store', + icon: 'aws', + }, + { + value: 'gcp-secret', + label: 'GCP Secret manager', + tableLabel: 'GCP secret manager', + icon: 'gcp', + }, +] + +const REFERENCE_OPTIONS = [ + 'my-app/prod/db-password', + 'my-app/prod/api-key', + 'my-app/prod/secret-token', + 'my-app/prod/user-credentials', + 'my-app/prod/payment-gateway', + 'my-app/prod/db-host', + 'my-app/prod/db-port', +] + +const ADD_SECRET_OPTIONS = [ + { + value: 'variable', + label: 'Add secret as variable', + icon: 'key' as const, + }, + { + value: 'file', + label: 'Add secret as file', + icon: 'file-lines' as const, + }, +] + +const gridLayoutClassName = 'grid w-full grid-cols-[32px_minmax(0,2fr)_minmax(0,2fr)_110px_minmax(0,1.5fr)_110px_80px]' + +const columnHelper = createColumnHelper() + +interface AddSecretModalProps { + defaultSource?: SecretSourceOption + isFile?: boolean + mode?: 'create' | 'edit' + initialSecret?: ExternalSecret + onClose: () => void + onSubmit: (secret: Omit) => void +} + +type AddSecretFormValues = { + source: SecretSourceOption['value'] + reference: string + path: string + secretName: string + description: string +} + +function AddSecretModal({ + defaultSource, + isFile = false, + mode = 'create', + initialSecret, + onClose, + onSubmit, +}: AddSecretModalProps) { + const methods = useForm({ + defaultValues: { + source: defaultSource?.value ?? SECRET_SOURCES[0].value, + reference: initialSecret?.reference ?? '', + path: initialSecret?.filePath ?? '', + secretName: initialSecret?.name ?? '', + description: initialSecret?.description ?? '', + }, + mode: 'onChange', + }) + const { enableAlertClickOutside } = useModal() + const [referenceInput, setReferenceInput] = useState('') + + const secretNameValue = methods.watch('secretName') + + const handleSubmit = methods.handleSubmit((data) => { + const finalReference = data.reference || referenceInput || REFERENCE_OPTIONS[0] + const finalName = data.secretName.trim() || finalReference.split('/').pop()?.toUpperCase() || 'NEW_SECRET' + const selectedSource = SECRET_SOURCES.find((option) => option.value === data.source) ?? SECRET_SOURCES[0] + const descriptionValue = data.description.trim() + const fallbackFilePath = `/vault/secrets/${finalReference.split('/').pop() || 'secret'}` + const finalPath = data.path.trim() || fallbackFilePath + + onSubmit({ + name: finalName, + description: descriptionValue ? descriptionValue : undefined, + filePath: isFile ? finalPath : undefined, + isFile, + reference: finalReference, + source: selectedSource.tableLabel, + sourceIcon: selectedSource.icon, + }) + onClose() + }) + + useEffect(() => { + enableAlertClickOutside(methods.formState.isDirty) + }, [enableAlertClickOutside, methods.formState.isDirty]) + + useEffect(() => { + methods.trigger().then() + }, [methods.trigger]) + + return ( + + + <> + ( + ({ + label: option.label, + value: option.value, + icon: ( + 'AWS' as const) + .with('gcp', () => 'GCP' as const) + .exhaustive()} + width="16" + height="16" + /> + ), + }))} + value={field.value} + onChange={(value) => field.onChange(value as SecretSourceOption['value'])} + placeholder="Select a source" + /> + )} + /> + + ( + ({ label: reference, value: reference }))} + value={field.value} + onChange={(value) => { + const selected = value as string + field.onChange(selected) + if (!secretNameValue) { + const inferredName = selected.split('/').pop()?.toUpperCase() + if (inferredName) { + methods.setValue('secretName', inferredName, { shouldValidate: true }) + } + } + }} + onInputChange={(value) => setReferenceInput(value)} + isSearchable + isCreatable + placeholder="Reference" + /> + )} + /> + + {isFile && ( + ( + + )} + /> + )} + + ( + + )} + /> + + ( + + )} + /> + + + + ) +} + +interface AttachSecretsModalProps { + selectedCount: number + onClose: () => void + onAttach: (source: SecretSourceOption) => void +} + +type AttachSecretsFormValues = { + source: SecretSourceOption['value'] | '' +} + +function AttachSecretsModal({ selectedCount, onClose, onAttach }: AttachSecretsModalProps) { + const methods = useForm({ + defaultValues: { + source: '', + }, + mode: 'onChange', + }) + const { enableAlertClickOutside } = useModal() + const selectedSource = methods.watch('source') + + useEffect(() => { + enableAlertClickOutside(methods.formState.isDirty) + }, [enableAlertClickOutside, methods.formState.isDirty]) + + const handleSubmit = methods.handleSubmit((data) => { + const option = SECRET_SOURCES.find((source) => source.value === data.source) + if (!option) return + onAttach(option) + }) + + return ( + + + <> + ( + ({ + label: option.label, + value: option.value, + icon: ( + 'AWS' as const) + .with('gcp', () => 'GCP' as const) + .exhaustive()} + width="16" + height="16" + /> + ), + }))} + value={field.value} + onChange={(value) => field.onChange(value as SecretSourceOption['value'])} + placeholder="Select a source" + /> + )} + /> + {selectedSource && ( + + + + + + Warning + + Please ensure matching secrets exist in the target secret manager, or some variables may appear as + broken. + + + + )} + + + + ) +} + +export function ExternalSecretsTab() { + const useCaseOptions = EXTERNAL_SECRETS_USE_CASES + const { selectedCaseId } = useUseCasePage({ + pageId: 'service-variables-external-secrets', + options: useCaseOptions, + defaultCaseId: 'filled', + }) + + const baseSecrets = useMemo( + () => + match(selectedCaseId) + .with('secret-manager-addon-not-detected', () => EMPTY_SECRETS) + .with('secret-manager-addon-no-manager', () => EMPTY_SECRETS) + .with('secret-manager-addon-not-redeployed', () => EMPTY_SECRETS) + .with('empty', () => EMPTY_SECRETS) + .otherwise(() => FAKE_SECRETS), + [selectedCaseId] + ) + + const [search, setSearch] = useState('') + const [secrets, setSecrets] = useState(baseSecrets) + const [sorting, setSorting] = useState([]) + const [rowSelection, setRowSelection] = useState({}) + const { openModal, closeModal } = useModal() + const { openModalConfirmation } = useModalConfirmation() + + useEffect(() => { + setSecrets(baseSecrets) + setSearch('') + setSorting([]) + setRowSelection({}) + }, [baseSecrets]) + + const handleSynchronize = useCallback((secretIds?: string[]) => { + const targetIds = secretIds ? new Set(secretIds) : null + const shouldSync = (secret: ExternalSecret) => !targetIds || targetIds.has(secret.id) + setSecrets((prev) => + prev.map((s) => + shouldSync(s) && (s.status === 'synced' || s.status === 'broken') ? { ...s, status: 'syncing' as const } : s + ) + ) + setTimeout(() => { + setSecrets((prev) => + prev.map((s) => (shouldSync(s) && s.status === 'syncing' ? { ...s, status: 'synced' as const } : s)) + ) + }, 3000) + }, []) + + const getSourceOption = useCallback((secret?: ExternalSecret) => { + if (!secret?.source) { + return SECRET_SOURCES[0] + } + return SECRET_SOURCES.find((option) => option.tableLabel === secret.source) ?? SECRET_SOURCES[0] + }, []) + + const handleOpenAddSecret = useCallback( + (isFile: boolean) => { + openModal({ + content: ( + { + setSecrets((prev) => [ + { + ...secret, + id: `secret-${Date.now()}`, + status: 'synced', + lastSync: 'just now', + }, + ...prev, + ]) + }} + /> + ), + options: { + width: 520, + fakeModal: true, + }, + }) + }, + [closeModal, openModal] + ) + + const handleOpenEditSecret = useCallback( + (secret: ExternalSecret) => { + openModal({ + content: ( + { + setSecrets((prev) => + prev.map((item) => + item.id === secret.id + ? { + ...item, + ...updated, + } + : item + ) + ) + }} + /> + ), + options: { + width: 520, + fakeModal: true, + }, + }) + }, + [closeModal, getSourceOption, openModal] + ) + + const handleOpenAttach = useCallback( + (selectedIds: string[]) => { + openModal({ + content: ( + { + setSecrets((prev) => + prev.map((secret) => + selectedIds.includes(secret.id) + ? { + ...secret, + source: source.tableLabel, + sourceIcon: source.icon, + } + : secret + ) + ) + setRowSelection({}) + closeModal() + }} + /> + ), + options: { + width: 520, + fakeModal: true, + }, + }) + }, + [closeModal, openModal] + ) + + const handleDeleteSecrets = useCallback((secretIds: string[]) => { + const idsToDelete = new Set(secretIds) + setSecrets((prev) => prev.filter((secret) => !idsToDelete.has(secret.id))) + setRowSelection((prev) => { + const next = { ...prev } + for (const secretId of secretIds) { + delete next[secretId] + } + return next + }) + }, []) + + const handleConfirmDeleteSecrets = useCallback( + (secretIds: string[]) => { + if (secretIds.length === 0) return + + const deletionTargetLabel = + secretIds.length === 1 + ? secrets.find((secret) => secret.id === secretIds[0])?.name ?? 'this variable' + : 'these variables' + + openModalConfirmation({ + title: `Delete ${secretIds.length} ${pluralize(secretIds.length, 'variable')}`, + name: deletionTargetLabel, + confirmationMethod: 'action', + action: () => handleDeleteSecrets(secretIds), + }) + }, + [handleDeleteSecrets, openModalConfirmation, secrets] + ) + + const emptyStateConfig = useMemo( + () => + match(selectedCaseId) + .with('secret-manager-addon-not-detected', () => ({ + title: 'Secret manager add-on not installed on your cluster', + description: 'Install it and start linking external secrets to your service', + icon: 'puzzle-piece' as const, + actions: ( + + ), + })) + .with('secret-manager-addon-no-manager', () => ({ + title: 'No secret manager linked on your cluster', + description: 'Secret add-on has been activated on your cluster but no secret manager are linked to it.', + icon: 'lock-keyhole' as const, + actions: ( +
+ + +
+ ), + })) + .with('secret-manager-addon-not-redeployed', () => ({ + title: 'Secret manager not deployed', + description: + 'We have detected linked secret manager on your cluster, but they have not been deployed yet. Secrets will be available as soon as their secret manager is deployed.', + icon: 'lock-keyhole' as const, + actions: ( + + ), + })) + .with('empty', () => ({ + title: 'No external secrets yet', + description: 'Add a secret or connect a secret manager to sync external secrets.', + icon: 'lock-keyhole' as const, + actions: ( + + ), + })) + .otherwise(() => null), + [handleOpenAddSecret, selectedCaseId] + ) + + const columns = useMemo( + () => [ + columnHelper.display({ + id: 'select', + enableColumnFilter: false, + enableSorting: false, + header: ({ table }) => ( +
+ { + if (checked === 'indeterminate') return + table.toggleAllRowsSelected(checked) + }} + /> +
+ ), + cell: ({ row }) => ( + + ), + }), + columnHelper.accessor('name', { + header: 'Name', + enableSorting: true, + cell: (info) => { + const secret = info.row.original + const showFilePath = secret.isFile && secret.filePath + return ( +
+
+ + {secret.name} + + {secret.description && ( + + + + + + )} +
+ {showFilePath && ( +
+ + {secret.filePath} +
+ )} +
+ ) + }, + }), + columnHelper.accessor('reference', { + header: 'Reference', + enableSorting: true, + cell: (info) => {info.getValue()}, + }), + columnHelper.accessor('status', { + header: 'Status', + enableSorting: false, + cell: (info) => , + }), + columnHelper.accessor('source', { + header: 'Source', + enableSorting: false, + cell: (info) => { + const secret = info.row.original + if (!secret.source) { + return No source + } + return ( + + {secret.sourceIcon && ( + 'AWS' as const) + .with('gcp', () => 'GCP' as const) + .otherwise(() => 'AWS' as const)} + width="16" + height="16" + /> + )} + {secret.source} + + ) + }, + }), + columnHelper.accessor('lastSync', { + header: 'Last sync', + enableSorting: false, + cell: (info) => {info.getValue()}, + }), + columnHelper.display({ + id: 'actions', + cell: (info) => { + const secret = info.row.original + const isSyncing = secret.status === 'syncing' + return ( +
+ + + + + + + } + disabled={isSyncing} + onSelect={() => handleSynchronize([secret.id])} + > + Synchronize + + } + color="red" + disabled={isSyncing} + onSelect={() => handleConfirmDeleteSecrets([secret.id])} + > + Delete + + + +
+ ) + }, + }), + ], + [handleConfirmDeleteSecrets, handleOpenEditSecret, handleSynchronize] + ) + + const table = useReactTable({ + data: secrets, + columns, + state: { sorting, rowSelection, globalFilter: search }, + onSortingChange: setSorting, + onRowSelectionChange: setRowSelection, + onGlobalFilterChange: setSearch, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getRowId: (row) => row.id, + }) + + const shouldShowEmptyState = secrets.length === 0 && Boolean(emptyStateConfig) + const totalRows = table.getPreFilteredRowModel().rows.length + const isSearching = table.getRowCount() !== totalRows + const countText = isSearching + ? `${table.getRowCount()}/${totalRows} external ${pluralize(table.getRowCount(), 'secret')}` + : `${totalRows} external ${pluralize(totalRows, 'secret')}` + const selectedRows = table.getSelectedRowModel().rows.map(({ original }) => original) + const selectedIds = selectedRows.map((secret) => secret.id) + const hasSelection = selectedIds.length > 0 + + return ( +
+ {/* Header bar */} + {!shouldShowEmptyState && ( +
+ {countText} +
+ setSearch(e.target.value)} + /> + + + + + + + {ADD_SECRET_OPTIONS.map((option) => ( + } + onSelect={() => handleOpenAddSecret(option.value === 'file')} + > + {option.label} + + ))} + + +
+
+ )} + + {/* Table */} + {shouldShowEmptyState && emptyStateConfig ? ( +
+ + {emptyStateConfig.actions} + +
+ ) : ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.column.getCanSort() ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + + )} + +
+
+ + {selectedIds.length} selected {pluralize(selectedIds.length, 'secret')} + +
+ + + + +
+
+
+
+ ) +} 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..831b7b71dea 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,14 @@ 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' +import { Heading, Icon, LoaderSpinner, Navbar, Section } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { BuiltInTab } from './variables-built-in-tab' +import { CustomTab } from './variables-custom-tab' +import { ExternalSecretsTab } from './variables-external-secrets-tab' + +type VariableTab = 'custom' | 'external-secrets' | 'built-in' export const Route = createFileRoute( '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables' @@ -20,6 +20,8 @@ function RouteComponent() { const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) useDocumentTitle('Service - Variables') + const [activeTab, setActiveTab] = useState('custom') + const { data: service } = useService({ environmentId, serviceId, @@ -59,95 +61,64 @@ 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/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/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index a01a44343e0..ab5b892e7ab 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -29,6 +29,7 @@ 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/cluster-general-settings/cluster-general-settings' 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..0377dcaf6cb 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,31 @@ 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' + region?: string + roleArn?: string + accessKey?: string + secretAccessKey?: string +} + +export type ClusterAddonsData = { + observabilityActivated: boolean + kedaActivated: boolean + secretManagers: ClusterAddonsSecretManager[] +} + export const ClusterContainerCreateContext = createContext( undefined ) @@ -68,6 +90,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 +102,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 +151,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 +209,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..48a10f3127b --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-creation-flow/step-addons/step-addons.tsx @@ -0,0 +1,775 @@ +import { type FormEventHandler, useEffect, useMemo, useState } from 'react' +import { useDropzone } from 'react-dropzone' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { + Badge, + Button, + Callout, + CopyButton, + DropdownMenu, + Dropzone, + ExternalLink, + FunnelFlowBody, + Heading, + Icon, + IconFlag, + InputSelect, + InputText, + Link, + Navbar, + Section, + useModal, +} from '@qovery/shared/ui' +import { type CloudProvider, type ClusterRegion } from 'qovery-typescript-axios' +import { useCloudProviders } from '@qovery/domains/cloud-providers/feature' +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 + } + +type SecretManagerOption = { + value: 'aws-manager' | 'aws-parameter' | 'gcp-secret' + label: string + icon: 'AWS' | 'GCP' + typeLabel: string +} + +type IntegrationTab = 'automatic' | 'manual' + +type SecretManagerIntegrationFormValues = { + authenticationType: string + region: string + roleArn: string + accessKey: string + secretAccessKey: string + secretManagerName: string +} + +const SECRET_MANAGER_OPTIONS: SecretManagerOption[] = [ + { + value: 'aws-manager', + label: 'AWS Manager type', + icon: 'AWS', + typeLabel: 'AWS Manager type', + }, + { + 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 SecretManagerIntegrationModal({ + option, + regionOptions, + clusterProvider, + mode = 'create', + initialValues, + onClose, + onSubmit, +}: { + option: SecretManagerOption + regionOptions: Array<{ label: string; value: string; icon?: JSX.Element }> + clusterProvider?: string + mode?: 'create' | 'edit' + initialValues?: ClusterAddonsSecretManager + onClose: () => void + onSubmit: (payload: ClusterAddonsSecretManager) => void +}) { + const [activeTab, setActiveTab] = useState( + initialValues?.authentication === 'Manual' ? 'manual' : 'automatic' + ) + const methods = useForm({ + mode: 'onChange', + defaultValues: { + authenticationType: initialValues?.authType ?? '', + region: initialValues?.region ?? '', + roleArn: initialValues?.roleArn ?? '', + accessKey: initialValues?.accessKey ?? '', + secretAccessKey: initialValues?.secretAccessKey ?? '', + secretManagerName: initialValues?.name ?? '', + }, + }) + + const authenticationOptions = useMemo( + () => [ + { label: 'Assume role via STS', value: 'sts' }, + { label: 'Static credentials', value: 'static' }, + ], + [] + ) + + const authenticationType = methods.watch('authenticationType') + const isStaticCredentials = authenticationType === 'static' + const isAwsCluster = clusterProvider === 'AWS' + const isGcpSecretManagerOnAws = option.value === 'gcp-secret' && isAwsCluster + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + multiple: false, + accept: { 'application/json': ['.json'] }, + }) + + useEffect(() => { + if (activeTab === 'manual' && !authenticationType) { + methods.setValue('authenticationType', 'sts', { shouldDirty: false }) + } + }, [activeTab, authenticationType, methods]) + + const handleSubmit = methods.handleSubmit((data) => { + onSubmit({ + id: initialValues?.id ?? `secret-manager-${Date.now()}`, + name: data.secretManagerName.trim() || 'Secret manager', + typeLabel: option.typeLabel, + authentication: activeTab === 'manual' ? 'Manual' : 'Automatic', + provider: option.icon, + source: option.value, + authType: + activeTab === 'manual' && data.authenticationType + ? (data.authenticationType as 'sts' | 'static') + : undefined, + region: data.region || undefined, + roleArn: data.roleArn || undefined, + accessKey: data.accessKey || undefined, + secretAccessKey: data.secretAccessKey || undefined, + }) + onClose() + }) + + const handleGcpAwsSubmit = methods.handleSubmit((data) => { + onSubmit({ + id: initialValues?.id ?? `secret-manager-${Date.now()}`, + name: data.secretManagerName.trim() || 'Secret manager', + typeLabel: option.typeLabel, + authentication: 'Manual', + provider: option.icon, + source: option.value, + authType: 'static', + }) + onClose() + }) + + useEffect(() => { + methods.trigger().then() + }, [methods.trigger]) + + if (isGcpSecretManagerOnAws) { + return ( + +
+
+

{`${option.label} integration`}

+

+ Link your AWS secret manager to use external secrets on all service running on your cluster +

+
+
+
+

+ 1. Connect to your GCP Console and create/open a project +

+

Make sure you are connected to the right GCP account

+ + https://console.cloud.google.com/ + +
+
+

+ 2. Open the embedded Google shell and run the following command +

+
+
+ $ + curl https://setup.qovery.com/create_credentials_gcp.sh | \ bash -s -- $GOOGLE_CLOUD_PROJECT + qovery_role qovery-service-account{' '} +
+ +
+
+
+

+ 3. Download the key.json generated and drag and drop it here +

+
+ + +
+ ( + + )} + /> +
+
+
+ + +
+
+
+ ) + } + + return ( + +
+
+

{`${option.label} integration`}

+

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

+
+ + setActiveTab('automatic')}> + + Automatic integration + + setActiveTab('manual')}> + + Manual integration + + +
+
+
+ {activeTab === 'automatic' && ( +
+
+

Automatic integration

+

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

+
+
+ ( + 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 + + + )} +
+ )} + + {activeTab === 'manual' && ( +
+ ( + field.onChange(value as string)} + options={authenticationOptions} + portal + /> + )} + /> + + {!authenticationType && ( +

+ Select an authentication type to see the required information. +

+ )} + {authenticationType && isStaticCredentials ? ( + <> +
+

1. Create a user for Qovery

+

Follow the instructions available on this page

+ + How to create new credentials + +
+
+

2. Fill in these information

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + ) : authenticationType ? ( +
+
+

1. Connect to your AWS Console

+

Make sure you are connected to the right AWS account

+ + https://aws.amazon.com/fr/console/ + +
+
+

+ 2. Create a role for Qovery and grant assume role permissions +

+

+ Execute the following Cloudformation stack and retrieve the role ARN from the “Output” section. +

+ + Cloudformation stack + +
+
+

3. Provide your credentials info

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> +
+
+ ) : null} +
+ )} +
+
+ + +
+
+
+ ) +} + +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 handleFormSubmit: FormEventHandler = (event) => { + event.preventDefault() + onSubmit() + } + + useEffect(() => { + setAddonsData({ + observabilityActivated: Boolean(activatedAddons['qovery-observe']), + kedaActivated: Boolean(activatedAddons['keda-autoscaler']), + secretManagers: integrations, + }) + }, [activatedAddons, 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. +

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

{addon.description}

+
+ {addon.primaryAction.type === 'toggle' ? ( +
+ + {addon.secondaryAction && ( + + )} +
+ ) : ( +
+ + + + + + {SECRET_MANAGER_OPTIONS.map((option) => ( + } + onClick={() => 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 && ( +
+ )} +
+ ) + })} +
+ )} +
+ + +
+ )} +
+
+ 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 +392,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 +419,101 @@ 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 (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 +526,8 @@ export function CreateCloneEnvironmentModal({ navigate({ to: `/organization/${organizationId}/project/${project_id}/environment/${result.id}/overview`, }) + onClose() } - onClose() }) const environmentModes = [ @@ -100,178 +550,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..e3ca1a4b9ca 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,11 +246,11 @@ export function MenuManageDeployment({ export function MenuOtherActions({ state, environment, - variant = 'default', + cloneUseCaseId, }: { state: StateEnum environment: Environment - variant?: ActionToolbarVariant + cloneUseCaseId?: string }) { const { openModal, closeModal } = useModal() const { openModalConfirmation } = useModalConfirmation() @@ -274,6 +274,7 @@ export function MenuOtherActions({ } const openCloneModal = () => { + const modalWidth = cloneUseCaseId === 'multi-source' ? 676 : undefined openModal({ content: ( ), options: { fakeModal: true, + ...(modalWidth ? { width: modalWidth } : {}), }, }) } @@ -348,9 +351,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 +387,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..514272b8510 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,8 +17,7 @@ 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 { data: environments = [] } = useEnvironments({ projectId, suspense: true }) const environment = environments.find((env) => env.id === overview.id) @@ -108,8 +107,16 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { > {environment && overview.deployment_status && overview.service_count > 0 && ( <> - - + + )} @@ -122,10 +129,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 +202,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..651c5c355e8 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 @@ -101,6 +101,11 @@ function EnvironmentsTableSkeleton() { } function EnvironmentsTableContent() { +export interface EnvironmentsTableProps { + cloneUseCaseId?: string +} + +export function EnvironmentsTable({ cloneUseCaseId }: EnvironmentsTableProps) { const { openModal, closeModal } = useModal() const { organizationId = '', projectId = '' } = useParams({ strict: false }) const { data: project } = useProject({ organizationId, projectId, suspense: true }) @@ -122,14 +127,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 +150,7 @@ function EnvironmentsTableContent() { type={section} items={groupedEnvs?.get(section) || []} onCreateEnvClicked={() => onCreateEnvClicked(section)} + cloneUseCaseId={cloneUseCaseId} /> ))} diff --git a/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx b/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx index c4af355ee76..6f4293747ee 100644 --- a/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx +++ b/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx @@ -12,7 +12,7 @@ import { useReactTable, } from '@tanstack/react-table' import { type APIVariableScopeEnum, type APIVariableTypeEnum, type VariableResponse } from 'qovery-typescript-axios' -import { Fragment, useMemo, useState } from 'react' +import { Fragment, type ReactNode, useMemo, useState } from 'react' import { match } from 'ts-pattern' import { ExternalServiceEnum, IconEnum } from '@qovery/shared/enums' import { APPLICATION_GENERAL_URL, APPLICATION_URL, DATABASE_GENERAL_URL, DATABASE_URL } from '@qovery/shared/routes' @@ -53,6 +53,9 @@ type Scope = Exclude export type VariableListProps = { className?: string + hideSectionLabel?: boolean + showOnly?: 'custom' | 'built-in' + headerActions?: ReactNode onCreateVariable?: (variable: VariableResponse | void) => void onEditVariable?: (variable: VariableResponse | void) => void onDeleteVariable?: (variable: VariableResponse) => void @@ -78,6 +81,9 @@ export type VariableListProps = { export function VariableList({ className, + hideSectionLabel = false, + showOnly, + headerActions, onCreateVariable, onEditVariable, onDeleteVariable, @@ -170,15 +176,17 @@ export function VariableList({ ? 'grid w-full grid-cols-[32px_minmax(0,40%)_50px_minmax(0%,40%)_minmax(0,12%)]' : isEnvironmentScope ? 'grid w-full grid-cols-[32px_minmax(0,40%)_50px_minmax(0,30%)_minmax(0,15%)_minmax(0,12%)]' - : 'grid w-full grid-cols-[32px_minmax(0,40%)_50px_minmax(0,20%)_minmax(0,15%)_minmax(0,10%)_minmax(0,12%)]' + : 'grid w-full grid-cols-[32px_minmax(0,40%)_minmax(0,20%)_minmax(0,15%)_minmax(0,10%)_minmax(0,12%)_88px]' const builtInGridLayoutClassName = props.scope === 'PROJECT' ? 'grid w-full grid-cols-[minmax(0,calc(40%_+_32px))_50px_minmax(0,40%)_minmax(0,12%)]' - : 'grid w-full grid-cols-[minmax(0,calc(40%_+_32px))_50px_minmax(0,30%)_minmax(0,15%)_minmax(0,12%)]' + : isEnvironmentScope + ? 'grid w-full grid-cols-[minmax(0,calc(40%_+_32px))_50px_minmax(0,30%)_minmax(0,15%)_minmax(0,12%)]' + : 'grid w-full grid-cols-[minmax(0,calc(40%_+_32px))_minmax(0,30%)_minmax(0,15%)_minmax(0,12%)_88px]' const columnHelper = createColumnHelper<(typeof variables)[number]>() - const columns = useMemo( - () => [ + const columns = useMemo(() => { + const baseColumns = [ columnHelper.display({ id: 'select', enableColumnFilter: false, @@ -220,6 +228,7 @@ export function VariableList({ columnHelper.accessor('key', { id: 'key', header: ({ table }) => { + if (headerActions) return 'Name' const totalRows = table.getPreFilteredRowModel().rows.length const isSearching = table.getRowCount() !== totalRows return isSearching @@ -230,6 +239,8 @@ export function VariableList({ size: showServiceLinkColumn ? 40 : 45, cell: (info) => { const variable = info.row.original + const isFileVariable = environmentVariableFile(variable) + const showFilePathUnderName = isServiceScope && showOnly === 'custom' && isFileVariable return (
@@ -253,7 +264,7 @@ export function VariableList({ OVERRIDE )} - {variable.mount_path && ( + {variable.mount_path && !showFilePathUnderName && ( FILE @@ -277,6 +288,12 @@ export function VariableList({ )}
+ {showFilePathUnderName && ( +
+ + {getEnvironmentVariableFileMountPath(variable)} +
+ )} {(variable.aliased_variable || variable.overridden_variable) && (
@@ -296,73 +313,195 @@ export function VariableList({ const disableOverride = match(variable.scope) .with('APPLICATION', 'CONTAINER', 'JOB', 'HELM', () => true) .otherwise(() => alreadyOverridden) + const isDoppler = variable.owned_by === ExternalServiceEnum.DOPPLER + const canEdit = isDoppler || variable.scope !== 'BUILT_IN' + const isBuiltIn = variable.scope === 'BUILT_IN' + const showAliasOnly = isServiceScope && showOnly === 'built-in' && isBuiltIn - return ( - - - - - - {variable.owned_by === ExternalServiceEnum.DOPPLER ? ( - } - onSelect={() => window.open('https://dashboard.doppler.com', '_blank')} - > - Edit in Doppler - - ) : ( - <> - {variable.scope !== 'BUILT_IN' && ( - } onSelect={() => _onEditVariable(variable)}> - Edit - - )} - {!variable.overridden_variable && !variable.aliased_variable && ( - <> - } - onSelect={() => _onCreateVariable(variable, 'ALIAS')} - > - Create alias + if (!isServiceScope) { + return ( + + + + + + {isDoppler ? ( + } + onSelect={() => window.open('https://dashboard.doppler.com', '_blank')} + > + Edit in Doppler + + ) : ( + <> + {variable.scope !== 'BUILT_IN' && ( + } onSelect={() => _onEditVariable(variable)}> + Edit - {variable.scope !== 'BUILT_IN' && variable.scope !== props.scope && ( + )} + {!variable.overridden_variable && !variable.aliased_variable && ( + <> } - disabled={disableOverride} - onSelect={() => _onCreateVariable(variable, 'OVERRIDE')} + icon={} + onSelect={() => _onCreateVariable(variable, 'ALIAS')} > - + {variable.scope !== 'BUILT_IN' && variable.scope !== props.scope && ( + } + disabled={disableOverride} + onSelect={() => _onCreateVariable(variable, 'OVERRIDE')} > - Create override - + + Create override + + + )} + + )} + {variable.owned_by === 'QOVERY' && variable.scope !== 'BUILT_IN' && ( + <> + + } + onSelect={() => _onDeleteVariable(variable)} + color="red" + > + Delete - )} - - )} - {variable.owned_by === 'QOVERY' && variable.scope !== 'BUILT_IN' && ( - <> - - } - onSelect={() => _onDeleteVariable(variable)} - color="red" - > - Delete - - - )} - - )} - - + + )} + + )} + + + ) + } + + if (showAliasOnly) { + if (variable.overridden_variable || variable.aliased_variable) { + return
+ } + + return ( + + ) + } + + return ( +
+ + + + + + + {isDoppler ? ( + } + onSelect={() => window.open('https://dashboard.doppler.com', '_blank')} + > + Edit in Doppler + + ) : ( + <> + {!variable.overridden_variable && !variable.aliased_variable && ( + <> + } + onSelect={() => _onCreateVariable(variable, 'ALIAS')} + > + Create alias + + {variable.scope !== 'BUILT_IN' && variable.scope !== props.scope && ( + } + disabled={disableOverride} + onSelect={() => _onCreateVariable(variable, 'OVERRIDE')} + > + + Create override + + + )} + + )} + {variable.owned_by === 'QOVERY' && variable.scope !== 'BUILT_IN' && ( + <> + + } + onSelect={() => _onDeleteVariable(variable)} + color="red" + > + Delete + + + )} + + )} + + +
) }, }), @@ -373,7 +512,9 @@ export function VariableList({ filterFn: 'arrIncludesSome', cell: (info) => { const variable = info.row.original - if (environmentVariableFile(variable)) { + const shouldRenderFilePathInName = isServiceScope && showOnly === 'custom' + + if (environmentVariableFile(variable) && !shouldRenderFilePathInName) { return (
_onEditVariable(variable)}> {variable.value !== null ? ( @@ -476,9 +617,21 @@ export function VariableList({ ) }, }), - ], - [variables.length, _onCreateVariable, _onEditVariable, props.scope] - ) + ] + + if (!isServiceScope) { + return baseColumns + } + + const actionsColumn = baseColumns.find((column) => (column as { id?: string }).id === 'actions') + const orderedColumns = baseColumns.filter((column) => (column as { id?: string }).id !== 'actions') + + if (actionsColumn) { + orderedColumns.push(actionsColumn) + } + + return orderedColumns + }, [variables.length, _onCreateVariable, _onEditVariable, props.scope, showOnly, !!headerActions, isServiceScope]) const nonBuiltInColumns = useMemo(() => { if (!isEnvironmentScope && props.scope !== 'PROJECT') { return columns @@ -599,7 +752,7 @@ export function VariableList({ ) } @@ -611,10 +764,31 @@ export function VariableList({ rowGridClassName: string, isBuiltInTable: boolean ) => { - const hideServiceLinkColumn = isServiceScope && !isBuiltInTable + const totalRows = tableInstance.getPreFilteredRowModel().rows.length + const isSearching = tableInstance.getRowCount() !== totalRows + const countText = isSearching + ? `${tableInstance.getRowCount()}/${totalRows} ${pluralize(tableInstance.getRowCount(), 'variable')}` + : `${totalRows} ${pluralize(totalRows, 'variable')}` + return (
- + {headerActions && ( +
+ {countText} +
+ setFilterValue(event.target.value)} + /> + {headerActions} +
+
+ )} + {tableInstance.getHeaderGroups().map((headerGroup) => ( @@ -622,8 +796,11 @@ export function VariableList({ // Keep this column hidden (not removed) in Service scope (custom vars only) to preserve visual column alignment @@ -653,9 +830,10 @@ export function VariableList({ ) : ( flexRender(header.column.columnDef.header, header.getContext()) )} - {header.column.id === 'key' && ( + {header.column.id === 'key' && !headerActions && ( setFilterValue(event.target.value)} /> @@ -675,8 +853,11 @@ export function VariableList({ {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -695,17 +876,17 @@ export function VariableList({ return (
- {nonBuiltInVariables.length > 0 && ( -
- Custom variables - {renderTable(table, globalFilter, setGlobalFilter, gridLayoutClassName, false)} -
+ {showOnly !== 'built-in' && nonBuiltInVariables.length > 0 && ( +
+ {!hideSectionLabel &&

Custom variables

} + {renderTable(table, globalFilter, setGlobalFilter, gridLayoutClassName)} +
)} - {builtInVariables.length > 0 && ( -
- Built-in variables - {renderTable(builtInTable, builtInGlobalFilter, setBuiltInGlobalFilter, builtInGridLayoutClassName, true)} -
+ {showOnly !== 'custom' && builtInVariables.length > 0 && ( +
+ {!hideSectionLabel &&

Built-in variables

} + {renderTable(builtInTable, builtInGlobalFilter, setBuiltInGlobalFilter, builtInGridLayoutClassName)} +
)} table.resetRowSelection()} />
diff --git a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx index 484b616e45c..4e68d8fb576 100644 --- a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx +++ b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx @@ -7,7 +7,7 @@ type Scope = Exclude export type VariablesActionToolbarProps = { onCreateVariable?: (variable: VariableResponse | void) => void onImportEnvFile?: () => void - importEnvFileAccess?: 'button' | 'dropdown' + showDopplerButton?: boolean } & ( | { scope: Extract @@ -29,7 +29,7 @@ export type VariablesActionToolbarProps = { export function VariablesActionToolbar({ onCreateVariable, onImportEnvFile, - importEnvFileAccess = 'button', + showDopplerButton = false, ...props }: VariablesActionToolbarProps) { const { openModal, closeModal } = useModal() @@ -54,52 +54,57 @@ export function VariablesActionToolbar({ }) return ( -
- - {showImportButton ? ( - - - - ) : ( - - - - )} - - {showImportButton && onImportEnvFile && ( - }> - Import from .env file - +
+ {showDopplerButton ? ( + + ) : ( + + {onImportEnvFile ? ( + + + + ) : ( + + + )} + + {onImportEnvFile && ( + }> + Import from .env file + + )} - {!showImportButton && onImportEnvFile && ( - }> - Import from .env file + }> + + Import from Doppler + + + + + + - )} - - }> - - Import from Doppler - - - - - - - - - + + + )} +
-

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

-
-
- -
-
+ )}
diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx index 9c466825a5d..d0321da7722 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx @@ -1,8 +1,11 @@ import { + type ColumnFiltersState, type RowSelectionState, type SortingState, createColumnHelper, flexRender, + getFacetedRowModel, + getFacetedUniqueValues, getCoreRowModel, getFilteredRowModel, getSortedRowModel, @@ -26,6 +29,7 @@ import { SyncStatusBadge, TableFilterSearch, TablePrimitives, + TableFilter, Tooltip, useModal, useModalConfirmation, @@ -292,6 +296,7 @@ function AddSecretModal({ className="mb-3 w-full" name={field.name} label="Source" + portal options={SECRET_SOURCES.map((option) => ({ label: option.label, value: option.value, @@ -440,6 +445,7 @@ function AttachSecretsModal({ selectedCount, onClose, onAttach }: AttachSecretsM className="mb-3 w-full" name={field.name} label="Source" + portal options={SECRET_SOURCES.map((option) => ({ label: option.label, value: option.value, @@ -502,6 +508,7 @@ export function ExternalSecretsTab() { const [search, setSearch] = useState('') const [secrets, setSecrets] = useState(baseSecrets) const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) const [rowSelection, setRowSelection] = useState({}) const { openModal, closeModal } = useModal() const { openModalConfirmation } = useModalConfirmation() @@ -510,6 +517,7 @@ export function ExternalSecretsTab() { setSecrets(baseSecrets) setSearch('') setSorting([]) + setColumnFilters([]) setRowSelection({}) }, [baseSecrets]) @@ -800,11 +808,22 @@ export function ExternalSecretsTab() { columnHelper.accessor('status', { header: 'Status', enableSorting: false, + enableColumnFilter: true, + filterFn: (row, columnId, filterValue) => { + if (!Array.isArray(filterValue) || filterValue.length === 0) return true + return filterValue.includes(row.getValue(columnId)) + }, cell: (info) => , }), - columnHelper.accessor('source', { + columnHelper.accessor((row) => row.source ?? 'No source', { + id: 'source', header: 'Source', enableSorting: false, + enableColumnFilter: true, + filterFn: (row, columnId, filterValue) => { + if (!Array.isArray(filterValue) || filterValue.length === 0) return true + return filterValue.includes(row.getValue(columnId)) + }, cell: (info) => { const secret = info.row.original if (!secret.source) { @@ -894,13 +913,16 @@ export function ExternalSecretsTab() { const table = useReactTable({ data: secrets, columns, - state: { sorting, rowSelection, globalFilter: search }, + state: { sorting, rowSelection, globalFilter: search, columnFilters }, onSortingChange: setSorting, onRowSelectionChange: setRowSelection, onGlobalFilterChange: setSearch, + onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), getRowId: (row) => row.id, }) @@ -981,7 +1003,9 @@ export function ExternalSecretsTab() { header.column.id === 'name' && 'border-r border-neutral' )} > - {header.column.getCanSort() ? ( + {['status', 'source'].includes(header.column.id) ? ( + + ) : header.column.getCanSort() ? ( - - - +
+ + + +
diff --git a/apps/console-v5/vite.config.ts b/apps/console-v5/vite.config.ts index 7411db3243e..4423fd28e66 100644 --- a/apps/console-v5/vite.config.ts +++ b/apps/console-v5/vite.config.ts @@ -102,3 +102,20 @@ export default defineConfig(({ mode }) => { }, } }) + +function resolveGitBranch() { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim() + if (branch && branch !== 'HEAD') { + return branch + } + const shortSha = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim() + return shortSha || 'unknown' + } catch { + return 'unknown' + } +} 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 index 48a10f3127b..f5deaaad02c 100644 --- 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 @@ -167,19 +167,29 @@ function SecretManagerIntegrationModal({ const authenticationType = methods.watch('authenticationType') const isStaticCredentials = authenticationType === 'static' const isAwsCluster = clusterProvider === 'AWS' + const isGcpCluster = clusterProvider === 'GCP' const isGcpSecretManagerOnAws = option.value === 'gcp-secret' && isAwsCluster + const isAwsSecretManagerOnGcp = option.icon === 'AWS' && isGcpCluster + const isManualOnlyGcpIntegration = isGcpSecretManagerOnAws + const isManualOnlyAwsIntegration = isAwsSecretManagerOnGcp + const isGcpManualTabOnGcpSecretManager = option.value === 'gcp-secret' && isGcpCluster && activeTab === 'manual' const { getRootProps, getInputProps, isDragActive } = useDropzone({ multiple: false, accept: { 'application/json': ['.json'] }, }) useEffect(() => { - if (activeTab === 'manual' && !authenticationType) { - methods.setValue('authenticationType', 'sts', { shouldDirty: false }) + if ( + (activeTab === 'manual' || isManualOnlyAwsIntegration) && + !authenticationType && + !(isGcpCluster && option.value === 'gcp-secret') + ) { + methods.setValue('authenticationType', isManualOnlyAwsIntegration ? 'static' : 'sts', { shouldDirty: false }) } - }, [activeTab, authenticationType, methods]) + }, [activeTab, authenticationType, isGcpCluster, isManualOnlyAwsIntegration, methods, option.value]) const handleSubmit = methods.handleSubmit((data) => { + const useGcpManualPayload = activeTab === 'manual' && isGcpCluster && option.value === 'gcp-secret' onSubmit({ id: initialValues?.id ?? `secret-manager-${Date.now()}`, name: data.secretManagerName.trim() || 'Secret manager', @@ -187,8 +197,9 @@ function SecretManagerIntegrationModal({ authentication: activeTab === 'manual' ? 'Manual' : 'Automatic', provider: option.icon, source: option.value, - authType: - activeTab === 'manual' && data.authenticationType + authType: useGcpManualPayload + ? 'static' + : activeTab === 'manual' && data.authenticationType ? (data.authenticationType as 'sts' | 'static') : undefined, region: data.region || undefined, @@ -212,68 +223,295 @@ function SecretManagerIntegrationModal({ onClose() }) + const handleAwsManualOnlySubmit = methods.handleSubmit((data) => { + onSubmit({ + id: initialValues?.id ?? `secret-manager-${Date.now()}`, + name: data.secretManagerName.trim() || 'Secret manager', + typeLabel: option.typeLabel, + authentication: 'Manual', + provider: option.icon, + source: option.value, + authType: 'static', + region: data.region || undefined, + roleArn: undefined, + accessKey: data.accessKey || undefined, + secretAccessKey: data.secretAccessKey || undefined, + }) + onClose() + }) + useEffect(() => { methods.trigger().then() }, [methods.trigger]) - if (isGcpSecretManagerOnAws) { + const renderGcpManualIntegrationSections = () => ( + <> +
+

1. Connect to your GCP Console and create/open a project

+

Make sure you are connected to the right GCP account

+ + https://console.cloud.google.com/ + +
+
+

+ 2. Open the embedded Google shell and run the following command +

+
+
+ $ + curl https://setup.qovery.com/create_credentials_gcp.sh | \ bash -s -- $GOOGLE_CLOUD_PROJECT qovery_role + qovery-service-account{' '} +
+ +
+
+
+

3. Download the key.json generated and drag and drop it here

+
+ + +
+ ( + + )} + /> +
+ + ) + + const renderAwsManualIntegrationSections = () => ( +
+ {isManualOnlyAwsIntegration ? ( + <> +
+

1. Create a user for Qovery

+

Follow the instructions available on this page

+ + How to create new credentials + +
+
+

2. Fill in these information

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + ) : ( + <> + ( + field.onChange(value as string)} + options={authenticationOptions} + portal + /> + )} + /> + + {!authenticationType && ( +

Select an authentication type to see the required information.

+ )} + {authenticationType && isStaticCredentials ? ( + <> +
+

1. Create a user for Qovery

+

Follow the instructions available on this page

+ + How to create new credentials + +
+
+

2. Fill in these information

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + ) : authenticationType ? ( +
+
+

1. Connect to your AWS Console

+

Make sure you are connected to the right AWS account

+ + https://aws.amazon.com/fr/console/ + +
+
+

2. Create a role for Qovery and grant assume role permissions

+

+ Execute the following Cloudformation stack and retrieve the role ARN from the “Output” section. +

+ + Cloudformation stack + +
+
+

3. Provide your credentials info

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> +
+
+ ) : null} + + )} +
+ ) + + if (isManualOnlyGcpIntegration) { return (

{`${option.label} integration`}

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

-
-

- 1. Connect to your GCP Console and create/open a project -

-

Make sure you are connected to the right GCP account

- - https://console.cloud.google.com/ - -
-
-

- 2. Open the embedded Google shell and run the following command -

-
-
- $ - curl https://setup.qovery.com/create_credentials_gcp.sh | \ bash -s -- $GOOGLE_CLOUD_PROJECT - qovery_role qovery-service-account{' '} -
- -
-
-
-

- 3. Download the key.json generated and drag and drop it here -

-
- - -
- ( - - )} - /> -
+ {renderGcpManualIntegrationSections()}
+ +
+
+
+ ) + } + return (
@@ -362,164 +624,11 @@ bash -s -- $GOOGLE_CLOUD_PROJECT qovery_role qovery-service-account" )} {activeTab === 'manual' && ( -
- ( - field.onChange(value as string)} - options={authenticationOptions} - portal - /> - )} - /> - - {!authenticationType && ( -

- Select an authentication type to see the required information. -

- )} - {authenticationType && isStaticCredentials ? ( - <> -
-

1. Create a user for Qovery

-

Follow the instructions available on this page

- - How to create new credentials - -
-
-

2. Fill in these information

- ( - field.onChange(value as string)} - options={regionOptions} - isSearchable - portal - /> - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> -
- - ) : authenticationType ? ( -
-
-

1. Connect to your AWS Console

-

Make sure you are connected to the right AWS account

- - https://aws.amazon.com/fr/console/ - -
-
-

- 2. Create a role for Qovery and grant assume role permissions -

-

- Execute the following Cloudformation stack and retrieve the role ARN from the “Output” section. -

- - Cloudformation stack - -
-
-

3. Provide your credentials info

- ( - field.onChange(value as string)} - options={regionOptions} - isSearchable - portal - /> - )} - /> - ( - - )} - /> - ( - - )} - /> -
-
- ) : null} -
+ isGcpManualTabOnGcpSecretManager ? ( +
{renderGcpManualIntegrationSections()}
+ ) : ( + renderAwsManualIntegrationSections() + ) )}
@@ -557,6 +666,20 @@ function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProp 'keda-autoscaler': addonsData.kedaActivated, })) const [integrations, setIntegrations] = useState(() => addonsData.secretManagers) + 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() @@ -565,10 +688,10 @@ function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProp useEffect(() => { setAddonsData({ observabilityActivated: Boolean(activatedAddons['qovery-observe']), - kedaActivated: Boolean(activatedAddons['keda-autoscaler']), + kedaActivated: generalData?.cloud_provider === 'GCP' ? false : Boolean(activatedAddons['keda-autoscaler']), secretManagers: integrations, }) - }, [activatedAddons, integrations, setAddonsData]) + }, [activatedAddons, generalData?.cloud_provider, integrations, setAddonsData]) const getSecretManagerOption = (source: SecretManagerOption['value']) => SECRET_MANAGER_OPTIONS.find((option) => option.value === source) ?? SECRET_MANAGER_OPTIONS[0] @@ -612,10 +735,10 @@ function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProp
- {ADDONS.map((addon, index) => ( + {visibleAddons.map((addon, index) => (
@@ -661,7 +784,7 @@ function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProp - {SECRET_MANAGER_OPTIONS.map((option) => ( + {secretManagerDropdownOptions.map((option) => ( New variable + diff --git a/libs/shared/ui/src/lib/components/button-primitive/button-primitive.tsx b/libs/shared/ui/src/lib/components/button-primitive/button-primitive.tsx index 10a3453b498..be2bff3c1a6 100644 --- a/libs/shared/ui/src/lib/components/button-primitive/button-primitive.tsx +++ b/libs/shared/ui/src/lib/components/button-primitive/button-primitive.tsx @@ -31,8 +31,10 @@ const _buttonVariants = cva( color: { brand: ['outline-brand-strong'], neutral: ['outline-neutral-strong'], + neutralInverted: ['outline-neutral-strong'], green: ['outline-positive-strong'], red: ['outline-negative-strong'], + redInverted: ['outline-negative-strong'], yellow: ['outline-warning-strong'], current: [''], }, @@ -158,6 +160,19 @@ const _buttonVariants = cva( 'text-neutral', ], }, + { + variant: ['outline'], + color: 'neutralInverted', + className: [ + 'bg-surface-neutralInvert', + 'border', + 'border-neutralInvert', + 'hover:border-neutralInvert', + 'hover:bg-surface-neutralInvert-component', + 'data-[state=open]:bg-surface-neutralInvert-component', + 'text-neutralInvert', + ], + }, { variant: ['outline'], color: 'red', @@ -170,6 +185,19 @@ const _buttonVariants = cva( 'data-[state=open]:bg-surface-negative-subtle', ], }, + { + variant: ['outline'], + color: 'redInverted', + className: [ + 'border', + 'border-negativeInvert-subtle', + 'bg-surface-negativeInvert-component', + 'text-negativeInvert', + 'hover:border-negativeInvert-subtle', + 'hover:bg-surface-negativeInvert-component', + 'data-[state=open]:bg-surface-negativeInvert-component', + ], + }, { variant: ['outline'], color: 'yellow', diff --git a/libs/shared/ui/src/lib/styles/base/themes.scss b/libs/shared/ui/src/lib/styles/base/themes.scss index 6365c5a521c..4cad2cd5fa2 100644 --- a/libs/shared/ui/src/lib/styles/base/themes.scss +++ b/libs/shared/ui/src/lib/styles/base/themes.scss @@ -6,6 +6,7 @@ --background-1: hsla(0, 0%, 100%, 1); --background-2: hsla(270, 20%, 98%, 1); --background-overlay: hsla(0, 0%, 0%, 0.8); + --background-invert-1: hsla(270, 6%, 3%, 1); /* Neutral */ --neutral-1: hsla(300, 100%, 100%, 1); @@ -100,6 +101,9 @@ --negative-12: hsla(8, 50%, 24%, 1); /* Negative invert */ + --negative-invert-2: hsla(10, 24%, 10%, 1); + --negative-invert-3: hsla(5, 48%, 15%, 1); + --negative-invert-6: hsla(7, 55%, 28%, 1); --negative-invert-11: hsla(12, 100%, 75%, 1); /* Info */ @@ -135,6 +139,7 @@ --background-1: hsla(270, 6%, 3%, 1); --background-2: hsla(270, 6%, 7%, 1); --background-overlay: hsla(0, 0%, 0%, 0.8); + --background-invert-1: hsla(0, 0%, 100%, 1); /* Neutral */ --neutral-1: hsla(270, 6%, 7%, 1); @@ -228,6 +233,9 @@ --negative-12: hsla(10, 86%, 89%, 1); /* Negative invert */ + --negative-invert-2: hsla(8, 100%, 98%, 1); + --negative-invert-3: hsla(10, 92%, 95%, 1); + --negative-invert-6: hsla(11, 95%, 84%, 1); --negative-invert-11: hsla(10, 82%, 45%, 1); /* Info */ diff --git a/tailwind-workspace-preset.js b/tailwind-workspace-preset.js index 3ae4d678d62..66340fc5615 100644 --- a/tailwind-workspace-preset.js +++ b/tailwind-workspace-preset.js @@ -318,6 +318,10 @@ module.exports = { component: 'var(--negative-3)', subtle: 'var(--negative-2)', }, + negativeInvert: { + component: 'var(--negative-invert-3)', + subtle: 'var(--negative-invert-2)', + }, positive: { solid: 'var(--positive-9)', solidHover: 'var(--positive-10)', @@ -414,6 +418,10 @@ module.exports = { component: 'var(--negative-alpha-7)', subtle: 'var(--negative-6)', }, + negativeInvert: { + DEFAULT: 'var(--negative-invert-6)', + subtle: 'var(--negative-invert-6)', + }, warning: { strong: 'var(--warning-9)', component: 'var(--warning-alpha-7)', From f7e9141e58f23ee335e5ac0c74c171623bfd10b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 9 Mar 2026 17:49:32 +0100 Subject: [PATCH 09/18] refactor(vite.config): remove Git branch resolution logic to simplify configuration --- apps/console-v5/vite.config.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/apps/console-v5/vite.config.ts b/apps/console-v5/vite.config.ts index 4423fd28e66..9b9232b6948 100644 --- a/apps/console-v5/vite.config.ts +++ b/apps/console-v5/vite.config.ts @@ -8,6 +8,7 @@ import { join } from 'path' import { defineConfig, loadEnv } from 'vite' import { viteStaticCopy } from 'vite-plugin-static-copy' +<<<<<<< HEAD const readGitValue = (command: string): string | undefined => { try { const value = execSync(command, { @@ -37,6 +38,10 @@ export default defineConfig(({ mode }) => { clientEnv.NX_PUBLIC_GIT_SHA = gitSha } +======= +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') +>>>>>>> ddc4969b3 (refactor(vite.config): remove Git branch resolution logic to simplify configuration) return { root: __dirname, cacheDir: '../../node_modules/.vite/apps/console-v5', @@ -102,20 +107,3 @@ export default defineConfig(({ mode }) => { }, } }) - -function resolveGitBranch() { - try { - const branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) - .toString() - .trim() - if (branch && branch !== 'HEAD') { - return branch - } - const shortSha = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) - .toString() - .trim() - return shortSha || 'unknown' - } catch { - return 'unknown' - } -} From b84845e20f8bfdc50ddf3499d77ea9b517de02c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 9 Mar 2026 17:57:58 +0100 Subject: [PATCH 10/18] feat(service-variables): enhance service variables management with new tabs and external secrets integration --- .../service/$serviceId/variables.tsx | 23 ++++++++++++++---- libs/domains/services/feature/src/index.ts | 3 +++ .../service-variables-built-in-tab.tsx | 0 .../service-variables-custom-tab.tsx | 0 ...service-variables-external-secrets-tab.tsx | 24 +++++++++---------- 5 files changed, 33 insertions(+), 17 deletions(-) rename apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-built-in-tab.tsx => libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-built-in-tab.tsx (100%) rename apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-custom-tab.tsx => libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx (100%) rename apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables-external-secrets-tab.tsx => libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-external-secrets-tab.tsx (98%) 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 831b7b71dea..9b5daea46ed 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,12 +1,18 @@ import { createFileRoute, useParams } from '@tanstack/react-router' import { Suspense, useState } from 'react' import { match } from 'ts-pattern' -import { useDeployService, useService } from '@qovery/domains/services/feature' +import { + 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 { BuiltInTab } from './variables-built-in-tab' -import { CustomTab } from './variables-custom-tab' -import { ExternalSecretsTab } from './variables-external-secrets-tab' +import { useUseCasePage } from '../../../../../../../../../../app/components/use-cases/use-case-context' type VariableTab = 'custom' | 'external-secrets' | 'built-in' @@ -21,6 +27,11 @@ function RouteComponent() { 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, @@ -105,7 +116,9 @@ function RouteComponent() { toasterCallback={toasterCallback} /> )} - {activeTab === 'external-secrets' && } + {activeTab === 'external-secrets' && ( + + )} {activeTab === 'built-in' && scope && ( match(selectedCaseId) From 9352d47524565f0615e2c6dfd32ae8a163a4fc7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 9 Mar 2026 18:04:38 +0100 Subject: [PATCH 11/18] feat(secret-managers): enhance secret manager options with type labels and improve clone environment modal functionality --- .../cluster/$clusterId/settings/addons.tsx | 8 ++--- .../create-clone-environment-modal.tsx | 32 ++++++++----------- ...service-variables-external-secrets-tab.tsx | 4 --- .../action-toolbar/action-toolbar.tsx | 2 ++ 4 files changed, 20 insertions(+), 26 deletions(-) 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 index 3c3700b8925..aa584834cd3 100644 --- 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 @@ -74,10 +74,10 @@ const SECRET_MANAGER_USE_CASES: UseCaseOption[] = [ ] const SECRET_MANAGER_OPTIONS: SecretManagerOption[] = [ - { value: 'aws-manager', label: 'AWS Manager type', icon: 'AWS' }, - { value: 'aws-parameter', label: 'AWS Parameter store', icon: 'AWS' }, - { value: 'gcp-secret', label: 'GCP Secret manager', icon: 'GCP' }, -].map((option) => ({ ...option, typeLabel: option.label })) + { value: 'aws-manager', label: 'AWS Manager type', icon: 'AWS', typeLabel: 'AWS Manager type' }, + { 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 BASE_SECRET_MANAGERS: SecretManagerItem[] = [ { 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 511dcad82aa..778efeda2c2 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 @@ -36,6 +36,12 @@ 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 }, @@ -157,24 +163,10 @@ function CloneMigrationHelperModal({ - -
- - - ) - } - - return ( - -
-
-

{`${option.label} integration`}

-

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

-
- - setActiveTab('automatic')}> - - Automatic integration - - setActiveTab('manual')}> - - Manual integration - - -
-
-
- {activeTab === 'automatic' && ( -
-
-

Automatic integration

-

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

-
-
- ( - 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 - - - )} -
- )} - - {activeTab === 'manual' && ( -
- ( - field.onChange(value as string)} - options={authenticationOptions} - portal - /> - )} - /> - - {!authenticationType && ( -

- Select an authentication type to see the required information. -

- )} - {authenticationType && isStaticCredentials ? ( - <> -
-

1. Create a user for Qovery

-

Follow the instructions available on this page

- - How to create new credentials - -
-
-

2. Fill in these information

- ( - field.onChange(value as string)} - options={regionOptions} - isSearchable - portal - /> - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> -
- - ) : authenticationType ? ( -
-
-

1. Connect to your AWS Console

-

Make sure you are connected to the right AWS account

- - https://aws.amazon.com/fr/console/ - -
-
-

- 2. Create a role for Qovery and grant assume role permissions -

-

- Execute the following Cloudformation stack and retrieve the role ARN from the “Output” section. -

- - Cloudformation stack - -
-
-

3. Provide your credentials info

- ( - field.onChange(value as string)} - options={regionOptions} - isSearchable - portal - /> - )} - /> - ( - - )} - /> - ( - - )} - /> -
-
- ) : null} -
- )} -
-
- - -
-
-
- ) -} - function RouteComponent() { const { organizationId = '', clusterId = '' } = useParams({ strict: false }) const { data: cluster } = useCluster({ organizationId, clusterId }) @@ -735,7 +399,22 @@ function RouteComponent() { [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)) @@ -757,6 +436,8 @@ function RouteComponent() { option={option} regionOptions={regionOptions} clusterProvider={cluster?.cloud_provider} + hasAwsAutomaticIntegrationConfigured={hasAwsAutomaticIntegrationConfigured} + hasAwsManualStsIntegrationConfigured={hasAwsManualStsIntegrationConfigured} mode={integration ? 'edit' : 'create'} initialValues={integration} onClose={closeModal} @@ -764,7 +445,13 @@ function RouteComponent() { setSecretManagers((prev) => { if (integration) { return prev.map((item) => - item.id === integration.id ? { ...payload, usedByServices: integration.usedByServices ?? 0 } : item + item.id === integration.id + ? { + ...payload, + usedByServices: integration.usedByServices ?? 0, + associatedItems: integration.associatedItems, + } + : item ) } return [...prev, { ...payload, usedByServices: 0 }] @@ -779,6 +466,23 @@ function RouteComponent() { }) } + const openSecretManagerAssociatedServicesModal = (integration: SecretManagerItem) => { + openModal({ + content: ( + + ), + options: { + fakeModal: true, + }, + }) + } + const handleSave = async () => { if (!cluster) return @@ -920,8 +624,8 @@ function RouteComponent() {

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

@@ -967,12 +671,12 @@ function RouteComponent() { - {SECRET_MANAGER_OPTIONS.map((option) => ( + {secretManagerDropdownOptions.map((option) => ( } - onClick={() => openSecretManagerModal(option)} + onSelect={() => openSecretManagerModal(option)} > {option.label} @@ -1003,6 +707,26 @@ function RouteComponent() {
+ + {manager.usedByServices ?? 0} + + } + > + + {manager.authentication !== 'Automatic' && ( - -
- - - ) - } - - 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`} -

-
- - setActiveTab('automatic')}> - - Automatic integration - - setActiveTab('manual')}> - - Manual integration - - -
-
-
- {activeTab === 'automatic' && ( -
-
-

Automatic integration

-

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

-
-
- ( - 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 - - - )} -
- )} - - {activeTab === 'manual' && ( - isGcpManualTabOnGcpSecretManager ? ( -
{renderGcpManualIntegrationSections()}
- ) : ( - renderAwsManualIntegrationSections() - ) - )} -
-
- - -
-
-
- ) -} - function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProps) { const { openModal, closeModal } = useModal() const { generalData, addonsData, setAddonsData } = useClusterContainerCreateContext() @@ -666,9 +116,15 @@ function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProp '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 === 'GCP' ? ADDONS.filter((addon) => addon.id !== 'keda-autoscaler') : ADDONS), [generalData?.cloud_provider] ) const secretManagerDropdownOptions = useMemo(() => { @@ -703,6 +159,8 @@ function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProp option={option} regionOptions={regionOptions} clusterProvider={generalData?.cloud_provider} + hasAwsAutomaticIntegrationConfigured={hasAwsAutomaticIntegrationConfigured} + hasAwsManualStsIntegrationConfigured={hasAwsManualStsIntegrationConfigured} mode={integration ? 'edit' : 'create'} initialValues={integration} onClose={closeModal} @@ -789,7 +247,7 @@ function StepAddonsForm({ onSubmit, organizationId, backTo }: StepAddonsFormProp key={option.value} color="neutral" icon={} - onClick={() => openSecretManagerModal(option)} + onSelect={() => openSecretManagerModal(option)} > {option.label} diff --git a/libs/domains/clusters/feature/src/lib/secret-manager-modals/secret-manager-associated-services-modal.tsx b/libs/domains/clusters/feature/src/lib/secret-manager-modals/secret-manager-associated-services-modal.tsx new file mode 100644 index 00000000000..14804f6ecd8 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/secret-manager-modals/secret-manager-associated-services-modal.tsx @@ -0,0 +1,169 @@ +import { type ServiceTypeEnum } from 'qovery-typescript-axios' +import { useState } from 'react' +import { match } from 'ts-pattern' +import { Heading, Icon, InputSearch, Link, Section, TreeView } from '@qovery/shared/ui' + +export type SecretManagerAssociatedService = { + service_id: string + service_name: string + service_type: ServiceTypeEnum +} + +export type SecretManagerAssociatedEnvironment = { + environment_id: string + environment_name: string + services: SecretManagerAssociatedService[] +} + +export type SecretManagerAssociatedProject = { + project_id: string + project_name: string + environments: SecretManagerAssociatedEnvironment[] +} + +export interface SecretManagerAssociatedServicesModalProps { + associatedItems: SecretManagerAssociatedProject[] + organizationId: string + title: string + description: string + onClose: () => void +} + +function groupByProjectEnvironmentsServices( + data: SecretManagerAssociatedProject[], + searchValue?: string +): SecretManagerAssociatedProject[] { + if (!searchValue) { + return data + } + + const search = searchValue.toLowerCase() + + return data + .map((project) => { + const environments = project.environments + .map((environment) => ({ + ...environment, + services: environment.services.filter( + (service) => + project.project_name.toLowerCase().includes(search) || + environment.environment_name.toLowerCase().includes(search) || + service.service_name.toLowerCase().includes(search) + ), + })) + .filter( + (environment) => + project.project_name.toLowerCase().includes(search) || + environment.environment_name.toLowerCase().includes(search) || + environment.services.length > 0 + ) + + return { ...project, environments } + }) + .filter( + (project) => + project.project_name.toLowerCase().includes(search) || + project.environments.some( + (environment) => + environment.services.length > 0 || environment.environment_name.toLowerCase().includes(search) + ) + ) +} + +export function SecretManagerAssociatedServicesModal({ + associatedItems, + organizationId, + title, + description, + onClose, +}: SecretManagerAssociatedServicesModalProps) { + const [searchValue, setSearchValue] = useState() + const treeData = groupByProjectEnvironmentsServices(associatedItems, searchValue) + + return ( +
+ {title} +

{description}

+ setSearchValue(value)} + /> + {treeData.length > 0 ? ( + + {treeData.map((project) => ( + + {project.project_name} + + {project.environments.map((environment) => ( + + + + onClose()} + to="/organization/$organizationId/project/$projectId/environment/$environmentId" + params={{ + organizationId, + environmentId: environment.environment_id, + projectId: project.project_id, + }} + className="text-sm" + > + {environment.environment_name} + + + +
    + {environment.services.map((service) => ( +
  • + onClose()} + to="/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId" + params={{ + organizationId, + environmentId: environment.environment_id, + serviceId: service.service_id, + projectId: project.project_id, + }} + className="flex items-center py-1.5 pl-5 text-sm" + > + 'APPLICATION') + .with('CONTAINER', () => 'CONTAINER') + .with('DATABASE', () => 'DATABASE') + .with('HELM', () => 'HELM') + .with('JOB', () => 'JOB') + .with('TERRAFORM', () => 'TERRAFORM') + .exhaustive()} + width={20} + className="mr-2" + /> + {service.service_name} + +
  • + ))} +
+
+
+
+ ))} +
+
+ ))} +
+ ) : ( +
+ +

No value found

+
+ )} +
+ ) +} diff --git a/libs/domains/clusters/feature/src/lib/secret-manager-modals/secret-manager-integration-modal.tsx b/libs/domains/clusters/feature/src/lib/secret-manager-modals/secret-manager-integration-modal.tsx new file mode 100644 index 00000000000..92b5e720df8 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/secret-manager-modals/secret-manager-integration-modal.tsx @@ -0,0 +1,666 @@ +import { useEffect, useMemo, useState } from 'react' +import { useDropzone } from 'react-dropzone' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { + Button, + Callout, + CopyButton, + Dropzone, + ExternalLink, + Icon, + InputSelect, + InputText, + Navbar, + Tooltip, +} from '@qovery/shared/ui' +import { type ClusterAddonsSecretManager } from '../cluster-creation-flow/cluster-creation-flow' + +export type SecretManagerOption = { + value: 'aws-manager' | 'aws-parameter' | 'gcp-secret' + label: string + icon: 'AWS' | 'GCP' + typeLabel: string +} + +type IntegrationTab = 'automatic' | 'manual' + +type SecretManagerIntegrationFormValues = { + authenticationType: string + gcpProjectId: string + region: string + roleArn: string + accessKey: string + secretAccessKey: string + secretManagerName: string +} + +const AUTOMATIC_INTEGRATION_DISABLED_TOOLTIP = + 'Automatic integration is unavailable because an STS manual integration is already configured.' + +const STATIC_CREDENTIALS_DISABLED_TOOLTIP = + 'Static credentials are the only available option while automatic integration is configured' + +export interface SecretManagerIntegrationModalProps { + option: SecretManagerOption + regionOptions: Array<{ label: string; value: string; icon?: JSX.Element }> + clusterProvider?: string + hasAwsAutomaticIntegrationConfigured?: boolean + hasAwsManualStsIntegrationConfigured?: boolean + mode?: 'create' | 'edit' + initialValues?: ClusterAddonsSecretManager + onClose: () => void + onSubmit: (payload: ClusterAddonsSecretManager) => void +} + +export function SecretManagerIntegrationModal({ + option, + regionOptions, + clusterProvider, + hasAwsAutomaticIntegrationConfigured = false, + hasAwsManualStsIntegrationConfigured = false, + mode = 'create', + initialValues, + onClose, + onSubmit, +}: SecretManagerIntegrationModalProps) { + const isAwsCluster = clusterProvider === 'AWS' + const isAwsIntegration = option.icon === 'AWS' + const isAwsAutomaticIntegrationBlockedByExistingRoleArn = + isAwsCluster && isAwsIntegration && hasAwsManualStsIntegrationConfigured + const isAwsStaticCredentialsBlockedByExistingAutomatic = + isAwsCluster && isAwsIntegration && hasAwsAutomaticIntegrationConfigured + + const [activeTab, setActiveTab] = useState(() => + initialValues?.authentication === 'Manual' || isAwsAutomaticIntegrationBlockedByExistingRoleArn + ? 'manual' + : 'automatic' + ) + const methods = useForm({ + mode: 'onChange', + defaultValues: { + authenticationType: initialValues?.authType ?? '', + gcpProjectId: initialValues?.gcpProjectId ?? '', + region: initialValues?.region ?? '', + roleArn: initialValues?.roleArn ?? '', + accessKey: initialValues?.accessKey ?? '', + secretAccessKey: initialValues?.secretAccessKey ?? '', + secretManagerName: initialValues?.name ?? '', + }, + }) + + const authenticationOptions = useMemo( + () => + isAwsStaticCredentialsBlockedByExistingAutomatic + ? [{ label: 'Static credentials', value: 'static' }] + : [ + { label: 'Assume role via STS', value: 'sts' }, + { label: 'Static credentials', value: 'static' }, + ], + [isAwsStaticCredentialsBlockedByExistingAutomatic] + ) + + const authenticationType = methods.watch('authenticationType') + const isStaticCredentials = authenticationType === 'static' + const isGcpCluster = clusterProvider === 'GCP' + const isGcpSecretManagerOnAws = option.value === 'gcp-secret' && isAwsCluster + const isAwsSecretManagerOnGcp = option.icon === 'AWS' && isGcpCluster + const isManualOnlyGcpIntegration = isGcpSecretManagerOnAws + const isManualOnlyAwsIntegration = isAwsSecretManagerOnGcp + const isGcpManualTabOnGcpSecretManager = option.value === 'gcp-secret' && isGcpCluster && activeTab === 'manual' + const shouldForceStaticCredentials = isAwsStaticCredentialsBlockedByExistingAutomatic + const shouldForceStsCredentials = isAwsAutomaticIntegrationBlockedByExistingRoleArn + const showAutomaticTabFirst = !isAwsAutomaticIntegrationBlockedByExistingRoleArn + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + multiple: false, + accept: { 'application/json': ['.json'] }, + }) + + useEffect(() => { + if (shouldForceStaticCredentials) { + methods.setValue('authenticationType', 'static', { shouldDirty: false }) + return + } + + if (shouldForceStsCredentials) { + methods.setValue('authenticationType', 'sts', { shouldDirty: false }) + return + } + + if ( + (activeTab === 'manual' || isManualOnlyAwsIntegration) && + !authenticationType && + !(isGcpCluster && option.value === 'gcp-secret') + ) { + methods.setValue('authenticationType', isManualOnlyAwsIntegration ? 'static' : 'sts', { shouldDirty: false }) + } + }, [ + activeTab, + authenticationType, + isGcpCluster, + isManualOnlyAwsIntegration, + methods, + option.value, + shouldForceStsCredentials, + shouldForceStaticCredentials, + ]) + + const handleSubmit = methods.handleSubmit((data) => { + const useGcpManualPayload = activeTab === 'manual' && isGcpCluster && option.value === 'gcp-secret' + onSubmit({ + id: initialValues?.id ?? `secret-manager-${Date.now()}`, + name: data.secretManagerName.trim() || 'Secret manager', + typeLabel: option.typeLabel, + authentication: activeTab === 'manual' ? 'Manual' : 'Automatic', + provider: option.icon, + source: option.value, + authType: useGcpManualPayload + ? 'static' + : activeTab === 'manual' && data.authenticationType + ? (data.authenticationType as 'sts' | 'static') + : undefined, + gcpProjectId: data.gcpProjectId || undefined, + region: data.region || undefined, + roleArn: data.roleArn || undefined, + accessKey: data.accessKey || undefined, + secretAccessKey: data.secretAccessKey || undefined, + }) + onClose() + }) + + const handleGcpAwsSubmit = methods.handleSubmit((data) => { + onSubmit({ + id: initialValues?.id ?? `secret-manager-${Date.now()}`, + name: data.secretManagerName.trim() || 'Secret manager', + typeLabel: option.typeLabel, + authentication: 'Manual', + provider: option.icon, + source: option.value, + authType: 'static', + }) + onClose() + }) + + const handleAwsManualOnlySubmit = methods.handleSubmit((data) => { + onSubmit({ + id: initialValues?.id ?? `secret-manager-${Date.now()}`, + name: data.secretManagerName.trim() || 'Secret manager', + typeLabel: option.typeLabel, + authentication: 'Manual', + provider: option.icon, + source: option.value, + authType: 'static', + region: data.region || undefined, + roleArn: undefined, + accessKey: data.accessKey || undefined, + secretAccessKey: data.secretAccessKey || undefined, + }) + onClose() + }) + + useEffect(() => { + methods.trigger().then() + }, [methods]) + + const renderGcpManualIntegrationSections = () => ( + <> +
+

1. Connect to your GCP Console and create/open a project

+

Make sure you are connected to the right GCP account

+ + https://console.cloud.google.com/ + +
+
+

+ 2. Open the embedded Google shell and run the following command +

+
+
+ $ + curl https://setup.qovery.com/create_credentials_gcp.sh | \ bash -s -- $GOOGLE_CLOUD_PROJECT qovery_role + qovery-service-account{' '} +
+ +
+
+
+

+ 3. Download the key.json generated and drag and drop it here +

+
+ + +
+ ( + + )} + /> +
+ + ) + + const renderAwsManualIntegrationSections = () => ( +
+ {isManualOnlyAwsIntegration ? ( + <> +
+

1. Create a user for Qovery

+

Follow the instructions available on this page

+ + How to create new credentials + +
+
+

2. Fill in these information

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + ) : ( + <> + { + const authenticationTypeSelect = ( + field.onChange(value as string)} + options={authenticationOptions} + disabled={shouldForceStaticCredentials} + portal + /> + ) + + if (shouldForceStaticCredentials) { + return ( + +
{authenticationTypeSelect}
+
+ ) + } + + return authenticationTypeSelect + }} + /> + + {!authenticationType && ( +

+ Select an authentication type to see the required information. +

+ )} + {authenticationType && isStaticCredentials ? ( + <> +
+

1. Create a user for Qovery

+

Follow the instructions available on this page

+ + How to create new credentials + +
+
+

2. Fill in these information

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + ) : authenticationType ? ( +
+
+

1. Connect to your AWS Console

+

Make sure you are connected to the right AWS account

+ + https://aws.amazon.com/fr/console/ + +
+
+

+ 2. Create a role for Qovery and grant assume role permissions +

+

+ Execute the following Cloudformation stack and retrieve the role ARN from the “Output” section. +

+ + Cloudformation stack + +
+
+

3. Provide your credentials info

+ ( + field.onChange(value as string)} + options={regionOptions} + isSearchable + portal + /> + )} + /> + ( + + )} + /> + ( + + )} + /> +
+
+ ) : null} + + )} +
+ ) + + if (isManualOnlyGcpIntegration) { + return ( + +
+
+

{`${option.label} integration`}

+

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

+
+
{renderGcpManualIntegrationSections()}
+
+ + +
+
+
+ ) + } + + 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 778efeda2c2..1bcb2d49fd5 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 @@ -222,7 +222,11 @@ function CloneMigrationHelperModal({ }`} > >>>>>> f98bdb5c4 (feat(secret-managers): integrate secret manager associated services and enhance variable management with new tabs) iconStyle="regular" className={selectedAction === 'detach' ? 'text-brand' : undefined} /> @@ -298,7 +302,7 @@ function CloneMigrationTableModal({ { label: 'Detach all references', value: '__detach_all__', - icon: , + icon: , }, { label: 'Convert to empty Qovery secrets', 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/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..4f8461a2e8c 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,228 @@ 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 +246,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 + + +