diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx
index c5d0fdf235f..77dd386af39 100644
--- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx
+++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx
@@ -17,7 +17,7 @@ import {
useDeploymentStatus,
useEnvironment,
} from '@qovery/domains/environments/feature'
-import { isEditableService } from '@qovery/domains/services/data-access'
+import { isArgoCd, isEditableService } from '@qovery/domains/services/data-access'
import { ArgoCdServiceList, useServices } from '@qovery/domains/services/feature'
import { Heading, Icon, Link, Navbar, Section, Tooltip } from '@qovery/shared/ui'
@@ -58,10 +58,7 @@ function RouteComponent() {
const activeTabId = tabs.find((tab) => matchRoute({ to: tab.routeId }))?.id
const isServicesListTab = activeTabId === 'services'
const qoveryServicesCount = useMemo(() => services.filter(isEditableService).length, [services])
- const argoCdServicesCount = useMemo(
- () => services.filter((service) => !isEditableService(service)).length,
- [services]
- )
+ const argoCdServicesCount = useMemo(() => services.filter(isArgoCd).length, [services])
const hasQoveryServices = qoveryServicesCount > 0
const hasArgoCdServices = argoCdServicesCount > 0
const shouldDisplayQoveryServicesSubtitle = isServicesListTab && hasArgoCdServices
@@ -130,7 +127,7 @@ function RouteComponent() {
{shouldDisplayQoveryServicesSubtitle && (
- Qovery native services
+ Qovery services
)}
{shouldDisplayArgoCdServicesAboveQovery ? (
diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx
index bce7cf281ec..97e88e3f6d0 100644
--- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx
+++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/manifest.tsx
@@ -1,4 +1,8 @@
-import { createFileRoute } from '@tanstack/react-router'
+import { Navigate, createFileRoute, useParams } from '@tanstack/react-router'
+import { Suspense } from 'react'
+import { isArgoCd } from '@qovery/domains/services/data-access'
+import { ArgoCdManifest, useServiceSummary } from '@qovery/domains/services/feature'
+import { ErrorBoundary, LoaderSpinner } from '@qovery/shared/ui'
import { useDocumentTitle } from '@qovery/shared/util-hooks'
export const Route = createFileRoute(
@@ -7,8 +11,44 @@ export const Route = createFileRoute(
component: RouteComponent,
})
-function RouteComponent() {
+function ManifestLoader() {
+ return (
+
+
+
+ )
+}
+
+function ManifestRouteContent() {
+ const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams({ strict: false })
+ const { data: service } = useServiceSummary({
+ environmentId,
+ serviceId,
+ enabled: Boolean(environmentId) && Boolean(serviceId),
+ suspense: true,
+ })
+
useDocumentTitle('Service - Manifest')
- return
+ if (!service || !isArgoCd(service)) {
+ return (
+
+ )
+ }
+
+ return
+}
+
+function RouteComponent() {
+ return (
+
+ }>
+
+
+
+ )
}
diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx
index 5636eeb3380..c7e12008dc0 100644
--- a/apps/console/src/routes/_authenticated/organization/route.tsx
+++ b/apps/console/src/routes/_authenticated/organization/route.tsx
@@ -6,7 +6,7 @@ import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useClusters } from '@qovery/domains/clusters/feature'
import { useEnvironment } from '@qovery/domains/environments/feature'
import { useProject } from '@qovery/domains/projects/feature'
-import { type AnyService, isEditableService, isManagedDatabase } from '@qovery/domains/services/data-access'
+import { type AnyService, isArgoCd, isEditableService, isManagedDatabase } from '@qovery/domains/services/data-access'
import { useRecentServices, useServiceSummary } from '@qovery/domains/services/feature'
import { AssistantPanelOutlet, AssistantProvider } from '@qovery/shared/assistant/feature'
import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context'
@@ -252,7 +252,7 @@ function createRoutePatternRegex(routeIdPattern: string): RegExp {
}
function getServiceTabs(service?: AnyService, cluster?: Cluster) {
- const isArgoCdService = service ? !isEditableService(service) : false
+ const isArgoCdService = service ? isArgoCd(service) : false
if (isArgoCdService) {
return SERVICE_TABS.filter((tab) => ARGOCD_SERVICE_TAB_IDS.includes(tab.id))
diff --git a/libs/domains/organizations/feature/src/lib/container-registry-services-list-modal/__snapshots__/container-registry-services-list-modal.spec.tsx.snap b/libs/domains/organizations/feature/src/lib/container-registry-services-list-modal/__snapshots__/container-registry-services-list-modal.spec.tsx.snap
index 40bdcecc699..3220c0048a3 100644
--- a/libs/domains/organizations/feature/src/lib/container-registry-services-list-modal/__snapshots__/container-registry-services-list-modal.spec.tsx.snap
+++ b/libs/domains/organizations/feature/src/lib/container-registry-services-list-modal/__snapshots__/container-registry-services-list-modal.spec.tsx.snap
@@ -21,7 +21,7 @@ exports[`ContainerRegistryServicesListModal should match snapshots 1`] = `
class="fa-regular fa-magnifying-glass absolute left-3 top-1/2 block -translate-y-1/2 text-base leading-none text-neutral-subtle"
/>
- {/* Split panel: Tree list (with search) and Details */}
- {/* Left panel: Search + Resource tree list */}
-
- {/* Search bar */}
-
-
-
+
+
- {/* Tree list */}
-
+
- {/* Right panel: Resource details */}
-
diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts
index 3fcce92f7e3..cdb40cea6fc 100644
--- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts
+++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts
@@ -218,6 +218,10 @@ export function isHelm(service: AnyService): service is Helm {
return service.service_type === 'HELM'
}
+export function isArgoCd(service: AnyService): service is ArgoCd {
+ return service.service_type === 'ARGOCD_APP'
+}
+
export function isEditableService(service: AnyService): service is EditableService {
return service.service_type !== 'ARGOCD_APP'
}
@@ -270,6 +274,13 @@ export const services = createQueryKeys('services', {
return (response.data.results ?? []).filter((service) => service.service_type === 'ARGOCD_APP')
},
}),
+ argocdManifest: (serviceId: string) => ({
+ queryKey: [serviceId],
+ async queryFn() {
+ const response = await argoCdApi.getArgoCdAppManifest(serviceId)
+ return response.data
+ },
+ }),
list: (environmentId: string) => ({
queryKey: [environmentId],
async queryFn() {
diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts
index fe3412d2987..1e9bae2d6a3 100644
--- a/libs/domains/services/feature/src/index.ts
+++ b/libs/domains/services/feature/src/index.ts
@@ -29,6 +29,7 @@ export * from './lib/hooks/use-service-summary/use-service-summary'
export * from './lib/hooks/use-service/use-service'
export * from './lib/hooks/use-services/use-services'
export * from './lib/hooks/use-argocd-services/use-argocd-services'
+export * from './lib/hooks/use-argocd-manifest/use-argocd-manifest'
export * from './lib/hooks/use-service-deployment-and-running-statuses/use-service-deployment-and-running-statuses'
export * from './lib/hooks/use-delete-all-services/use-delete-all-services'
export * from './lib/hooks/use-delete-service/use-delete-service'
@@ -55,6 +56,7 @@ export * from './lib/hooks/use-recent-services/use-recent-services'
export * from './lib/hooks/use-favorite-services/use-favorite-services'
export * from './lib/service-actions/service-actions'
export * from './lib/argocd-service-list/argocd-service-list'
+export * from './lib/argocd-manifest/argocd-manifest'
export * from './lib/service-deployment-status-label/service-deployment-status-label'
export * from './lib/service-overview/service-header/service-header'
export * from './lib/service-links-popover/service-links-popover'
diff --git a/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.spec.tsx b/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.spec.tsx
new file mode 100644
index 00000000000..0348068385e
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.spec.tsx
@@ -0,0 +1,167 @@
+import { type UseQueryResult } from '@tanstack/react-query'
+import { type ArgocdAppManifestResponse } from 'qovery-typescript-axios'
+import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
+import { useArgoCdManifest } from '../hooks/use-argocd-manifest/use-argocd-manifest'
+import { ArgoCdManifest, formatLiveState, toManifestResources } from './argocd-manifest'
+
+jest.mock('../hooks/use-argocd-manifest/use-argocd-manifest')
+
+jest.mock('@qovery/shared/ui', () => {
+ const actual = jest.requireActual('@qovery/shared/ui')
+
+ return {
+ ...actual,
+ CodeEditor: ({ value }: { value?: string }) =>
{value},
+ CodeDiffEditor: ({ original, modified }: { original: string; modified: string }) => (
+
+
{original}
+
{modified}
+
+ ),
+ }
+})
+
+const mockUseArgoCdManifest = useArgoCdManifest as jest.MockedFunction
+
+const createUseArgoCdManifestResult = (
+ result: Partial>
+): UseQueryResult =>
+ ({
+ data: undefined,
+ isLoading: false,
+ isError: false,
+ ...result,
+ }) as unknown as UseQueryResult
+
+const manifestResponse: ArgocdAppManifestResponse = {
+ manifest_metadata: {
+ managed_resources: [
+ {
+ kind: 'Service',
+ name: 'api',
+ liveState: JSON.stringify({ kind: 'Service', metadata: { name: 'api' } }),
+ targetState: JSON.stringify({ kind: 'Service', metadata: { name: 'api-target' } }),
+ },
+ {
+ kind: 'ConfigMap',
+ name: 'settings',
+ liveState: 'not-json',
+ targetState: 'target-not-json',
+ },
+ ],
+ },
+}
+
+describe('ArgoCdManifest', () => {
+ beforeEach(() => {
+ mockUseArgoCdManifest.mockReturnValue(createUseArgoCdManifestResult({ data: manifestResponse }))
+ })
+
+ it('should adapt ArgoCD resources to tree resources with stable ids', () => {
+ expect(toManifestResources(manifestResponse.manifest_metadata.managed_resources ?? [])).toEqual([
+ expect.objectContaining({
+ id: 'Service:api:0',
+ resourceType: 'Service',
+ displayName: 'Service',
+ name: 'api',
+ targetState: JSON.stringify({ kind: 'Service', metadata: { name: 'api-target' } }),
+ }),
+ expect.objectContaining({
+ id: 'ConfigMap:settings:1',
+ resourceType: 'ConfigMap',
+ displayName: 'ConfigMap',
+ name: 'settings',
+ }),
+ ])
+ })
+
+ it('should format valid JSON liveState', () => {
+ expect(formatLiveState(JSON.stringify({ kind: 'Service' }))).toBe('{\n "kind": "Service"\n}')
+ })
+
+ it('should keep raw liveState when JSON parsing fails', () => {
+ expect(formatLiveState('not-json')).toBe('not-json')
+ })
+
+ it('should display resources and select the first one by default', async () => {
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /api/ })).toBeInTheDocument()
+ })
+
+ expect(screen.getByRole('button', { name: /settings/ })).toBeInTheDocument()
+ expect(screen.getByTestId('code-editor')).toHaveTextContent('"kind": "Service"')
+ })
+
+ it('should display selected resource liveState', async () => {
+ const { userEvent } = renderWithProviders()
+
+ await userEvent.click(await screen.findByRole('button', { name: /settings/ }))
+
+ expect(screen.getByTestId('code-editor')).toHaveTextContent('not-json')
+ })
+
+ it('should display a diff between target and live state when diff is enabled', async () => {
+ const { userEvent } = renderWithProviders()
+
+ const toggle = screen.getByTestId('input-toggle-button')
+
+ expect(toggle).toBeInTheDocument()
+
+ await userEvent.click(toggle)
+
+ expect(screen.getByTestId('code-diff-original')).toHaveTextContent('"name": "api-target"')
+ expect(screen.getByTestId('code-diff-modified')).toHaveTextContent('"name": "api"')
+ expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
+ })
+
+ it('should toggle diff mode when clicking the diff label', async () => {
+ const { userEvent } = renderWithProviders()
+
+ await userEvent.click(await screen.findByText('Compare with target'))
+
+ expect(screen.getByTestId('code-diff-editor')).toBeInTheDocument()
+ })
+
+ it('should display an empty state when there are no managed resources', () => {
+ mockUseArgoCdManifest.mockReturnValue(
+ createUseArgoCdManifestResult({
+ data: {
+ manifest_metadata: {
+ managed_resources: [],
+ },
+ },
+ })
+ )
+
+ renderWithProviders()
+
+ expect(screen.getByText('No managed resources')).toBeInTheDocument()
+ })
+
+ it('should fallback optional resource fields in the UI adapter', () => {
+ expect(toManifestResources([{}])).toEqual([
+ expect.objectContaining({
+ id: 'Unknown:Unnamed:0',
+ resourceType: 'Unknown',
+ displayName: 'Unknown',
+ name: 'Unnamed',
+ liveState: '',
+ targetState: '',
+ }),
+ ])
+ })
+
+ it('should display an error state when manifest loading fails', () => {
+ mockUseArgoCdManifest.mockReturnValue(
+ createUseArgoCdManifestResult({
+ isError: true,
+ })
+ )
+
+ renderWithProviders()
+
+ expect(screen.getByText('Unable to load manifest')).toBeInTheDocument()
+ })
+})
diff --git a/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.tsx b/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.tsx
new file mode 100644
index 00000000000..ede43caf1eb
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/argocd-manifest/argocd-manifest.tsx
@@ -0,0 +1,173 @@
+import { useParams } from '@tanstack/react-router'
+import { type ArgocdManagedResource } from 'qovery-typescript-axios'
+import { type ReactElement, useEffect, useMemo, useState } from 'react'
+import { ResourceTreeList, type ResourceTreeResource } from '@qovery/shared/console-shared'
+import { CodeDiffEditor, CodeEditor, EmptyState, Heading, InputSearch, InputToggle, Section } from '@qovery/shared/ui'
+import { useArgoCdManifest } from '../hooks/use-argocd-manifest/use-argocd-manifest'
+
+interface ArgoCdManifestResource extends ResourceTreeResource {
+ liveState: string
+ targetState: string
+}
+
+export function formatLiveState(liveState: string): string {
+ try {
+ return JSON.stringify(JSON.parse(liveState), null, 2)
+ } catch {
+ return liveState
+ }
+}
+
+function getResourceId(resource: ArgocdManagedResource, index: number): string {
+ return `${resource.kind ?? 'Unknown'}:${resource.name ?? 'Unnamed'}:${index}`
+}
+
+export function toManifestResources(resources: ArgocdManagedResource[]): ArgoCdManifestResource[] {
+ return resources.map((resource, index) => ({
+ id: getResourceId(resource, index),
+ resourceType: resource.kind ?? 'Unknown',
+ displayName: resource.kind ?? 'Unknown',
+ name: resource.name ?? 'Unnamed',
+ address: `${resource.kind ?? 'Unknown'}.${resource.name ?? 'Unnamed'}`,
+ attributes: {
+ kind: resource.kind ?? '',
+ name: resource.name ?? '',
+ },
+ liveState: resource.liveState ?? '',
+ targetState: resource.targetState ?? '',
+ }))
+}
+
+function ArgoCdManifestState({
+ icon,
+ title,
+ description,
+}: {
+ icon: Parameters[0]['icon']
+ title: string
+ description: string
+}): ReactElement {
+ return (
+
+ )
+}
+
+export function ArgoCdManifest() {
+ const { serviceId = '' } = useParams({ strict: false }) ?? {}
+ const [searchQuery, setSearchQuery] = useState('')
+ const [selectedResourceId, setSelectedResourceId] = useState(null)
+ const [showDiff, setShowDiff] = useState(false)
+ const { data, isError } = useArgoCdManifest({ serviceId, suspense: true })
+
+ const resources = useMemo(
+ () => toManifestResources(data?.manifest_metadata?.managed_resources ?? []),
+ [data?.manifest_metadata?.managed_resources]
+ )
+ const stateResources = useMemo(
+ () =>
+ resources.filter((resource) => {
+ if (showDiff) return resource.liveState || resource.targetState
+ return resource.liveState
+ }),
+ [resources, showDiff]
+ )
+
+ // Necessary to ensure the selected resource is always valid
+ useEffect(() => {
+ if (!selectedResourceId && stateResources.length > 0) {
+ setSelectedResourceId(stateResources[0].id)
+ return
+ }
+
+ if (selectedResourceId && !stateResources.some((resource) => resource.id === selectedResourceId)) {
+ setSelectedResourceId(stateResources[0]?.id ?? null)
+ }
+ }, [stateResources, selectedResourceId])
+
+ const selectedResource =
+ stateResources.find((resource) => resource.id === selectedResourceId) ??
+ (stateResources.length > 0 ? stateResources[0] : null)
+
+ if (isError) {
+ return (
+
+ )
+ }
+
+ if (resources.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ Manifest
+
+
+
+
+
+
+
+ {showDiff ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
+export default ArgoCdManifest
diff --git a/libs/domains/services/feature/src/lib/hooks/use-argocd-manifest/use-argocd-manifest.ts b/libs/domains/services/feature/src/lib/hooks/use-argocd-manifest/use-argocd-manifest.ts
new file mode 100644
index 00000000000..a381d5831fb
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/hooks/use-argocd-manifest/use-argocd-manifest.ts
@@ -0,0 +1,18 @@
+import { useQuery } from '@tanstack/react-query'
+import { services } from '@qovery/domains/services/data-access'
+
+export interface UseArgoCdManifestProps {
+ serviceId?: string
+ enabled?: boolean
+ suspense?: boolean
+}
+
+export function useArgoCdManifest({ serviceId, enabled = true, suspense = false }: UseArgoCdManifestProps) {
+ return useQuery({
+ ...services.argocdManifest(serviceId ?? ''),
+ enabled: Boolean(serviceId) && enabled,
+ suspense,
+ })
+}
+
+export default useArgoCdManifest
diff --git a/libs/domains/services/feature/src/lib/select-commit-modal/__snapshots__/select-commit-modal.spec.tsx.snap b/libs/domains/services/feature/src/lib/select-commit-modal/__snapshots__/select-commit-modal.spec.tsx.snap
index 606bccaa0a3..f8b16020d34 100644
--- a/libs/domains/services/feature/src/lib/select-commit-modal/__snapshots__/select-commit-modal.spec.tsx.snap
+++ b/libs/domains/services/feature/src/lib/select-commit-modal/__snapshots__/select-commit-modal.spec.tsx.snap
@@ -28,7 +28,7 @@ exports[`SelectCommitModal should match snapshot 1`] = `
class="fa-regular fa-magnifying-glass absolute left-3 top-1/2 block -translate-y-1/2 text-base leading-none text-neutral-subtle"
/>
true)
- .otherwise(() => false)
+ const isArgoCdService = isArgoCd(service)
useClusterRunningStatusSocket({ organizationId, clusterId: environment.cluster_id })
@@ -175,9 +173,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) {
}))
.otherwise(() => undefined)
- const isArgoCdService = match(service)
- .with({ service_type: 'ARGOCD_APP' }, () => true)
- .otherwise(() => false)
+ const isArgoCdService = isArgoCd(service)
const handleCopyCredentials = (credentials: Credentials) => {
if (!databaseSource) {
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 475342a5e45..d4803a5d2e8 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
@@ -6,6 +6,7 @@ import {
type EditableService,
type Job,
type Terraform,
+ isArgoCd,
isEditableService,
} from '@qovery/domains/services/data-access'
import { OutputVariables } from '@qovery/domains/variables/feature'
@@ -210,9 +211,7 @@ function ServiceOverviewContent({
{!isTerraformService && isEditableService(service) && (
)}
- {service.serviceType === 'ARGOCD_APP' && (
-
- )}
+ {isArgoCd(service) && }
{isKedaAutoscaling && scaledObject && }
{service.serviceType === 'JOB' && service.job_type === 'LIFECYCLE' && (
diff --git a/libs/shared/console-shared/src/index.ts b/libs/shared/console-shared/src/index.ts
index 0b35b0d1707..c0f310b63d4 100644
--- a/libs/shared/console-shared/src/index.ts
+++ b/libs/shared/console-shared/src/index.ts
@@ -2,3 +2,4 @@
// then console-shared only as a last resort.
export * from './lib/settings-heading/settings-heading'
export * from './lib/live-elapsed-duration-cell/live-elapsed-duration-cell'
+export * from './lib/resource-tree-list/resource-tree-list'
diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx b/libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.spec.tsx
similarity index 69%
rename from libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx
rename to libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.spec.tsx
index 7e8408cb75a..abef83f9b31 100644
--- a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.spec.tsx
+++ b/libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.spec.tsx
@@ -1,27 +1,36 @@
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
-import { createMockTerraformResource } from '../test-fixtures/mock-terraform-resources'
-import { ResourceTreeList } from './resource-tree-list'
+import { ResourceTreeList, type ResourceTreeResource } from './resource-tree-list'
-const mockResources = [
- createMockTerraformResource({
+function createResource(overrides: Partial = {}): ResourceTreeResource {
+ return {
id: 'res-1',
+ resourceType: 'aws_s3_bucket',
name: 'app_bucket',
- provider: 'aws',
- }),
- createMockTerraformResource({
+ address: 'aws_s3_bucket.app_bucket',
+ displayName: 'S3 Bucket',
+ attributes: {
+ id: 'my-app-bucket',
+ bucket: 'my-app-bucket',
+ region: 'us-east-1',
+ },
+ ...overrides,
+ }
+}
+
+const mockResources = [
+ createResource({ id: 'res-1', name: 'app_bucket' }),
+ createResource({
id: 'res-2',
resourceType: 'aws_rds_instance',
name: 'app_db',
address: 'aws_rds_instance.app_db',
- provider: 'aws',
displayName: 'RDS Instance',
attributes: { id: 'mydb', engine: 'mysql', db_name: 'appdb' },
}),
- createMockTerraformResource({
+ createResource({
id: 'res-3',
name: 'backup_bucket',
address: 'aws_s3_bucket.backup_bucket',
- provider: 'aws',
attributes: { id: 'my-backup-bucket', bucket: 'my-backup-bucket', region: 'us-west-2' },
}),
]
@@ -56,11 +65,8 @@ describe('ResourceTreeList', () => {
/>
)
- // Should show resource types with counts
expect(screen.getByText(/RDS Instance/)).toBeInTheDocument()
- expect(screen.getByText(/\(1\)/)).toBeInTheDocument()
expect(screen.getByText(/S3 Bucket/)).toBeInTheDocument()
- expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
})
it('should highlight selected resource', () => {
@@ -74,7 +80,7 @@ describe('ResourceTreeList', () => {
)
const selectedButton = screen.getByRole('button', { name: /app_bucket/ })
- expect(selectedButton).toHaveClass('bg-surface-brand-subtle')
+ expect(selectedButton).toHaveClass('bg-surface-brand-component')
})
it('should call onSelectResource when clicking a resource', async () => {
@@ -93,7 +99,7 @@ describe('ResourceTreeList', () => {
expect(mockOnSelectResource).toHaveBeenCalledWith('res-1')
})
- it('should highlight matching resources when searching by name', () => {
+ it('should keep matching and non-matching resources visible when searching by name', () => {
renderWithProviders(
{
/>
)
- // All resources should be visible, but non-matching ones are dimmed
expect(screen.getByText('backup_bucket')).toBeInTheDocument()
expect(screen.getByText('app_bucket')).toBeInTheDocument()
-
- // Non-matching button should have dimmed styling
- const nonMatchingButton = screen.getByRole('button', { name: /app_bucket/ })
- expect(nonMatchingButton).toHaveClass('text-neutral-disabled')
})
- it('should highlight matching resources when searching by type', () => {
+ it('should match resources when searching by type', () => {
renderWithProviders(
{
)
expect(screen.getByText('app_db')).toBeInTheDocument()
- // Non-matching resources are visible but dimmed
- const nonMatchingButton = screen.getByRole('button', { name: /app_bucket/ })
- expect(nonMatchingButton).toHaveClass('text-neutral-disabled')
})
- it('should highlight matching resources when searching by attribute keys', () => {
+ it('should match resources when searching by attribute keys', () => {
renderWithProviders(
{
)
expect(screen.getByText('app_db')).toBeInTheDocument()
- // Non-matching resources are visible but dimmed
- const nonMatchingButton = screen.getByRole('button', { name: /app_bucket/ })
- expect(nonMatchingButton).toHaveClass('text-neutral-disabled')
})
- it('should highlight matching resources when searching by attribute values', () => {
+ it('should match resources when searching by attribute values', () => {
renderWithProviders(
{
)
expect(screen.getByText('app_db')).toBeInTheDocument()
- // Non-matching resources are visible but dimmed
- const nonMatchingButton = screen.getByRole('button', { name: /app_bucket/ })
- expect(nonMatchingButton).toHaveClass('text-neutral-disabled')
})
it('should show no results message when search returns nothing', () => {
@@ -183,9 +175,7 @@ describe('ResourceTreeList', () => {
/>
)
- // Get only the resource buttons (those containing bucket names), not tree triggers
const bucketButtons = screen.getAllByRole('button').filter((btn) => btn.textContent?.includes('_bucket'))
- // Should have app_bucket before backup_bucket alphabetically
expect(bucketButtons[0]).toHaveTextContent('app_bucket')
expect(bucketButtons[1]).toHaveTextContent('backup_bucket')
})
diff --git a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx b/libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.tsx
similarity index 50%
rename from libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx
rename to libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.tsx
index fc7e6baa1c6..f9bb4ed7e14 100644
--- a/libs/domains/service-terraform/feature/src/lib/resource-tree-list/resource-tree-list.tsx
+++ b/libs/shared/console-shared/src/lib/resource-tree-list/resource-tree-list.tsx
@@ -1,11 +1,18 @@
import clsx from 'clsx'
import { type ReactElement, useEffect, useMemo, useState } from 'react'
-import { type TerraformResource } from '@qovery/domains/service-terraform/data-access'
-import { Icon, TreeView } from '@qovery/shared/ui'
-import { matchesSearch } from '../utils/matches-search'
+import { Accordion, Icon } from '@qovery/shared/ui'
+
+export interface ResourceTreeResource {
+ id: string
+ resourceType: string
+ name: string
+ address: string
+ attributes: Record
+ displayName: string
+}
export interface ResourceTreeListProps {
- resources: TerraformResource[]
+ resources: ResourceTreeResource[]
selectedResourceId: string | null
onSelectResource: (resourceId: string) => void
searchQuery: string
@@ -14,11 +21,28 @@ export interface ResourceTreeListProps {
interface GroupedResources {
resourceType: string
displayName: string
- resources: TerraformResource[]
+ resources: ResourceTreeResource[]
+}
+
+function matchesSearch(resource: ResourceTreeResource, query: string): boolean {
+ const lowerQuery = query.toLowerCase()
+
+ if (resource.name.toLowerCase().includes(lowerQuery)) return true
+ if (resource.resourceType.toLowerCase().includes(lowerQuery)) return true
+ if (resource.displayName.toLowerCase().includes(lowerQuery)) return true
+ if (resource.address.toLowerCase().includes(lowerQuery)) return true
+
+ const attributeKeys = Object.keys(resource.attributes)
+ if (attributeKeys.some((key) => key.toLowerCase().includes(lowerQuery))) return true
+
+ const attributeValues = Object.values(resource.attributes).map((value) => String(value))
+ if (attributeValues.some((value) => value.toLowerCase().includes(lowerQuery))) return true
+
+ return false
}
-function groupResourcesByType(resources: TerraformResource[]): GroupedResources[] {
- const groups = new Map()
+function groupResourcesByType(resources: ResourceTreeResource[]): GroupedResources[] {
+ const groups = new Map()
for (const resource of resources) {
const list = groups.get(resource.resourceType) ?? []
@@ -43,10 +67,9 @@ export function ResourceTreeList({
}: ResourceTreeListProps): ReactElement {
const [expandedGroups, setExpandedGroups] = useState([])
- // Always show all resources, but mark which ones match the search
const resourceMatchMap = useMemo(() => {
- if (!searchQuery) return new Map(resources.map((r) => [r.id, true]))
- return new Map(resources.map((r) => [r.id, matchesSearch(r, searchQuery)]))
+ if (!searchQuery) return new Map(resources.map((resource) => [resource.id, true]))
+ return new Map(resources.map((resource) => [resource.id, matchesSearch(resource, searchQuery)]))
}, [resources, searchQuery])
const groupedResources = useMemo(() => {
@@ -81,20 +104,22 @@ export function ResourceTreeList({
}
return (
-
-
+
+
{groupedResources.map((group) => (
-
-
-
- {group.displayName}
- ({group.resources.length})
+
+
+
+
-
-
+ {group.displayName}
+
+
{group.resources.map((resource) => {
- const matches = resourceMatchMap.get(resource.id) ?? true
const isSelected = selectedResourceId === resource.id
return (
@@ -103,25 +128,23 @@ export function ResourceTreeList({
type="button"
onClick={() => onSelectResource(resource.id)}
className={clsx(
- 'mb-1 flex h-8 w-full cursor-pointer items-center gap-2 rounded px-2 text-sm transition-colors',
- isSelected && 'bg-surface-brand-subtle text-brand',
- !isSelected &&
- (matches
- ? 'text-neutral-subtle hover:bg-surface-neutral-subtle'
- : 'text-neutral-disabled hover:bg-surface-neutral-subtle')
+ 'mb-1 flex h-8 w-full cursor-pointer items-center gap-1 rounded-md pl-6 pr-1.5 text-left text-sm transition-colors',
+ isSelected ? 'bg-surface-brand-component text-brand' : 'hover:bg-surface-neutral-subtle'
)}
>
-
- {resource.name}
+
+
+
+ {resource.name}
)
})}
-
-
+
+
))}
-
+
)
}
diff --git a/libs/shared/ui/src/lib/components/accordion/accordion.tsx b/libs/shared/ui/src/lib/components/accordion/accordion.tsx
index 545b7d9b2e5..4cf889ff1e5 100644
--- a/libs/shared/ui/src/lib/components/accordion/accordion.tsx
+++ b/libs/shared/ui/src/lib/components/accordion/accordion.tsx
@@ -17,10 +17,12 @@ const AccordionItem = forwardRef, Acc
)
)
-interface AccordionTriggerProps extends ComponentPropsWithoutRef {}
+interface AccordionTriggerProps extends ComponentPropsWithoutRef {
+ iconClassName?: string
+}
const AccordionTrigger = forwardRef, AccordionTriggerProps>(
- ({ children, className, ...props }, forwardedRef) => (
+ ({ children, className, iconClassName, ...props }, forwardedRef) => (
{children}
diff --git a/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx b/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx
index c1622ced572..87c16a5ebfb 100644
--- a/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx
+++ b/libs/shared/ui/src/lib/components/inputs/input-search/input-search.tsx
@@ -53,7 +53,7 @@ export function InputSearch(props: InputSearchProps) {
ref={ref}
autoFocus={autofocus}
className={twMerge(
- 'w-full rounded border border-neutral bg-surface-neutral pl-10 pr-6 leading-none text-neutral placeholder:text-neutral-subtle focus:border-brand-strong focus:outline-none focus:transition-[border-color]',
+ 'w-full rounded-md border border-neutral bg-surface-neutral pl-10 pr-6 leading-none text-neutral placeholder:text-neutral-subtle focus:border-brand-strong focus:outline-none focus:transition-[border-color]',
customSize
)}
type="text"
diff --git a/package.json b/package.json
index 5718be9cf69..551eb73908b 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
"@xterm/xterm": "5.5.0",
"ansi-to-react": "6.1.6",
"autoprefixer": "10.4.13",
- "axios": "1.15.0",
+ "axios": "1.15.2",
"class-variance-authority": "0.7.0",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -75,7 +75,7 @@
"mermaid": "11.6.0",
"monaco-editor": "0.53.0",
"posthog-js": "1.345.1",
- "qovery-typescript-axios": "1.1.881",
+ "qovery-typescript-axios": "^1.1.886",
"react": "18.3.1",
"react-country-flag": "3.0.2",
"react-datepicker": "4.12.0",
diff --git a/yarn.lock b/yarn.lock
index a8c2efddc3e..4f4964e650f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6320,7 +6320,7 @@ __metadata:
"@xterm/xterm": 5.5.0
ansi-to-react: 6.1.6
autoprefixer: 10.4.13
- axios: 1.15.0
+ axios: 1.15.2
babel-jest: 30.0.5
babel-loader: 8.3.0
chance: 1.1.10
@@ -6369,7 +6369,7 @@ __metadata:
prettier: 3.2.5
prettier-plugin-tailwindcss: 0.5.14
pretty-quick: 4.0.0
- qovery-typescript-axios: 1.1.881
+ qovery-typescript-axios: ^1.1.886
qovery-ws-typescript-axios: 0.1.506
react: 18.3.1
react-country-flag: 3.0.2
@@ -12187,14 +12187,14 @@ __metadata:
languageName: node
linkType: hard
-"axios@npm:1.15.0":
- version: 1.15.0
- resolution: "axios@npm:1.15.0"
+"axios@npm:1.15.2":
+ version: 1.15.2
+ resolution: "axios@npm:1.15.2"
dependencies:
follow-redirects: ^1.15.11
form-data: ^4.0.5
proxy-from-env: ^2.1.0
- checksum: 95a8455554867a083ab3772fcadba42a22ec4bb546dccc66011556d837a07e544ae006675a30a5c43453f3e37e7c0982e934cec482c06b75abead2a2c157448a
+ checksum: e7d208b751959c7c6936417b870d6286979e0dff17784ae230d3988e754b6322682cb136e25669b534072b6f44c08715801ab961227eb8cc075323d9fda8ad43
languageName: node
linkType: hard
@@ -26327,12 +26327,12 @@ __metadata:
languageName: node
linkType: hard
-"qovery-typescript-axios@npm:1.1.881":
- version: 1.1.881
- resolution: "qovery-typescript-axios@npm:1.1.881"
+"qovery-typescript-axios@npm:^1.1.886":
+ version: 1.1.886
+ resolution: "qovery-typescript-axios@npm:1.1.886"
dependencies:
- axios: 1.15.0
- checksum: 230ffc8a4d5d18dd650e6f0ce9a71fedd69fa21c38b738905f9969e5f65e7eb2ed5cb490bc01334208b5da7dc7193604eaa3701d6b088bf19c79670684d9d033
+ axios: 1.15.2
+ checksum: 5816055f0c2fbde9d2cb1f5792ddbcebeb1f11af4373b15f0678123d173fc1a3dae6d8207e3745182282bf2f61ee40e36ce3d5d318b1a873d633f28592ac293c
languageName: node
linkType: hard