diff --git a/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx b/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx index 457cc4dcc07..1188ec8fd00 100644 --- a/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx +++ b/apps/console-v5/src/app/components/header/breadcrumbs/breadcrumbs.tsx @@ -1,7 +1,7 @@ import { useParams, useRouter } from '@tanstack/react-router' import { useMemo } from 'react' import { ClusterAvatar, useClusters } from '@qovery/domains/clusters/feature' -import { EnvironmentMode, useEnvironments } from '@qovery/domains/environments/feature' +import { EnvironmentMode, isFakeArgoCdService, useEnvironments } from '@qovery/domains/environments/feature' import { useOrganization, useOrganizations } from '@qovery/domains/organizations/feature' import { useProjects } from '@qovery/domains/projects/feature' import { ServiceAvatar, ServiceStateChip, useServices } from '@qovery/domains/services/feature' @@ -84,18 +84,29 @@ export function Breadcrumbs() { const serviceItems: BreadcrumbItemData[] = services .sort((a, b) => a.name.trim().localeCompare(b.name.trim())) - .map((service) => ({ - id: service.id, - label: service.name, - path: buildLocation({ - to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview', - params: { organizationId, projectId, environmentId, serviceId: service.id }, - }).href, - prefix: ( - - ), - suffix: , - })) + .map((service) => { + const serviceEnvironmentId = service.environment?.id ?? environmentId + const isArgoCdService = + Boolean(serviceEnvironmentId) && + isFakeArgoCdService({ + environmentId: serviceEnvironmentId, + serviceId: service.id, + }) + const serviceAvatar = isArgoCdService ? { ...service, icon_uri: 'app://qovery-console/argocd' } : service + + return { + id: service.id, + label: service.name, + path: buildLocation({ + to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview', + params: { organizationId, projectId, environmentId, serviceId: service.id }, + }).href, + prefix: ( + + ), + suffix: , + } + }) const currentCluster = useMemo( () => clusterItems.find((cluster) => cluster.id === clusterId), diff --git a/apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx b/apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx new file mode 100644 index 00000000000..5381917ff1d --- /dev/null +++ b/apps/console-v5/src/app/components/use-cases/use-case-bottom-bar.tsx @@ -0,0 +1,122 @@ +import { useLocation, useMatches } from '@tanstack/react-router' +import { Icon, InputSelect, Tooltip } from '@qovery/shared/ui' +import { GIT_BRANCH, GIT_SHA } from '@qovery/shared/util-node-env' +import { useUseCases } from './use-case-context' + +export function UseCaseBottomBar() { + const location = useLocation() + const matches = useMatches() + const routeId = matches[matches.length - 1]?.routeId + const scopeLabel = resolveScopeLabel(routeId) + const pageName = resolvePageName(routeId, location.pathname) + const pageLabel = `${scopeLabel} - ${pageName}` + + const { activePageId, optionsByPageId, selectionsByPageId, setSelection } = useUseCases() + const useCaseOptions = activePageId ? optionsByPageId[activePageId] ?? [] : [] + const selectedFromState = activePageId ? selectionsByPageId[activePageId] : undefined + const resolvedSelection = + selectedFromState && useCaseOptions.some((option) => option.id === selectedFromState) + ? selectedFromState + : useCaseOptions[0]?.id + + if (useCaseOptions.length === 0) { + return null + } + + const branchLabel = GIT_BRANCH || 'unknown' + const commitLabel = GIT_SHA ? GIT_SHA.slice(0, 7) : undefined + + return ( +
+
+
+
+ + + + + + Branch + + {branchLabel} + {commitLabel ? ` (${commitLabel})` : ''} + +
+ +
+ Page + + {pageLabel} + +
+ +
+ Use case + ({ + label: option.label, + value: option.id, + }))} + value={resolvedSelection} + onChange={(next) => { + if (activePageId && typeof next === 'string') { + setSelection(activePageId, next) + } + }} + className="min-w-0 flex-1 [&_.input-select__control]:!h-10 [&_.input-select__value-container]:!top-0 [&_.input-select__value-container]:!mt-0 [&_.input-select__value-container]:!h-10 [&_.input-select__value-container]:!items-center" + inputClassName="input--inline !min-h-0 !h-10 !border-0 !bg-transparent !px-0 !py-0 !hover:bg-transparent !outline-none focus-within:!outline-none !shadow-none" + valueClassName="text-xs font-mono text-neutral" + iconClassName="right-0" + /> +
+
+
+
+ ) +} + +export default UseCaseBottomBar + +function resolveScopeLabel(routeId?: string) { + if (!routeId) { + return 'Org' + } + + if (routeId.includes('/service/$serviceId')) { + return 'Service' + } + + if (routeId.includes('/environment/$environmentId')) { + return 'Env' + } + + if (routeId.includes('/project/$projectId')) { + return 'Project' + } + + if (routeId.includes('/organization/$organizationId')) { + return 'Org' + } + + return 'Org' +} + +function resolvePageName(routeId: string | undefined, pathname: string) { + if (routeId) { + const segments = routeId.split('/').filter(Boolean) + let lastSegment = segments[segments.length - 1] ?? 'index' + + if (lastSegment.startsWith('$')) { + lastSegment = segments[segments.length - 2] ?? lastSegment + } + + if (lastSegment === '_index' || lastSegment === 'index') { + return 'index' + } + + return lastSegment + } + + const pathSegments = pathname.split('/').filter(Boolean) + return pathSegments[pathSegments.length - 1] ?? 'index' +} diff --git a/apps/console-v5/src/app/components/use-cases/use-case-context.tsx b/apps/console-v5/src/app/components/use-cases/use-case-context.tsx new file mode 100644 index 00000000000..c1c69937a8b --- /dev/null +++ b/apps/console-v5/src/app/components/use-cases/use-case-context.tsx @@ -0,0 +1,159 @@ +import { + type ReactNode, + type SetStateAction, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' + +export type UseCaseOption = { + id: string + label: string +} + +type UseCaseContextValue = { + activePageId: string | null + optionsByPageId: Record + selectionsByPageId: Record + registerUseCases: (pageId: string, options: UseCaseOption[]) => void + setActivePageId: (pageId: SetStateAction) => void + setSelection: (pageId: string, selectionId: string) => void +} + +type UseCaseProviderProps = { + children: ReactNode +} + +type UseCasePageConfig = { + pageId: string + options: UseCaseOption[] + defaultCaseId?: string +} + +const STORAGE_KEY = 'qovery:use-cases' + +const UseCaseContext = createContext(undefined) + +const areOptionsEqual = (next: UseCaseOption[], prev: UseCaseOption[]) => + next.length === prev.length && + next.every((option, index) => option.id === prev[index]?.id && option.label === prev[index]?.label) + +const readSelections = () => { + if (typeof window === 'undefined') { + return {} + } + + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +export function UseCaseProvider({ children }: UseCaseProviderProps) { + const [activePageId, setActivePageId] = useState(null) + const [optionsByPageId, setOptionsByPageId] = useState>({}) + const [selectionsByPageId, setSelectionsByPageId] = useState>(readSelections) + + const registerUseCases = useCallback((pageId: string, options: UseCaseOption[]) => { + setOptionsByPageId((prev) => { + const existing = prev[pageId] + if (existing && areOptionsEqual(options, existing)) { + return prev + } + + return { + ...prev, + [pageId]: options, + } + }) + }, []) + + const setSelection = useCallback((pageId: string, selectionId: string) => { + setSelectionsByPageId((prev) => ({ + ...prev, + [pageId]: selectionId, + })) + }, []) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(selectionsByPageId)) + } catch { + // Ignore localStorage failures (private mode, quota, etc.) + } + }, [selectionsByPageId]) + + const value = useMemo( + () => ({ + activePageId, + optionsByPageId, + selectionsByPageId, + registerUseCases, + setActivePageId, + setSelection, + }), + [activePageId, optionsByPageId, registerUseCases, selectionsByPageId, setSelection] + ) + + return {children} +} + +export function useUseCases() { + const context = useContext(UseCaseContext) + + if (!context) { + throw new Error('useUseCases must be used within a UseCaseProvider') + } + + return context +} + +export function useUseCasePage({ pageId, options, defaultCaseId }: UseCasePageConfig) { + const { registerUseCases, setActivePageId, selectionsByPageId, setSelection } = useUseCases() + + useEffect(() => { + registerUseCases(pageId, options) + setActivePageId(pageId) + + return () => { + setActivePageId((current) => (current === pageId ? null : current)) + } + }, [options, pageId, registerUseCases, setActivePageId]) + + const selectedCaseId = useMemo(() => { + const selected = selectionsByPageId[pageId] + if (selected && options.some((option) => option.id === selected)) { + return selected + } + + if (defaultCaseId && options.some((option) => option.id === defaultCaseId)) { + return defaultCaseId + } + + return options[0]?.id ?? '' + }, [defaultCaseId, options, pageId, selectionsByPageId]) + + useEffect(() => { + if (!selectedCaseId) { + return + } + + if (selectionsByPageId[pageId] !== selectedCaseId) { + setSelection(pageId, selectedCaseId) + } + }, [pageId, selectedCaseId, selectionsByPageId, setSelection]) + + return { + selectedCaseId, + setSelectedCaseId: (nextId: string) => setSelection(pageId, nextId), + } +} diff --git a/apps/console-v5/src/routeTree.gen.ts b/apps/console-v5/src/routeTree.gen.ts index e65e7d13fd6..94d542900fb 100644 --- a/apps/console-v5/src/routeTree.gen.ts +++ b/apps/console-v5/src/routeTree.gen.ts @@ -40,6 +40,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdSettingsContainerRegist import { Route as AuthenticatedOrganizationOrganizationIdSettingsCloudCredentialsRouteImport } from './routes/_authenticated/organization/$organizationId/settings/cloud-credentials' import { Route as AuthenticatedOrganizationOrganizationIdSettingsBillingSummaryRouteImport } from './routes/_authenticated/organization/$organizationId/settings/billing-summary' import { Route as AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRouteImport } from './routes/_authenticated/organization/$organizationId/settings/billing-details' +import { Route as AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRouteImport } from './routes/_authenticated/organization/$organizationId/settings/argocd-integration' import { Route as AuthenticatedOrganizationOrganizationIdSettingsApiTokenRouteImport } from './routes/_authenticated/organization/$organizationId/settings/api-token' import { Route as AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRouteImport } from './routes/_authenticated/organization/$organizationId/settings/ai-copilot' import { Route as AuthenticatedOrganizationOrganizationIdClusterNewRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/new' @@ -99,6 +100,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnviron import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdServiceLogsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview' +import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdPreCheckLogsRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/pre-check-logs' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateDatabaseRouteRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/database/route' @@ -350,6 +352,15 @@ const AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRoute = AuthenticatedOrganizationOrganizationIdSettingsRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute = + AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRouteImport.update( + { + id: '/argocd-integration', + path: '/argocd-integration', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdSettingsRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute = AuthenticatedOrganizationOrganizationIdSettingsApiTokenRouteImport.update({ id: '/api-token', @@ -843,6 +854,14 @@ const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironm getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRoute = + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRouteImport.update( + { + id: '/project/$projectId/environment/$environmentId/service/$serviceId/manifest', + path: '/project/$projectId/environment/$environmentId/service/$serviceId/manifest', + getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, + } as any, + ) const AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRouteImport.update( { @@ -1237,6 +1256,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/cluster/new': typeof AuthenticatedOrganizationOrganizationIdClusterNewRoute '/organization/$organizationId/settings/ai-copilot': typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute '/organization/$organizationId/settings/api-token': typeof AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute + '/organization/$organizationId/settings/argocd-integration': typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute '/organization/$organizationId/settings/billing-details': typeof AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRoute '/organization/$organizationId/settings/billing-summary': typeof AuthenticatedOrganizationOrganizationIdSettingsBillingSummaryRoute '/organization/$organizationId/settings/cloud-credentials': typeof AuthenticatedOrganizationOrganizationIdSettingsCloudCredentialsRoute @@ -1304,6 +1324,7 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/database': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateDatabaseRouteRouteWithChildren '/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/pre-check-logs': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdPreCheckLogsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdServiceLogsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRoute @@ -1366,6 +1387,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/cluster/new': typeof AuthenticatedOrganizationOrganizationIdClusterNewRoute '/organization/$organizationId/settings/ai-copilot': typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute '/organization/$organizationId/settings/api-token': typeof AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute + '/organization/$organizationId/settings/argocd-integration': typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute '/organization/$organizationId/settings/billing-details': typeof AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRoute '/organization/$organizationId/settings/billing-summary': typeof AuthenticatedOrganizationOrganizationIdSettingsBillingSummaryRoute '/organization/$organizationId/settings/cloud-credentials': typeof AuthenticatedOrganizationOrganizationIdSettingsCloudCredentialsRoute @@ -1424,6 +1446,7 @@ export interface FileRoutesByTo { '/organization/$organizationId/project/$projectId/environment/$environmentId/settings': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdSettingsIndexRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/pre-check-logs': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdPreCheckLogsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRoute + '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdServiceLogsRoute '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRoute @@ -1492,6 +1515,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/cluster/new': typeof AuthenticatedOrganizationOrganizationIdClusterNewRoute '/_authenticated/organization/$organizationId/settings/ai-copilot': typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute '/_authenticated/organization/$organizationId/settings/api-token': typeof AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute + '/_authenticated/organization/$organizationId/settings/argocd-integration': typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute '/_authenticated/organization/$organizationId/settings/billing-details': typeof AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRoute '/_authenticated/organization/$organizationId/settings/billing-summary': typeof AuthenticatedOrganizationOrganizationIdSettingsBillingSummaryRoute '/_authenticated/organization/$organizationId/settings/cloud-credentials': typeof AuthenticatedOrganizationOrganizationIdSettingsCloudCredentialsRoute @@ -1559,6 +1583,7 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/database': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateDatabaseRouteRouteWithChildren '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/pre-check-logs': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdPreCheckLogsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRoute + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdServiceLogsRoute '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRoute @@ -1627,6 +1652,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/new' | '/organization/$organizationId/settings/ai-copilot' | '/organization/$organizationId/settings/api-token' + | '/organization/$organizationId/settings/argocd-integration' | '/organization/$organizationId/settings/billing-details' | '/organization/$organizationId/settings/billing-summary' | '/organization/$organizationId/settings/cloud-credentials' @@ -1694,6 +1720,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/database' | '/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/pre-check-logs' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables' @@ -1756,6 +1783,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/new' | '/organization/$organizationId/settings/ai-copilot' | '/organization/$organizationId/settings/api-token' + | '/organization/$organizationId/settings/argocd-integration' | '/organization/$organizationId/settings/billing-details' | '/organization/$organizationId/settings/billing-summary' | '/organization/$organizationId/settings/cloud-credentials' @@ -1814,6 +1842,7 @@ export interface FileRouteTypes { | '/organization/$organizationId/project/$projectId/environment/$environmentId/settings' | '/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/pre-check-logs' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell' + | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs' | '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables' @@ -1881,6 +1910,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/cluster/new' | '/_authenticated/organization/$organizationId/settings/ai-copilot' | '/_authenticated/organization/$organizationId/settings/api-token' + | '/_authenticated/organization/$organizationId/settings/argocd-integration' | '/_authenticated/organization/$organizationId/settings/billing-details' | '/_authenticated/organization/$organizationId/settings/billing-summary' | '/_authenticated/organization/$organizationId/settings/cloud-credentials' @@ -1948,6 +1978,7 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/create/database' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployment/$deploymentId/pre-check-logs' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell' + | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs' | '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/variables' @@ -2218,6 +2249,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/settings/argocd-integration': { + id: '/_authenticated/organization/$organizationId/settings/argocd-integration' + path: '/argocd-integration' + fullPath: '/organization/$organizationId/settings/argocd-integration' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRouteRoute + } '/_authenticated/organization/$organizationId/settings/api-token': { id: '/_authenticated/organization/$organizationId/settings/api-token' path: '/api-token' @@ -2631,6 +2669,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute } + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest': { + id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest' + path: '/project/$projectId/environment/$environmentId/service/$serviceId/manifest' + fullPath: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell': { id: '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell' path: '/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell' @@ -2955,6 +3000,7 @@ const AuthenticatedOrganizationOrganizationIdAlertsRouteRouteWithChildren = interface AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren { AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute + AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRoute AuthenticatedOrganizationOrganizationIdSettingsBillingSummaryRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsBillingSummaryRoute AuthenticatedOrganizationOrganizationIdSettingsCloudCredentialsRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsCloudCredentialsRoute @@ -2977,6 +3023,8 @@ const AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren: Authent AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute, AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute: AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute, + AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute: + AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute, AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRoute: AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsRoute, AuthenticatedOrganizationOrganizationIdSettingsBillingSummaryRoute: @@ -3346,6 +3394,7 @@ interface AuthenticatedOrganizationOrganizationIdRouteRouteChildren { AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateDatabaseRouteRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceCreateDatabaseRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdPreCheckLogsRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdPreCheckLogsRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRoute + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdServiceLogsRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdServiceLogsRoute AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRoute: typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdVariablesRoute @@ -3424,6 +3473,8 @@ const AuthenticatedOrganizationOrganizationIdRouteRouteChildren: AuthenticatedOr AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdDeploymentDeploymentIdPreCheckLogsRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdCloudShellRoute, + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRoute: + AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdManifestRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRoute: AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdOverviewRoute, AuthenticatedOrganizationOrganizationIdProjectProjectIdEnvironmentEnvironmentIdServiceServiceIdServiceLogsRoute: diff --git a/apps/console-v5/src/routes/__root.tsx b/apps/console-v5/src/routes/__root.tsx index a03381a75a4..d2db9047262 100644 --- a/apps/console-v5/src/routes/__root.tsx +++ b/apps/console-v5/src/routes/__root.tsx @@ -1,6 +1,8 @@ import { type QueryClient } from '@tanstack/react-query' import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import { ModalProvider, ToastBehavior } from '@qovery/shared/ui' +import { UseCaseBottomBar } from '../app/components/use-cases/use-case-bottom-bar' +import { UseCaseProvider } from '../app/components/use-cases/use-case-context' import { type Auth0ContextType } from '../auth/auth0' interface RouterContext { @@ -10,10 +12,13 @@ interface RouterContext { const RootLayout = () => { return ( - - - - + + + + + + + ) } diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployments.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployments.tsx index 3a6a0c1289d..fe0da9d995b 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployments.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/deployments.tsx @@ -1,7 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' import { Suspense } from 'react' -import { EnvironmentDeploymentListSkeleton } from '@qovery/domains/environments/feature' -import { EnvironmentDeploymentList } from '@qovery/domains/environments/feature' +import { + EnvironmentDeploymentList, + EnvironmentDeploymentListSkeleton, + getFakeArgoCdMode, +} from '@qovery/domains/environments/feature' import { Heading, Section } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' @@ -12,7 +15,9 @@ export const Route = createFileRoute( }) function RouteComponent() { + const { environmentId } = Route.useParams() useDocumentTitle('Deployment history') + const shouldShowArgoCdDescription = getFakeArgoCdMode(environmentId) !== 'none' return (
@@ -23,7 +28,18 @@ function RouteComponent() {

-
+
+ {shouldShowArgoCdDescription ? ( +
+ + Last deployments + +

+ Only deployments of Qovery services are shown here. Deployments performed through ArgoCD are not + tracked. +

+
+ ) : null} }> diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx index e26222889ea..a2a7928d532 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx @@ -1,14 +1,153 @@ import { type IconName } from '@fortawesome/fontawesome-common-types' -import { Outlet, createFileRoute, useMatchRoute } from '@tanstack/react-router' +import { Outlet, createFileRoute, useMatchRoute, useNavigate } from '@tanstack/react-router' +import { type Environment } from 'qovery-typescript-axios' import { EnvironmentLastDeploymentSection, EnvironmentMode, + MenuArgoCdOnlyActions, MenuManageDeployment, MenuOtherActions, + getFakeArgoCdMode, + isFakeArgoCdService, useDeploymentStatus, useEnvironment, } from '@qovery/domains/environments/feature' -import { Heading, Icon, Link, Navbar, Section } from '@qovery/shared/ui' +import { ServiceList, useServices } from '@qovery/domains/services/feature' +import { Button, EmptyState, Heading, Icon, Link, Navbar, Section, Tooltip } from '@qovery/shared/ui' + +const ARGOCD_HYBRID_REDEPLOY_TOOLTIP = 'Redeploy will only target Qovery created services and not ArgoCD imported ones.' +const ARGOCD_STATUS_SYNCED_THRESHOLD = 0.8 +const ARGOCD_OPERATION_LABEL = 'No operation detected' + +type ArgoCdServiceStatus = 'Synced' | 'Out of sync' +type ServiceListRows = ReturnType['data'] + +function seededRandom(seed: string): number { + let hash = 2166136261 + + for (let i = 0; i < seed.length; i++) { + hash ^= seed.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + + return (hash >>> 0) / 4294967295 +} + +function getArgoCdStatusByServiceId(services: ServiceListRows, seed: string): Record { + return Object.fromEntries( + services.map((service) => { + const status = + seededRandom(`${seed}:${service.id}:status`) < ARGOCD_STATUS_SYNCED_THRESHOLD ? 'Synced' : 'Out of sync' + return [service.id, status] + }) + ) as Record +} + +function getArgoCdOperationByServiceId(services: ServiceListRows): Record { + return Object.fromEntries(services.map((service) => [service.id, ARGOCD_OPERATION_LABEL])) as Record +} + +function ArgoCdTag() { + return ( + + ARGOCD + + ) +} + +function ArgoCdImportedServicesTable({ + title, + environment, + services, + argocdStatusByServiceId, + argocdOperationByServiceId, +}: { + title: string + environment: Environment + services: ServiceListRows + argocdStatusByServiceId: Record + argocdOperationByServiceId: Record +}) { + return ( +
+
+ + {title} + +

+ Services managed by ArgoCD, they cannot be deployed or modified through Qovery. +

+
+
+ +
+
+ ) +} + +function QoveryNativeServicesEmptyState({ + organizationId, + projectId, + environmentId, +}: { + organizationId: string + projectId: string + environmentId: string +}) { + const navigate = useNavigate() + + return ( +
+ + Qovery native services + + +
+ + +
+
+
+ ) +} export const Route = createFileRoute( '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview' @@ -22,6 +161,7 @@ function RouteComponent() { const { data: environment } = useEnvironment({ environmentId, suspense: true }) const { data: deploymentStatus } = useDeploymentStatus({ environmentId }) + const { data: services = [] } = useServices({ environmentId, suspense: true }) const tabs = [ { @@ -43,6 +183,20 @@ function RouteComponent() { return null } + const argoCdMode = getFakeArgoCdMode(environment.id ?? environmentId) + const shouldDisplayArgoCdTag = argoCdMode !== 'none' + const isArgoCdOnly = argoCdMode === 'argocd-only' + const isArgoCdHybrid = argoCdMode === 'hybrid' + const splitSeed = environment.id ?? environmentId + const argoCdServices = services.filter((service) => + isFakeArgoCdService({ environmentId: splitSeed, serviceId: service.id }) + ) + const qoveryServices = services.filter( + (service) => !argoCdServices.some((argoCdService) => argoCdService.id === service.id) + ) + const argocdStatusByServiceId = getArgoCdStatusByServiceId(argoCdServices, splitSeed) + const argocdOperationByServiceId = getArgoCdOperationByServiceId(argoCdServices) + return (
@@ -51,15 +205,40 @@ function RouteComponent() {
{environment?.name} + {shouldDisplayArgoCdTag && }
- - + {isArgoCdOnly ? ( + <> + + + + + + + + ) : ( + <> + + + + )}

@@ -80,24 +259,57 @@ function RouteComponent() { New service
-
-
-
- - {tabs.map((tab) => ( - - - {tab.label} - - ))} - +
+ {!isArgoCdOnly && ( +
+ {shouldDisplayArgoCdTag && ( + + Qovery services + + )} +
+
+
+ + {tabs.map((tab) => ( + + + {tab.label} + + ))} + +
+
+
+
+ {activeTabId === 'services' && shouldDisplayArgoCdTag ? ( + + ) : ( + + )} +
+
+
-
-
-
- + )} + {(isArgoCdOnly || isArgoCdHybrid) && ( + + )} + {isArgoCdOnly && ( +
+
-
+ )}
diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx new file mode 100644 index 00000000000..6f4688f1ce2 --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx @@ -0,0 +1,250 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' +import { useMemo, useState } from 'react' +import { isFakeArgoCdService } from '@qovery/domains/environments/feature' +import { type TerraformResource } from '@qovery/domains/service-terraform/data-access' +import { ResourceTreeList } from '@qovery/domains/service-terraform/feature' +import { CodeEditor, Heading, InputSearch, Section } from '@qovery/shared/ui' +import { useDocumentTitle } from '@qovery/shared/util-hooks' + +export const Route = createFileRoute( + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest' +)({ + component: RouteComponent, +}) + +interface ManifestResource extends TerraformResource { + manifest: string +} + +const MOCK_EXTRACTED_AT = '2026-03-24T12:00:00.000Z' + +const APPLICATION_MANIFEST = `apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mail-tools + namespace: argocd + labels: + app.kubernetes.io/name: mail-tools +spec: + project: default + source: + repoURL: https://github.com/acme/platform.git + targetRevision: main + path: apps/mail-tools + destination: + namespace: kube-system + server: https://kubernetes.default.svc` + +function createFakeResource({ + id, + displayName, + name, + manifest, +}: { + id: string + displayName: string + name: string + manifest: string +}): ManifestResource { + return { + id, + displayName, + name, + manifest, + resourceType: id, + address: `${displayName.toLowerCase().replace(/\s+/g, '_')}.${name.toLowerCase().replace(/\s+/g, '_')}`, + provider: 'kubernetes', + mode: 'managed', + extractedAt: MOCK_EXTRACTED_AT, + attributes: {}, + } +} + +const MANIFEST_RESOURCES: ManifestResource[] = [ + createFakeResource({ + id: 'workloads', + displayName: 'Workloads', + name: 'Deployments', + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: mail-tools + namespace: kube-system`, + }), + createFakeResource({ + id: 'workloads', + displayName: 'Workloads', + name: 'StatefulSets', + manifest: `apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: kube-system`, + }), + createFakeResource({ + id: 'workloads', + displayName: 'Workloads', + name: 'Jobs', + manifest: `apiVersion: batch/v1 +kind: Job +metadata: + name: db-backup + namespace: kube-system`, + }), + createFakeResource({ + id: 'workloads', + displayName: 'Workloads', + name: 'CronJobs', + manifest: `apiVersion: batch/v1 +kind: CronJob +metadata: + name: cleanup + namespace: kube-system`, + }), + createFakeResource({ + id: 'workloads', + displayName: 'Workloads', + name: 'Application', + manifest: APPLICATION_MANIFEST, + }), + createFakeResource({ + id: 'ingresses', + displayName: 'Ingresses', + name: 'mail-tools-ingress', + manifest: `apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mail-tools-ingress + namespace: kube-system`, + }), + createFakeResource({ + id: 'cloud-resources', + displayName: 'Cloud resources', + name: 'aws-load-balancer', + manifest: `apiVersion: elbv2.k8s.aws/v1beta1 +kind: TargetGroupBinding +metadata: + name: mail-tools-tgb + namespace: kube-system`, + }), + createFakeResource({ + id: 'secrets', + displayName: 'Secrets', + name: 'mail-tools-secrets', + manifest: `apiVersion: v1 +kind: Secret +metadata: + name: mail-tools-secrets + namespace: kube-system`, + }), + createFakeResource({ + id: 'config-maps', + displayName: 'ConfigMaps', + name: 'mail-tools-config', + manifest: `apiVersion: v1 +kind: ConfigMap +metadata: + name: mail-tools-config + namespace: kube-system`, + }), + createFakeResource({ + id: 'persistent-volume-claims', + displayName: 'PersistentVolumeClaims', + name: 'mail-tools-data', + manifest: `apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mail-tools-data + namespace: kube-system`, + }), + createFakeResource({ + id: 'service-accounts', + displayName: 'ServiceAccounts', + name: 'mail-tools-sa', + manifest: `apiVersion: v1 +kind: ServiceAccount +metadata: + name: mail-tools-sa + namespace: kube-system`, + }), + createFakeResource({ + id: 'roles', + displayName: 'Roles', + name: 'mail-tools-role', + manifest: `apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: mail-tools-role + namespace: kube-system`, + }), +] + +const MANIFEST_RESOURCES_WITH_IDS = MANIFEST_RESOURCES.map((resource) => ({ + ...resource, + id: `${resource.id}:${resource.name}`, +})) + +function RouteComponent() { + const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false }) + const [searchQuery, setSearchQuery] = useState('') + const [selectedResourceId, setSelectedResourceId] = useState('workloads:Application') + + useDocumentTitle('Service - Manifest') + + const isArgoCdManifestAvailable = useMemo( + () => Boolean(environmentId && serviceId) && isFakeArgoCdService({ environmentId, serviceId }), + [environmentId, serviceId] + ) + + const selectedResource = useMemo( + () => + MANIFEST_RESOURCES_WITH_IDS.find((resource) => resource.id === selectedResourceId) ?? + MANIFEST_RESOURCES_WITH_IDS[0], + [selectedResourceId] + ) + + if (!isArgoCdManifestAvailable) { + return ( + + ) + } + + return ( +
+
+
+ Manifest +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview.tsx index 18b49b32bbc..3178fd90b86 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview.tsx @@ -2,7 +2,7 @@ import { createFileRoute, useParams } from '@tanstack/react-router' import { memo, useMemo } from 'react' import { match } from 'ts-pattern' import { useCluster } from '@qovery/domains/clusters/feature' -import { useEnvironment } from '@qovery/domains/environments/feature' +import { isFakeArgoCdService, useEnvironment } from '@qovery/domains/environments/feature' import { EnableObservabilityModal } from '@qovery/domains/observability/feature' import { TerraformResourcesSection } from '@qovery/domains/service-terraform/feature' import { ObservabilityCallout, ServiceOverview, useService } from '@qovery/domains/services/feature' @@ -35,11 +35,16 @@ function RouteComponent() { .otherwise(() => false), [cluster?.metrics_parameters?.enabled, service?.serviceType, cluster?.cloud_provider] ) + const isArgoCdService = useMemo( + () => Boolean(environmentId && serviceId) && isFakeArgoCdService({ environmentId, serviceId }), + [environmentId, serviceId] + ) return ( <> : undefined} hasNoMetrics={hasNoMetrics} observabilityCallout={ diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/new.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/new.tsx index c169a10d938..344b9141958 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/new.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/new.tsx @@ -40,6 +40,7 @@ function ServiceNewContent() { organizationId={organizationId} projectId={projectId} environmentId={environmentId} + clusterId={environment?.cluster_id} cloudProvider={cloudProvider} availableTemplates={availableTemplates} /> diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/settings/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/settings/route.tsx index 6993de56ca7..53e5d74c60a 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/settings/route.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/settings/route.tsx @@ -1,4 +1,5 @@ import { Outlet, createFileRoute, useParams } from '@tanstack/react-router' +import { getFakeArgoCdMode } from '@qovery/domains/environments/feature' import { Sidebar } from '@qovery/shared/ui' export const Route = createFileRoute( @@ -10,6 +11,7 @@ export const Route = createFileRoute( function RouteComponent() { const { organizationId, projectId, environmentId } = useParams({ strict: false }) const pathSettings = `/organization/${organizationId}/project/${projectId}/environment/${environmentId}/settings` + const argoCdMode = getFakeArgoCdMode(environmentId ?? '') const generalLink = { title: 'General', @@ -35,7 +37,12 @@ function RouteComponent() { icon: 'skull' as const, } - const LINKS_SETTINGS = [generalLink, deploymentRulesLink, previewEnvironmentsLink, dangerZoneLink] + const LINKS_SETTINGS = + argoCdMode === 'argocd-only' + ? [generalLink, deploymentRulesLink] + : argoCdMode === 'hybrid' + ? [generalLink, deploymentRulesLink, previewEnvironmentsLink] + : [generalLink, deploymentRulesLink, previewEnvironmentsLink, dangerZoneLink] return (
diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/argocd-integration.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/argocd-integration.tsx new file mode 100644 index 00000000000..2db2918e3a1 --- /dev/null +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/argocd-integration.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SettingsArgoCdIntegration, type SettingsArgoCdIntegrationUseCase } from '@qovery/domains/organizations/feature' +import { useUseCasePage } from '../../../../../app/components/use-cases/use-case-context' + +const PAGE_ID = 'org-settings-argocd-integration' +const USE_CASE_OPTIONS: { id: SettingsArgoCdIntegrationUseCase; label: string }[] = [ + { id: 'empty-state', label: 'Empty state' }, + { id: 'loading-integration', label: 'Importing' }, + { id: 'loaded', label: 'Loaded' }, + { id: 'loaded-single-cluster', label: 'Loaded (1 cluster)' }, +] + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/argocd-integration')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { selectedCaseId } = useUseCasePage({ + pageId: PAGE_ID, + options: USE_CASE_OPTIONS, + defaultCaseId: 'empty-state', + }) + + return +} diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/route.tsx index be2b95252e3..ed46f416f7e 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/route.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/route.tsx @@ -55,6 +55,12 @@ function RouteComponent() { icon: 'box' as const, } + const argoCdIntegrationLink = { + title: 'ArgoCD integration', + to: `${pathSettings}/argocd-integration`, + icon: 'link' as const, + } + const helmRepositoriesLink = { title: 'Helm repositories', to: `${pathSettings}/helm-repositories`, @@ -103,6 +109,7 @@ function RouteComponent() { teamLink, billingPlansLink, labelsAnnotationsLink, + argoCdIntegrationLink, containerRegistriesLink, helmRepositoriesLink, cloudCredentialsLink, diff --git a/apps/console-v5/src/routes/_authenticated/organization/route.tsx b/apps/console-v5/src/routes/_authenticated/organization/route.tsx index 89aa1572e87..0ea88536637 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/route.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/route.tsx @@ -2,6 +2,7 @@ import { type IconName } from '@fortawesome/fontawesome-common-types' import { Outlet, createFileRoute, useLocation, useMatches, useParams } from '@tanstack/react-router' import posthog from 'posthog-js' import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { isFakeArgoCdService } from '@qovery/domains/environments/feature' import { useServiceSummary } from '@qovery/domains/services/feature' import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context' import { DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature' @@ -202,6 +203,44 @@ const SERVICE_TABS: NavigationTab[] = [ }, ] +const SERVICE_TABS_ARGO: NavigationTab[] = [ + { + id: 'overview', + label: 'Overview', + iconName: 'table-layout', + routeId: + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview', + }, + { + id: 'monitoring', + label: 'Monitoring', + iconName: 'chart-column', + routeId: + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring', + }, + { + id: 'service-logs', + label: 'Service logs', + iconName: 'scroll', + routeId: + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs', + }, + { + id: 'cloud-shell', + label: 'Cloud shell', + iconName: 'terminal', + routeId: + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/cloud-shell', + }, + { + id: 'manifest', + label: 'Manifest', + iconName: 'file-lines', + routeId: + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest', + }, +] + function createRoutePatternRegex(routeIdPattern: string): RegExp { const patternPath = routeIdPattern.replace('/_authenticated/organization', '/organization') return new RegExp('^' + patternPath.replace(/\$(\w+)/g, '[^/]+') + '(/.*)?$') @@ -266,6 +305,10 @@ function useNavigationContext(): NavigationContext | null { serviceId: params.serviceId, enabled: Boolean(params.environmentId) && Boolean(params.serviceId), }) + const isArgoCdService = + typeof params.environmentId === 'string' && + typeof params.serviceId === 'string' && + isFakeArgoCdService({ environmentId: params.environmentId, serviceId: params.serviceId }) for (const context of NAVIGATION_CONTEXTS) { const patternRegex = createRoutePatternRegex(context.routeIdPattern) @@ -291,12 +334,13 @@ function useNavigationContext(): NavigationContext | null { // Managed databases should not have cloud shell access. // Databases should not expose the variables tab. + const serviceTabs = isArgoCdService ? SERVICE_TABS_ARGO : context.tabs const tabs = context.type === 'service' - ? context.tabs.filter( + ? serviceTabs.filter( (tab) => !(isDatabase && tab.id === 'variables') && !(isManagedDatabase && tab.id === 'cloud-shell') ) - : context.tabs + : serviceTabs return { type: context.type, @@ -400,6 +444,7 @@ const fullWidthRouteIds: FileRouteTypes['id'][] = [ '/_authenticated/organization/$organizationId/settings', '/_authenticated/organization/$organizationId/audit-logs', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/monitoring', + '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/service-logs', '/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/deployments/logs/$executionId', 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, diff --git a/libs/domains/environments/feature/src/index.ts b/libs/domains/environments/feature/src/index.ts index 60b40294cc5..2a2ba6c0ee0 100644 --- a/libs/domains/environments/feature/src/index.ts +++ b/libs/domains/environments/feature/src/index.ts @@ -36,3 +36,4 @@ export * from './lib/settings-preview-environments/settings-preview-environments export * from './lib/settings-danger-zone/settings-danger-zone' export * from './lib/environment-deployment-list/environment-deployment-list-skeleton' export * from './lib/environment-last-deployment-section/environment-last-deployment-section' +export * from './lib/fake-argocd-mode/fake-argocd-mode' diff --git a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.spec.tsx b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.spec.tsx index e1eef8c7091..f7a72272f35 100644 --- a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.spec.tsx +++ b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.spec.tsx @@ -83,6 +83,14 @@ describe('CreateCloneEnvironmentModal', () => { }) describe('cloning mode', function () { + it('should display ArgoCD hybrid callout', () => { + const mockEnv = environmentFactoryMock(1)[0] + + renderWithProviders() + + expect(screen.getByText('ArgoCD imported services will not be cloned.')).toBeInTheDocument() + }) + it('should submit form on click on button', async () => { const mockEnv = environmentFactoryMock(1)[0] const { userEvent } = renderWithProviders() diff --git a/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx b/libs/domains/environments/feature/src/lib/create-clone-environment-modal/create-clone-environment-modal.tsx index fc0d9b52804..17a2fc07bde 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 @@ -10,7 +10,7 @@ import { Controller, FormProvider, useForm } from 'react-hook-form' import { P, match } from 'ts-pattern' import { useClusters } from '@qovery/domains/clusters/feature' import { useProjects } from '@qovery/domains/projects/feature' -import { ExternalLink, Icon, InputSelect, InputText, ModalCrud, useModal } from '@qovery/shared/ui' +import { Callout, ExternalLink, Icon, InputSelect, InputText, ModalCrud, useModal } from '@qovery/shared/ui' import { EnvironmentMode } from '../environment-mode/environment-mode' import { useCloneEnvironment } from '../hooks/use-clone-environment/use-clone-environment' import { useCreateEnvironment } from '../hooks/use-create-environment/use-create-environment' @@ -19,6 +19,7 @@ export interface CreateCloneEnvironmentModalProps { projectId: string organizationId: string environmentToClone?: Environment + isArgoCdHybrid?: boolean onClose: () => void type?: EnvironmentModeEnum } @@ -27,6 +28,7 @@ export function CreateCloneEnvironmentModal({ projectId, organizationId, environmentToClone, + isArgoCdHybrid = false, onClose, type, }: CreateCloneEnvironmentModalProps) { @@ -271,6 +273,14 @@ export function CreateCloneEnvironmentModal({ /> )} /> + {environmentToClone && isArgoCdHybrid && ( + + + + + ArgoCD imported services will not be cloned. + + )} ) 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 d779d7a2624..0ea95d51f62 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 @@ -30,13 +30,34 @@ import { UpdateAllModal } from '../update-all-modal/update-all-modal' type ActionToolbarVariant = 'default' | 'header' +function ArgoCdHybridDeployInfoModal({ onUnderstood }: { onUnderstood: () => void }) { + return ( +
+

Environment actions will only affect Qovery services

+

+ This environment contains both Qovery and ArgoCD services. Bulk actions such as deploy, redeploy, or restart + only apply to Qovery services. ArgoCD services are managed and deployed through ArgoCD. +

+
+ +
+
+ ) +} + export function MenuManageDeployment({ environment, deploymentStatus, + redeployTooltip, + requireArgoCdHybridAck = false, variant = 'default', }: { environment: Environment deploymentStatus: EnvironmentStatus + redeployTooltip?: string + requireArgoCdHybridAck?: boolean variant?: ActionToolbarVariant }) { const state = deploymentStatus.state @@ -48,11 +69,16 @@ export function MenuManageDeployment({ ) + const tooltipInfo = (content: string) => ( + + + + ) const tooltipEnvironmentNeedUpdate = displayYellowColor && tooltipService('Environment has changed and needs to be applied') - const { openModal } = useModal() + const { openModal, closeModal } = useModal() const { openModalConfirmation } = useModalConfirmation() const logsLink = @@ -72,21 +98,48 @@ export function MenuManageDeployment({ // https://qovery.atlassian.net/jira/software/projects/FRT/boards/23?selectedIssue=FRT-1416 const { data: services = [] } = useServices({ environmentId: environment.id }) - const mutationDeploy = () => + const executeDeploy = () => deployEnvironment({ environmentId: environment.id, }) - const mutationRedeploy = () => { - openModalConfirmation({ - mode: environment.mode, - title: 'Confirm redeploy', - description: 'To confirm the redeploy of your environment, please type the name:', - name: environment.name, - action: () => deployEnvironment({ environmentId: environment.id }), + const withArgoCdHybridInfoModal = (action: () => void) => { + if (!requireArgoCdHybridAck) { + action() + return + } + + openModal({ + content: ( + { + closeModal() + action() + }} + /> + ), + options: { + width: 488, + }, }) } + const mutationDeploy = () => { + withArgoCdHybridInfoModal(executeDeploy) + } + + const mutationRedeploy = () => { + withArgoCdHybridInfoModal(() => + openModalConfirmation({ + mode: environment.mode, + title: 'Confirm redeploy', + description: 'To confirm the redeploy of your environment, please type the name:', + name: environment.name, + action: () => deployEnvironment({ environmentId: environment.id }), + }) + ) + } + const mutationStop = () => { const hasDatabase = services.some( (service) => @@ -95,27 +148,31 @@ export function MenuManageDeployment({ (service.type === 'POSTGRESQL' || service.type === 'MYSQL') ) - openModalConfirmation({ - mode: environment.mode, - title: 'Confirm stop', - description: 'To confirm the stopping of your environment, please type the name:', - warning: hasDatabase - ? "RDS instances are automatically restarted by AWS after 7 days. After 7 days, Qovery won't pause it again for you." - : null, - name: environment.name, - action: () => stopEnvironment({ environmentId: environment.id }), - }) + withArgoCdHybridInfoModal(() => + openModalConfirmation({ + mode: environment.mode, + title: 'Confirm stop', + description: 'To confirm the stopping of your environment, please type the name:', + warning: hasDatabase + ? "RDS instances are automatically restarted by AWS after 7 days. After 7 days, Qovery won't pause it again for you." + : null, + name: environment.name, + action: () => stopEnvironment({ environmentId: environment.id }), + }) + ) } const mutationUninstall = () => { - openModalConfirmation({ - mode: 'PRODUCTION', - title: 'Confirm uninstall', - description: 'To confirm the uninstall of your environment, please type the name:', - warning: 'Uninstall delete all compute and data of your service', - name: environment.name, - action: () => uninstallEnvironment({ environmentId: environment.id }), - }) + withArgoCdHybridInfoModal(() => + openModalConfirmation({ + mode: 'PRODUCTION', + title: 'Confirm uninstall', + description: 'To confirm the uninstall of your environment, please type the name:', + warning: 'Uninstall delete all compute and data of your service', + name: environment.name, + action: () => uninstallEnvironment({ environmentId: environment.id }), + }) + ) } const mutationCancelDeployment = () => { @@ -130,12 +187,14 @@ export function MenuManageDeployment({ } const openUpdateAllModal = () => { - openModal({ - content: , - options: { - width: 676, - }, - }) + withArgoCdHybridInfoModal(() => + openModal({ + content: , + options: { + width: 676, + }, + }) + ) } return ( @@ -196,7 +255,7 @@ export function MenuManageDeployment({ >
Redeploy - {tooltipEnvironmentNeedUpdate} + {redeployTooltip ? tooltipInfo(redeployTooltip) : tooltipEnvironmentNeedUpdate}
)} @@ -246,10 +305,12 @@ export function MenuManageDeployment({ export function MenuOtherActions({ state, environment, + isArgoCdHybrid = false, variant = 'default', }: { state: StateEnum environment: Environment + isArgoCdHybrid?: boolean variant?: ActionToolbarVariant }) { const { openModal, closeModal } = useModal() @@ -281,6 +342,7 @@ export function MenuOtherActions({ projectId={environment.project.id} organizationId={environment.organization.id} environmentToClone={environment} + isArgoCdHybrid={isArgoCdHybrid} /> ), options: { @@ -345,6 +407,82 @@ export function MenuOtherActions({ ) } +export function MenuArgoCdOnlyActions({ + environment, + variant = 'default', +}: { + environment: Environment + variant?: ActionToolbarVariant +}) { + const { openModal } = useModal() + const [, copyToClipboard] = useCopyToClipboard() + const copyContent = `Cluster ID: ${environment.cluster_id}\nOrganization ID: ${environment.organization.id}\nProject ID: ${environment.project.id}\nEnvironment ID: ${environment.id}` + + const openTerraformExportModal = () => { + openModal({ + content: , + }) + } + + return ( + + + + + + } asChild> + + See audit logs + + + } onSelect={() => copyToClipboard(copyContent)}> + Copy identifier + + } onSelect={openTerraformExportModal}> + Export as Terraform + + + } + color="neutral" + disabled + className="cursor-not-allowed data-[highlighted]:bg-transparent" + > +
+ Delete environment + + + +
+
+
+
+ ) +} + export interface EnvironmentActionToolbarProps { environment: Environment variant?: ActionToolbarVariant diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx index 05227c201d8..df99313d934 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx @@ -32,9 +32,19 @@ jest.mock('../../hooks/use-environments/use-environments', () => ({ }), })) +jest.mock('../../fake-argocd-mode/fake-argocd-mode', () => ({ + getFakeArgoCdMode: () => 'hybrid', +})) + jest.mock('../../environment-action-toolbar/environment-action-toolbar', () => ({ MenuManageDeployment: () => , MenuOtherActions: () => , + MenuArgoCdOnlyActions: () => , +})) + +jest.mock('../../environment-state-chip/environment-state-chip', () => ({ + __esModule: true, + default: () => Status, })) const overview: EnvironmentOverviewResponse = { 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..ba79639c4ea 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 @@ -4,18 +4,24 @@ import { type KeyboardEvent, type MouseEvent } from 'react' import { useMediaQuery } from 'react-responsive' import { match } from 'ts-pattern' import { ClusterAvatar } from '@qovery/domains/clusters/feature' -import { Button, DeploymentAction, Heading, Icon, Section, TablePrimitives, Truncate } from '@qovery/shared/ui' +import { Button, DeploymentAction, Heading, Icon, Section, TablePrimitives, Tooltip, Truncate } from '@qovery/shared/ui' import { timeAgo } from '@qovery/shared/util-dates' import { pluralize, twMerge } from '@qovery/shared/util-js' -import { MenuManageDeployment, MenuOtherActions } from '../../environment-action-toolbar/environment-action-toolbar' +import { + MenuArgoCdOnlyActions, + MenuManageDeployment, + MenuOtherActions, +} from '../../environment-action-toolbar/environment-action-toolbar' import EnvironmentMode from '../../environment-mode/environment-mode' import EnvironmentStateChip from '../../environment-state-chip/environment-state-chip' +import { getFakeArgoCdMode } from '../../fake-argocd-mode/fake-argocd-mode' import useEnvironments from '../../hooks/use-environments/use-environments' 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]' +const ARGOCD_HYBRID_REDEPLOY_TOOLTIP = 'Redeploy will only target Qovery created services and not ArgoCD imported ones.' function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { const navigate = useNavigate() @@ -29,6 +35,10 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { const isVeryLargeScreen = useMediaQuery({ query: '(min-width: 1536px)', }) + const argoCdMode = getFakeArgoCdMode(overview.id) + const shouldDisplayArgoCdTag = argoCdMode !== 'none' + const isArgoCdOnly = argoCdMode === 'argocd-only' + const isArgoCdHybrid = argoCdMode === 'hybrid' const stopRowNavigation = (event: MouseEvent | KeyboardEvent) => { event.stopPropagation() @@ -57,9 +67,16 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { >
- - - +
+ + + + {shouldDisplayArgoCdTag && ( + + ARGOCD + + )} +
{overview.service_count} {pluralize(overview.service_count, 'service')} @@ -70,13 +87,19 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
-
- - - {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago - -
- + {isArgoCdOnly ? ( + No operation detected + ) : ( + <> +
+ + + {timeAgo(new Date(overview.deployment_status?.last_deployment_date ?? Date.now()))} ago + +
+ + + )}
@@ -106,10 +129,31 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) { onClick={stopRowNavigation} onKeyDown={stopRowNavigation} > - {environment && overview.deployment_status && overview.service_count > 0 && ( + {environment && overview.deployment_status && overview.service_count > 0 && isArgoCdOnly && ( + <> + + + + )} + {environment && overview.deployment_status && overview.service_count > 0 && !isArgoCdOnly && ( <> - - + + )}
diff --git a/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.spec.ts b/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.spec.ts new file mode 100644 index 00000000000..f3eb947abee --- /dev/null +++ b/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.spec.ts @@ -0,0 +1,55 @@ +import { getFakeArgoCdMode, isFakeArgoCdService } from './fake-argocd-mode' + +describe('getFakeArgoCdMode', () => { + it('should return a deterministic mode for the same seed', () => { + expect(getFakeArgoCdMode('env-1')).toBe(getFakeArgoCdMode('env-1')) + }) + + it('should return only supported modes', () => { + const mode = getFakeArgoCdMode('env-2') + + expect(['none', 'argocd-only', 'hybrid']).toContain(mode) + }) + + it('should roughly match target probabilities', () => { + const seeds = Array.from({ length: 10000 }, (_, index) => `env-${index + 1}`) + const modes = seeds.map((seed) => getFakeArgoCdMode(seed)) + const displayedModes = modes.filter((mode) => mode !== 'none') + const displayedRate = displayedModes.length / modes.length + const onlyRate = displayedModes.filter((mode) => mode === 'argocd-only').length / displayedModes.length + + expect(displayedRate).toBeGreaterThan(0.66) + expect(displayedRate).toBeLessThan(0.74) + expect(onlyRate).toBeGreaterThan(0.66) + expect(onlyRate).toBeLessThan(0.74) + }) +}) + +describe('isFakeArgoCdService', () => { + const findSeedByMode = (mode: ReturnType) => { + const match = Array.from({ length: 10000 }, (_, index) => `env-${index + 1}`).find( + (seed) => getFakeArgoCdMode(seed) === mode + ) + if (!match) { + throw new Error(`No seed found for mode: ${mode}`) + } + return match + } + + it('should be deterministic for the same environment and service', () => { + expect(isFakeArgoCdService({ environmentId: 'env-1', serviceId: 'svc-1' })).toBe( + isFakeArgoCdService({ environmentId: 'env-1', serviceId: 'svc-1' }) + ) + }) + + it('should return false when env mode is none', () => { + const environmentId = findSeedByMode('none') + expect(isFakeArgoCdService({ environmentId, serviceId: 'svc-1' })).toBe(false) + }) + + it('should return true when env mode is argocd-only', () => { + const environmentId = findSeedByMode('argocd-only') + expect(isFakeArgoCdService({ environmentId, serviceId: 'svc-1' })).toBe(true) + expect(isFakeArgoCdService({ environmentId, serviceId: 'svc-2' })).toBe(true) + }) +}) diff --git a/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.ts b/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.ts new file mode 100644 index 00000000000..1f070576635 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/fake-argocd-mode/fake-argocd-mode.ts @@ -0,0 +1,49 @@ +export type FakeArgoCdMode = 'none' | 'argocd-only' | 'hybrid' + +const ARGO_CD_TAG_DISPLAY_THRESHOLD = 0.7 +const ARGO_CD_ONLY_THRESHOLD = 0.7 +const ARGO_CD_HYBRID_SERVICE_THRESHOLD = 0.5 + +function seededRandom(seed: string): number { + // FNV-1a hash to keep pseudo-random values stable for a given seed. + let hash = 2166136261 + + for (let i = 0; i < seed.length; i++) { + hash ^= seed.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + + return (hash >>> 0) / 4294967295 +} + +export function getFakeArgoCdMode(seed: string): FakeArgoCdMode { + const normalizedSeed = seed.trim() || 'default' + const shouldDisplayArgoCdTag = seededRandom(`${normalizedSeed}:display`) < ARGO_CD_TAG_DISPLAY_THRESHOLD + + if (!shouldDisplayArgoCdTag) { + return 'none' + } + + return seededRandom(`${normalizedSeed}:mode`) < ARGO_CD_ONLY_THRESHOLD ? 'argocd-only' : 'hybrid' +} + +export function isFakeArgoCdService({ + environmentId, + serviceId, +}: { + environmentId: string + serviceId: string +}): boolean { + const normalizedEnvironmentId = environmentId.trim() || 'default' + const mode = getFakeArgoCdMode(normalizedEnvironmentId) + + if (mode === 'none') { + return false + } + + if (mode === 'argocd-only') { + return true + } + + return seededRandom(`${normalizedEnvironmentId}:${serviceId}:bucket`) < ARGO_CD_HYBRID_SERVICE_THRESHOLD +} diff --git a/libs/domains/environments/feature/src/lib/settings-preview-environments/settings-preview-environments.tsx b/libs/domains/environments/feature/src/lib/settings-preview-environments/settings-preview-environments.tsx index 835dd3cac97..6824cffa383 100644 --- a/libs/domains/environments/feature/src/lib/settings-preview-environments/settings-preview-environments.tsx +++ b/libs/domains/environments/feature/src/lib/settings-preview-environments/settings-preview-environments.tsx @@ -7,8 +7,9 @@ import { type AnyService } from '@qovery/domains/services/data-access' import { useEditService, useServices } from '@qovery/domains/services/feature' import { SettingsHeading } from '@qovery/shared/console-shared' import { IconEnum } from '@qovery/shared/enums' -import { BlockContent, Button, Icon, InputToggle, Section } from '@qovery/shared/ui' +import { BlockContent, Button, Callout, Icon, InputToggle, Section } from '@qovery/shared/ui' import { buildEditServicePayload } from '@qovery/shared/util-services' +import { getFakeArgoCdMode } from '../fake-argocd-mode/fake-argocd-mode' import { useDeploymentRule } from '../hooks/use-deployment-rule/use-deployment-rule' import { useEditDeploymentRule } from '../hooks/use-edit-deployment-rule/use-edit-deployment-rule' @@ -18,10 +19,11 @@ interface PageSettingsPreviewEnvironmentsProps { loading: boolean toggleAll: (value: boolean) => void toggleEnablePreview: (value: boolean) => void + showArgoCdCallout?: boolean } export function PageSettingsPreviewEnvironments(props: PageSettingsPreviewEnvironmentsProps) { - const { onSubmit, services, loading, toggleAll, toggleEnablePreview } = props + const { onSubmit, services, loading, toggleAll, toggleEnablePreview, showArgoCdCallout } = props const { control, formState } = useFormContext() const getIconName = (service: AnyService) => @@ -36,6 +38,16 @@ export function PageSettingsPreviewEnvironments(props: PageSettingsPreviewEnviro
+ {showArgoCdCallout ? ( + + + + + + ArgoCD linked services won't be copied in your preview environments. + + + ) : null} ) @@ -235,6 +254,7 @@ export function SettingsPreviewEnvironmentsFeature({ services }: { services: Any export function PageSettingsPreviewEnvironmentsFeature() { const { environmentId = '' } = useParams({ strict: false }) const { data: services } = useServices({ environmentId }) + const showArgoCdCallout = getFakeArgoCdMode(environmentId) === 'hybrid' - return + return } diff --git a/libs/domains/organizations/feature/src/index.ts b/libs/domains/organizations/feature/src/index.ts index 06fbe80d77b..e4522cf0b2e 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -96,6 +96,7 @@ export * from './lib/settings-cloud-credentials/settings-cloud-credentials' export * from './lib/settings-git-repository-access/settings-git-repository-access' export * from './lib/settings-helm-repositories/settings-helm-repositories' export * from './lib/settings-container-registries/settings-container-registries' +export * from './lib/settings-argocd-integration/settings-argocd-integration' export * from './lib/settings-billing-summary/settings-billing-summary' export * from './lib/settings-webhook/settings-webhook' export * from './lib/settings-api-token/settings-api-token' diff --git a/libs/domains/organizations/feature/src/lib/settings-argocd-integration/argocd-associated-services-modal.tsx b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/argocd-associated-services-modal.tsx new file mode 100644 index 00000000000..b9d62b2b009 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/argocd-associated-services-modal.tsx @@ -0,0 +1,217 @@ +import { useMemo, useState } from 'react' +import { match } from 'ts-pattern' +import { Heading, Icon, InputSearch, Link, Section, TreeView } from '@qovery/shared/ui' + +interface FakeService { + service_id: string + service_name: string + service_type: 'APPLICATION' | 'CONTAINER' | 'DATABASE' | 'CRON' +} + +interface FakeEnvironment { + environment_id: string + environment_name: string + services: FakeService[] +} + +interface FakeProject { + project_id: string + project_name: string + environments: FakeEnvironment[] +} + +export interface ArgoCdAssociatedServicesModalProps { + organizationId: string + clusterName: string + associatedItemsCount: number + onClose: () => void +} + +const SERVICE_TYPES: FakeService['service_type'][] = ['APPLICATION', 'CONTAINER', 'DATABASE', 'CRON'] + +const createFakeProjects = (clusterName: string, associatedItemsCount: number): FakeProject[] => { + const safeCount = Math.max(1, associatedItemsCount) + const projects: FakeProject[] = [ + { + project_id: 'fake-project-core', + project_name: `${clusterName} Core`, + environments: [ + { + environment_id: 'fake-env-core-prod', + environment_name: 'production', + services: [], + }, + { + environment_id: 'fake-env-core-staging', + environment_name: 'staging', + services: [], + }, + ], + }, + { + project_id: 'fake-project-platform', + project_name: `${clusterName} Platform`, + environments: [ + { + environment_id: 'fake-env-platform-prod', + environment_name: 'production', + services: [], + }, + { + environment_id: 'fake-env-platform-staging', + environment_name: 'staging', + services: [], + }, + ], + }, + ] + + for (let index = 0; index < safeCount; index++) { + const projectIndex = index % projects.length + const environmentIndex = Math.floor(index / projects.length) % projects[projectIndex].environments.length + const serviceType = SERVICE_TYPES[index % SERVICE_TYPES.length] + + projects[projectIndex].environments[environmentIndex].services.push({ + service_id: `fake-service-${index + 1}`, + service_name: `service-${index + 1}`, + service_type: serviceType, + }) + } + + return projects +} + +const filterProjects = (projects: FakeProject[], searchValue?: string): FakeProject[] => { + if (!searchValue) { + return projects + } + + const search = searchValue.toLowerCase() + + return projects.reduce((acc, project) => { + const projectMatches = project.project_name.toLowerCase().includes(search) + + const environments = project.environments.reduce((environmentsAcc, environment) => { + const environmentMatches = environment.environment_name.toLowerCase().includes(search) + const services = environment.services.filter( + (service) => projectMatches || environmentMatches || service.service_name.toLowerCase().includes(search) + ) + + if (services.length > 0) { + environmentsAcc.push({ + ...environment, + services, + }) + } + + return environmentsAcc + }, []) + + if (environments.length > 0) { + acc.push({ + ...project, + environments, + }) + } + + return acc + }, []) +} + +export function ArgoCdAssociatedServicesModal({ + organizationId, + clusterName, + associatedItemsCount, + onClose, +}: ArgoCdAssociatedServicesModalProps) { + const [searchValue, setSearchValue] = useState() + + const fakeProjects = useMemo( + () => createFakeProjects(clusterName, associatedItemsCount), + [clusterName, associatedItemsCount] + ) + const filteredProjects = useMemo(() => filterProjects(fakeProjects, searchValue), [fakeProjects, searchValue]) + + return ( +
+ Associated services ({associatedItemsCount}) + setSearchValue(value)} + /> + {filteredProjects.length > 0 ? ( + + {filteredProjects.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" + > + 'CRON_JOB') + .otherwise((serviceType) => serviceType)} + width={20} + className="mr-2" + /> + {service.service_name} + +
  • + ))} +
+
+
+
+ ))} +
+
+ ))} +
+ ) : ( +
+ +

No value found

+
+ )} +
+ ) +} + +export default ArgoCdAssociatedServicesModal diff --git a/libs/domains/organizations/feature/src/lib/settings-argocd-integration/connect-argocd-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/connect-argocd-modal.spec.tsx new file mode 100644 index 00000000000..d374ddf7c4c --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/connect-argocd-modal.spec.tsx @@ -0,0 +1,47 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { ConnectArgoCdModal } from './connect-argocd-modal' + +const mockUseClusters = jest.fn() + +jest.mock('@qovery/domains/clusters/feature', () => ({ + useClusters: () => mockUseClusters(), +})) + +describe('ConnectArgoCdModal', () => { + beforeEach(() => { + mockUseClusters.mockReturnValue({ + data: [{ id: 'cluster-1', name: 'Cluster one' }], + isLoading: false, + }) + }) + + it('should render modal content', () => { + renderWithProviders() + + expect(screen.getByText('Connect ArgoCD with Qovery')).toBeInTheDocument() + expect(screen.getByText('1. Select the cluster hosting your ArgoCD instance')).toBeInTheDocument() + expect(screen.getByText('2. ArgoCD API endpoint')).toBeInTheDocument() + expect(screen.getByText('3. Generate an access token')).toBeInTheDocument() + expect(screen.getByText('$ argocd account generate-token')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Connect ArgoCD' })).toBeDisabled() + }) + + it('should render edit mode labels', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Edit ArgoCD connection')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Update connection' })).toBeInTheDocument() + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/settings-argocd-integration/connect-argocd-modal.tsx b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/connect-argocd-modal.tsx new file mode 100644 index 00000000000..9ce659e5871 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/connect-argocd-modal.tsx @@ -0,0 +1,225 @@ +import { useMemo, useState } from 'react' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { useClusters } from '@qovery/domains/clusters/feature' +import { CopyButton } from '@qovery/shared/ui' +import { ExternalLink, Icon, InputSelect, InputText, ModalCrud } from '@qovery/shared/ui' + +export interface ConnectArgoCdModalProps { + organizationId: string + onClose: (response?: ConnectArgoCdModalResponse) => void + isEdit?: boolean + disableTargetClusterSelection?: boolean + initialValues?: Partial + initialCluster?: { + id: string + name: string + cloudProvider?: string + } +} + +interface ConnectArgoCdFormValues { + targetCluster: string + argoCdApiUrl: string + accessToken: string +} + +export interface ConnectArgoCdModalResponse { + clusterId: string + clusterName: string + clusterCloudProvider?: string + argoCdApiUrl: string + accessToken: string +} + +const ARGOCD_TOKEN_COMMAND = '$ argocd account generate-token' +const ARGOCD_API_ENDPOINT_GUIDE_URL = 'https://argo-cd.readthedocs.io/' + +export function ConnectArgoCdModal({ + organizationId, + onClose, + isEdit = false, + disableTargetClusterSelection = false, + initialValues, + initialCluster, +}: ConnectArgoCdModalProps) { + const [isConnecting, setIsConnecting] = useState(false) + const methods = useForm({ + mode: 'onChange', + defaultValues: { + targetCluster: initialValues?.targetCluster ?? '', + argoCdApiUrl: initialValues?.argoCdApiUrl ?? '', + accessToken: initialValues?.accessToken ?? '', + }, + }) + + const { data: clusters = [], isLoading: isLoadingClusters } = useClusters({ + organizationId, + enabled: !!organizationId, + }) + + const clusterOptions = useMemo(() => { + const options = clusters.map((cluster) => ({ + label: cluster.name, + value: cluster.id, + })) + + if (initialCluster && !options.some((option) => option.value === initialCluster.id)) { + options.unshift({ + label: initialCluster.name, + value: initialCluster.id, + }) + } + + return options + }, [clusters, initialCluster]) + + const onSubmit = methods.handleSubmit(async () => { + setIsConnecting(true) + // Temporary fake loading state until backend integration is implemented. + await new Promise((resolve) => setTimeout(resolve, 2000)) + setIsConnecting(false) + + const selectedClusterId = methods.getValues('targetCluster') + const selectedCluster = clusters.find((cluster) => cluster.id === selectedClusterId) + + if (!selectedCluster && !initialCluster) { + onClose() + return + } + + const resolvedCluster = selectedCluster ?? initialCluster + + onClose({ + clusterId: resolvedCluster?.id ?? '', + clusterName: resolvedCluster?.name ?? '', + clusterCloudProvider: selectedCluster?.cloud_provider ?? initialCluster?.cloudProvider, + argoCdApiUrl: methods.getValues('argoCdApiUrl'), + accessToken: methods.getValues('accessToken'), + }) + }) + + const onOpenHowItWorks = () => { + window.open(ARGOCD_API_ENDPOINT_GUIDE_URL, '_blank', 'noopener,noreferrer') + } + + return ( + + +

+ You can define here the repository (HTTPS or OCI) that you want to use within your organization to deploy + your helm charts. +

+

You can create a new repository by defining:

+
    +
  • its name and description
  • +
  • kind (HTTPS or OCI)
  • +
  • the repository URL (Starting with https:// or oci://)
  • +
  • the credentials
  • +
  • the skip TLS verification option (to activate the helm argument --insecure-skip-tls-verify)
  • +
+ + More information here + + + } + > +
+
+

1. Select the cluster hosting your ArgoCD instance

+
+ ( + field.onChange(value)} + options={clusterOptions} + disabled={disableTargetClusterSelection} + error={error?.message} + isLoading={isLoadingClusters} + /> + )} + /> +
+
+ +
+

2. ArgoCD API endpoint

+

Enter the internal URL of the ArgoCD API server

+ + How to find the ArgoCD API endpoint + +
+ ( + + )} + /> +
+
+ +
+

3. Generate an access token

+

+ Generate an API token from your ArgoCD instance and paste it below +

+
+ {ARGOCD_TOKEN_COMMAND} + +
+
+ ( + + )} + /> +
+
+
+
+
+ ) +} + +export default ConnectArgoCdModal diff --git a/libs/domains/organizations/feature/src/lib/settings-argocd-integration/delete-argocd-modal.tsx b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/delete-argocd-modal.tsx new file mode 100644 index 00000000000..6ce18921fab --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/delete-argocd-modal.tsx @@ -0,0 +1,23 @@ +import { ModalConfirmation } from '@qovery/shared/ui' + +export interface DeleteArgoCdModalProps { + onSubmit: () => void +} + +const DELETE_CONFIRMATION_ACTION = 'delete' + +export function DeleteArgoCdModal({ onSubmit }: DeleteArgoCdModalProps) { + return ( + + ) +} + +export default DeleteArgoCdModal diff --git a/libs/domains/organizations/feature/src/lib/settings-argocd-integration/link-qovery-cluster-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/link-qovery-cluster-modal.spec.tsx new file mode 100644 index 00000000000..45ca3edc334 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/link-qovery-cluster-modal.spec.tsx @@ -0,0 +1,35 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { LinkQoveryClusterModal } from './link-qovery-cluster-modal' + +const mockUseClusters = jest.fn() + +jest.mock('@qovery/domains/clusters/feature', () => ({ + useClusters: () => mockUseClusters(), +})) + +describe('LinkQoveryClusterModal', () => { + beforeEach(() => { + mockUseClusters.mockReturnValue({ + data: [{ id: 'cluster-1', name: 'Cluster one' }], + isLoading: false, + }) + }) + + it('should render modal content', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Link to Qovery cluster')).toBeInTheDocument() + expect(screen.getByText(/kube-node-lease/)).toBeInTheDocument() + expect(screen.getByText(/88 detected services/)).toBeInTheDocument() + expect(screen.getByText(/33 environments/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Link cluster' })).toBeDisabled() + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/settings-argocd-integration/link-qovery-cluster-modal.tsx b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/link-qovery-cluster-modal.tsx new file mode 100644 index 00000000000..14c5d6e511a --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/link-qovery-cluster-modal.tsx @@ -0,0 +1,119 @@ +import { useMemo } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { useClusters } from '@qovery/domains/clusters/feature' +import { Button, InputSelect } from '@qovery/shared/ui' + +export interface LinkQoveryClusterModalProps { + organizationId: string + argoCdClusterName: string + environmentsDetected: number + servicesDetected: number + linkedClusterIds?: string[] + onClose: (response?: LinkQoveryClusterModalResponse) => void +} + +interface LinkQoveryClusterFormValues { + qoveryClusterId: string +} + +export interface LinkQoveryClusterModalResponse { + clusterId: string + clusterName: string + clusterCloudProvider?: string + clusterType: 'Qovery managed' | 'Self managed' +} + +export function LinkQoveryClusterModal({ + organizationId, + argoCdClusterName, + environmentsDetected, + servicesDetected, + linkedClusterIds = [], + onClose, +}: LinkQoveryClusterModalProps) { + const methods = useForm({ + mode: 'onChange', + defaultValues: { + qoveryClusterId: '', + }, + }) + const { control, formState } = methods + + const { data: clusters = [], isLoading: isLoadingClusters } = useClusters({ + organizationId, + enabled: !!organizationId, + }) + + const linkedClusterIdSet = useMemo(() => new Set(linkedClusterIds), [linkedClusterIds]) + + const availableClusters = useMemo( + () => clusters.filter((cluster) => !linkedClusterIdSet.has(cluster.id)), + [clusters, linkedClusterIdSet] + ) + + const clusterOptions = useMemo( + () => + availableClusters.map((cluster) => ({ + label: cluster.name, + value: cluster.id, + })), + [availableClusters] + ) + + const onSubmit = methods.handleSubmit(({ qoveryClusterId }) => { + const selectedCluster = availableClusters.find((cluster) => cluster.id === qoveryClusterId) + + if (!selectedCluster) { + onClose() + return + } + + onClose({ + clusterId: selectedCluster.id, + clusterName: selectedCluster.name, + clusterCloudProvider: selectedCluster.cloud_provider, + clusterType: selectedCluster.kubernetes === 'SELF_MANAGED' ? 'Self managed' : 'Qovery managed', + }) + }) + + return ( +
+

Link to Qovery cluster

+

+ Select the Qovery cluster that corresponds to {argoCdClusterName}. Qovery + will display the {servicesDetected} detected services across{' '} + {environmentsDetected} environments. +

+ + + ( + field.onChange(value)} + options={clusterOptions} + error={error?.message} + isLoading={isLoadingClusters} + isSearchable + portal + /> + )} + /> +
+ + +
+ +
+ ) +} diff --git a/libs/domains/organizations/feature/src/lib/settings-argocd-integration/settings-argocd-integration.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/settings-argocd-integration.spec.tsx new file mode 100644 index 00000000000..564fbf2cac3 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/settings-argocd-integration.spec.tsx @@ -0,0 +1,271 @@ +import { act } from 'react' +import { type ReactNode } from 'react' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { SettingsArgoCdIntegration } from './settings-argocd-integration' + +const mockOpenModal = jest.fn() +const mockCloseModal = jest.fn() + +jest.mock('@qovery/shared/ui', () => ({ + ...jest.requireActual('@qovery/shared/ui'), + useModal: () => ({ + openModal: mockOpenModal, + closeModal: mockCloseModal, + }), + Link: ({ children, ...props }: { children: ReactNode }) => {children}, +})) + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ organizationId: 'org-id' }), +})) + +jest.mock('@qovery/shared/util-hooks', () => ({ + useDocumentTitle: jest.fn(), +})) + +describe('SettingsArgoCdIntegration', () => { + beforeEach(() => { + mockOpenModal.mockReset() + mockCloseModal.mockReset() + jest.spyOn(Math, 'random').mockReturnValue(0) + }) + + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + }) + + it('should render the empty state', () => { + renderWithProviders() + + expect(screen.getByText('Argo CD integration')).toBeInTheDocument() + expect(screen.getAllByText('Add ArgoCD')).toHaveLength(2) + expect(screen.getByText('No ArgoCD integration configured')).toBeInTheDocument() + expect( + screen.getByText('Add your first ArgoCD instance to automatically visualize them in Qovery.') + ).toBeInTheDocument() + }) + + it('should open the connect modal from both add buttons', async () => { + const { userEvent } = renderWithProviders() + + const addButtons = screen.getAllByRole('button', { name: 'Add ArgoCD' }) + await userEvent.click(addButtons[0]) + await userEvent.click(addButtons[1]) + + expect(mockOpenModal).toHaveBeenCalledTimes(2) + expect(mockOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + width: 676, + }), + }) + ) + }) + + it('should switch to loading integration state and disable actions while importing', async () => { + const { userEvent } = renderWithProviders() + const addButton = screen.getAllByRole('button', { name: 'Add ArgoCD' })[0] + await userEvent.click(addButton) + + const modalConfig = mockOpenModal.mock.calls[0][0] + act(() => { + modalConfig.content.props.onClose({ + clusterId: 'cluster-id', + clusterName: 'undeletable_cluster', + clusterCloudProvider: 'AWS', + }) + }) + + expect(mockCloseModal).toHaveBeenCalled() + expect(screen.getByText('ArgoCD running on')).toBeInTheDocument() + expect(screen.getByText('Detecting all namespaces and services')).toBeInTheDocument() + expect(screen.getByTestId('edit-argocd-integration')).toBeDisabled() + expect(screen.getByTestId('delete-argocd-integration')).toBeDisabled() + expect(screen.getByTestId('argocd-cluster-link')).toHaveTextContent('undeletable_cluster') + }) + + it('should render loading integration use case when forced by prop', () => { + renderWithProviders() + + expect(screen.getByText('ArgoCD running on')).toBeInTheDocument() + expect(screen.getByText('Detecting all namespaces and services')).toBeInTheDocument() + expect(screen.getByTestId('edit-argocd-integration')).toBeDisabled() + expect(screen.getByTestId('delete-argocd-integration')).toBeDisabled() + expect(screen.getByTestId('argocd-cluster-link')).toHaveTextContent('undeletable_cluster') + }) + + it('should render loaded integration use case when forced by prop', () => { + renderWithProviders() + + expect(screen.getByText('Linked clusters (3)')).toBeInTheDocument() + expect(screen.getByText('Unlinked clusters (2)')).toBeInTheDocument() + expect(screen.getByText('Connected')).toBeInTheDocument() + expect(screen.getByTestId('edit-argocd-integration')).not.toBeDisabled() + expect(screen.getByTestId('delete-argocd-integration')).not.toBeDisabled() + }) + + it('should render loaded single cluster use case when forced by prop', () => { + renderWithProviders() + + expect(screen.queryByText('Linked clusters (3)')).not.toBeInTheDocument() + expect(screen.queryByText('Unlinked clusters (2)')).not.toBeInTheDocument() + expect(screen.getByText('AWS EKS Demo')).toBeInTheDocument() + expect(screen.getByText('Connected')).toBeInTheDocument() + expect(screen.getByTestId('open-associated-services-linked-aws')).toBeInTheDocument() + expect(screen.queryByTestId('link-unlinked-cluster-unlinked-kube-node')).not.toBeInTheDocument() + }) + + it('should open associated services modal when clicking the layer button', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByText('Linked clusters (3)')) + await userEvent.click(screen.getByTestId('open-associated-services-linked-aws')) + + expect(mockOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.anything(), + }) + ) + + const modalConfig = mockOpenModal.mock.calls[0][0] + expect(modalConfig.content.props.clusterName).toBe('AWS EKS Demo') + expect(modalConfig.content.props.associatedItemsCount).toBe(4) + }) + + it('should move an unlinked cluster to linked clusters when link modal is confirmed', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByText('Unlinked clusters (2)')) + await userEvent.click(screen.getByTestId('link-unlinked-cluster-unlinked-kube-node')) + + expect(mockOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + width: 488, + }), + }) + ) + + const modalConfig = mockOpenModal.mock.calls[0][0] + act(() => { + modalConfig.content.props.onClose({ + clusterId: 'cluster-42', + clusterName: 'Qovery linked cluster', + clusterCloudProvider: 'AWS', + clusterType: 'Qovery managed', + }) + }) + + expect(mockCloseModal).toHaveBeenCalled() + expect(screen.getByText('Linked clusters (4)')).toBeInTheDocument() + expect(screen.getByText('Unlinked clusters (1)')).toBeInTheDocument() + expect(screen.queryByTestId('link-unlinked-cluster-unlinked-kube-node')).not.toBeInTheDocument() + + await userEvent.click(screen.getByText('Linked clusters (4)')) + expect(screen.getByText('Qovery linked cluster')).toBeInTheDocument() + expect(screen.getByText('kube-node-lease')).toBeInTheDocument() + }) + + it('should move a linked cluster to unlinked clusters when unlink icon is clicked', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByText('Linked clusters (3)')) + await userEvent.click(screen.getByTestId('unlink-linked-cluster-linked-aws')) + + expect(screen.getByText('Linked clusters (2)')).toBeInTheDocument() + expect(screen.getByText('Unlinked clusters (3)')).toBeInTheDocument() + + await userEvent.click(screen.getByText('Unlinked clusters (3)')) + expect(screen.getByText('kube-system')).toBeInTheDocument() + }) + + it('should hide unlinked clusters section when all unlinked clusters are linked', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByText('Unlinked clusters (2)')) + await userEvent.click(screen.getByTestId('link-unlinked-cluster-unlinked-kube-node')) + let modalConfig = mockOpenModal.mock.calls.at(-1)?.[0] + act(() => { + modalConfig.content.props.onClose({ + clusterId: 'cluster-1', + clusterName: 'Cluster One', + clusterCloudProvider: 'AWS', + clusterType: 'Qovery managed', + }) + }) + + await userEvent.click(screen.getByTestId('link-unlinked-cluster-unlinked-istio')) + modalConfig = mockOpenModal.mock.calls.at(-1)?.[0] + act(() => { + modalConfig.content.props.onClose({ + clusterId: 'cluster-2', + clusterName: 'Cluster Two', + clusterCloudProvider: 'GCP', + clusterType: 'Self managed', + }) + }) + + expect(screen.queryByText(/Unlinked clusters \(\d+\)/)).not.toBeInTheDocument() + expect(screen.getByText('Linked clusters (5)')).toBeInTheDocument() + }) + + it('should hide linked clusters section when all linked clusters are unlinked', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByText('Linked clusters (3)')) + await userEvent.click(screen.getByTestId('unlink-linked-cluster-linked-aws')) + + await userEvent.click(screen.getByTestId('unlink-linked-cluster-linked-gitlab')) + + await userEvent.click(screen.getByTestId('unlink-linked-cluster-linked-gcp')) + + expect(screen.queryByText(/Linked clusters \(\d+\)/)).not.toBeInTheDocument() + expect(screen.getByText('Unlinked clusters (5)')).toBeInTheDocument() + }) + + it('should open delete modal and clear integration on confirm', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByTestId('delete-argocd-integration')) + + expect(mockOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + width: 488, + }), + }) + ) + + const modalConfig = mockOpenModal.mock.calls[0][0] + act(() => { + modalConfig.content.props.onSubmit() + }) + + expect(screen.getByText('No ArgoCD integration configured')).toBeInTheDocument() + }) + + it('should open edit connection modal with locked target cluster', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByTestId('edit-argocd-integration')) + + expect(mockOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + width: 676, + }), + }) + ) + + const modalConfig = mockOpenModal.mock.calls[0][0] + expect(modalConfig.content.props.isEdit).toBe(true) + expect(modalConfig.content.props.disableTargetClusterSelection).toBe(true) + expect(modalConfig.content.props.initialValues).toEqual({ + targetCluster: 'cluster-id', + argoCdApiUrl: 'https://argocd.example.com/api', + accessToken: 'fake-token', + }) + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/settings-argocd-integration/settings-argocd-integration.tsx b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/settings-argocd-integration.tsx new file mode 100644 index 00000000000..b057d94b7dd --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-argocd-integration/settings-argocd-integration.tsx @@ -0,0 +1,871 @@ +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { useParams } from '@tanstack/react-router' +import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { P, match } from 'ts-pattern' +import { SettingsHeading } from '@qovery/shared/console-shared' +import { Badge, Button, EmptyState, Icon, Link, Section, useModal } from '@qovery/shared/ui' +import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { ArgoCdAssociatedServicesModal } from './argocd-associated-services-modal' +import { ConnectArgoCdModal, type ConnectArgoCdModalResponse } from './connect-argocd-modal' +import { DeleteArgoCdModal } from './delete-argocd-modal' +import { LinkQoveryClusterModal, type LinkQoveryClusterModalResponse } from './link-qovery-cluster-modal' + +const IMPORT_STEPS = [ + 'Detecting all namespaces and services', + 'Linking detected cluster to existing clusters', + 'Creating new environments with detected services', + 'Updating existing environments with detected services', +] as const + +interface LinkedArgoCdCluster { + id: string + name: string + cloudProvider?: string + argoCdName: string + clusterType: 'Qovery managed' | 'Self managed' + environmentsDetected?: number + detectedServices: number + qoveryClusterId?: string +} + +interface UnlinkedArgoCdCluster { + id: string + name: string + environmentsDetected: number + servicesDetected: number +} + +const INITIAL_LINKED_CLUSTERS: LinkedArgoCdCluster[] = [ + { + id: 'linked-aws', + name: 'AWS EKS Demo', + cloudProvider: 'AWS', + argoCdName: 'kube-system', + clusterType: 'Qovery managed', + detectedServices: 4, + }, + { + id: 'linked-gitlab', + name: 'GitLab Runner Cluster', + argoCdName: 'gitlab-system', + clusterType: 'Qovery managed', + detectedServices: 13, + }, + { + id: 'linked-gcp', + name: 'GCP Tokyo Cluster', + cloudProvider: 'GCP', + argoCdName: 'argocd', + clusterType: 'Self managed', + detectedServices: 42, + }, +] + +const INITIAL_UNLINKED_CLUSTERS: UnlinkedArgoCdCluster[] = [ + { + id: 'unlinked-kube-node', + name: 'kube-node-lease', + environmentsDetected: 32, + servicesDetected: 88, + }, + { + id: 'unlinked-istio', + name: 'istio-system', + environmentsDetected: 32, + servicesDetected: 88, + }, +] + +const getInitialLinkedClusters = () => INITIAL_LINKED_CLUSTERS.map((cluster) => ({ ...cluster })) +const getInitialUnlinkedClusters = () => INITIAL_UNLINKED_CLUSTERS.map((cluster) => ({ ...cluster })) +const getInitialSingleLinkedCluster = () => { + const [firstCluster] = getInitialLinkedClusters() + return firstCluster ? [firstCluster] : [] +} + +type ArgoCdImportStepStatus = 'done' | 'current' | 'pending' + +interface ArgoCdIntegrationState extends ConnectArgoCdModalResponse { + completedStepsCount: number + currentStepProgress: number + currentStepDurationMs: number + isImporting: boolean +} + +// Temporary fake import timeline until ArgoCD integration status is streamed by backend. +const getFakeImportStepDelayMs = () => 2000 + Math.floor(Math.random() * 1000) + +const STEP_ICON_SIZE = 14 +const STEP_ICON_CENTER = STEP_ICON_SIZE / 2 +const STEP_ICON_OUTER_STROKE_WIDTH = 0.8 +const STEP_ICON_OUTER_RADIUS = 6.7 +const STEP_ICON_STROKE_WIDTH = 1.5 +const STEP_ICON_RADIUS = 6.25 +const STEP_ICON_CIRCUMFERENCE = 2 * Math.PI * STEP_ICON_RADIUS + +const USE_CASE_EMPTY_STATE = 'empty-state' +const USE_CASE_LOADING_INTEGRATION = 'loading-integration' +const USE_CASE_LOADED = 'loaded' +const USE_CASE_LOADED_SINGLE_CLUSTER = 'loaded-single-cluster' + +export type SettingsArgoCdIntegrationUseCase = + | typeof USE_CASE_EMPTY_STATE + | typeof USE_CASE_LOADING_INTEGRATION + | typeof USE_CASE_LOADED + | typeof USE_CASE_LOADED_SINGLE_CLUSTER + +export interface SettingsArgoCdIntegrationProps { + useCaseId?: SettingsArgoCdIntegrationUseCase +} + +const createLoadingIntegrationState = (): ArgoCdIntegrationState => ({ + clusterId: 'cluster-id', + clusterName: 'undeletable_cluster', + clusterCloudProvider: 'AWS', + argoCdApiUrl: 'https://argocd.example.com/api', + accessToken: 'fake-token', + completedStepsCount: 0, + currentStepProgress: 0, + currentStepDurationMs: getFakeImportStepDelayMs(), + isImporting: true, +}) + +const createLoadedIntegrationState = (): ArgoCdIntegrationState => ({ + clusterId: 'cluster-id', + clusterName: 'undeletable_cluster', + clusterCloudProvider: 'AWS', + argoCdApiUrl: 'https://argocd.example.com/api', + accessToken: 'fake-token', + completedStepsCount: IMPORT_STEPS.length, + currentStepProgress: 100, + currentStepDurationMs: 0, + isImporting: false, +}) + +function ArgoCdStepIcon({ + status, + progress = 0, + progressDurationMs = 2500, +}: { + status: ArgoCdImportStepStatus + progress?: number + progressDurationMs?: number +}) { + const clampedProgress = Math.max(0, Math.min(progress, 100)) + const progressValue = clampedProgress / 100 + + return match(status) + .with('done', () => ( + + + + )) + .with('current', () => ( + + + + + )) + .otherwise(() => ( + + + + )) +} + +interface ArgoCdSectionProps { + id: 'linked' | 'unlinked' + title: string + count: number + isOpen: boolean + onOpenChange: (isOpen: boolean) => void + hasBottomBorder?: boolean + children?: ReactNode +} + +function ArgoCdSection({ + id, + title, + count, + isOpen, + onOpenChange, + hasBottomBorder = true, + children, +}: ArgoCdSectionProps) { + return ( + onOpenChange(value === id)} + className="w-full" + > + + + match(id) + .with('unlinked', () => 'group flex w-full items-center justify-between px-4 pb-1 pt-4 text-left') + .otherwise(() => 'group flex w-full items-center justify-between px-4 pb-3 pt-4 text-left') + ) + .otherwise(() => 'group flex w-full items-center justify-between p-4 text-left')} + > + 'text-sm font-medium text-neutral') + .otherwise(() => 'text-sm font-medium text-neutral-subtle')} + > + {title} ({count}) + + 'rotate-180 text-sm text-neutral transition-transform duration-200') + .otherwise(() => 'text-sm text-neutral-subtle transition-transform duration-200')} + /> + + + {children} + + + + ) +} + +export function SettingsArgoCdIntegration({ useCaseId }: SettingsArgoCdIntegrationProps) { + const { organizationId = '' } = useParams({ strict: false }) + const { openModal, closeModal } = useModal() + const [argoCdIntegration, setArgoCdIntegration] = useState(null) + const [isLinkedSectionOpen, setIsLinkedSectionOpen] = useState(false) + const [isUnlinkedSectionOpen, setIsUnlinkedSectionOpen] = useState(false) + const [linkedClusters, setLinkedClusters] = useState(() => getInitialLinkedClusters()) + const [unlinkedClusters, setUnlinkedClusters] = useState(() => getInitialUnlinkedClusters()) + useDocumentTitle('Argo CD integration - Organization settings') + + const resetClusterLists = useCallback(() => { + setLinkedClusters(getInitialLinkedClusters()) + setUnlinkedClusters(getInitialUnlinkedClusters()) + }, []) + + useEffect(() => { + if (!useCaseId) { + return + } + + if (useCaseId === USE_CASE_EMPTY_STATE) { + setArgoCdIntegration(null) + resetClusterLists() + return + } + + if (useCaseId === USE_CASE_LOADING_INTEGRATION) { + setArgoCdIntegration(createLoadingIntegrationState()) + resetClusterLists() + return + } + + if (useCaseId === USE_CASE_LOADED) { + setArgoCdIntegration(createLoadedIntegrationState()) + setIsLinkedSectionOpen(false) + setIsUnlinkedSectionOpen(false) + resetClusterLists() + return + } + + if (useCaseId === USE_CASE_LOADED_SINGLE_CLUSTER) { + setArgoCdIntegration(createLoadedIntegrationState()) + setIsLinkedSectionOpen(false) + setIsUnlinkedSectionOpen(false) + setLinkedClusters(getInitialSingleLinkedCluster()) + setUnlinkedClusters([]) + } + }, [useCaseId, resetClusterLists]) + + useEffect(() => { + if (!argoCdIntegration?.isImporting) { + return + } + + const durationMs = argoCdIntegration.currentStepDurationMs + const animationFrame = requestAnimationFrame(() => { + setArgoCdIntegration((currentState) => { + if (!currentState || !currentState.isImporting) { + return currentState + } + + return { + ...currentState, + currentStepProgress: 100, + } + }) + }) + + const timeout = setTimeout(() => { + setArgoCdIntegration((currentState) => { + if (!currentState || !currentState.isImporting) { + return currentState + } + + const nextCompletedStepsCount = currentState.completedStepsCount + 1 + if (nextCompletedStepsCount >= IMPORT_STEPS.length) { + return { + ...currentState, + completedStepsCount: IMPORT_STEPS.length, + currentStepProgress: 100, + isImporting: false, + } + } + + return { + ...currentState, + completedStepsCount: nextCompletedStepsCount, + currentStepProgress: 0, + currentStepDurationMs: getFakeImportStepDelayMs(), + } + }) + }, durationMs) + + return () => { + cancelAnimationFrame(animationFrame) + clearTimeout(timeout) + } + }, [argoCdIntegration?.isImporting, argoCdIntegration?.completedStepsCount, argoCdIntegration?.currentStepDurationMs]) + + const importStepsWithStatus = useMemo(() => { + const completedStepsCount = argoCdIntegration?.completedStepsCount ?? 0 + const isImporting = argoCdIntegration?.isImporting ?? false + + return IMPORT_STEPS.map((step, index) => ({ + label: step, + status: match({ completedStepsCount, isImporting, index }) + .with({ index: P.when((stepIndex) => stepIndex < completedStepsCount) }, () => 'done' as const) + .with({ index: completedStepsCount, isImporting: true }, () => 'current' as const) + .otherwise(() => 'pending' as const), + })) + }, [argoCdIntegration?.completedStepsCount, argoCdIntegration?.isImporting]) + + const hasLinkedClusters = linkedClusters.length > 0 + const hasUnlinkedClusters = unlinkedClusters.length > 0 + + const onConnectSuccess = (response: ConnectArgoCdModalResponse) => { + resetClusterLists() + setArgoCdIntegration({ + ...response, + completedStepsCount: 0, + currentStepProgress: 0, + currentStepDurationMs: getFakeImportStepDelayMs(), + isImporting: true, + }) + } + + const onLinkClusterSuccess = (unlinkedCluster: UnlinkedArgoCdCluster, response: LinkQoveryClusterModalResponse) => { + setUnlinkedClusters((currentUnlinkedClusters) => + currentUnlinkedClusters.filter(({ id }) => id !== unlinkedCluster.id) + ) + setLinkedClusters((currentLinkedClusters) => [ + ...currentLinkedClusters, + { + id: `linked-${response.clusterId}-${unlinkedCluster.id}`, + qoveryClusterId: response.clusterId, + name: response.clusterName, + cloudProvider: response.clusterCloudProvider, + argoCdName: unlinkedCluster.name, + clusterType: response.clusterType, + environmentsDetected: unlinkedCluster.environmentsDetected, + detectedServices: unlinkedCluster.servicesDetected, + }, + ]) + } + + const onUnlinkCluster = (linkedCluster: LinkedArgoCdCluster) => { + setLinkedClusters((currentLinkedClusters) => currentLinkedClusters.filter(({ id }) => id !== linkedCluster.id)) + setUnlinkedClusters((currentUnlinkedClusters) => [ + ...currentUnlinkedClusters, + { + id: `unlinked-${linkedCluster.id}`, + name: linkedCluster.argoCdName, + environmentsDetected: linkedCluster.environmentsDetected ?? 0, + servicesDetected: linkedCluster.detectedServices, + }, + ]) + } + + const onDeleteSuccess = () => { + setArgoCdIntegration(null) + setIsLinkedSectionOpen(false) + setIsUnlinkedSectionOpen(false) + resetClusterLists() + } + + const onOpenConnectModal = () => { + openModal({ + content: ( + { + closeModal() + if (response) { + onConnectSuccess(response) + } + }} + /> + ), + options: { + width: 676, + }, + }) + } + + const onOpenEditModal = () => { + if (!argoCdIntegration) { + return + } + + openModal({ + content: ( + { + closeModal() + if (response) { + onConnectSuccess(response) + } + }} + /> + ), + options: { + width: 676, + }, + }) + } + + const onOpenDeleteModal = () => { + openModal({ + content: , + options: { + width: 488, + }, + }) + } + + const onOpenLinkClusterModal = (unlinkedCluster: UnlinkedArgoCdCluster) => { + openModal({ + content: ( + qoveryClusterId) + .filter((clusterId): clusterId is string => !!clusterId)} + onClose={(response) => { + closeModal() + if (response) { + onLinkClusterSuccess(unlinkedCluster, response) + } + }} + /> + ), + options: { + width: 488, + }, + }) + } + + const onOpenAssociatedServicesModal = (linkedCluster: LinkedArgoCdCluster) => { + openModal({ + content: ( + + ), + }) + } + + const isSingleClusterDetected = useCaseId === USE_CASE_LOADED_SINGLE_CLUSTER + const singleDetectedCluster = linkedClusters[0] + + return ( +
+
+
+ + +
+
+ {argoCdIntegration ? ( +
+ {argoCdIntegration.isImporting ? ( +
+
+
+ ArgoCD running on + + {argoCdIntegration.clusterCloudProvider && ( + + )} + {argoCdIntegration.clusterName} + +
+
+ + +
+
+
+ ) : null} + + {argoCdIntegration.isImporting ? ( +
+
+ {importStepsWithStatus.map((step) => ( +
+ + 'text-ssm text-neutral-disabled') + .otherwise(() => 'text-ssm text-neutral')} + > + {step.label} + +
+ ))} +
+
+ ) : ( +
+
+
+
+ ArgoCD running on + + {argoCdIntegration.clusterCloudProvider && ( + + )} + {argoCdIntegration.clusterName} + +
+
+ + +
+
+ + {isSingleClusterDetected && singleDetectedCluster ? ( +
+
+
+
+
+ {singleDetectedCluster.cloudProvider ? ( + + ) : null} + {singleDetectedCluster.name} +
+
+ + ArgoCD name: {singleDetectedCluster.argoCdName} + + + Cluster type:{' '} + {singleDetectedCluster.clusterType} + +
+
+ +
+
+
+ ) : ( + <> + {hasLinkedClusters ? ( + +
+ {linkedClusters.map((cluster) => ( +
+
+
+ {cluster.cloudProvider ? ( + + ) : null} + {cluster.name} +
+
+ + ArgoCD name: {cluster.argoCdName} + + + Cluster type: {cluster.clusterType} + +
+
+
+ + +
+
+ ))} +
+
+ ) : null} + + {hasUnlinkedClusters ? ( + +

+ Unlinked clusters are clusters detected by ArgoCD that are not yet associated with a + cluster in Qovery. Add the cluster to Qovery, then link it here to display the services + running on it. +

+
+ {unlinkedClusters.map((cluster) => ( +
+
+ {cluster.name} +
+ + Environments detected:{' '} + {cluster.environmentsDetected} + + + Services detected:{' '} + {cluster.servicesDetected} + +
+
+ +
+ ))} +
+
+ ) : null} + + )} +
+ +
+ + + Connected + +

+ Last update 3 min ago +

+
+
+ )} +
+ ) : ( + + + + )} +
+
+
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-actions/service-actions.tsx b/libs/domains/services/feature/src/lib/service-actions/service-actions.tsx index 475244fa38f..3f7f217c340 100644 --- a/libs/domains/services/feature/src/lib/service-actions/service-actions.tsx +++ b/libs/domains/services/feature/src/lib/service-actions/service-actions.tsx @@ -66,11 +66,13 @@ function MenuManageDeployment({ environment, service, variant, + isArgoCdService = false, }: { deploymentStatus: Status environment: Environment service: AnyService variant: ActionToolbarVariant + isArgoCdService?: boolean }) { const { openModal, closeModal } = useModal() const { openModalConfirmation } = useModalConfirmation() @@ -382,14 +384,9 @@ function MenuManageDeployment({ )) .otherwise(() => ( - + ))} - {variant === 'header' && ( - <> - Deploy - - - )} + {variant === 'header' && {isArgoCdService ? 'Rollout' : 'Deploy'}}
@@ -488,7 +485,7 @@ function MenuManageDeployment({ icon={} onSelect={() => restartService({ serviceId: service.id, serviceType: service.serviceType })} > - Restart Service + {isArgoCdService ? 'Rollout' : 'Restart Service'} )} {service.serviceType === 'JOB' && @@ -907,10 +904,12 @@ export function ServiceActions({ environment, serviceId, variant = 'default', + isArgoCdService = false, }: { environment: Environment serviceId: string variant?: ActionToolbarVariant + isArgoCdService?: boolean }) { const { data: service } = useService({ environmentId: environment.id, serviceId }) const { data: deploymentStatus } = useDeploymentStatus({ environmentId: environment.id, serviceId }) @@ -925,6 +924,7 @@ export function ServiceActions({ environment={environment} service={service} variant={variant} + isArgoCdService={isArgoCdService} /> {variant === 'default' && ( diff --git a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx index 06b39298323..9ec171c8dfd 100644 --- a/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx +++ b/libs/domains/services/feature/src/lib/service-icon/service-icon.tsx @@ -65,6 +65,7 @@ const serviceIcons = { 'app://qovery-console/container': { icon: '/assets/services/application.svg', title: 'Container' }, 'app://qovery-console/database': { icon: '/assets/services/database.svg', title: 'Database' }, 'app://qovery-console/helm': { icon: '/assets/services/helm.svg', title: 'Helm' }, + 'app://qovery-console/argocd': { icon: '/assets/services/argocd.svg', title: 'ArgoCD' }, 'app://qovery-console/application': { icon: '/assets/services/application.svg', title: 'Application', diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-last-deployment-cell.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-last-deployment-cell.tsx index 9964d9d8588..a78c38de071 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-last-deployment-cell.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-last-deployment-cell.tsx @@ -8,6 +8,7 @@ type ServiceLastDeploymentCellProps = { organizationId: string projectId: string environmentId: string + timeLabelOverride?: string } export function ServiceLastDeploymentCell({ @@ -15,10 +16,19 @@ export function ServiceLastDeploymentCell({ organizationId, projectId, environmentId, + timeLabelOverride, }: ServiceLastDeploymentCellProps) { - const { data: deploymentStatus } = useDeploymentStatus({ environmentId: environmentId, serviceId: service.id }) + const hasTimeOverride = Boolean(timeLabelOverride) + const { data: deploymentStatus } = useDeploymentStatus({ + environmentId: hasTimeOverride ? undefined : environmentId, + serviceId: hasTimeOverride ? undefined : service.id, + }) const date = deploymentStatus?.last_deployment_date + if (timeLabelOverride) { + return {timeLabelOverride} + } + return date ? ( { @@ -58,6 +74,102 @@ export function ServiceNameCell({ service, environment }: { service: AnyService; .otherwise(() => null) } + if (argocdOperationLabelOverride) { + const isOutOfSync = argocdStatusLabelOverride === 'Out of sync' + const serviceAvatar = { + ...service, + icon_uri: 'app://qovery-console/argocd', + } + + return ( +
+ + + + + + {service.name} + + +
+ + + + + e.stopPropagation()} + > + + + + + + + + e.stopPropagation()}> + {isOutOfSync && }>Force sync} + } asChild> + + See audit logs + + + } onSelect={() => copyToClipboard(service.id)}> + Copy identifier + + } asChild> + + See manifest + + + + +
+
+ ) + } + return (
diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-running-status-cell.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-running-status-cell.tsx index 8f857e57df6..77c3d94688f 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-running-status-cell.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-running-status-cell.tsx @@ -14,6 +14,7 @@ type ServiceRunningStatusCellProps = { projectId: string environment: Environment clusterId: string + statusLabelOverride?: 'Synced' | 'Out of sync' } export function ServiceRunningStatusCell({ @@ -22,16 +23,32 @@ export function ServiceRunningStatusCell({ projectId, environment, clusterId, + statusLabelOverride, }: ServiceRunningStatusCellProps) { - const { data } = useServiceDeploymentAndRunningStatuses({ environmentId: environment.id, service }) + const hasStatusOverride = Boolean(statusLabelOverride) + const { data } = useServiceDeploymentAndRunningStatuses({ + environmentId: hasStatusOverride ? '' : environment.id, + service: hasStatusOverride ? undefined : service, + }) const { runningStatus, deploymentStatus } = data const { setDevopsCopilotOpen, sendMessageRef } = useContext(DevopsCopilotContext) const { data: checkRunningStatusClosed } = useCheckRunningStatusClosed({ - clusterId, - environmentId: environment.id, + clusterId: hasStatusOverride ? '' : clusterId, + environmentId: hasStatusOverride ? '' : environment.id, }) + if (statusLabelOverride) { + return ( +
+ + + {statusLabelOverride} + +
+ ) + } + const Wrapper = ({ children }: PropsWithChildren) =>
{children}
const value = match(runningStatus?.triggered_action) diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx index 0cd96651c06..c84492952f5 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx @@ -29,9 +29,19 @@ type ServiceVersionCellProps = { service: AnyService organizationId: string projectId: string + versionOverride?: { primary: string; secondary: string } } -export function ServiceVersionCell({ service, organizationId, projectId }: ServiceVersionCellProps) { +export function ServiceVersionCell({ service, organizationId, projectId, versionOverride }: ServiceVersionCellProps) { + if (versionOverride) { + return ( +
+ {versionOverride.primary} + {versionOverride.secondary} +
+ ) + } + const gitInfo = (service: Application | Job | Helm | Terraform, gitRepository?: ApplicationGitRepository) => gitRepository && (
e.stopPropagation()}> diff --git a/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx b/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx index f1e4097988a..bf1b202a509 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx @@ -1,3 +1,4 @@ +import { within } from '@testing-library/react' import type { ReactNode } from 'react' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { ServiceList, type ServiceListProps } from './service-list' @@ -485,4 +486,45 @@ describe('ServiceList', () => { mockDeploymentStagesData = undefined }) + + it('should hide selection checkboxes when selection is disabled', () => { + renderWithProviders() + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument() + }) + + it('should display force sync action for out of sync argocd services', async () => { + const { userEvent } = renderWithProviders( + + ) + + const serviceRow = screen.getByText(/front-end/i).closest('tr') + expect(serviceRow).toBeTruthy() + if (!serviceRow) return + + await userEvent.click(within(serviceRow).getByLabelText(/more actions/i)) + + expect(await screen.findByRole('menuitem', { name: /force sync/i })).toBeInTheDocument() + }) + + it('should not display force sync action for synced argocd services', async () => { + const { userEvent } = renderWithProviders( + + ) + + const serviceRow = screen.getByText(/front-end/i).closest('tr') + expect(serviceRow).toBeTruthy() + if (!serviceRow) return + + await userEvent.click(within(serviceRow).getByLabelText(/more actions/i)) + + expect(screen.queryByRole('menuitem', { name: /force sync/i })).not.toBeInTheDocument() + }) }) diff --git a/libs/domains/services/feature/src/lib/service-list/service-list.tsx b/libs/domains/services/feature/src/lib/service-list/service-list.tsx index 30f608fa392..03d19b90f4f 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list.tsx @@ -39,12 +39,32 @@ import { } from './service-list-cells' const { Table } = TablePrimitives +type ServiceListRows = ReturnType['data'] +type ServiceListRow = ServiceListRows[number] +type ArgoCdServiceStatus = 'Synced' | 'Out of sync' export interface ServiceListProps extends ComponentProps { environment: Environment + enableSelection?: boolean + servicesOverride?: ServiceListRows + argocdStatusByServiceId?: Record + argocdOperationByServiceId?: Record + argocdTargetVersionByServiceId?: Record + argocdLastDeploymentByServiceId?: Record } -export function ServiceList({ className, containerClassName, environment, ...props }: ServiceListProps) { +export function ServiceList({ + className, + containerClassName, + environment, + enableSelection = true, + servicesOverride, + argocdStatusByServiceId, + argocdOperationByServiceId, + argocdTargetVersionByServiceId, + argocdLastDeploymentByServiceId, + ...props +}: ServiceListProps) { const clusterId = environment.cluster_id || '' const environmentId = environment.id || '' const organizationId = environment.organization.id || '' @@ -70,8 +90,11 @@ export function ServiceList({ className, containerClassName, environment, ...pro return map }, [deploymentStages]) + const sourceServices: ServiceListRows = servicesOverride ?? services + const hasSelectionColumn = enableSelection + const sortedServices = useMemo(() => { - return [...services].sort((a, b) => { + return [...sourceServices].sort((a, b) => { const aIsSkipped = skippedServicesMap.get(a.id) || false const bIsSkipped = skippedServicesMap.get(b.id) || false @@ -81,63 +104,70 @@ export function ServiceList({ className, containerClassName, environment, ...pro return 0 }) - }, [services, skippedServicesMap]) + }, [sourceServices, skippedServicesMap]) - const columnHelper = createColumnHelper<(typeof services)[number]>() + const columnHelper = createColumnHelper() const columns = useMemo( () => [ - columnHelper.display({ - id: 'select', - enableColumnFilter: false, - enableSorting: false, - header: ({ table }) => ( -
- {/** XXX: fix css weird 1px vertical shift when checked/unchecked **/} - { - if (checked === 'indeterminate') { - return - } - table.toggleAllRowsSelected(checked) - }} - /> -
- ), - cell: ({ row }) => { - const isDisabled = !row.getCanSelect() - const checkbox = ( - { - if (checked === 'indeterminate') { - return - } - row.toggleSelected(checked) - }} - /> - ) + ...(enableSelection + ? [ + columnHelper.display({ + id: 'select', + enableColumnFilter: false, + enableSorting: false, + header: ({ table }) => ( +
+ {/** XXX: fix css weird 1px vertical shift when checked/unchecked **/} + { + if (checked === 'indeterminate') { + return + } + table.toggleAllRowsSelected(checked) + }} + /> +
+ ), + cell: ({ row }) => { + const isDisabled = !row.getCanSelect() + const checkbox = ( + { + if (checked === 'indeterminate') { + return + } + row.toggleSelected(checked) + }} + /> + ) - return ( - - ) - }, - }), + return ( + + ) + }, + }), + ] + : []), columnHelper.accessor('name', { header: 'Service', enableColumnFilter: true, @@ -162,7 +192,12 @@ export function ServiceList({ className, containerClassName, environment, ...pro cell: (info) => { return (
- +
) }, @@ -174,15 +209,18 @@ export function ServiceList({ className, containerClassName, environment, ...pro enableSorting: false, filterFn: 'arrIncludesSome', size: 15, - cell: (info) => ( - - ), + cell: (info) => { + return ( + + ) + }, }), columnHelper.accessor('version', { header: 'Target version', @@ -191,7 +229,12 @@ export function ServiceList({ className, containerClassName, environment, ...pro size: 30, cell: (info) => { return ( - + ) }, }), @@ -207,24 +250,39 @@ export function ServiceList({ className, containerClassName, environment, ...pro organizationId={organizationId} projectId={projectId} environmentId={environmentId} + timeLabelOverride={argocdLastDeploymentByServiceId?.[info.row.original.id]} /> ) }, }), ], - [columnHelper, environment, clusterId, organizationId, projectId, environmentId] + [ + columnHelper, + environment, + clusterId, + organizationId, + projectId, + environmentId, + argocdStatusByServiceId, + argocdOperationByServiceId, + argocdLastDeploymentByServiceId, + argocdTargetVersionByServiceId, + enableSelection, + ] ) - const table = useReactTable({ + const table = useReactTable({ data: sortedServices, columns, state: { sorting, rowSelection, }, - enableRowSelection: (row) => { - return !skippedServicesMap.get(row.original.id) - }, + enableRowSelection: enableSelection + ? (row) => { + return !skippedServicesMap.get(row.original.id) + } + : false, onSortingChange: setSorting, onRowSelectionChange: setRowSelection, getCoreRowModel: getCoreRowModel(), @@ -246,7 +304,7 @@ export function ServiceList({ className, containerClassName, environment, ...pro table.getColumn('runningStatus')?.getFacetedUniqueValues().entries() ?? [] ) - if (services.length === 0) { + if (sourceServices.length === 0) { return ( {headerGroup.headers.map((header, i) => ( {header.column.getCanFilter() ? ( @@ -339,8 +401,12 @@ export function ServiceList({ className, containerClassName, environment, ...pro {row.getVisibleCells().map((cell, i) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -350,11 +416,13 @@ export function ServiceList({ className, containerClassName, environment, ...pro ))} - table.resetRowSelection()} - /> + {enableSelection ? ( + table.resetRowSelection()} + /> + ) : null}
) diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx index c18af4666aa..95153f62b0b 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx @@ -2,6 +2,9 @@ import type { ReactNode } from 'react' import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import { ServiceNew } from './service-new' +const mockShowPylonForm = jest.fn() +const mockShowChat = jest.fn() + jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), })) @@ -31,10 +34,14 @@ jest.mock('@qovery/shared/ui', () => { jest.mock('@qovery/shared/util-hooks', () => ({ ...jest.requireActual('@qovery/shared/util-hooks'), - useSupportChat: () => ({ showPylonForm: jest.fn() }), + useSupportChat: () => ({ showPylonForm: mockShowPylonForm, showChat: mockShowChat }), })) describe('ServiceNew', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + it('should render successfully', () => { const { baseElement } = renderWithProviders( @@ -67,6 +74,9 @@ describe('ServiceNew', () => { renderWithProviders( ) + expect(screen.getByRole('heading', { name: 'Integrations' })).toBeInTheDocument() + expect(screen.getByText('Want more integrations?')).toBeInTheDocument() + expect(screen.getByText('Tell us about which integration you would like to see in the future')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Data & Storage' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Back-end' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Front-end' })).toBeInTheDocument() @@ -74,6 +84,43 @@ describe('ServiceNew', () => { expect(screen.getByRole('heading', { name: 'More template' })).toBeInTheDocument() }) + it('should link the ArgoCD integration card with the environment cluster context', () => { + const { container } = renderWithProviders( + + ) + + expect(screen.getByText('ArgoCD')).toBeInTheDocument() + expect( + container.querySelector('a[href="/organization/org-1/settings/argocd-integration?clusterId=cluster-1"]') + ).toBeInTheDocument() + }) + + it('should open support chat when clicking on Want more integrations card', async () => { + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByText('Want more integrations?')) + + expect(mockShowChat).toHaveBeenCalledTimes(1) + }) + + it('should exclude Want more integrations card from search results', async () => { + const { userEvent } = renderWithProviders( + + ) + + await userEvent.type(screen.getByPlaceholderText('Search…'), 'integrations') + + expect(screen.queryByText('Want more integrations?')).not.toBeInTheDocument() + }) + it('should link database entries to the database create flow', async () => { const { container, userEvent } = renderWithProviders( `${getEnvironmentBasePath(organizationId, projectId, environmentId)}${subPath}` +const getArgoCdIntegrationsPath = (organizationId: string, clusterId?: string) => { + const path = `/organization/${organizationId}/settings/argocd-integration` + + if (!clusterId) return path + + return `${path}?clusterId=${encodeURIComponent(clusterId)}` +} + const CREATE_FLOW_SLUG_BY_TYPE: Partial> = { APPLICATION: 'application', CONTAINER: 'container', @@ -102,19 +110,22 @@ function Card({ onClick, disabledCTA, badge, + cardClassName, }: { title: string description: string - icon: ReactElement + icon?: ReactElement link?: string onClick?: () => void disabledCTA?: ReactElement badge?: string + cardClassName?: string }) { const Wrapper = ({ children }: { children: ReactElement }) => { const className = twMerge( 'flex cursor-pointer items-center justify-between gap-5 rounded border border-neutral px-5 py-4 transition [box-shadow:0px_2px_8px_-1px_rgba(27,36,44,0.08),0px_2px_2px_-1px_rgba(27,36,44,0.04)]', - disabledCTA ? 'border-neutral bg-surface-neutral-subtle' : 'hover:bg-surface-neutral-subtle' + disabledCTA ? 'border-neutral bg-surface-neutral-subtle' : 'hover:bg-surface-neutral-subtle', + cardClassName ) if (onClick) { @@ -130,7 +141,7 @@ function Card({ } return ( - // @ts-expect-error-next-line TODO new-nav : Route strings need to be updated using the next typed routes + // @ts-ignore TODO new-nav: Route strings need to be updated using the next typed routes {children} @@ -160,7 +171,7 @@ function Card({
{disabledCTA}
- {icon} + {icon ? icon : null} ) @@ -246,7 +257,7 @@ function CardOption({ return ( @@ -419,7 +430,7 @@ function CardService({ return ( @@ -499,18 +510,21 @@ function SectionByTag({ type ServiceBlock = { title: string description: string - icon: ReactElement + icon?: ReactElement cloud_provider?: CloudProviderEnum | string link?: string onClick?: () => void disabledCTA?: ReactElement badge?: string + cardClassName?: string + searchable?: boolean } export interface ServiceNewProps { organizationId: string projectId: string environmentId: string + clusterId?: string /** From environment.cloud_provider.provider (may be string from API) */ cloudProvider?: CloudProviderEnum | string availableTemplates?: LifecycleTemplateListResponseResultsInner[] @@ -520,11 +534,12 @@ export function ServiceNew({ organizationId, projectId, environmentId, + clusterId, cloudProvider, availableTemplates = [], }: ServiceNewProps) { const isTerraformFeatureFlag = Boolean(useFeatureFlagEnabled('terraform')) - const { showPylonForm } = useSupportChat() + const { showPylonForm, showChat } = useSupportChat() const serviceEmpty: ServiceBlock[] = useMemo( () => [ @@ -598,12 +613,48 @@ export function ServiceNew({ [cloudProvider, organizationId, projectId, environmentId, isTerraformFeatureFlag, showPylonForm] ) + const integrations: ServiceBlock[] = useMemo( + () => [ + { + title: 'ArgoCD', + description: 'Import and visualize any ArgoCD services directly onto Qovery.', + icon: ( + ArgoCD + ), + link: getArgoCdIntegrationsPath(organizationId, clusterId), + }, + { + title: 'Want more integrations?', + description: 'Tell us about which integration you would like to see in the future', + onClick: () => showChat(), + cardClassName: 'bg-surface-neutral-subtle [box-shadow:none] hover:bg-surface-neutral-subtle', + searchable: false, + }, + ], + [organizationId, clusterId, showChat] + ) + const [searchInput, setSearchInput] = useState('') + const searchableServiceBlocks = [...serviceEmpty, ...integrations].filter((service) => service.searchable !== false) + const searchableServices = [...searchableServiceBlocks, ...serviceTemplates] const filterService = ({ title }: { title: string }) => title.toLowerCase().includes(searchInput.toLowerCase()) + const filteredSearchableServiceBlocks = searchableServiceBlocks + .filter((c) => c.cloud_provider === cloudProvider || !c.cloud_provider) + .filter(filterService) + const filteredSearchableTemplates = serviceTemplates + .filter((c) => c.cloud_provider === cloudProvider || !c.cloud_provider) + .filter(filterService) + const hasSearchResults = filteredSearchableServiceBlocks.length + filteredSearchableTemplates.length > 0 const handleSearchInputChange = (value: string) => { - if ([...serviceEmpty, ...serviceTemplates].filter(filterService).length === 0) { + if (searchableServices.filter(filterService).length === 0) { posthog.capture('search-service', { qoveryServiceType: 'INPUT_SEARCH', searchValue: value, @@ -677,6 +728,18 @@ export function ServiceNew({ ))}
+
+ Integrations +

+ Existing services running on other stacks that can be imported for better integration with Qovery + services. +

+
+ {integrations.map((integration) => ( + + ))} +
+
showPylonForm('request-upgrade-plan')} /> - ) : [...serviceEmpty, ...serviceTemplates] - .filter((c) => c.cloud_provider === cloudProvider || !c.cloud_provider) - .filter(filterService).length > 0 ? ( + ) : hasSearchResults ? (
Search results

Find the service you need to kickstart your next project.

- {[...serviceEmpty, ...serviceTemplates] - .filter((c) => c.cloud_provider === cloudProvider || !c.cloud_provider) - .filter(filterService) - .map((service) => ( - showPylonForm('request-upgrade-plan')} - {...service} - /> - ))} + {filteredSearchableServiceBlocks.map((service) => ( + + ))} + {filteredSearchableTemplates.map((service) => ( + showPylonForm('request-upgrade-plan')} + {...service} + /> + ))}
) : ( diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx index be422ebe947..d9874324ce1 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx @@ -62,9 +62,18 @@ export interface ServiceHeaderProps { environment: Environment serviceId: string service: AnyService + isArgoCdService?: boolean } -function ServiceHeaderContent({ environment, serviceId, service }: ServiceHeaderProps) { +function ArgoCdTag() { + return ( + + ARGOCD + + ) +} + +function ServiceHeaderContent({ environment, serviceId, service, isArgoCdService = false }: ServiceHeaderProps) { const { organizationId = '', projectId = '' } = useParams({ strict: false }) const { data: masterCredentials } = useMasterCredentials({ serviceId, serviceType: service?.serviceType }) @@ -102,6 +111,18 @@ function ServiceHeaderContent({ environment, serviceId, service }: ServiceHeader toast(ToastEnum.SUCCESS, 'Credentials copied to clipboard') } + const avatarService = + service.serviceType === 'JOB' + ? { + icon_uri: isArgoCdService ? 'app://qovery-console/argocd' : service.icon_uri ?? '', + serviceType: 'JOB' as const, + job_type: service.job_type, + } + : { + icon_uri: isArgoCdService ? 'app://qovery-console/argocd' : service.icon_uri ?? '', + serviceType: service.serviceType, + } + return (
@@ -126,6 +147,12 @@ function ServiceHeaderContent({ environment, serviceId, service }: ServiceHeader /> {service.name} + {isArgoCdService && ( + <> + + + + )} {environment.cluster_name}
- +
{service.description &&

{service.description}

}
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-overview.tsx b/libs/domains/services/feature/src/lib/service-overview/service-overview.tsx index e8fa45a5770..c400f5fce94 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-overview.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-overview.tsx @@ -15,6 +15,7 @@ import { ServiceOverviewSkeleton } from './service-overview-skeleton' export interface ServiceOverviewProps { environment?: Environment + isArgoCdService?: boolean hasNoMetrics?: boolean terraformResourcesSection?: ReactNode observabilityCallout?: ReactNode @@ -22,6 +23,7 @@ export interface ServiceOverviewProps { function ServiceOverviewContent({ environment, + isArgoCdService = false, hasNoMetrics = false, terraformResourcesSection, observabilityCallout, @@ -72,7 +74,12 @@ function ServiceOverviewContent({ <>
- + {isDatabaseManaged ? (
Metrics for managed databases are not available @@ -95,7 +102,12 @@ function ServiceOverviewContent({
- + {hasNoMetrics && observabilityCallout}
diff --git a/libs/shared/routes/src/lib/sub-router/settings.router.ts b/libs/shared/routes/src/lib/sub-router/settings.router.ts index d6e7367e8ce..b87e6bc852a 100644 --- a/libs/shared/routes/src/lib/sub-router/settings.router.ts +++ b/libs/shared/routes/src/lib/sub-router/settings.router.ts @@ -9,6 +9,7 @@ export const SETTINGS_CREDENTIALS_URL = '/credentials' export const SETTINGS_MEMBERS_URL = '/members' export const SETTINGS_ROLES_URL = '/roles' export const SETTINGS_LABELS_ANNOTATIONS_URL = '/labels-annotations' +export const SETTINGS_ARGOCD_INTEGRATION_URL = '/argocd-integration' export const SETTINGS_ROLES_EDIT_URL = (roleId = ':roleId') => `/roles/edit/${roleId}` export const SETTINGS_BILLING_URL = '/billing-detail' diff --git a/libs/shared/ui/src/lib/assets/services/Icon isometrie=ArgoCD.svg b/libs/shared/ui/src/lib/assets/services/Icon isometrie=ArgoCD.svg new file mode 100644 index 00000000000..1da74d19daa --- /dev/null +++ b/libs/shared/ui/src/lib/assets/services/Icon isometrie=ArgoCD.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/shared/ui/src/lib/assets/services/argocd.svg b/libs/shared/ui/src/lib/assets/services/argocd.svg new file mode 100644 index 00000000000..1da74d19daa --- /dev/null +++ b/libs/shared/ui/src/lib/assets/services/argocd.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/base/themes.scss b/libs/shared/ui/src/lib/styles/base/themes.scss index 6365c5a521c..5e79e79de27 100644 --- a/libs/shared/ui/src/lib/styles/base/themes.scss +++ b/libs/shared/ui/src/lib/styles/base/themes.scss @@ -48,6 +48,11 @@ --brand-11: hsla(254, 86%, 57%, 1); --brand-12: hsla(252, 64%, 29%, 1); + /* ArgoCD */ + --argocd-text: #ef754f; + --argocd-background: #fdf1ed; + --argocd-border: #fce5dd; + /* Positive */ --positive-1: hsla(140, 60%, 99%, 1); --positive-2: hsla(137, 47%, 97%, 1); @@ -176,6 +181,11 @@ --brand-11: hsla(258, 100%, 82%, 1); --brand-12: hsla(252, 100%, 93%, 1); + /* ArgoCD */ + --argocd-text: #ff9979; + --argocd-background: #211714; + --argocd-border: #37241e; + /* Positive */ --positive-1: hsla(154, 20%, 7%, 1); --positive-2: hsla(153, 20%, 9%, 1); 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; 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, diff --git a/tailwind-workspace-preset.js b/tailwind-workspace-preset.js index 3ae4d678d62..7f83d93d12a 100644 --- a/tailwind-workspace-preset.js +++ b/tailwind-workspace-preset.js @@ -200,6 +200,11 @@ module.exports = { colors: { brand: { ...colorsBrand, ...colorsIndigo }, indigo: colorsIndigo, + argocd: { + text: 'var(--argocd-text)', + background: 'var(--argocd-background)', + border: 'var(--argocd-border)', + }, purple: { 50: '#FCF4FF', 100: '#F7DFFE', @@ -340,6 +345,9 @@ module.exports = { solid: 'var(--accent-9)', component: 'var(--accent-3)', }, + argocd: { + subtle: 'var(--argocd-background)', + }, }, }, textColor: { @@ -364,6 +372,7 @@ module.exports = { warning: { DEFAULT: 'var(--warning-11)', hover: 'var(--warning-10)' }, warningInvert: { DEFAULT: 'var(--warning-invert-11)' }, accent1: { DEFAULT: 'var(--accent-11)', hover: 'var(--accent1-10)' }, + argocd: { DEFAULT: 'var(--argocd-text)' }, }, fill: ({ theme }) => ({ ...theme('colors'), @@ -423,6 +432,9 @@ module.exports = { strong: 'var(--accent-9)', subtle: 'var(--accent-6)', }, + argocd: { + DEFAULT: 'var(--argocd-border)', + }, }, outlineColor: ({ theme }) => theme('borderColor'), animation: {